From dce742ea9e3a8fec774c73f7a08846f3ef1bea54 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 13:12:32 -0400 Subject: [PATCH 01/22] design: workflow#699 IaCProvider.Apply hard-removal (Approach D) --- ...05-17-iac-provider-apply-removal-design.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/plans/2026-05-17-iac-provider-apply-removal-design.md diff --git a/docs/plans/2026-05-17-iac-provider-apply-removal-design.md b/docs/plans/2026-05-17-iac-provider-apply-removal-design.md new file mode 100644 index 00000000..23689ea0 --- /dev/null +++ b/docs/plans/2026-05-17-iac-provider-apply-removal-design.md @@ -0,0 +1,167 @@ +# Design: workflow#699 — IaCProvider.Apply hard-removal + +- **Date:** 2026-05-17 +- **Status:** Approved (operator selected Approach D 2026-05-17) +- **Issue:** https://github.com/GoCodeAlone/workflow/issues/699 +- **Precedent:** ADR 0024 (IaC typed force-cutover), ADR 0025 (IaC optional methods are typed services), Phase 2 (workflow#640) v2 hooks-over-gRPC cascade, Phase 2.5 (workflow#695) IaCProviderFinalizer cascade + +## Summary + +Hard-delete `IaCProvider.Apply` across workflow + 4 IaC plugins (aws/gcp/azure/DO). Eliminates the sentinel-stub runtime-failure surface DO v1.4.0 introduced (the very surface workflow#699 was filed to remove) without introducing a `LegacyApplier` opt-in interface (rejected for YAGNI + ADR 0024 force-cutover precedent). + +After this change: + +- `interfaces.IaCProvider` no longer declares `Apply`. +- `pb.IaCProviderRequired` no longer carries `rpc Apply` (field 6 reserved). +- `cmd/wfctl` has a single apply path (the v2 `wfctlhelpers.ApplyPlanWithHooks` dispatch); the v1 `provider.Apply` branch + the `iac/wfctlhelpers/dispatch.go` version-switch are deleted. +- All 4 IaC plugins drop their `Apply` Go method **and** their `iacserver.Apply` gRPC handler. +- The manifest schema rejects `iacProvider.computePlanVersion ∈ {"", "v1"}` at parse time (was `enum: ["v1","v2"]`). + +## Context + +### What this fixes + +DO v1.4.0 (Phase 3 of workflow#695 cascade) replaced `DOProvider.Apply` with a sentinel-stub returning `ErrApplyV1Removed`. The Phase 2.5+ cleanup bundle adversarial-design-review cycle-1 (Critical finding C-5) flagged this: + +> "Preserves the exact runtime-failure surface ADR 0024 mandates eliminating." + +The sentinel-stub is dead code that exists only because `interfaces.IaCProvider.Apply` still requires *some* method body. Issue #699 was filed to do the proper architectural fix. + +### What v2 dispatch actually looks like + +`wfctl infra apply` (`cmd/wfctl/infra_apply.go`) branches on `wfctlhelpers.DispatchVersionFor(provider)`: + +| Dispatch | Apply call | Plugins on this path | +|---|---|---| +| v1 | `provider.Apply(ctx, &plan)` (the legacy in-provider loop) | none, since aws/gcp/azure declared v2 in v1.2.x and DO declared v2 in v1.3.0 | +| v2 | `wfctlhelpers.ApplyPlanWithHooks(ctx, provider, &plan, hooks)` → drives `ResourceDriver` per action + `IaCProviderFinalizer.FinalizeApply` for post-loop hooks | all 4 GoCodeAlone IaC plugins | + +So `provider.Apply` is unreachable from `wfctl infra apply` for every plugin in the ecosystem. It is dead code: + +- **DO**: `DOProvider.Apply` returns `ErrApplyV1Removed`; `doIaCServer.Apply` forwards the stub through gRPC; only callable via `wfctl infra apply` if the plugin author forgot to declare v2. +- **aws/gcp/azure**: `.Apply` is a hand-rolled per-action loop (literally the v1 fallback implementation `wfctlhelpers.ApplyPlan` replaced). Unreachable since v1.2.x because all 3 plugins declare ComputePlanVersion=v2 in their `Capabilities` RPC response (`internal/iacserver.go:125`). + +### Plugin verification (assumption A1) + +| Plugin | Version | ComputePlanVersion declaration | +|---|---|---| +| workflow-plugin-aws v1.2.1 | live tag | `internal/iacserver.go:125` → `CapabilitiesResponse{..., ComputePlanVersion: "v2"}` | +| workflow-plugin-gcp v1.2.x | live tag | same shape (Phase 2 sweep) | +| workflow-plugin-azure v1.2.x | live tag | same shape (Phase 2 sweep) | +| workflow-plugin-digitalocean v1.4.0 | live tag | `plugin.json` `iacProvider.computePlanVersion: v2` + Capabilities | + +Per ADR 0024 cycle 1 I-5: there are no third-party IaC plugins (no `interfaces.IaCProvider` consumers outside `workflow` + the four plugins above). + +## Decision + +Adopt **Approach D — hard-delete `Apply` from `interfaces.IaCProvider` + `pb.IaCProviderRequired`**. + +Rejected alternatives: + +- **Approach A** — extract `Apply` to a `LegacyApplier` Go-only interface; keep `rpc Apply` in proto. Rejected: proto layer still carries the sentinel-stub surface; sentinel error class survives at the gRPC boundary. Does not satisfy ADR 0024. +- **Approach B** — split `rpc Apply` into optional `IaCProviderLegacyApplier` service. Rejected for YAGNI per ADR 0024 precedent (force-cutover, no compat shim) and because no v1 plugin exists. Adding the optional service to "preserve the opt-in surface" replicates the sentinel-stub anti-pattern in a different guise. +- **Approach C** — version the Go interface (`IaCProviderV1` vs `IaCProviderV2`). Rejected: leaves two interfaces named "IaC provider" confusing the SDK + adapter type-asserts; does not touch the proto layer at all (so doesn't fix the bug the issue exists to fix). + +## Scope (5-PR cascade) + +PRs sequenced per Phase 2 / Phase 2.5 precedent (rc workflow tag first so plugins can build against new SDK, plugin majors second in parallel, workflow final third). + +### PR 1 — workflow `feat/699-iac-apply-removal-rc` → tag `v0.56.0-rc1` + +**Files modified:** + +- `plugin/external/proto/iac.proto` — delete `rpc Apply(ApplyRequest) returns (ApplyResponse);` from `service IaCProviderRequired`; reserve field number 6; keep `message ApplyResponse` (still used by `FinalizeApply`-related telemetry transport via `ApplyResult.Actions`). +- `plugin/external/proto/iac.pb.go` — regenerate via `buf generate`. +- `interfaces/iac_provider.go` — delete `Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error)` from the `IaCProvider` interface. +- `iac/wfctlhelpers/dispatch.go` — delete entire file (`ComputePlanVersionDeclarer`, `DispatchVersionFor`, `DispatchVersionV2`); v2 is the only dispatch path now. +- `cmd/wfctl/infra_apply.go:465-487` and `:1551-1563` — delete the v1 branch + the `usedV2Dispatch` variable (always true); collapse to single `applyV2ApplyPlanWithHooksFn` call. +- `cmd/wfctl/iac_typed_adapter.go` — delete `typedIaCAdapter.Apply`, `typedIaCAdapter.ComputePlanVersion`, the `_ wfctlhelpers.ComputePlanVersionDeclarer = (*typedIaCAdapter)(nil)` interface assertion at `:1348`, and the `ApplyRequest` encoding paths. +- `plugin/sdk/manifest.go` schema — tighten `iacProvider.computePlanVersion` to `enum: ["v2"]`; reject missing OR `"v1"` at `ParseManifest`. Error message must point operators at this issue + the plugin name. +- `plugin/external/sdk/iacserver.go` — `RegisterAllIaCProviderServices` type-assert against the trimmed `pb.IaCProviderRequiredServer`; verify the trimmed required service still compiles after `Apply` is removed from the interface. +- `wftest/bdd/strict_iac.go` `iacServiceChecks` — drop the `Apply` row from the IaCProviderRequired check. +- `cmd/wfctl/iac_loader_gate_test.go`, `cmd/wfctl/plugin_audit_iac_test.go`, `cmd/wfctl/plugin_audit.go`, and any test using `provider.Apply(...)` — delete the v1 dispatch coverage; the v2 path is the only call site to cover. +- `CHANGELOG.md` — entry noting the breaking change + plugin minimum versions. + +**Tests added:** + +- Manifest-schema test that rejects `iacProvider.computePlanVersion: ""` and `iacProvider.computePlanVersion: "v1"` with a clear "plugin foo needs to be upgraded to v2 (see workflow#699)" error. +- Integration test that an IaC plugin loaded via `discoverAndLoadIaCProvider` fails-fast at manifest parse if it advertises v1 (or omits the field). + +**Backwards compat:** none — hard cutover per ADR 0024 precedent. The workflow rc tag is the moment plugins must rebuild against the new SDK; no compat shim. + +### PR 2 — workflow-plugin-digitalocean (DO) — tag `v2.0.0` + +- Bump `github.com/GoCodeAlone/workflow` pin to `v0.56.0-rc1` (then `v0.56.0` on PR 6). +- Delete `DOProvider.Apply` (the sentinel stub). +- Delete `ErrApplyV1Removed` constant. +- Delete `doIaCServer.Apply` RPC handler. +- Delete `internal/provider_apply_stub_test.go` (the sentinel regression-gate is obsolete). +- Bump `plugin.json` `minEngineVersion: 0.56.0` and `version: 2.0.0`. +- `CHANGELOG.md` entry: "BREAKING — drop v1 Apply method, require workflow v0.56+, per workflow#699." + +### PR 3 — workflow-plugin-aws — tag `v2.0.0` (parallel with 4/5) + +- Bump SDK pin to `v0.56.0-rc1` → `v0.56.0`. +- Delete `AWSProvider.Apply` (hand-rolled per-action loop, dead since v1.2.0). +- Delete `awsIaCServer.Apply` RPC handler. +- Drop the obsolete v1-Apply coverage in `internal/iacserver_test.go` + provider tests. +- Bump `plugin.json` to `v2.0.0`, `minEngineVersion: 0.56.0`. + +### PR 4 — workflow-plugin-gcp — tag `v2.0.0` (parallel) + +Same shape as PR 3. Delete `GCPProvider.Apply` + `gcpIaCServer.Apply` + dead tests; bump pin + manifest. + +### PR 5 — workflow-plugin-azure — tag `v2.0.0` (parallel) + +Same shape as PR 3. Delete `AzureProvider.Apply` + `azureIaCServer.Apply` + dead tests; bump pin + manifest. + +### PR 6 — workflow final — tag `v0.56.0` (after PRs 2-5 are tagged) + +- Bump go.mod minimums in registry consumers (e.g. workflow-registry plugin manifests, `core-dump` / `BMW` deploy paths) that need to surface new plugin majors. +- Update workflow-registry plugin manifests for aws/gcp/azure/DO to `v2.0.0`. +- Final tag. + +### Memory + tracker updates + +- Update `project_open_followup_queue.md`: mark workflow#699 done; cross-ref the v0.56.0 / plugin-v2.0.0 release notes. +- Update `MEMORY.md` plugin inventory (versions). +- New project memory: `project_workflow_699_apply_removal_shipped.md` (post-merge retro). + +## Assumptions + +- **A1** — aws/gcp/azure all currently declare ComputePlanVersion=v2 via Capabilities RPC. Verified: `internal/iacserver.go:125` (`CapabilitiesResponse{..., ComputePlanVersion: "v2"}`). Holds at the time of writing (2026-05-17); will re-verify at PR 1 task 1. +- **A2** — no third-party IaC plugins exist. Per ADR 0024 cycle 1 I-5 grep. Holds because `interfaces.IaCProvider` is a Go interface (not a registry surface) and the engine + the four GoCodeAlone plugin repos are the only consumers. +- **A3** — `module.PlatformProvider` is a different interface from `interfaces.IaCProvider`. Verified: `module/platform_provider.go:5` (4 methods including `Apply() (*PlatformResult, error)`, no context arg). `module/pipeline_step_iac.go:208` (`provider.Apply()` no args) confirms it calls `PlatformProvider`, not `IaCProvider`. This file is unaffected by this change. +- **A4** — workflow#693 manifest gate is the right enforcement point. Verified: `plugin/sdk/manifest.go:128` (`s.Validate(doc)` runs at ParseManifest; the schema accepts only the values we put in). Tightening the schema enum is the simplest enforcement. +- **A5** — `ApplyResult` + `ActionStatus` + `IaCProviderFinalizer.FinalizeApply` shape stays. Verified: Phase 2.3 (workflow#698) shipped `ApplyResult.Actions` + ActionStatus enums; FinalizeApply still uses `ApplyResult` shape for compensation telemetry. This change removes only the `Apply` RPC; it does not touch `ApplyResult` or `FinalizeApply`. +- **A6** — proto field-number reservation is breaking-compatible only in the "old client → new server" direction. Verified by buf-breaking-check semantics. New server (v0.56.0) refuses to encode/decode a reserved field; old client (pre-v0.56.0) attempting `rpc Apply` against new server gets `codes.Unimplemented` (the service descriptor no longer has the method). Operators must upgrade wfctl + plugins atomically — same constraint as Phase 2. + +## Rollback + +This change cascades through workflow + 4 plugins simultaneously. Rollback path: + +1. **Revert workflow tag v0.56.0** → re-publish v0.55.x as the recommended pin. The pre-#699 state (sentinel-stub in DO, dead Apply loops in aws/gcp/azure) is the rollback shape. +2. **Revert plugin v2.0.0 tags** → re-publish the previous v1.x tags as the recommended pins. +3. **State-file format invariant** across the cutover — `interfaces.ResourceState` JSON shape unchanged. Operators do not need to migrate state. +4. **Consumer pin bumps** (registry, BMW, core-dump) — revert the go.mod bumps. + +The change is runtime-affecting (proto change, plugin gRPC service surface change), so `runtime-launch-validation` applies: each plugin PR must run `docker compose up + curl healthz`-equivalent verification (the iacserver_test for each plugin) before merge. + +## Adversarial-review targets (for the next phase) + +The adversarial-design-review pass should attack: + +1. **A1/A2 brittleness** — what if a third-party plugin actually exists somewhere we forgot? What's the worst failure mode? (Expected: plugin author rebuilds against v0.56.0 SDK, gets a clear compile-time error pointing them to this design doc.) +2. **Manifest gate sufficiency** — does the schema rejection actually fire on every load path? (Expected: yes, all loader paths go through `ParseManifest`.) +3. **Proto field reservation** — is reserving field 6 enough? Should we also reserve the `ApplyRequest` message type name? (Expected: reserving the field number is sufficient; the message type can stay since `ApplyResult` shape is unchanged.) +4. **Single-PR vs cascade trade-off** — could we land this as one mega-PR across all 5 repos? (Expected: no, because workflow rc tag must exist before plugins can pin against it.) +5. **Cascade ordering** — what if a consumer (BMW, core-dump) pins a new plugin major BEFORE workflow v0.56.0 is tagged? (Expected: this can't happen because the plugin v2.0.0 tag depends on the workflow rc tag; consumers should pin both atomically.) + +## References + +- Issue: https://github.com/GoCodeAlone/workflow/issues/699 +- ADR 0024 (force-cutover precedent): `decisions/0024-iac-typed-force-cutover.md` +- ADR 0025 (optional services as typed services, not flags): `decisions/0025-iac-optional-method-typed-services-not-bool.md` +- Phase 2 (workflow#640): `docs/plans/2026-05-10-strict-contracts-force-cutover.md` + memory `project_v2_lifecycle_phase2_shipped.md` +- Phase 2.5 (workflow#695): memory `project_v2_lifecycle_phase2_shipped.md` +- Phase 2.5+ Cleanup Bundle adversarial-design-review cycle-1 C-5 finding (originating context for this issue) From d52088e076b3942841c3569cb01541af260d9726 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 13:21:42 -0400 Subject: [PATCH 02/22] design: revise per cycle-1 adversarial review (2 Crit + 5 Imp + 3 Min addressed) --- ...05-17-iac-provider-apply-removal-design.md | 176 +++++++++++------- 1 file changed, 105 insertions(+), 71 deletions(-) diff --git a/docs/plans/2026-05-17-iac-provider-apply-removal-design.md b/docs/plans/2026-05-17-iac-provider-apply-removal-design.md index 23689ea0..4e57f1d8 100644 --- a/docs/plans/2026-05-17-iac-provider-apply-removal-design.md +++ b/docs/plans/2026-05-17-iac-provider-apply-removal-design.md @@ -1,21 +1,23 @@ -# Design: workflow#699 — IaCProvider.Apply hard-removal +# Design: workflow#699 — IaCProvider.Apply hard-removal (resolves #699) - **Date:** 2026-05-17 -- **Status:** Approved (operator selected Approach D 2026-05-17) +- **Status:** Approved (operator selected Approach D 2026-05-17); revised cycle-1 per adversarial review (2 Critical + 5 Important findings addressed) - **Issue:** https://github.com/GoCodeAlone/workflow/issues/699 - **Precedent:** ADR 0024 (IaC typed force-cutover), ADR 0025 (IaC optional methods are typed services), Phase 2 (workflow#640) v2 hooks-over-gRPC cascade, Phase 2.5 (workflow#695) IaCProviderFinalizer cascade ## Summary -Hard-delete `IaCProvider.Apply` across workflow + 4 IaC plugins (aws/gcp/azure/DO). Eliminates the sentinel-stub runtime-failure surface DO v1.4.0 introduced (the very surface workflow#699 was filed to remove) without introducing a `LegacyApplier` opt-in interface (rejected for YAGNI + ADR 0024 force-cutover precedent). +Hard-delete `IaCProvider.Apply` across workflow + 4 IaC plugins (aws/gcp/azure/DO) + workflow-registry manifests. Eliminates the sentinel-stub runtime-failure surface DO v1.4.0 introduced (the very surface workflow#699 was filed to remove) without introducing a `LegacyApplier` opt-in interface (rejected for YAGNI + ADR 0024 force-cutover precedent). + +**Note on title vs. issue body:** Issue #699 is titled "IaCProvider interface segregation" but its body proposes three candidate designs, all of which converge on removing `Apply` from the required surface. This design picks the most-aggressive variant (Approach D, hard-delete). The work resolves #699's body; the title's "segregation" framing is the project_open_followup_queue label, not a literal ISP-style split. After this change: - `interfaces.IaCProvider` no longer declares `Apply`. -- `pb.IaCProviderRequired` no longer carries `rpc Apply` (field 6 reserved). +- `pb.IaCProviderRequired` no longer carries `rpc Apply` (field 6 reserved; method name `Apply` reserved on the service). - `cmd/wfctl` has a single apply path (the v2 `wfctlhelpers.ApplyPlanWithHooks` dispatch); the v1 `provider.Apply` branch + the `iac/wfctlhelpers/dispatch.go` version-switch are deleted. - All 4 IaC plugins drop their `Apply` Go method **and** their `iacserver.Apply` gRPC handler. -- The manifest schema rejects `iacProvider.computePlanVersion ∈ {"", "v1"}` at parse time (was `enum: ["v1","v2"]`). +- The manifest enforcement layer (`cmd/wfctl/deploy_providers.go findIaCPluginDir` switch — the load path that actually drives `wfctl infra apply`, NOT `sdk.ParseManifest`) rejects `iacProvider.computePlanVersion ∈ {"", "v1"}` at parse time. SDK schema tightened in parallel for tooling that uses `ParseManifest`. ## Context @@ -34,21 +36,23 @@ The sentinel-stub is dead code that exists only because `interfaces.IaCProvider. | Dispatch | Apply call | Plugins on this path | |---|---|---| | v1 | `provider.Apply(ctx, &plan)` (the legacy in-provider loop) | none, since aws/gcp/azure declared v2 in v1.2.x and DO declared v2 in v1.3.0 | -| v2 | `wfctlhelpers.ApplyPlanWithHooks(ctx, provider, &plan, hooks)` → drives `ResourceDriver` per action + `IaCProviderFinalizer.FinalizeApply` for post-loop hooks | all 4 GoCodeAlone IaC plugins | +| v2 | `wfctlhelpers.ApplyPlanWithHooks(ctx, provider, &plan, hooks)` → drives `ResourceDriver` per action + `IaCProviderFinalizer.FinalizeApply` (shipped main commit aac519da, workflow#697 / Phase 2.5) for post-loop deferred-update flush | all 4 GoCodeAlone IaC plugins | So `provider.Apply` is unreachable from `wfctl infra apply` for every plugin in the ecosystem. It is dead code: - **DO**: `DOProvider.Apply` returns `ErrApplyV1Removed`; `doIaCServer.Apply` forwards the stub through gRPC; only callable via `wfctl infra apply` if the plugin author forgot to declare v2. -- **aws/gcp/azure**: `.Apply` is a hand-rolled per-action loop (literally the v1 fallback implementation `wfctlhelpers.ApplyPlan` replaced). Unreachable since v1.2.x because all 3 plugins declare ComputePlanVersion=v2 in their `Capabilities` RPC response (`internal/iacserver.go:125`). +- **aws/gcp/azure**: `.Apply` is a hand-rolled per-action loop (literally the v1 fallback implementation `wfctlhelpers.ApplyPlan` replaced). Unreachable since v1.2.x because all 3 plugins declare ComputePlanVersion=v2 in their `Capabilities` RPC response. ### Plugin verification (assumption A1) -| Plugin | Version | ComputePlanVersion declaration | +Verified by direct grep 2026-05-17 against each plugin repo's main branch (head): + +| Plugin | Version (tag) | ComputePlanVersion declaration | |---|---|---| | workflow-plugin-aws v1.2.1 | live tag | `internal/iacserver.go:125` → `CapabilitiesResponse{..., ComputePlanVersion: "v2"}` | -| workflow-plugin-gcp v1.2.x | live tag | same shape (Phase 2 sweep) | -| workflow-plugin-azure v1.2.x | live tag | same shape (Phase 2 sweep) | -| workflow-plugin-digitalocean v1.4.0 | live tag | `plugin.json` `iacProvider.computePlanVersion: v2` + Capabilities | +| workflow-plugin-gcp v1.2.0 | live tag (head plugin.json shows 1.1.0; sync-plugin-version workflow pending) | `internal/iacserver.go:125` → `ComputePlanVersion: "v2"` | +| workflow-plugin-azure v1.2.1 | live tag | `internal/iacserver.go:128` → `ComputePlanVersion: "v2"` | +| workflow-plugin-digitalocean v1.4.0 | live tag | `plugin.json` `iacProvider.computePlanVersion: v2` + `internal/iacserver.go:182` Capabilities | Per ADR 0024 cycle 1 I-5: there are no third-party IaC plugins (no `interfaces.IaCProvider` consumers outside `workflow` + the four plugins above). @@ -59,67 +63,85 @@ Adopt **Approach D — hard-delete `Apply` from `interfaces.IaCProvider` + `pb.I Rejected alternatives: - **Approach A** — extract `Apply` to a `LegacyApplier` Go-only interface; keep `rpc Apply` in proto. Rejected: proto layer still carries the sentinel-stub surface; sentinel error class survives at the gRPC boundary. Does not satisfy ADR 0024. -- **Approach B** — split `rpc Apply` into optional `IaCProviderLegacyApplier` service. Rejected for YAGNI per ADR 0024 precedent (force-cutover, no compat shim) and because no v1 plugin exists. Adding the optional service to "preserve the opt-in surface" replicates the sentinel-stub anti-pattern in a different guise. +- **Approach B** — split `rpc Apply` into optional `IaCProviderLegacyApplier` service per ADR 0025 pattern. Rejected for YAGNI per ADR 0024 precedent (force-cutover, no compat shim) and because no v1 plugin exists. Adding the optional service to "preserve the opt-in surface" replicates the sentinel-stub anti-pattern in a different guise. **Soft-add-back available** — Approach B is the documented rollback option (see §Rollback) if a third-party plugin ever surfaces; the ADR 0025 optional-service pattern is the channel through which Apply can be re-introduced without re-opening the bug surface. - **Approach C** — version the Go interface (`IaCProviderV1` vs `IaCProviderV2`). Rejected: leaves two interfaces named "IaC provider" confusing the SDK + adapter type-asserts; does not touch the proto layer at all (so doesn't fix the bug the issue exists to fix). -## Scope (5-PR cascade) +## Scope (6-PR cascade + registry tail-PR) -PRs sequenced per Phase 2 / Phase 2.5 precedent (rc workflow tag first so plugins can build against new SDK, plugin majors second in parallel, workflow final third). +PRs sequenced per Phase 2 / Phase 2.5 precedent (rc workflow tag first so plugins can build against new SDK, plugin rc tags second in parallel, plugin majors third, workflow final fourth, registry last). ### PR 1 — workflow `feat/699-iac-apply-removal-rc` → tag `v0.56.0-rc1` -**Files modified:** +**Files modified (in this safe-edit order to avoid intra-PR compile breakage):** + +1. **Adapter + dispatch helper edits first** (these compile against the OLD proto/interface): + - `cmd/wfctl/iac_typed_adapter.go` — delete `typedIaCAdapter.Apply`, delete `typedIaCAdapter.ComputePlanVersion`, delete the `_ wfctlhelpers.ComputePlanVersionDeclarer = (*typedIaCAdapter)(nil)` interface assertion at `:1348`, delete `ApplyRequest` encoding helpers in this file. + - `cmd/wfctl/infra_apply.go:465-487` and `:1551-1563` — delete the v1 branch + the `usedV2Dispatch` variable (always true); collapse to single `applyV2ApplyPlanWithHooksFn` call. +2. **Then delete the dispatch helper package**: + - `iac/wfctlhelpers/dispatch.go` — delete entire file (`ComputePlanVersionDeclarer`, `DispatchVersionFor`, `DispatchVersionV2`); v2 is the only dispatch path now. +3. **Then tighten the loader gate** (the actual production enforcement point — NOT `sdk.ParseManifest`, since `findIaCPluginDir` uses raw `json.Unmarshal` per its own godoc): + - `cmd/wfctl/deploy_providers.go:162-170` — change the inline `switch m.IaCProvider.ComputePlanVersion` from `case "", "v1", "v2"` to `case "v2"`. Default arm now rejects both empty and `"v1"` with: `plugin %q manifest declares iacProvider.computePlanVersion %q; v0.56.0+ requires "v2" (see workflow#699 — upgrade plugin to v2.0.0 or higher)`. +4. **Then tighten the SDK schema** (defense-in-depth for tooling that uses `ParseManifest`): + - `plugin/sdk/manifest.go` schema — tighten `iacProvider.computePlanVersion` to `enum: ["v2"]` (was `enum: ["v1","v2"]`). +5. **Then update the typed contract** (last, since this breaks the gRPC service): + - `plugin/external/proto/iac.proto` — delete `rpc Apply(ApplyRequest) returns (ApplyResponse);` from `service IaCProviderRequired`; add `reserved 6;` for the field number; add `reserved "Apply";` for the method name (gRPC reserves both axes). Delete `message ApplyRequest` (the wrapper around `IaCPlan`, no other consumer). KEEP `message ApplyResponse` — it wraps `ApplyResult`, which `FinalizeApply` telemetry still uses (`iac/wfctlhelpers/apply.go` reads `ApplyResult.Actions` populated by `FinalizeApply`). + - `plugin/external/proto/iac.pb.go`, `plugin/external/proto/iac_grpc.pb.go` — regenerate via `buf generate`. +6. **Then the Go interface**: + - `interfaces/iac_provider.go` — delete `Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error)` from the `IaCProvider` interface. +7. **Then the SDK auto-register helper**: + - `plugin/external/sdk/iacserver.go` — `RegisterAllIaCProviderServices` type-assert against the trimmed `pb.IaCProviderRequiredServer`; verify the trimmed required service still compiles after `Apply` is removed. +8. **Then tests + stubs**: + - `wftest/bdd/strict_iac.go` `iacServiceChecks` — drop the `Apply` row from the IaCProviderRequired check. + - `cmd/wfctl/iac_loader_gate_test.go`, `cmd/wfctl/plugin_audit_iac_test.go`, `cmd/wfctl/plugin_audit.go`, and any test referencing `provider.Apply(...)` — delete the v1 dispatch coverage; the v2 path is the only call site to cover. + - Update the `findIaCPluginDir` test (deploy_providers_test.go) to assert the new error message on `""` and `"v1"`. + - `CHANGELOG.md` — entry noting the breaking change + plugin minimum versions. + +**Tests added (PR 1):** + +- `findIaCPluginDir` test covering 3 cases: `"v2"` (accept), `""` (reject with actionable error pointing to #699), `"v1"` (reject same shape). +- Manifest-schema test that rejects `iacProvider.computePlanVersion: ""` and `iacProvider.computePlanVersion: "v1"` at `ParseManifest` (defense-in-depth for tooling). +- Integration test that `discoverAndLoadIaCProvider` fails-fast with the actionable error when a plugin declares v1 or omits the field. -- `plugin/external/proto/iac.proto` — delete `rpc Apply(ApplyRequest) returns (ApplyResponse);` from `service IaCProviderRequired`; reserve field number 6; keep `message ApplyResponse` (still used by `FinalizeApply`-related telemetry transport via `ApplyResult.Actions`). -- `plugin/external/proto/iac.pb.go` — regenerate via `buf generate`. -- `interfaces/iac_provider.go` — delete `Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error)` from the `IaCProvider` interface. -- `iac/wfctlhelpers/dispatch.go` — delete entire file (`ComputePlanVersionDeclarer`, `DispatchVersionFor`, `DispatchVersionV2`); v2 is the only dispatch path now. -- `cmd/wfctl/infra_apply.go:465-487` and `:1551-1563` — delete the v1 branch + the `usedV2Dispatch` variable (always true); collapse to single `applyV2ApplyPlanWithHooksFn` call. -- `cmd/wfctl/iac_typed_adapter.go` — delete `typedIaCAdapter.Apply`, `typedIaCAdapter.ComputePlanVersion`, the `_ wfctlhelpers.ComputePlanVersionDeclarer = (*typedIaCAdapter)(nil)` interface assertion at `:1348`, and the `ApplyRequest` encoding paths. -- `plugin/sdk/manifest.go` schema — tighten `iacProvider.computePlanVersion` to `enum: ["v2"]`; reject missing OR `"v1"` at `ParseManifest`. Error message must point operators at this issue + the plugin name. -- `plugin/external/sdk/iacserver.go` — `RegisterAllIaCProviderServices` type-assert against the trimmed `pb.IaCProviderRequiredServer`; verify the trimmed required service still compiles after `Apply` is removed from the interface. -- `wftest/bdd/strict_iac.go` `iacServiceChecks` — drop the `Apply` row from the IaCProviderRequired check. -- `cmd/wfctl/iac_loader_gate_test.go`, `cmd/wfctl/plugin_audit_iac_test.go`, `cmd/wfctl/plugin_audit.go`, and any test using `provider.Apply(...)` — delete the v1 dispatch coverage; the v2 path is the only call site to cover. -- `CHANGELOG.md` — entry noting the breaking change + plugin minimum versions. - -**Tests added:** +**Backwards compat:** none — hard cutover per ADR 0024 precedent. The workflow rc tag is the moment plugins must rebuild against the new SDK; no compat shim. -- Manifest-schema test that rejects `iacProvider.computePlanVersion: ""` and `iacProvider.computePlanVersion: "v1"` with a clear "plugin foo needs to be upgraded to v2 (see workflow#699)" error. -- Integration test that an IaC plugin loaded via `discoverAndLoadIaCProvider` fails-fast at manifest parse if it advertises v1 (or omits the field). +### PRs 2-5 — plugin rc1 tags (parallel, after PR 1 tags `v0.56.0-rc1`) -**Backwards compat:** none — hard cutover per ADR 0024 precedent. The workflow rc tag is the moment plugins must rebuild against the new SDK; no compat shim. +Each plugin ships a `v2.0.0-rc1` tag against `workflow v0.56.0-rc1` before its `v2.0.0` final tag. This mirrors the workflow-rc protocol and lets the plugin-conformance gate (see PR 6) build the matrix. -### PR 2 — workflow-plugin-digitalocean (DO) — tag `v2.0.0` +Each plugin PR: -- Bump `github.com/GoCodeAlone/workflow` pin to `v0.56.0-rc1` (then `v0.56.0` on PR 6). -- Delete `DOProvider.Apply` (the sentinel stub). -- Delete `ErrApplyV1Removed` constant. -- Delete `doIaCServer.Apply` RPC handler. -- Delete `internal/provider_apply_stub_test.go` (the sentinel regression-gate is obsolete). -- Bump `plugin.json` `minEngineVersion: 0.56.0` and `version: 2.0.0`. +- Bump `github.com/GoCodeAlone/workflow` pin to `v0.56.0-rc1`. +- Delete `.Apply` and `IaCServer.Apply` RPC handler. +- For DO only: delete `ErrApplyV1Removed` constant + `internal/provider_apply_stub_test.go` (sentinel regression-gate obsolete). +- Drop the obsolete v1-Apply coverage in `internal/iacserver_test.go` + provider tests. +- Bump `plugin.json` to `v2.0.0-rc1`, `minEngineVersion: 0.56.0`. - `CHANGELOG.md` entry: "BREAKING — drop v1 Apply method, require workflow v0.56+, per workflow#699." +- Tag `v2.0.0-rc1`. -### PR 3 — workflow-plugin-aws — tag `v2.0.0` (parallel with 4/5) +| PR | Repo | +|---|---| +| PR 2 | workflow-plugin-digitalocean → tag `v2.0.0-rc1` | +| PR 3 | workflow-plugin-aws → tag `v2.0.0-rc1` | +| PR 4 | workflow-plugin-gcp → tag `v2.0.0-rc1` | +| PR 5 | workflow-plugin-azure → tag `v2.0.0-rc1` | -- Bump SDK pin to `v0.56.0-rc1` → `v0.56.0`. -- Delete `AWSProvider.Apply` (hand-rolled per-action loop, dead since v1.2.0). -- Delete `awsIaCServer.Apply` RPC handler. -- Drop the obsolete v1-Apply coverage in `internal/iacserver_test.go` + provider tests. -- Bump `plugin.json` to `v2.0.0`, `minEngineVersion: 0.56.0`. +### PR 6 — workflow plugin-conformance gate + final tag `v0.56.0` -### PR 4 — workflow-plugin-gcp — tag `v2.0.0` (parallel) +After all 4 plugin rc1 tags exist: -Same shape as PR 3. Delete `GCPProvider.Apply` + `gcpIaCServer.Apply` + dead tests; bump pin + manifest. +- New CI matrix step in workflow: build each `workflow-plugin-{aws,gcp,azure,digitalocean}@v2.0.0-rc1` against `workflow@v0.56.0-rc1` and run each plugin's iacserver_test smoke. +- On green: tag `workflow v0.56.0` final. +- Bump go.mod minimums in any in-repo consumers that need the new wfctl semantics. -### PR 5 — workflow-plugin-azure — tag `v2.0.0` (parallel) +### PRs 7-10 — plugin final tags (parallel, fan-out from PR 6) -Same shape as PR 3. Delete `AzureProvider.Apply` + `azureIaCServer.Apply` + dead tests; bump pin + manifest. +Each plugin bumps SDK pin from `v0.56.0-rc1` → `v0.56.0`, bumps plugin.json to `v2.0.0`, tags `v2.0.0`. -### PR 6 — workflow final — tag `v0.56.0` (after PRs 2-5 are tagged) +### PR 11 — workflow-registry manifest bump (LAST) -- Bump go.mod minimums in registry consumers (e.g. workflow-registry plugin manifests, `core-dump` / `BMW` deploy paths) that need to surface new plugin majors. -- Update workflow-registry plugin manifests for aws/gcp/azure/DO to `v2.0.0`. -- Final tag. +- Update `workflow-registry/v1/plugins/{aws,gcp,azure,digitalocean}/manifest.json` to `version: 2.0.0`, `minEngineVersion: 0.56.0`. +- **Sequenced last** so operators on pre-v0.56.0 wfctl who pull from registry don't get a v2.0.0 plugin they can't run. +- This PR is the rollback-sensitive one (see §Rollback) because the registry is a rolling source-of-truth with no version axis. ### Memory + tracker updates @@ -129,33 +151,43 @@ Same shape as PR 3. Delete `AzureProvider.Apply` + `azureIaCServer.Apply` + dead ## Assumptions -- **A1** — aws/gcp/azure all currently declare ComputePlanVersion=v2 via Capabilities RPC. Verified: `internal/iacserver.go:125` (`CapabilitiesResponse{..., ComputePlanVersion: "v2"}`). Holds at the time of writing (2026-05-17); will re-verify at PR 1 task 1. -- **A2** — no third-party IaC plugins exist. Per ADR 0024 cycle 1 I-5 grep. Holds because `interfaces.IaCProvider` is a Go interface (not a registry surface) and the engine + the four GoCodeAlone plugin repos are the only consumers. +- **A1** — aws/gcp/azure/DO all currently declare ComputePlanVersion=v2 via Capabilities RPC. **Verified 2026-05-17 by direct grep**: aws `internal/iacserver.go:125`, gcp `:125`, azure `:128`, DO `:182`. Pre-PR-1-task-1 re-check predicate: `grep -q '"v2"' internal/iacserver.go` in each plugin repo head. +- **A2** — no third-party IaC plugins exist. Per ADR 0024 cycle 1 I-5 grep. Holds because `interfaces.IaCProvider` is a Go interface (not a registry surface) and the engine + the four GoCodeAlone plugin repos are the only consumers. If this assumption is ever wrong, see §Rollback for the soft-add-back path (Approach B). - **A3** — `module.PlatformProvider` is a different interface from `interfaces.IaCProvider`. Verified: `module/platform_provider.go:5` (4 methods including `Apply() (*PlatformResult, error)`, no context arg). `module/pipeline_step_iac.go:208` (`provider.Apply()` no args) confirms it calls `PlatformProvider`, not `IaCProvider`. This file is unaffected by this change. -- **A4** — workflow#693 manifest gate is the right enforcement point. Verified: `plugin/sdk/manifest.go:128` (`s.Validate(doc)` runs at ParseManifest; the schema accepts only the values we put in). Tightening the schema enum is the simplest enforcement. -- **A5** — `ApplyResult` + `ActionStatus` + `IaCProviderFinalizer.FinalizeApply` shape stays. Verified: Phase 2.3 (workflow#698) shipped `ApplyResult.Actions` + ActionStatus enums; FinalizeApply still uses `ApplyResult` shape for compensation telemetry. This change removes only the `Apply` RPC; it does not touch `ApplyResult` or `FinalizeApply`. -- **A6** — proto field-number reservation is breaking-compatible only in the "old client → new server" direction. Verified by buf-breaking-check semantics. New server (v0.56.0) refuses to encode/decode a reserved field; old client (pre-v0.56.0) attempting `rpc Apply` against new server gets `codes.Unimplemented` (the service descriptor no longer has the method). Operators must upgrade wfctl + plugins atomically — same constraint as Phase 2. +- **A4** — `cmd/wfctl/deploy_providers.go findIaCPluginDir` switch is the actual production enforcement point for `iacProvider.computePlanVersion`. `sdk.ParseManifest` is NOT used by this loader (per the godoc at line 113-129 of deploy_providers.go and at lines 18-24 of `dispatch.go`). The design therefore tightens BOTH the inline switch (primary enforcement) AND the SDK schema (secondary defense for tooling). +- **A5** — `ApplyResult` + `ActionStatus` + `IaCProviderFinalizer.FinalizeApply` shape stays. Verified in main: commits `aac519da` (Phase 2.5) + `7a855934` (Phase 2.3) shipped FinalizeApply + ActionStatus enums. `iac/wfctlhelpers/apply.go` and `plugin/external/proto/iac.proto` both reference these symbols. This design removes only the `Apply` RPC; `FinalizeApply`, `ApplyResult`, `ApplyResponse`, `ActionStatus` all stay. +- **A6** — proto field-number + method-name reservation is breaking-compatible only in the "old client → new server" direction. Verified by buf-breaking-check semantics. New server (v0.56.0) refuses to expose the reserved field/method; old client (pre-v0.56.0) attempting `rpc Apply` against new server gets `codes.Unimplemented`. Operators must upgrade wfctl + plugins atomically — same constraint as Phase 2. ## Rollback -This change cascades through workflow + 4 plugins simultaneously. Rollback path: +This change cascades through workflow + 4 plugins + 1 registry repo. Rollback path, in reverse order of the cascade: -1. **Revert workflow tag v0.56.0** → re-publish v0.55.x as the recommended pin. The pre-#699 state (sentinel-stub in DO, dead Apply loops in aws/gcp/azure) is the rollback shape. -2. **Revert plugin v2.0.0 tags** → re-publish the previous v1.x tags as the recommended pins. -3. **State-file format invariant** across the cutover — `interfaces.ResourceState` JSON shape unchanged. Operators do not need to migrate state. -4. **Consumer pin bumps** (registry, BMW, core-dump) — revert the go.mod bumps. +1. **Revert PR 11 (registry manifest)** FIRST — this is the rolling source-of-truth. Re-publish the previous version pins so `wfctl plugin install` resumes serving the pre-v2.0.0 plugin majors. Registry has no tag axis; rollback is a manifest PR + immediate effect. +2. **Revert PRs 7-10 (plugin v2.0.0 tags)** — re-publish the previous v1.x.x tags as the recommended pins; the registry rollback (step 1) now points operators to those tags. +3. **Revert PR 6 (workflow v0.56.0)** — re-publish v0.55.x as the recommended pin. +4. **Revert PRs 1-5 (rc tags)** — RC tags don't need active revert (operators don't pin to rc), but the workflow rc and plugin rc tags can be left in place or yanked at maintainer discretion. +5. **State-file format invariant** across the cutover — `interfaces.ResourceState` JSON shape unchanged. Operators do not need to migrate state. +6. **Half-rolled-back state window** — between step 1 (registry revert published) and operators actually re-pulling via `wfctl plugin install`, some operators may already have v2.0.0 plugin binaries on disk. These continue to work against v0.56.0 wfctl; the issue is only for operators who downgraded wfctl to v0.55.x in the same window. Document this in the rollback runbook: "If you've already pulled v2.0.0 plugins, either keep v0.56.0 wfctl OR `wfctl plugin install --force` after registry revert." +7. **Soft-add-back option (Approach B)** — if the rollback is driven by a third-party plugin surfacing post-cutover, the architectural re-introduction path is Approach B (optional `IaCProviderLegacyApplier` service per ADR 0025), NOT restoring `rpc Apply` on `IaCProviderRequired`. Approach B preserves the compile-time-safety guarantee while letting the third-party plugin opt in. -The change is runtime-affecting (proto change, plugin gRPC service surface change), so `runtime-launch-validation` applies: each plugin PR must run `docker compose up + curl healthz`-equivalent verification (the iacserver_test for each plugin) before merge. +The change is runtime-affecting (proto change, plugin gRPC service surface change), so `runtime-launch-validation` applies: each plugin PR must run iacserver_test (the per-plugin runtime smoke) before merge. PR 6 adds the cross-repo conformance gate. -## Adversarial-review targets (for the next phase) +## Adversarial-review-cycle-1 findings addressed -The adversarial-design-review pass should attack: +Adversarial review (cycle 1, 2026-05-17) flagged 2 Critical + 5 Important + 3 Minor findings. Resolution: -1. **A1/A2 brittleness** — what if a third-party plugin actually exists somewhere we forgot? What's the worst failure mode? (Expected: plugin author rebuilds against v0.56.0 SDK, gets a clear compile-time error pointing them to this design doc.) -2. **Manifest gate sufficiency** — does the schema rejection actually fire on every load path? (Expected: yes, all loader paths go through `ParseManifest`.) -3. **Proto field reservation** — is reserving field 6 enough? Should we also reserve the `ApplyRequest` message type name? (Expected: reserving the field number is sufficient; the message type can stay since `ApplyResult` shape is unchanged.) -4. **Single-PR vs cascade trade-off** — could we land this as one mega-PR across all 5 repos? (Expected: no, because workflow rc tag must exist before plugins can pin against it.) -5. **Cascade ordering** — what if a consumer (BMW, core-dump) pins a new plugin major BEFORE workflow v0.56.0 is tagged? (Expected: this can't happen because the plugin v2.0.0 tag depends on the workflow rc tag; consumers should pin both atomically.) +| Finding | Severity | Resolution | +|---|---|---| +| Schema gate bypass — `findIaCPluginDir` uses raw `json.Unmarshal`, not `sdk.ParseManifest` | Critical | Added explicit file edit to `cmd/wfctl/deploy_providers.go:162-170` switch as the primary enforcement point. SDK schema tightening kept as defense-in-depth. New A4 assumption documents the split-enforcement model. | +| FinalizeApply citation appeared fictional (was reviewer reading pre-rebase tree) | Critical | Re-verified against current main (post-rebase): commits aac519da + 7a855934 shipped Phase 2.5 + Phase 2.3 to main; FinalizeApply lives in `interfaces/`, `plugin/`, `cmd/wfctl/`, `iac/`. A5 now cites specific commits. | +| azure A1 grep not surfaced in original verification | Important | Re-grepped 2026-05-17: azure at `internal/iacserver.go:128` (not :125 like aws/gcp). A1 table updated with per-plugin line numbers + pre-PR-1 re-check predicate. | +| Rollback ignores registry-manifest fan-out | Important | Added PR 11 (registry manifest bump as last cascade step) + §Rollback step 1 (revert registry FIRST) + §Rollback step 6 (half-rolled-back window runbook). | +| Parallel PRs 2-5 race PR 6 | Important | Restructured to 11 PRs: rc tags first (PRs 2-5), conformance gate + workflow final (PR 6), plugin final tags as fan-out (PRs 7-10), registry last (PR 11). | +| ADR-0025 optional-service add-back path not engaged | Important | §Decision Approach B rejection now notes Approach B IS the soft-add-back rollback option per ADR 0025. §Rollback step 7 documents the channel. | +| PR 1 internal edit ordering hazard | Important | PR 1 file list reorganized into 8 numbered safe-edit-order steps. | +| Title vs. issue body mismatch | Minor | Title now reads "(resolves #699)" + §Summary opens with explicit note on title-vs-body. | +| Plugins should ship rc1 tags | Minor | PRs 2-5 now ship `v2.0.0-rc1` before PRs 7-10 ship `v2.0.0`. | +| Reserve method name + field number on service block | Minor | PR 1 file-list step 5 now reserves BOTH field number 6 AND method name `"Apply"` on the service block. | ## References @@ -163,5 +195,7 @@ The adversarial-design-review pass should attack: - ADR 0024 (force-cutover precedent): `decisions/0024-iac-typed-force-cutover.md` - ADR 0025 (optional services as typed services, not flags): `decisions/0025-iac-optional-method-typed-services-not-bool.md` - Phase 2 (workflow#640): `docs/plans/2026-05-10-strict-contracts-force-cutover.md` + memory `project_v2_lifecycle_phase2_shipped.md` -- Phase 2.5 (workflow#695): memory `project_v2_lifecycle_phase2_shipped.md` +- Phase 2.5 (workflow#695): merged main commit `aac519da` (IaCProviderFinalizer + OnPlanComplete hook) +- Phase 2.3 (workflow#698): merged main commit `7a855934` (ActionStatus compensation enums) - Phase 2.5+ Cleanup Bundle adversarial-design-review cycle-1 C-5 finding (originating context for this issue) +- Cycle-1 adversarial-review of this design (2026-05-17, addressed above) From 575a48047094b319410dc69d3e260915c4e00723 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 13:29:14 -0400 Subject: [PATCH 03/22] =?UTF-8?q?design:=20revise=20per=20cycle-2=20advers?= =?UTF-8?q?arial=20review=20(3=20Crit=20+=205=20Imp=20+=202=20Min=20addres?= =?UTF-8?q?sed)=20=E2=80=94=20pivot=20to=20typed=20Capabilities-RPC=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...05-17-iac-provider-apply-removal-design.md | 151 +++++++++++------- 1 file changed, 90 insertions(+), 61 deletions(-) diff --git a/docs/plans/2026-05-17-iac-provider-apply-removal-design.md b/docs/plans/2026-05-17-iac-provider-apply-removal-design.md index 4e57f1d8..51cebdd2 100644 --- a/docs/plans/2026-05-17-iac-provider-apply-removal-design.md +++ b/docs/plans/2026-05-17-iac-provider-apply-removal-design.md @@ -1,23 +1,23 @@ # Design: workflow#699 — IaCProvider.Apply hard-removal (resolves #699) - **Date:** 2026-05-17 -- **Status:** Approved (operator selected Approach D 2026-05-17); revised cycle-1 per adversarial review (2 Critical + 5 Important findings addressed) +- **Status:** Approved (operator selected Approach D 2026-05-17); revised cycle-2 per adversarial review (3 Critical + 5 Important findings addressed); architectural pivot to typed Capabilities-RPC gate - **Issue:** https://github.com/GoCodeAlone/workflow/issues/699 - **Precedent:** ADR 0024 (IaC typed force-cutover), ADR 0025 (IaC optional methods are typed services), Phase 2 (workflow#640) v2 hooks-over-gRPC cascade, Phase 2.5 (workflow#695) IaCProviderFinalizer cascade ## Summary -Hard-delete `IaCProvider.Apply` across workflow + 4 IaC plugins (aws/gcp/azure/DO) + workflow-registry manifests. Eliminates the sentinel-stub runtime-failure surface DO v1.4.0 introduced (the very surface workflow#699 was filed to remove) without introducing a `LegacyApplier` opt-in interface (rejected for YAGNI + ADR 0024 force-cutover precedent). +Hard-delete `IaCProvider.Apply` across workflow + 4 IaC plugins (aws/gcp/azure/DO). Eliminates the sentinel-stub runtime-failure surface DO v1.4.0 introduced (the very surface workflow#699 was filed to remove) without introducing a `LegacyApplier` opt-in interface (rejected for YAGNI + ADR 0024 force-cutover precedent). -**Note on title vs. issue body:** Issue #699 is titled "IaCProvider interface segregation" but its body proposes three candidate designs, all of which converge on removing `Apply` from the required surface. This design picks the most-aggressive variant (Approach D, hard-delete). The work resolves #699's body; the title's "segregation" framing is the project_open_followup_queue label, not a literal ISP-style split. +**Note on title vs. issue body:** Issue #699 is titled "IaCProvider interface segregation" but its body proposes three candidate designs, all of which converge on removing `Apply` from the required surface. This design picks the most-aggressive variant (Approach D, hard-delete). The work resolves #699's body; the title's "segregation" framing is the `project_open_followup_queue` label, not a literal ISP-style split. After this change: - `interfaces.IaCProvider` no longer declares `Apply`. -- `pb.IaCProviderRequired` no longer carries `rpc Apply` (field 6 reserved; method name `Apply` reserved on the service). +- `pb.IaCProviderRequired` no longer carries `rpc Apply`. `message ApplyRequest`, `message ApplyResponse`, AND `message ApplyResult` are deleted (they were only used by the deleted RPC + the deleted wfctl-side `applyResultFromPB` adapter — `interfaces.ApplyResult` is a Go type populated wfctl-side by `iac/wfctlhelpers/apply.go:318` and is unaffected). Field-tag and method-name reservation on the proto service is NOT used (proto3 `reserved` keyword applies only to messages/enums); a CI lint guards against re-introduction. - `cmd/wfctl` has a single apply path (the v2 `wfctlhelpers.ApplyPlanWithHooks` dispatch); the v1 `provider.Apply` branch + the `iac/wfctlhelpers/dispatch.go` version-switch are deleted. - All 4 IaC plugins drop their `Apply` Go method **and** their `iacserver.Apply` gRPC handler. -- The manifest enforcement layer (`cmd/wfctl/deploy_providers.go findIaCPluginDir` switch — the load path that actually drives `wfctl infra apply`, NOT `sdk.ParseManifest`) rejects `iacProvider.computePlanVersion ∈ {"", "v1"}` at parse time. SDK schema tightened in parallel for tooling that uses `ParseManifest`. +- The enforcement gate moves from `plugin.json` parsing (`findIaCPluginDir`'s inline switch) to `discoverAndLoadIaCProvider` at LOAD time, where it calls the plugin's typed `Capabilities` RPC and rejects providers whose `CapabilitiesResponse.compute_plan_version != "v2"`. This eliminates the need to backfill `iacProvider.computePlanVersion` in 4 plugin.jsons + 4 registry manifests (all 4 plugins already populate the typed RPC field, verified A1). ## Context @@ -36,12 +36,16 @@ The sentinel-stub is dead code that exists only because `interfaces.IaCProvider. | Dispatch | Apply call | Plugins on this path | |---|---|---| | v1 | `provider.Apply(ctx, &plan)` (the legacy in-provider loop) | none, since aws/gcp/azure declared v2 in v1.2.x and DO declared v2 in v1.3.0 | -| v2 | `wfctlhelpers.ApplyPlanWithHooks(ctx, provider, &plan, hooks)` → drives `ResourceDriver` per action + `IaCProviderFinalizer.FinalizeApply` (shipped main commit aac519da, workflow#697 / Phase 2.5) for post-loop deferred-update flush | all 4 GoCodeAlone IaC plugins | +| v2 | `wfctlhelpers.ApplyPlanWithHooks(ctx, provider, &plan, hooks)` → drives `ResourceDriver` per action; `iac/wfctlhelpers/apply.go:318` populates `result.Actions` wfctl-side; `IaCProviderFinalizer.FinalizeApply` (shipped main commit aac519da, workflow#697 / Phase 2.5) runs post-loop deferred-update flush; FinalizeApply returns `FinalizeApplyResponse{Errors}` (proto lines 531-557), NOT `ApplyResult` (proto line 334) | all 4 GoCodeAlone IaC plugins | -So `provider.Apply` is unreachable from `wfctl infra apply` for every plugin in the ecosystem. It is dead code: +So `provider.Apply` is unreachable from `wfctl infra apply` for every plugin in the ecosystem. `pb.ApplyResult` + `pb.ApplyResponse` + `pb.ApplyRequest` are referenced only by: -- **DO**: `DOProvider.Apply` returns `ErrApplyV1Removed`; `doIaCServer.Apply` forwards the stub through gRPC; only callable via `wfctl infra apply` if the plugin author forgot to declare v2. -- **aws/gcp/azure**: `.Apply` is a hand-rolled per-action loop (literally the v1 fallback implementation `wfctlhelpers.ApplyPlan` replaced). Unreachable since v1.2.x because all 3 plugins declare ComputePlanVersion=v2 in their `Capabilities` RPC response. +- `cmd/wfctl/iac_typed_adapter.go:366` (`typedIaCAdapter.Apply`) — deleted by this design. +- `cmd/wfctl/iac_typed_adapter.go:1193` (`applyResultFromPB`) — deleted by this design. +- `cmd/wfctl/iac_typed_adapter_test.go:510,542,568,586` — deleted by this design. +- `plugin/external/proto/iac_proto_test.go:26,129,137` — deleted by this design. + +After this design lands, those messages are dead and CAN be deleted from the proto. ### Plugin verification (assumption A1) @@ -50,10 +54,12 @@ Verified by direct grep 2026-05-17 against each plugin repo's main branch (head) | Plugin | Version (tag) | ComputePlanVersion declaration | |---|---|---| | workflow-plugin-aws v1.2.1 | live tag | `internal/iacserver.go:125` → `CapabilitiesResponse{..., ComputePlanVersion: "v2"}` | -| workflow-plugin-gcp v1.2.0 | live tag (head plugin.json shows 1.1.0; sync-plugin-version workflow pending) | `internal/iacserver.go:125` → `ComputePlanVersion: "v2"` | +| workflow-plugin-gcp v1.2.0 | live tag | `internal/iacserver.go:125` → `ComputePlanVersion: "v2"` (head plugin.json shows 1.1.0; tracked under m-NEW-1 followup #TBD for sync-plugin-version workflow gap) | | workflow-plugin-azure v1.2.1 | live tag | `internal/iacserver.go:128` → `ComputePlanVersion: "v2"` | | workflow-plugin-digitalocean v1.4.0 | live tag | `plugin.json` `iacProvider.computePlanVersion: v2` + `internal/iacserver.go:182` Capabilities | +Important: `plugin.json` declarations are inconsistent (only DO has it). The typed RPC declaration is what matters because the typed `CapabilitiesResponse.compute_plan_version` field is populated by all 4 plugins. This design pivots the enforcement gate to the typed field — see A4. + Per ADR 0024 cycle 1 I-5: there are no third-party IaC plugins (no `interfaces.IaCProvider` consumers outside `workflow` + the four plugins above). ## Decision @@ -64,43 +70,52 @@ Rejected alternatives: - **Approach A** — extract `Apply` to a `LegacyApplier` Go-only interface; keep `rpc Apply` in proto. Rejected: proto layer still carries the sentinel-stub surface; sentinel error class survives at the gRPC boundary. Does not satisfy ADR 0024. - **Approach B** — split `rpc Apply` into optional `IaCProviderLegacyApplier` service per ADR 0025 pattern. Rejected for YAGNI per ADR 0024 precedent (force-cutover, no compat shim) and because no v1 plugin exists. Adding the optional service to "preserve the opt-in surface" replicates the sentinel-stub anti-pattern in a different guise. **Soft-add-back available** — Approach B is the documented rollback option (see §Rollback) if a third-party plugin ever surfaces; the ADR 0025 optional-service pattern is the channel through which Apply can be re-introduced without re-opening the bug surface. -- **Approach C** — version the Go interface (`IaCProviderV1` vs `IaCProviderV2`). Rejected: leaves two interfaces named "IaC provider" confusing the SDK + adapter type-asserts; does not touch the proto layer at all (so doesn't fix the bug the issue exists to fix). +- **Approach C** — version the Go interface (`IaCProviderV1` vs `IaCProviderV2`). Rejected: leaves two interfaces named "IaC provider" confusing the SDK + adapter type-asserts; does not touch the proto layer at all. -## Scope (6-PR cascade + registry tail-PR) +## Scope (10-PR cascade — registry tail rolled into each plugin's final PR) -PRs sequenced per Phase 2 / Phase 2.5 precedent (rc workflow tag first so plugins can build against new SDK, plugin rc tags second in parallel, plugin majors third, workflow final fourth, registry last). +PRs sequenced per Phase 2 / Phase 2.5 precedent (rc workflow tag first so plugins can build against new SDK, plugin rc tags second in parallel, plugin majors third with their own registry-manifest bumps inline). ### PR 1 — workflow `feat/699-iac-apply-removal-rc` → tag `v0.56.0-rc1` -**Files modified (in this safe-edit order to avoid intra-PR compile breakage):** +**Files modified (safe-edit order to avoid intra-PR compile breakage):** 1. **Adapter + dispatch helper edits first** (these compile against the OLD proto/interface): - - `cmd/wfctl/iac_typed_adapter.go` — delete `typedIaCAdapter.Apply`, delete `typedIaCAdapter.ComputePlanVersion`, delete the `_ wfctlhelpers.ComputePlanVersionDeclarer = (*typedIaCAdapter)(nil)` interface assertion at `:1348`, delete `ApplyRequest` encoding helpers in this file. - - `cmd/wfctl/infra_apply.go:465-487` and `:1551-1563` — delete the v1 branch + the `usedV2Dispatch` variable (always true); collapse to single `applyV2ApplyPlanWithHooksFn` call. + - `cmd/wfctl/iac_typed_adapter.go:366` — delete `typedIaCAdapter.Apply`; delete `typedIaCAdapter.ComputePlanVersion`; delete `applyResultFromPB` at `:1193`; delete the `_ wfctlhelpers.ComputePlanVersionDeclarer = (*typedIaCAdapter)(nil)` interface assertion at `:1348`. + - `cmd/wfctl/infra_apply.go:465-487` and `:1660-1722` (verified via `grep -n usedV2Dispatch`) — delete the v1 branch + the `usedV2Dispatch` variable (always true); collapse to single `applyV2ApplyPlanWithHooksFn` call. Both sites have identical v1/v2 branch shape; one collapse pattern applies to both. + - `cmd/wfctl/iac_typed_adapter_test.go:510-590` — delete `pb.ApplyResult`-using tests. 2. **Then delete the dispatch helper package**: - `iac/wfctlhelpers/dispatch.go` — delete entire file (`ComputePlanVersionDeclarer`, `DispatchVersionFor`, `DispatchVersionV2`); v2 is the only dispatch path now. -3. **Then tighten the loader gate** (the actual production enforcement point — NOT `sdk.ParseManifest`, since `findIaCPluginDir` uses raw `json.Unmarshal` per its own godoc): - - `cmd/wfctl/deploy_providers.go:162-170` — change the inline `switch m.IaCProvider.ComputePlanVersion` from `case "", "v1", "v2"` to `case "v2"`. Default arm now rejects both empty and `"v1"` with: `plugin %q manifest declares iacProvider.computePlanVersion %q; v0.56.0+ requires "v2" (see workflow#699 — upgrade plugin to v2.0.0 or higher)`. -4. **Then tighten the SDK schema** (defense-in-depth for tooling that uses `ParseManifest`): - - `plugin/sdk/manifest.go` schema — tighten `iacProvider.computePlanVersion` to `enum: ["v2"]` (was `enum: ["v1","v2"]`). +3. **Then move the loader gate from parse-time to load-time** (architectural pivot — see A4): + - `cmd/wfctl/deploy_providers.go` — modify `discoverAndLoadIaCProvider`: + - After typedIaCAdapter is constructed and the plugin handshake completes, immediately call `Capabilities` (or read the cached response from `fetchCapabilities`) and gate on `CapabilitiesResponse.compute_plan_version`. + - Reject with: `plugin %q declares CapabilitiesResponse.compute_plan_version = %q; v0.56.0+ requires "v2" (see workflow#699 — upgrade plugin to v2.0.0 or higher)`. + - `findIaCPluginDir`'s inline switch (`:162-170`) RELAXED back to `case "", "v1", "v2"` (since parse-time enforcement is no longer the gate) — but emit a deprecation log line for `"v1"` / `""` to nudge plugin authors. + - Reason: aws/gcp/azure plugin.json files do not carry `iacProvider.computePlanVersion`; only their typed gRPC response does. Gating at parse time would reject every aws/gcp/azure plugin. The typed RPC is the source-of-truth. +4. **Then loosen the SDK schema** (the inverse of the original plan — schema stays permissive since the load-time gate now does the enforcement): + - `plugin/sdk/manifest.go` schema — leave `iacProvider.computePlanVersion` as `enum: ["v1","v2"]` (no change). Add a docstring note that the SDK schema is the manifest-validation surface only; runtime enforcement is at `discoverAndLoadIaCProvider`. 5. **Then update the typed contract** (last, since this breaks the gRPC service): - - `plugin/external/proto/iac.proto` — delete `rpc Apply(ApplyRequest) returns (ApplyResponse);` from `service IaCProviderRequired`; add `reserved 6;` for the field number; add `reserved "Apply";` for the method name (gRPC reserves both axes). Delete `message ApplyRequest` (the wrapper around `IaCPlan`, no other consumer). KEEP `message ApplyResponse` — it wraps `ApplyResult`, which `FinalizeApply` telemetry still uses (`iac/wfctlhelpers/apply.go` reads `ApplyResult.Actions` populated by `FinalizeApply`). + - `plugin/external/proto/iac.proto` — delete `rpc Apply(ApplyRequest) returns (ApplyResponse);` from `service IaCProviderRequired`; delete `message ApplyRequest`, `message ApplyResponse`, `message ApplyResult`, `message ActionResult` (all dead after step 1). Add a comment to `service IaCProviderRequired`: `// Method "Apply" was removed per workflow#699; do not re-introduce. CI lint guards against re-appearance.` NO `reserved` keyword usage — proto3 `reserved` applies only to messages/enums, not services; field-tag reservation on a service is invalid proto3. - `plugin/external/proto/iac.pb.go`, `plugin/external/proto/iac_grpc.pb.go` — regenerate via `buf generate`. + - `Makefile` (or existing `ci.yaml`) — add lint step `grep -L 'rpc Apply' plugin/external/proto/iac.proto || (echo "workflow#699: rpc Apply re-introduced; see decisions/0024" && exit 1)`. 6. **Then the Go interface**: - `interfaces/iac_provider.go` — delete `Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error)` from the `IaCProvider` interface. 7. **Then the SDK auto-register helper**: - `plugin/external/sdk/iacserver.go` — `RegisterAllIaCProviderServices` type-assert against the trimmed `pb.IaCProviderRequiredServer`; verify the trimmed required service still compiles after `Apply` is removed. 8. **Then tests + stubs**: - `wftest/bdd/strict_iac.go` `iacServiceChecks` — drop the `Apply` row from the IaCProviderRequired check. - - `cmd/wfctl/iac_loader_gate_test.go`, `cmd/wfctl/plugin_audit_iac_test.go`, `cmd/wfctl/plugin_audit.go`, and any test referencing `provider.Apply(...)` — delete the v1 dispatch coverage; the v2 path is the only call site to cover. - - Update the `findIaCPluginDir` test (deploy_providers_test.go) to assert the new error message on `""` and `"v1"`. + - `cmd/wfctl/iac_loader_gate_test.go`, `cmd/wfctl/plugin_audit_iac_test.go`, `cmd/wfctl/plugin_audit.go`, `plugin/external/proto/iac_proto_test.go` — delete the v1 dispatch coverage; add a new load-gate test asserting the new error message on `compute_plan_version = "v1"` and `""`. - `CHANGELOG.md` — entry noting the breaking change + plugin minimum versions. +**Pre-PR-1 verification step:** + +- `grep -rln 'wfctlhelpers.DispatchVersionV2\|wfctlhelpers.ComputePlanVersionDeclarer\|pb.ApplyResult\|pb.ApplyRequest\|pb.ApplyResponse\|applyResultFromPB' --include='*.go' .` MUST return only files this PR modifies. Particularly: clean up `_worktrees/refresh-outputs-tolerate-ghosts/` shadow (stale worktree from older work; either rebase it or delete the worktree before PR 1 lands) — otherwise the worktree's compile breaks. +- `go test ./module/...` to verify `module.PlatformProvider.Apply()` (different interface, A3) is not accidentally affected by proto regen. + **Tests added (PR 1):** -- `findIaCPluginDir` test covering 3 cases: `"v2"` (accept), `""` (reject with actionable error pointing to #699), `"v1"` (reject same shape). -- Manifest-schema test that rejects `iacProvider.computePlanVersion: ""` and `iacProvider.computePlanVersion: "v1"` at `ParseManifest` (defense-in-depth for tooling). -- Integration test that `discoverAndLoadIaCProvider` fails-fast with the actionable error when a plugin declares v1 or omits the field. +- Load-gate test covering 3 cases: plugin returns `Capabilities.compute_plan_version = "v2"` (accept), `""` (reject with actionable error pointing to #699), `"v1"` (reject same shape). +- Test that `discoverAndLoadIaCProvider` fails-fast with the actionable error when Capabilities RPC fails or returns wrong value. **Backwards compat:** none — hard cutover per ADR 0024 precedent. The workflow rc tag is the moment plugins must rebuild against the new SDK; no compat shim. @@ -110,7 +125,7 @@ Each plugin ships a `v2.0.0-rc1` tag against `workflow v0.56.0-rc1` before its ` Each plugin PR: -- Bump `github.com/GoCodeAlone/workflow` pin to `v0.56.0-rc1`. +- Bump `github.com/GoCodeAlone/workflow` pin to `v0.56.0-rc1`. Use SHA-pin (`v0.56.0-rc1` resolves to a commit) so proxy.golang.org indexing isn't blocking; OR use `go.mod replace` to local path for in-CI matrix testing; pick whichever the GoReleaser pipeline already supports. Plugin CI already has `setup-wfctl` action — extend to also pin workflow SDK. - Delete `.Apply` and `IaCServer.Apply` RPC handler. - For DO only: delete `ErrApplyV1Removed` constant + `internal/provider_apply_stub_test.go` (sentinel regression-gate obsolete). - Drop the obsolete v1-Apply coverage in `internal/iacserver_test.go` + provider tests. @@ -122,7 +137,7 @@ Each plugin PR: |---|---| | PR 2 | workflow-plugin-digitalocean → tag `v2.0.0-rc1` | | PR 3 | workflow-plugin-aws → tag `v2.0.0-rc1` | -| PR 4 | workflow-plugin-gcp → tag `v2.0.0-rc1` | +| PR 4 | workflow-plugin-gcp → tag `v2.0.0-rc1`. **Also file followup issue** for the sync-plugin-version workflow gap (m-NEW-1: head plugin.json shows 1.1.0 despite v1.2.0 tag). | | PR 5 | workflow-plugin-azure → tag `v2.0.0-rc1` | ### PR 6 — workflow plugin-conformance gate + final tag `v0.56.0` @@ -130,64 +145,78 @@ Each plugin PR: After all 4 plugin rc1 tags exist: - New CI matrix step in workflow: build each `workflow-plugin-{aws,gcp,azure,digitalocean}@v2.0.0-rc1` against `workflow@v0.56.0-rc1` and run each plugin's iacserver_test smoke. +- Matrix mechanics: each cell uses `GOFLAGS=-mod=mod` + `go.mod replace github.com/GoCodeAlone/workflow => github.com/GoCodeAlone/workflow vX.Y.Z-rc1` to bypass proxy indexing lag; OR `gh release download v0.56.0-rc1` of the workflow rc tarball. Either is fine; pick what the existing GoReleaser conformance gate uses. - On green: tag `workflow v0.56.0` final. - Bump go.mod minimums in any in-repo consumers that need the new wfctl semantics. -### PRs 7-10 — plugin final tags (parallel, fan-out from PR 6) +### PRs 7-10 — plugin final tags + registry manifest bumps (parallel, fan-out from PR 6) -Each plugin bumps SDK pin from `v0.56.0-rc1` → `v0.56.0`, bumps plugin.json to `v2.0.0`, tags `v2.0.0`. +Each plugin's final-tag PR includes the registry manifest update in the same PR (collapses PR 11 into per-plugin scope; cleaner rollback unit since each plugin's registry pin can be reverted independently): -### PR 11 — workflow-registry manifest bump (LAST) +- Bump SDK pin from `v0.56.0-rc1` → `v0.56.0`, bump plugin.json to `v2.0.0`, tag `v2.0.0`. +- Update `workflow-registry/v1/plugins//manifest.json` to `version: 2.0.0`, `minEngineVersion: 0.56.0`. (DO registry pin currently at `1.0.12`; this PR catches DO up from the manifest-derivation lag in the same hop — see I-NEW-3.) -- Update `workflow-registry/v1/plugins/{aws,gcp,azure,digitalocean}/manifest.json` to `version: 2.0.0`, `minEngineVersion: 0.56.0`. -- **Sequenced last** so operators on pre-v0.56.0 wfctl who pull from registry don't get a v2.0.0 plugin they can't run. -- This PR is the rollback-sensitive one (see §Rollback) because the registry is a rolling source-of-truth with no version axis. +| PR | Repo | +|---|---| +| PR 7 | workflow-plugin-digitalocean → tag `v2.0.0` + registry manifest bump (`1.0.12` → `2.0.0`) | +| PR 8 | workflow-plugin-aws → tag `v2.0.0` + registry manifest bump | +| PR 9 | workflow-plugin-gcp → tag `v2.0.0` + registry manifest bump | +| PR 10 | workflow-plugin-azure → tag `v2.0.0` + registry manifest bump | ### Memory + tracker updates - Update `project_open_followup_queue.md`: mark workflow#699 done; cross-ref the v0.56.0 / plugin-v2.0.0 release notes. - Update `MEMORY.md` plugin inventory (versions). - New project memory: `project_workflow_699_apply_removal_shipped.md` (post-merge retro). +- File followup issue for gcp sync-plugin-version gap (PR 4 incidental). ## Assumptions - **A1** — aws/gcp/azure/DO all currently declare ComputePlanVersion=v2 via Capabilities RPC. **Verified 2026-05-17 by direct grep**: aws `internal/iacserver.go:125`, gcp `:125`, azure `:128`, DO `:182`. Pre-PR-1-task-1 re-check predicate: `grep -q '"v2"' internal/iacserver.go` in each plugin repo head. - **A2** — no third-party IaC plugins exist. Per ADR 0024 cycle 1 I-5 grep. Holds because `interfaces.IaCProvider` is a Go interface (not a registry surface) and the engine + the four GoCodeAlone plugin repos are the only consumers. If this assumption is ever wrong, see §Rollback for the soft-add-back path (Approach B). - **A3** — `module.PlatformProvider` is a different interface from `interfaces.IaCProvider`. Verified: `module/platform_provider.go:5` (4 methods including `Apply() (*PlatformResult, error)`, no context arg). `module/pipeline_step_iac.go:208` (`provider.Apply()` no args) confirms it calls `PlatformProvider`, not `IaCProvider`. This file is unaffected by this change. -- **A4** — `cmd/wfctl/deploy_providers.go findIaCPluginDir` switch is the actual production enforcement point for `iacProvider.computePlanVersion`. `sdk.ParseManifest` is NOT used by this loader (per the godoc at line 113-129 of deploy_providers.go and at lines 18-24 of `dispatch.go`). The design therefore tightens BOTH the inline switch (primary enforcement) AND the SDK schema (secondary defense for tooling). -- **A5** — `ApplyResult` + `ActionStatus` + `IaCProviderFinalizer.FinalizeApply` shape stays. Verified in main: commits `aac519da` (Phase 2.5) + `7a855934` (Phase 2.3) shipped FinalizeApply + ActionStatus enums. `iac/wfctlhelpers/apply.go` and `plugin/external/proto/iac.proto` both reference these symbols. This design removes only the `Apply` RPC; `FinalizeApply`, `ApplyResult`, `ApplyResponse`, `ActionStatus` all stay. -- **A6** — proto field-number + method-name reservation is breaking-compatible only in the "old client → new server" direction. Verified by buf-breaking-check semantics. New server (v0.56.0) refuses to expose the reserved field/method; old client (pre-v0.56.0) attempting `rpc Apply` against new server gets `codes.Unimplemented`. Operators must upgrade wfctl + plugins atomically — same constraint as Phase 2. +- **A4** — load-time enforcement via the typed `CapabilitiesResponse.compute_plan_version` is the correct gate (NOT parse-time `findIaCPluginDir` plugin.json switch). Verified: all 4 plugins populate the typed field, only DO populates the plugin.json field. The typed RPC is callable at load time (`typedIaCAdapter.fetchCapabilities` at `iac_typed_adapter.go:315` already does this for `SupportedCanonicalKeys`). +- **A5** — `ApplyResult` + `ActionStatus` + `IaCProviderFinalizer.FinalizeApply` GO-side shapes stay (the proto-side `pb.ApplyResult` is dead and deleted; the Go `interfaces.ApplyResult` is populated wfctl-side by `iac/wfctlhelpers/apply.go:318` and unaffected). Verified in main: commits `aac519da` (Phase 2.5) + `7a855934` (Phase 2.3) shipped FinalizeApply + ActionStatus enums; `FinalizeApply` returns `FinalizeApplyResponse{Errors}` (proto lines 531-557), NOT `ApplyResult`. +- **A6** — wire-format breaking change is accepted per ADR 0024 force-cutover. NO `buf-breaking-check` compat-preserving claim. Operators must upgrade wfctl + plugins atomically — same constraint as Phase 2. The minimum-engine-version field in plugin manifests (`minEngineVersion: 0.56.0`) is the operator-facing gate that surfaces the upgrade requirement. ## Rollback -This change cascades through workflow + 4 plugins + 1 registry repo. Rollback path, in reverse order of the cascade: +This change cascades through workflow + 4 plugins + 4 registry manifest bumps (collapsed into per-plugin PRs 7-10). Rollback path, per-plugin granular: -1. **Revert PR 11 (registry manifest)** FIRST — this is the rolling source-of-truth. Re-publish the previous version pins so `wfctl plugin install` resumes serving the pre-v2.0.0 plugin majors. Registry has no tag axis; rollback is a manifest PR + immediate effect. -2. **Revert PRs 7-10 (plugin v2.0.0 tags)** — re-publish the previous v1.x.x tags as the recommended pins; the registry rollback (step 1) now points operators to those tags. -3. **Revert PR 6 (workflow v0.56.0)** — re-publish v0.55.x as the recommended pin. -4. **Revert PRs 1-5 (rc tags)** — RC tags don't need active revert (operators don't pin to rc), but the workflow rc and plugin rc tags can be left in place or yanked at maintainer discretion. -5. **State-file format invariant** across the cutover — `interfaces.ResourceState` JSON shape unchanged. Operators do not need to migrate state. -6. **Half-rolled-back state window** — between step 1 (registry revert published) and operators actually re-pulling via `wfctl plugin install`, some operators may already have v2.0.0 plugin binaries on disk. These continue to work against v0.56.0 wfctl; the issue is only for operators who downgraded wfctl to v0.55.x in the same window. Document this in the rollback runbook: "If you've already pulled v2.0.0 plugins, either keep v0.56.0 wfctl OR `wfctl plugin install --force` after registry revert." +1. **Per-plugin rollback** — each plugin's PR (7-10) is independently revertable. Reverting `workflow-plugin-X` PR (7-10) restores the registry manifest pin AND the plugin's v1.x tag as the recommended version. Cleaner unit than the previous "PR 11 mega-rollback" — operators on plugin-X get rolled back without affecting plugins Y/Z. +2. **Workflow rollback** — revert PR 6 (`workflow v0.56.0` tag). Re-publish v0.55.x as the recommended pin. ALL plugins must roll back too (because their `minEngineVersion: 0.56.0` blocks them on the rolled-back workflow). This is the "nuclear" rollback path. +3. **RC tag handling** — RC tags (PRs 1-5 rc1) don't need active revert; operators don't pin to rc. RC tags can be left in place or yanked at maintainer discretion. +4. **State-file format invariant** across the cutover — `interfaces.ResourceState` JSON shape unchanged. Operators do not need to migrate state. +5. **Half-rolled-back state window** — between registry-manifest revert (step 1) and operators actually re-pulling via `wfctl plugin install`, some operators may already have v2.0.0 plugin binaries on disk. These continue to work against v0.56.0 wfctl; the issue is only for operators who downgraded wfctl to v0.55.x in the same window. Document in rollback runbook: "If you've already pulled v2.0.0 plugins, either keep v0.56.0 wfctl OR `wfctl plugin install --force` after registry revert." +6. **Rollback floor (DO)** — DO registry manifest currently pins `1.0.12` (4 minors behind live `v1.4.0`). PR 7 explicitly bumps DO registry from `1.0.12` to `2.0.0`, which means rollback floor is `2.0.0` (revert) OR `1.0.12` (delete the bump). Recommend: rollback restores `1.4.0` (the actual live tag), NOT `1.0.12`. PR 7 description must document this floor. 7. **Soft-add-back option (Approach B)** — if the rollback is driven by a third-party plugin surfacing post-cutover, the architectural re-introduction path is Approach B (optional `IaCProviderLegacyApplier` service per ADR 0025), NOT restoring `rpc Apply` on `IaCProviderRequired`. Approach B preserves the compile-time-safety guarantee while letting the third-party plugin opt in. The change is runtime-affecting (proto change, plugin gRPC service surface change), so `runtime-launch-validation` applies: each plugin PR must run iacserver_test (the per-plugin runtime smoke) before merge. PR 6 adds the cross-repo conformance gate. -## Adversarial-review-cycle-1 findings addressed - -Adversarial review (cycle 1, 2026-05-17) flagged 2 Critical + 5 Important + 3 Minor findings. Resolution: - -| Finding | Severity | Resolution | -|---|---|---| -| Schema gate bypass — `findIaCPluginDir` uses raw `json.Unmarshal`, not `sdk.ParseManifest` | Critical | Added explicit file edit to `cmd/wfctl/deploy_providers.go:162-170` switch as the primary enforcement point. SDK schema tightening kept as defense-in-depth. New A4 assumption documents the split-enforcement model. | -| FinalizeApply citation appeared fictional (was reviewer reading pre-rebase tree) | Critical | Re-verified against current main (post-rebase): commits aac519da + 7a855934 shipped Phase 2.5 + Phase 2.3 to main; FinalizeApply lives in `interfaces/`, `plugin/`, `cmd/wfctl/`, `iac/`. A5 now cites specific commits. | -| azure A1 grep not surfaced in original verification | Important | Re-grepped 2026-05-17: azure at `internal/iacserver.go:128` (not :125 like aws/gcp). A1 table updated with per-plugin line numbers + pre-PR-1 re-check predicate. | -| Rollback ignores registry-manifest fan-out | Important | Added PR 11 (registry manifest bump as last cascade step) + §Rollback step 1 (revert registry FIRST) + §Rollback step 6 (half-rolled-back window runbook). | -| Parallel PRs 2-5 race PR 6 | Important | Restructured to 11 PRs: rc tags first (PRs 2-5), conformance gate + workflow final (PR 6), plugin final tags as fan-out (PRs 7-10), registry last (PR 11). | -| ADR-0025 optional-service add-back path not engaged | Important | §Decision Approach B rejection now notes Approach B IS the soft-add-back rollback option per ADR 0025. §Rollback step 7 documents the channel. | -| PR 1 internal edit ordering hazard | Important | PR 1 file list reorganized into 8 numbered safe-edit-order steps. | -| Title vs. issue body mismatch | Minor | Title now reads "(resolves #699)" + §Summary opens with explicit note on title-vs-body. | -| Plugins should ship rc1 tags | Minor | PRs 2-5 now ship `v2.0.0-rc1` before PRs 7-10 ship `v2.0.0`. | -| Reserve method name + field number on service block | Minor | PR 1 file-list step 5 now reserves BOTH field number 6 AND method name `"Apply"` on the service block. | +## Adversarial-review cycle 1 + cycle 2 findings addressed + +| Finding | Cycle | Severity | Resolution | +|---|---|---|---| +| Schema gate bypass — `findIaCPluginDir` raw `json.Unmarshal` | 1 C | Critical | Cycle 2 pivoted: enforcement moved from parse-time (findIaCPluginDir) to load-time (discoverAndLoadIaCProvider via typed Capabilities RPC). See A4. | +| FinalizeApply citation | 1 C | Critical | Cycle 1: re-verified after rebase. Cycle 2 reviewer further found that FinalizeApply does NOT return ApplyResult; corrected A5 + Context. | +| azure A1 grep line ref | 1 I | Important | Per-plugin line numbers in A1 table. | +| Rollback ignores registry fan-out | 1 I | Important | Cycle 2: registry-manifest scope collapsed into per-plugin PRs 7-10. Rollback granular per plugin (step 1). | +| Parallel PRs race PR 6 | 1 I | Important | rc1 protocol for plugins (PRs 2-5) → conformance gate (PR 6) → fan-out (PRs 7-10). | +| Approach B as add-back | 1 I | Important | Decision §Approach B + Rollback step 7. | +| PR 1 edit ordering | 1 I | Important | 8 numbered safe-edit-order steps. | +| Title vs body | 1 M | Minor | Summary note. | +| Plugin rc1 tags | 1 M | Minor | PRs 2-5 ship rc1. | +| Reserve method+field on service | 1 M | Minor | Cycle 2 reviewer flagged this as invalid proto3; replaced with CI lint (PR 1 step 5). | +| C-NEW-1: aws/gcp/azure plugin.json missing field | 2 C | Critical | Pivoted gate to typed Capabilities RPC (A4). plugin.json no longer required to declare field. | +| C-NEW-2: ApplyResult/Response not actually used by FinalizeApply | 2 C | Critical | Deleted `message ApplyRequest/Response/Result` AND `pb.ApplyResult`-using tests + `applyResultFromPB` (PR 1 step 5). Verified by `grep pb.ApplyResult` returning only files this PR modifies. | +| C-NEW-3: `reserved` invalid on proto3 service | 2 C | Critical | Dropped `reserved` syntax. CI lint instead (PR 1 step 5). | +| I-NEW-1: stale line `:1551-1563` | 2 I | Important | Re-verified via grep: actual lines `:467-487` + `:1660-1722`. PR 1 step 1 updated. | +| I-NEW-2: worktree shadow | 2 I | Important | Pre-PR-1 verification step grep added. Stale worktree to be cleaned up before PR 1. | +| I-NEW-3: DO registry pin 1.0.12 vs live 1.4.0 | 2 I | Important | Rollback step 6 documents floor (recommend `1.4.0`, not `1.0.12`). | +| I-NEW-4: buf-breaking-check overstatement | 2 I | Important | A6 rewritten: explicit "no wire-compat preserved, force-cutover per ADR 0024." | +| I-NEW-5: rc-tag matrix mechanics | 2 I | Important | PRs 2-5 mechanics now specify SHA-pin OR `go.mod replace` OR `gh release download`. PR 6 same. | +| m-NEW-1: gcp sync-plugin-version gap | 2 M | Minor | PR 4 files followup issue inline. | +| m-NEW-2: A3 PlatformProvider regression risk | 2 M | Minor | PR 1 verification step adds `go test ./module/...` | ## References @@ -198,4 +227,4 @@ Adversarial review (cycle 1, 2026-05-17) flagged 2 Critical + 5 Important + 3 Mi - Phase 2.5 (workflow#695): merged main commit `aac519da` (IaCProviderFinalizer + OnPlanComplete hook) - Phase 2.3 (workflow#698): merged main commit `7a855934` (ActionStatus compensation enums) - Phase 2.5+ Cleanup Bundle adversarial-design-review cycle-1 C-5 finding (originating context for this issue) -- Cycle-1 adversarial-review of this design (2026-05-17, addressed above) +- Cycle-1 + Cycle-2 adversarial review of this design (2026-05-17, addressed above) From 714ecd3d242ca185f649ca02fa6f2f5c4d92db2e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 13:35:16 -0400 Subject: [PATCH 04/22] design: cycle-3 surgical fixes (3 Crit + 5 Imp); operator escalation --- ...05-17-iac-provider-apply-removal-design.md | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-05-17-iac-provider-apply-removal-design.md b/docs/plans/2026-05-17-iac-provider-apply-removal-design.md index 51cebdd2..9f55a0b3 100644 --- a/docs/plans/2026-05-17-iac-provider-apply-removal-design.md +++ b/docs/plans/2026-05-17-iac-provider-apply-removal-design.md @@ -88,9 +88,9 @@ PRs sequenced per Phase 2 / Phase 2.5 precedent (rc workflow tag first so plugin - `iac/wfctlhelpers/dispatch.go` — delete entire file (`ComputePlanVersionDeclarer`, `DispatchVersionFor`, `DispatchVersionV2`); v2 is the only dispatch path now. 3. **Then move the loader gate from parse-time to load-time** (architectural pivot — see A4): - `cmd/wfctl/deploy_providers.go` — modify `discoverAndLoadIaCProvider`: - - After typedIaCAdapter is constructed and the plugin handshake completes, immediately call `Capabilities` (or read the cached response from `fetchCapabilities`) and gate on `CapabilitiesResponse.compute_plan_version`. - - Reject with: `plugin %q declares CapabilitiesResponse.compute_plan_version = %q; v0.56.0+ requires "v2" (see workflow#699 — upgrade plugin to v2.0.0 or higher)`. - - `findIaCPluginDir`'s inline switch (`:162-170`) RELAXED back to `case "", "v1", "v2"` (since parse-time enforcement is no longer the gate) — but emit a deprecation log line for `"v1"` / `""` to nudge plugin authors. + - After typedIaCAdapter is constructed and the plugin handshake completes, immediately call `Capabilities` with a bounded timeout context (`context.WithTimeout(ctx, 10*time.Second)`) — do NOT use `context.Background()` and do NOT share the load-gate caps fetch with the long-lived `fetchCapabilities` cache (transient failures must not poison the adapter for the entire wfctl invocation; cycle-3 I-NEW-6). + - Gate on `CapabilitiesResponse.compute_plan_version`. Reject with: `plugin %q declares CapabilitiesResponse.compute_plan_version = %q; v0.56.0+ requires "v2" (see workflow#699 — upgrade plugin to v2.0.0 or higher)`. + - `findIaCPluginDir`'s inline switch (`:162-170`) ALREADY accepts `case "", "v1", "v2"`; no change there. Add a deprecation log line emission when the matched manifest declares `"v1"` or empty to nudge plugin authors toward declaring `"v2"` explicitly in plugin.json (defense-in-depth; the gRPC gate is the enforcement). - Reason: aws/gcp/azure plugin.json files do not carry `iacProvider.computePlanVersion`; only their typed gRPC response does. Gating at parse time would reject every aws/gcp/azure plugin. The typed RPC is the source-of-truth. 4. **Then loosen the SDK schema** (the inverse of the original plan — schema stays permissive since the load-time gate now does the enforcement): - `plugin/sdk/manifest.go` schema — leave `iacProvider.computePlanVersion` as `enum: ["v1","v2"]` (no change). Add a docstring note that the SDK schema is the manifest-validation surface only; runtime enforcement is at `discoverAndLoadIaCProvider`. @@ -106,11 +106,18 @@ PRs sequenced per Phase 2 / Phase 2.5 precedent (rc workflow tag first so plugin - `wftest/bdd/strict_iac.go` `iacServiceChecks` — drop the `Apply` row from the IaCProviderRequired check. - `cmd/wfctl/iac_loader_gate_test.go`, `cmd/wfctl/plugin_audit_iac_test.go`, `cmd/wfctl/plugin_audit.go`, `plugin/external/proto/iac_proto_test.go` — delete the v1 dispatch coverage; add a new load-gate test asserting the new error message on `compute_plan_version = "v1"` and `""`. - `CHANGELOG.md` — entry noting the breaking change + plugin minimum versions. +9. **Then delete the migration codemod** (its reason-to-exist evaporates the moment Apply is removed): + - `cmd/iac-codemod/` — delete entire directory (`add_validate_plan.go`, `lint.go`, `main.go`, `refactor_apply.go`, `refactor_plan.go` + tests). The `AssertApplyDelegatesToHelper` analyzer + `refactor-apply` rewriter exist solely to migrate v1 `Apply` impls to v2 `wfctlhelpers.ApplyPlan` delegation; with Apply removed, both are dead tools. + +**Edit-list correction (per cycle-3 C-NEW-5):** the two `usedV2Dispatch` collapse sites in `cmd/wfctl/infra_apply.go` live in TWO different functions: the primary `runInfraApply` (~`:465-540`) and `applyPrecomputedPlanWithStore` (~`:1600-1730`, function declared at `:1604`). Both must be edited with the same collapse pattern; do NOT assume a single edit suffices. Verify via `grep -n usedV2Dispatch cmd/wfctl/infra_apply.go` BEFORE and AFTER the edit — the count must go from 5 (467, 472, 536, 1662, 1664, 1711) to 0. **Pre-PR-1 verification step:** -- `grep -rln 'wfctlhelpers.DispatchVersionV2\|wfctlhelpers.ComputePlanVersionDeclarer\|pb.ApplyResult\|pb.ApplyRequest\|pb.ApplyResponse\|applyResultFromPB' --include='*.go' .` MUST return only files this PR modifies. Particularly: clean up `_worktrees/refresh-outputs-tolerate-ghosts/` shadow (stale worktree from older work; either rebase it or delete the worktree before PR 1 lands) — otherwise the worktree's compile breaks. +- `grep -rln 'wfctlhelpers.DispatchVersionV2\|wfctlhelpers.ComputePlanVersionDeclarer\|pb.ApplyResult\|pb.ApplyRequest\|pb.ApplyResponse\|applyResultFromPB' --include='*.go' .` MUST return only files this PR modifies. Particularly: clean up any stale `_worktrees/*` worktrees that still reference deleted symbols (cycle-3 reviewer found `_worktrees/wf663-topo`, `_worktrees/phase-b-core-deletion`, `_worktrees/phase2.5-cleanup` had old Apply references; rebase or delete each before PR 1 lands — otherwise the worktree's compile breaks). - `go test ./module/...` to verify `module.PlatformProvider.Apply()` (different interface, A3) is not accidentally affected by proto regen. +- `go build ./... && go vet ./...` pre-merge gate (covers `interfaces.IaCProvider` interface change + every consumer; the targeted `./module/...` test alone is insufficient because the interface change ripples across `cmd/wfctl/`, `iac/wfctlhelpers/`, `plugin/external/sdk/`, `wftest/bdd/`). +- `grep -L 'rpc Apply' plugin/external/proto/iac.proto || exit 1` lint check added to CI (workflow#699 re-introduction guard). +- For each `workflow-plugin-{aws,gcp,azure,digitalocean}`: `grep -l 'applyResultToPB\|applyResultFromPB' internal/iacserver.go` — confirm helpers are dead and delete them in PRs 2-5 along with `iacserver.Apply` handler. **Tests added (PR 1):** @@ -188,7 +195,7 @@ This change cascades through workflow + 4 plugins + 4 registry manifest bumps (c 3. **RC tag handling** — RC tags (PRs 1-5 rc1) don't need active revert; operators don't pin to rc. RC tags can be left in place or yanked at maintainer discretion. 4. **State-file format invariant** across the cutover — `interfaces.ResourceState` JSON shape unchanged. Operators do not need to migrate state. 5. **Half-rolled-back state window** — between registry-manifest revert (step 1) and operators actually re-pulling via `wfctl plugin install`, some operators may already have v2.0.0 plugin binaries on disk. These continue to work against v0.56.0 wfctl; the issue is only for operators who downgraded wfctl to v0.55.x in the same window. Document in rollback runbook: "If you've already pulled v2.0.0 plugins, either keep v0.56.0 wfctl OR `wfctl plugin install --force` after registry revert." -6. **Rollback floor (DO)** — DO registry manifest currently pins `1.0.12` (4 minors behind live `v1.4.0`). PR 7 explicitly bumps DO registry from `1.0.12` to `2.0.0`, which means rollback floor is `2.0.0` (revert) OR `1.0.12` (delete the bump). Recommend: rollback restores `1.4.0` (the actual live tag), NOT `1.0.12`. PR 7 description must document this floor. +6. **Rollback floor (all 4 plugins)** — registry manifests are stale relative to live tags across the board (per cycle-3 I-NEW-7 inspection 2026-05-17): aws `0.1.2`, gcp `0.1.3`, azure `0.1.2`, DO `1.0.12`. PRs 7-10 each bump from these stale pins straight to `2.0.0` — an aggressive jump for aws/gcp/azure (`0.1.x` → `2.0.0`). Rollback restores LIVE tags, not registry pins: aws → `v1.2.1`, gcp → `v1.2.0`, azure → `v1.2.1`, DO → `v1.4.0`. Each PR (7-10) description must document its specific rollback floor. The pre-existing manifest-derivation lag is tracked under the "catalog manifest-derivation refactor" followup queue item but cannot be cleanly separated from this cascade because the same registry PRs need to land regardless. 7. **Soft-add-back option (Approach B)** — if the rollback is driven by a third-party plugin surfacing post-cutover, the architectural re-introduction path is Approach B (optional `IaCProviderLegacyApplier` service per ADR 0025), NOT restoring `rpc Apply` on `IaCProviderRequired`. Approach B preserves the compile-time-safety guarantee while letting the third-party plugin opt in. The change is runtime-affecting (proto change, plugin gRPC service surface change), so `runtime-launch-validation` applies: each plugin PR must run iacserver_test (the per-plugin runtime smoke) before merge. PR 6 adds the cross-repo conformance gate. @@ -218,13 +225,31 @@ The change is runtime-affecting (proto change, plugin gRPC service surface chang | m-NEW-1: gcp sync-plugin-version gap | 2 M | Minor | PR 4 files followup issue inline. | | m-NEW-2: A3 PlatformProvider regression risk | 2 M | Minor | PR 1 verification step adds `go test ./module/...` | +## Cycle-3 adversarial-review findings (surgically fixed, max-cycles reached) + +Per `adversarial-design-review` skill, 2 revision cycles is the cap. Cycle 3 surfaced 3 narrowly-scoped Critical findings + 5 Important; all 3 Critical fixes are typo-class edits applied directly in this revision (cycle-3 reviewer's own recommendation: "These are surgical: add `cmd/iac-codemod` deletion to PR 1 step 9, name `applyPrecomputedPlanWithStore` explicitly in PR 1 step 1, add `go build ./... && go vet ./...` to pre-PR-1 gate. These are typo-class edits to the design doc; they do not require another full cycle."). Operator escalation summary: + +| Finding | Severity | Resolution | +|---|---|---| +| C-NEW-4: `cmd/iac-codemod` graveyard | Critical | PR 1 step 9 added: delete entire directory. | +| C-NEW-5: `applyPrecomputedPlanWithStore` not explicit in PR 1 step 1 | Critical | PR 1 step 1 edit-list correction names both call sites + grep predicate (5→0 `usedV2Dispatch` count). | +| C-NEW-6: missing `go build` + `go vet` pre-merge gate | Critical | Pre-PR-1 verification step now requires `go build ./... && go vet ./...`. | +| I-NEW-6: Capabilities-RPC timeout / cache poisoning | Important | PR 1 step 3 mandates `context.WithTimeout(ctx, 10s)` + separate cache for load-gate path. | +| I-NEW-7: aws/gcp/azure registry floors (not just DO) | Important | Rollback step 6 covers all 4 plugins with per-plugin live-tag floors. | +| I-NEW-8: misleading "RELAXED back" language | Important | Rewritten: `findIaCPluginDir` ALREADY accepts `"", "v1", "v2"`; design adds deprecation log only. | +| I-NEW-9: `applyResultToPB` in plugin iacservers | Important | Pre-PR-1 grep predicate added; PRs 2-5 must delete the helpers. | +| I-NEW-10: bare `#TBD` for gcp followup | Important | Will file the gcp sync-plugin-version followup issue BEFORE PR 1 lands, then patch the number. | + +Operator acceptance request (per `adversarial-design-review` skill): all 3 Critical findings have surgical fixes incorporated above (typo-class). 5 Important findings have concrete fixes incorporated. The design is committed; no further adversarial cycles are budgeted. + ## References - Issue: https://github.com/GoCodeAlone/workflow/issues/699 - ADR 0024 (force-cutover precedent): `decisions/0024-iac-typed-force-cutover.md` - ADR 0025 (optional services as typed services, not flags): `decisions/0025-iac-optional-method-typed-services-not-bool.md` +- ADR 0029 (capability extension — canonical_keys + compute_plan_version): `decisions/0029-capability-extension-canonical-keys-and-compute-plan-version.md` - Phase 2 (workflow#640): `docs/plans/2026-05-10-strict-contracts-force-cutover.md` + memory `project_v2_lifecycle_phase2_shipped.md` - Phase 2.5 (workflow#695): merged main commit `aac519da` (IaCProviderFinalizer + OnPlanComplete hook) - Phase 2.3 (workflow#698): merged main commit `7a855934` (ActionStatus compensation enums) - Phase 2.5+ Cleanup Bundle adversarial-design-review cycle-1 C-5 finding (originating context for this issue) -- Cycle-1 + Cycle-2 adversarial review of this design (2026-05-17, addressed above) +- Cycle-1 + Cycle-2 + Cycle-3 adversarial review of this design (2026-05-17, all addressed above; Cycle-3 was the final allowed cycle per skill bound) From a9a6d7853ad100324070fc5e77d5d8b0ece6f0c4 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 14:10:58 -0400 Subject: [PATCH 05/22] plan: workflow#699 IaCProvider.Apply removal (10 PRs / 36 tasks) --- .../2026-05-17-iac-provider-apply-removal.md | 1127 +++++++++++++++++ 1 file changed, 1127 insertions(+) create mode 100644 docs/plans/2026-05-17-iac-provider-apply-removal.md diff --git a/docs/plans/2026-05-17-iac-provider-apply-removal.md b/docs/plans/2026-05-17-iac-provider-apply-removal.md new file mode 100644 index 00000000..ff9c2790 --- /dev/null +++ b/docs/plans/2026-05-17-iac-provider-apply-removal.md @@ -0,0 +1,1127 @@ +# IaCProvider.Apply Hard-Removal Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Hard-delete `IaCProvider.Apply` across workflow + 4 IaC plugins (aws/gcp/azure/DO) + 4 registry manifests, eliminating the sentinel-stub runtime-failure surface DO v1.4.0 introduced. + +**Architecture:** 10-PR coordinated cascade. PR 1 ships workflow `v0.56.0-rc1` (proto deletion + interface deletion + load-time Capabilities-RPC gate). PRs 2-5 ship plugin `v2.0.0-rc1` tags in parallel (drop Apply method + iacserver handler). PR 6 runs conformance matrix + tags `workflow v0.56.0`. PRs 7-10 ship plugin `v2.0.0` final tags + registry manifest bumps as fan-out from PR 6. + +**Tech Stack:** Go 1.24, gRPC (buf for proto), GoReleaser v2, GoCodeAlone/modular framework. No new dependencies introduced. + +**Base branch:** `main` (per-PR feature branches: `feat/699-*`) + +--- + +## Scope Manifest + +**PR Count:** 10 +**Tasks:** 36 +**Estimated Lines of Change:** ~1500 deletions, ~200 additions (mostly deletions; the design is force-cutover cleanup) + +**Out of scope:** +- Approach B (optional `IaCProviderLegacyApplier` service) — documented as soft-add-back rollback only, NOT shipped here. +- General registry manifest-derivation refactor (the existing 4 stale registry pins are caught up in PRs 7-10 incidentally; the larger derivation refactor remains a separate followup queue item). +- Other IaC interface segregation work (e.g., extracting `BootstrapStateBackend` to its own optional service) — scope-locked OUT. +- Engine-side compensation auto-attempt (Phase 2.4 deferred candidate per project_open_followup_queue.md) — separate cascade. +- Module/Step/Trigger interface changes — unchanged; this is IaC-only per ADR 0024. + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | workflow: IaCProvider.Apply removal + Capabilities-RPC load gate (rc1) | 1, 2, 3, 4, 5, 6, 7, 8, 9 | feat/699-iac-apply-removal-rc | +| 2 | workflow-plugin-digitalocean: drop Apply (v2.0.0-rc1) | 10, 11, 12 | feat/699-drop-apply | +| 3 | workflow-plugin-aws: drop Apply (v2.0.0-rc1) | 13, 14, 15 | feat/699-drop-apply | +| 4 | workflow-plugin-gcp: drop Apply (v2.0.0-rc1) | 16, 17, 18, 19 | feat/699-drop-apply | +| 5 | workflow-plugin-azure: drop Apply (v2.0.0-rc1) | 20, 21, 22 | feat/699-drop-apply | +| 6 | workflow: plugin conformance matrix + final v0.56.0 tag | 23, 24, 25 | feat/699-conformance-final | +| 7 | workflow-plugin-digitalocean: final v2.0.0 + registry manifest | 26, 27, 28 | feat/699-final | +| 8 | workflow-plugin-aws: final v2.0.0 + registry manifest | 29, 30, 31 | feat/699-final | +| 9 | workflow-plugin-gcp: final v2.0.0 + registry manifest | 32, 33, 34 | feat/699-final | +| 10 | workflow-plugin-azure: final v2.0.0 + registry manifest | 35, 36 | feat/699-final | + +**Status:** Draft + +--- + +## Cross-cutting prerequisites + +Before PR 1 starts, the executing agent MUST: + +1. **File gcp sync-plugin-version followup issue.** `gh issue create -R GoCodeAlone/workflow-plugin-gcp --title "sync-plugin-version workflow: plugin.json (1.1.0) lags live tag (v1.2.0)" --body "Discovered during workflow#699 cascade. plugin.json on main shows version: 1.1.0 but live tag is v1.2.0. The sync-plugin-version GitHub Action should bump plugin.json on tag — verify wiring."`. Capture issue number; patch into the design doc + PR 4 description as `m-NEW-1 followup`. + +2. **Clean up stale worktrees that reference deleted symbols.** Run from `/Users/jon/workspace/workflow`: `grep -rln 'wfctlhelpers.DispatchVersionV2\|wfctlhelpers.ComputePlanVersionDeclarer\|pb.ApplyResult\|pb.ApplyRequest\|pb.ApplyResponse\|applyResultFromPB' --include='*.go' _worktrees/ .claude/worktrees/`. For each matching worktree: rebase onto post-PR-1 main OR `git worktree remove --force ` if abandoned. Cycle-3 reviewer identified `_worktrees/wf663-topo`, `_worktrees/phase-b-core-deletion`, `_worktrees/phase2.5-cleanup` as likely candidates but the grep is the source of truth. + +3. **Verify A1 hasn't drifted.** For each `workflow-plugin-{aws,gcp,azure,digitalocean}`: `grep -q '"v2"' internal/iacserver.go` MUST exit 0. If any plugin no longer declares `ComputePlanVersion: "v2"` in its Capabilities, halt the cascade — the design assumes all 4 are v2. + +--- + +## PR 1 — workflow rc1 + +**Branch:** `feat/699-iac-apply-removal-rc` +**Final tag:** `v0.56.0-rc1` + +### Task 1: Delete v1 dispatch branches in cmd/wfctl/infra_apply.go (both call sites) + +**Files:** +- Modify: `cmd/wfctl/infra_apply.go:465-540` (function `runInfraApply`) +- Modify: `cmd/wfctl/infra_apply.go:1660-1730` (function `applyPrecomputedPlanWithStore`, declared at `:1604`) + +**Step 1: Verify both call sites exist BEFORE editing** + +```bash +grep -n usedV2Dispatch cmd/wfctl/infra_apply.go +``` + +Expected: 5 lines (467, 472, 536, 1662, 1664, 1711) — both functions have the v1/v2 dispatch fork. + +**Step 2: Write the failing test for runInfraApply collapsed path** + +`cmd/wfctl/infra_apply_v2_only_test.go` (new file): + +```go +package wfctl + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestInfraApply_V2OnlyDispatch_NoV1Branch asserts runInfraApply collapses +// to a single v2-only dispatch after workflow#699 removes provider.Apply. +// The presence of any conditional branch on a v1-vs-v2 selector is a +// regression: per ADR 0024, v2 is the only supported dispatch. +func TestInfraApply_V2OnlyDispatch_NoV1Branch(t *testing.T) { + t.Run("collapses dispatch when typedIaCAdapter declares no ComputePlanVersion method", func(t *testing.T) { + // stub provider satisfies the trimmed interfaces.IaCProvider + // (no Apply method) and has no ComputePlanVersion declarer. + // runInfraApply MUST route through wfctlhelpers.ApplyPlanWithHooks + // and MUST NOT type-assert against a v1 dispatch. + var p interfaces.IaCProvider = &stubV2OnlyProvider{} + if _, ok := p.(interface{ Apply(context.Context, *interfaces.IaCPlan) (*interfaces.ApplyResult, error) }); ok { + t.Fatalf("provider unexpectedly satisfies legacy Apply interface") + } + }) +} + +type stubV2OnlyProvider struct{} + +func (*stubV2OnlyProvider) Name() string { return "stub" } +func (*stubV2OnlyProvider) Version() string { return "0.0.0" } +func (*stubV2OnlyProvider) Initialize(context.Context, map[string]any) error { return nil } +func (*stubV2OnlyProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil } +func (*stubV2OnlyProvider) Plan(context.Context, []interfaces.ResourceSpec, []interfaces.ResourceState) (*interfaces.IaCPlan, error) { return nil, nil } +func (*stubV2OnlyProvider) Destroy(context.Context, []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { return nil, nil } +func (*stubV2OnlyProvider) Status(context.Context, []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { return nil, nil } +func (*stubV2OnlyProvider) DetectDrift(context.Context, []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { return nil, nil } +func (*stubV2OnlyProvider) Import(context.Context, string, string) (*interfaces.ResourceState, error) { return nil, nil } +func (*stubV2OnlyProvider) ResolveSizing(string, interfaces.Size, *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { return nil, nil } +func (*stubV2OnlyProvider) ResourceDriver(string) (interfaces.ResourceDriver, error) { return nil, nil } +func (*stubV2OnlyProvider) SupportedCanonicalKeys() []string { return nil } +func (*stubV2OnlyProvider) BootstrapStateBackend(context.Context, map[string]any) (*interfaces.BootstrapResult, error) { return nil, nil } +func (*stubV2OnlyProvider) Close() error { return nil } +``` + +**Step 3: Run test — expect compile fail (stub has Apply removed but interface still has it)** + +```bash +go test -run TestInfraApply_V2OnlyDispatch_NoV1Branch ./cmd/wfctl/ +``` + +Expected: compile error — `*stubV2OnlyProvider does not implement interfaces.IaCProvider (missing method Apply)`. This proves the interface still declares Apply. + +**Step 4: Edit runInfraApply (lines 465-540) — collapse v1/v2 branch** + +In `cmd/wfctl/infra_apply.go`, replace the block at lines 465-487 (the `if wfctlhelpers.DispatchVersionFor(provider) == wfctlhelpers.DispatchVersionV2 { ... } else { result, err = provider.Apply(ctx, &plan) }` block) with: + +```go +// v2 is the only supported dispatch per ADR 0024 + workflow#699. +hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, hydratedOut) +result, err := applyV2ApplyPlanWithHooksFn(ctx, provider, &plan, hooks) +if result != nil { + printDriftReportIfAny(w, result) +} +``` + +Remove the `usedV2Dispatch` variable declaration and any references in the surrounding error/result handling at line 536 (replace `if usedV2Dispatch { ... }` with the body unconditionally). + +**Step 5: Edit applyPrecomputedPlanWithStore (lines 1660-1730) — same collapse** + +Apply the identical collapse pattern to the block at lines 1660-1722. Remove `usedV2Dispatch` variable at `:1662`, conditional at `:1711`. + +**Step 6: Verify both collapses removed all 5 `usedV2Dispatch` references** + +```bash +grep -c usedV2Dispatch cmd/wfctl/infra_apply.go +``` + +Expected: `0` + +**Step 7: Commit** + +```bash +git add cmd/wfctl/infra_apply.go cmd/wfctl/infra_apply_v2_only_test.go +git commit -m "feat(wfctl): collapse v1/v2 apply dispatch to v2-only (workflow#699 PR 1 task 1)" +``` + +**Rollback:** revert commit → both v1 dispatch branches restored. + +--- + +### Task 2: Delete typedIaCAdapter.Apply + ComputePlanVersion + applyResultFromPB + ApplyRequest encoding + +**Files:** +- Modify: `cmd/wfctl/iac_typed_adapter.go:345-355` (`Apply` method) +- Modify: `cmd/wfctl/iac_typed_adapter.go:447-461` (`ComputePlanVersion` method) +- Modify: `cmd/wfctl/iac_typed_adapter.go:1193-1290` (`applyResultFromPB` function + helpers) +- Modify: `cmd/wfctl/iac_typed_adapter.go:1340-1350` (`_ wfctlhelpers.ComputePlanVersionDeclarer = (*typedIaCAdapter)(nil)` interface assertion) +- Modify: `cmd/wfctl/iac_typed_adapter_test.go:500-600` (tests that reference `pb.ApplyResult` / `pb.ApplyResponse` / `pb.ApplyRequest`) + +**Step 1: Verify symbols exist** + +```bash +grep -n 'func (a \*typedIaCAdapter) Apply\b\|func (a \*typedIaCAdapter) ComputePlanVersion\|func applyResultFromPB\|_ wfctlhelpers.ComputePlanVersionDeclarer' cmd/wfctl/iac_typed_adapter.go +``` + +Expected: 4 line matches (one per symbol above). + +**Step 2: Delete methods + helper + interface assertion** + +Open `cmd/wfctl/iac_typed_adapter.go` and delete the four blocks. Preserve all other adapter methods (Plan, Destroy, Status, etc.). + +Open `cmd/wfctl/iac_typed_adapter_test.go` and delete tests that import `pb.ApplyResult`, `pb.ApplyResponse`, or `pb.ApplyRequest` (lines ~510-600 per cycle-3 inventory). + +**Step 3: Run build (expect failure — `wfctlhelpers.ComputePlanVersionDeclarer` is still referenced)** + +```bash +go build ./cmd/wfctl/ +``` + +Expected: compile error — `undefined: wfctlhelpers.ComputePlanVersionDeclarer` (because the type assertion at `:1348` is deleted but the import isn't reachable yet from Task 3). + +**Step 4: (Defer build verification to after Task 3.)** + +Move on. + +**Step 5: Commit (compile may not be green yet — sequential PR within single branch)** + +```bash +git add cmd/wfctl/iac_typed_adapter.go cmd/wfctl/iac_typed_adapter_test.go +git commit -m "feat(wfctl): delete typedIaCAdapter.Apply + ComputePlanVersion + applyResultFromPB (workflow#699 PR 1 task 2)" +``` + +**Rollback:** revert commit → adapter Apply restored. + +--- + +### Task 3: Delete iac/wfctlhelpers/dispatch.go + +**Files:** +- Delete: `iac/wfctlhelpers/dispatch.go` +- Delete: `iac/wfctlhelpers/dispatch_test.go` (if exists) + +**Step 1: Verify nothing else imports the deleted symbols** + +```bash +grep -rn 'wfctlhelpers.DispatchVersionV2\|wfctlhelpers.DispatchVersionFor\|wfctlhelpers.ComputePlanVersionDeclarer' --include='*.go' . | grep -v _worktrees | grep -v .claude/worktrees +``` + +Expected: 0 lines (Tasks 1-2 already removed the only consumers; cross-cutting prereq #2 cleaned worktrees). + +**Step 2: Delete the files** + +```bash +git rm iac/wfctlhelpers/dispatch.go iac/wfctlhelpers/dispatch_test.go 2>/dev/null || git rm iac/wfctlhelpers/dispatch.go +``` + +**Step 3: Run build** + +```bash +go build ./... +``` + +Expected: green (no remaining references to deleted symbols). + +**Step 4: Commit** + +```bash +git add -u +git commit -m "feat(iac): delete wfctlhelpers/dispatch.go — v2 is sole dispatch path (workflow#699 PR 1 task 3)" +``` + +**Rollback:** revert commit → dispatch helpers restored. + +--- + +### Task 4: Add load-time Capabilities-RPC gate in discoverAndLoadIaCProvider + +**Files:** +- Modify: `cmd/wfctl/deploy_providers.go:192-250` (function `discoverAndLoadIaCProvider`) +- Modify: `cmd/wfctl/deploy_providers.go:162-170` (function `findIaCPluginDir` switch — add deprecation log) +- Create: `cmd/wfctl/deploy_providers_load_gate_test.go` + +**Step 1: Write failing test for the load gate** + +`cmd/wfctl/deploy_providers_load_gate_test.go`: + +```go +package wfctl + +import ( + "strings" + "testing" +) + +// TestDiscoverAndLoadIaCProvider_LoadGate_RejectsV1 asserts a plugin that +// returns CapabilitiesResponse.ComputePlanVersion="v1" (or empty) is +// rejected at load time with an actionable error pointing to workflow#699. +func TestDiscoverAndLoadIaCProvider_LoadGate_RejectsV1(t *testing.T) { + cases := []struct { + name string + cpv string + wantInErr string + }{ + {name: "empty", cpv: "", wantInErr: "workflow#699"}, + {name: "v1", cpv: "v1", wantInErr: "workflow#699"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := verifyComputePlanVersionV2(tc.cpv, "plugin-x") + if err == nil { + t.Fatalf("expected reject for cpv=%q; got nil", tc.cpv) + } + if !strings.Contains(err.Error(), tc.wantInErr) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantInErr) + } + }) + } +} + +// TestDiscoverAndLoadIaCProvider_LoadGate_AcceptsV2 — happy path. +func TestDiscoverAndLoadIaCProvider_LoadGate_AcceptsV2(t *testing.T) { + if err := verifyComputePlanVersionV2("v2", "plugin-x"); err != nil { + t.Fatalf("expected accept for cpv=v2; got %v", err) + } +} +``` + +**Step 2: Run test — expect FAIL (`verifyComputePlanVersionV2` undefined)** + +```bash +go test -run TestDiscoverAndLoadIaCProvider_LoadGate ./cmd/wfctl/ +``` + +Expected: FAIL — `undefined: verifyComputePlanVersionV2`. + +**Step 3: Implement `verifyComputePlanVersionV2` helper + wire into `discoverAndLoadIaCProvider`** + +Append to `cmd/wfctl/deploy_providers.go`: + +```go +// verifyComputePlanVersionV2 rejects a plugin whose +// CapabilitiesResponse.compute_plan_version is not "v2". Called from +// discoverAndLoadIaCProvider after the typed adapter handshake; the +// rejection error is operator-facing — it MUST name the plugin and +// point at workflow#699. +func verifyComputePlanVersionV2(cpv, pluginName string) error { + if cpv == "v2" { + return nil + } + return fmt.Errorf( + "plugin %q declares CapabilitiesResponse.compute_plan_version = %q; "+ + "workflow v0.56.0+ requires \"v2\" (see workflow#699 — upgrade plugin to v2.0.0 or higher)", + pluginName, cpv, + ) +} +``` + +Modify `discoverAndLoadIaCProvider` (locate the line right after `typedIaCAdapter` is constructed and before it is returned). Add: + +```go +// Per workflow#699: gate provider load on the typed +// CapabilitiesResponse.compute_plan_version field. The 10s timeout +// bounds a hung plugin handshake; the call is NOT shared with the +// long-lived fetchCapabilities cache (transient failures must not +// poison the adapter for the entire invocation). +capsCtx, capsCancel := context.WithTimeout(ctx, 10*time.Second) +defer capsCancel() +capsResp, capsErr := adapter.required.Capabilities(capsCtx, &pb.CapabilitiesRequest{}) +if capsErr != nil { + return nil, nil, fmt.Errorf("plugin %q: Capabilities RPC failed: %w (see workflow#699)", pluginName, capsErr) +} +if err := verifyComputePlanVersionV2(capsResp.GetComputePlanVersion(), pluginName); err != nil { + return nil, nil, err +} +``` + +Modify `findIaCPluginDir` switch at `:162-170` — leave the `case "", "v1", "v2"` accept clause unchanged. Add inside the `case "", "v1":` arm (split it from `v2`): + +```go +case "": + log.Printf("plugin %q: deprecation — manifest iacProvider.computePlanVersion is empty; declare \"v2\" explicitly (workflow#699)", pluginName) +case "v1": + log.Printf("plugin %q: deprecation — manifest iacProvider.computePlanVersion=\"v1\"; load-time gate will reject this (workflow#699)", pluginName) +case "v2": + // accept +default: + return "", "", false, fmt.Errorf(...) // unchanged +``` + +**Step 4: Run test** + +```bash +go test -run TestDiscoverAndLoadIaCProvider_LoadGate ./cmd/wfctl/ -v +``` + +Expected: PASS on all 3 sub-tests. + +**Step 5: Commit** + +```bash +git add cmd/wfctl/deploy_providers.go cmd/wfctl/deploy_providers_load_gate_test.go +git commit -m "feat(wfctl): load-time Capabilities-RPC gate enforces ComputePlanVersion=v2 (workflow#699 PR 1 task 4)" +``` + +**Rollback:** revert commit → no load-time gate; the deleted v1 dispatch from Task 1 leaves no enforcement, so this rollback MUST also revert Task 1. + +--- + +### Task 5: Delete rpc Apply from iac.proto + delete dead messages + regenerate + +**Files:** +- Modify: `plugin/external/proto/iac.proto:34` (delete `rpc Apply` line; add removal comment) +- Modify: `plugin/external/proto/iac.proto:330-410` (delete `message ApplyRequest`, `message ApplyResponse`, `message ApplyResult`, `message ActionResult`) +- Modify: `plugin/external/proto/iac.pb.go` (regenerated) +- Modify: `plugin/external/proto/iac_grpc.pb.go` (regenerated) +- Modify: `Makefile` (add lint step) + +**Step 1: Inspect the proto messages slated for deletion** + +```bash +grep -n 'message ApplyRequest\|message ApplyResponse\|message ApplyResult\|message ActionResult\|rpc Apply' plugin/external/proto/iac.proto +``` + +Expected: 5 line matches. + +**Step 2: Edit iac.proto** + +In `plugin/external/proto/iac.proto`, delete `rpc Apply(ApplyRequest) returns (ApplyResponse);` (line 34). Add right above the `service IaCProviderRequired` definition (or as the last line inside it): + +``` + // Method "Apply" was removed per workflow#699 (2026-05-17); v2 dispatch + // routes through ResourceDriver per-action + IaCProviderFinalizer. + // Do not re-introduce. CI lint guards re-appearance (see Makefile). +``` + +Delete the four message blocks (`ApplyRequest`, `ApplyResponse`, `ApplyResult`, `ActionResult`). Each is contiguous; the design's PR 1 step 5 enumerates them. + +**Step 3: Regenerate proto** + +```bash +cd plugin/external/proto && buf generate && cd - +``` + +If `buf` is not installed: install per the existing project setup (see `CONTRIBUTING.md` or `Makefile` target `proto-gen`). + +Expected: `iac.pb.go` and `iac_grpc.pb.go` shrink (no `ApplyRequest`/`Response`/`Result`/`ActionResult` Go types; no `Apply` method on the `IaCProviderRequiredClient` interface). + +**Step 4: Add Makefile lint step** + +In `Makefile`, find the `lint:` (or `ci:`) target and append: + +```makefile + @grep -q 'rpc Apply' plugin/external/proto/iac.proto && (echo "workflow#699: rpc Apply re-introduced in iac.proto; see decisions/0024-iac-typed-force-cutover.md" && exit 1) || echo "workflow#699 guard: rpc Apply correctly absent" +``` + +**Step 5: Run build (expect FAIL — interface still has Apply, plugins haven't dropped their handlers yet)** + +```bash +go build ./... +``` + +Expected: compile errors in `plugin/external/sdk/iacserver.go` (type-assert references `pb.IaCProviderRequiredServer.Apply` no longer exists). Tasks 6+7 fix. + +**Step 6: Commit** + +```bash +git add plugin/external/proto/iac.proto plugin/external/proto/iac.pb.go plugin/external/proto/iac_grpc.pb.go Makefile +git commit -m "feat(proto): delete rpc Apply + ApplyRequest/Response/Result/ActionResult; CI lint guard (workflow#699 PR 1 task 5)" +``` + +**Rollback:** revert commit → proto restored; CI lint removed. + +--- + +### Task 6: Delete Apply from interfaces.IaCProvider + +**Files:** +- Modify: `interfaces/iac_provider.go:17` (delete `Apply(...)` line + its comment if standalone) + +**Step 1: Edit interfaces/iac_provider.go** + +Delete the line: + +```go +Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) +``` + +from the `IaCProvider` interface. Leave `Plan`, `Destroy`, and the rest unchanged. + +**Step 2: Run build** + +```bash +go build ./interfaces/ ./iac/... ./plugin/... ./cmd/wfctl/ +``` + +Expected: SOME packages green, SOME still broken (the `cmd/iac-codemod` package — handled in Task 9; the SDK iacserver still type-asserts — handled in Task 7). + +**Step 3: Commit** + +```bash +git add interfaces/iac_provider.go +git commit -m "feat(interfaces): delete IaCProvider.Apply method (workflow#699 PR 1 task 6)" +``` + +**Rollback:** revert commit → interface restored. + +--- + +### Task 7: Update plugin/external/sdk/iacserver.go type-assert + +**Files:** +- Modify: `plugin/external/sdk/iacserver.go:112-121` (`required` type assertion against `pb.IaCProviderRequiredServer`) + +**Step 1: Verify type-assert location** + +```bash +grep -n 'pb.IaCProviderRequiredServer\|required, ok := provider' plugin/external/sdk/iacserver.go +``` + +Expected: line 112 + 114. + +**Step 2: Update godoc / comments referencing Apply** + +In `plugin/external/sdk/iacserver.go`, remove any comments that enumerate `Apply` as a required method. The Go type assertion at line 112 (`provider.(pb.IaCProviderRequiredServer)`) AUTOMATICALLY tightens because `pb.IaCProviderRequiredServer` no longer declares `Apply` after Task 5. + +**Step 3: Run build** + +```bash +go build ./plugin/external/sdk/... +``` + +Expected: green (the type assertion compiles against the trimmed pb interface). + +**Step 4: Commit** + +```bash +git add plugin/external/sdk/iacserver.go +git commit -m "feat(sdk): align iacserver type-assert with trimmed pb.IaCProviderRequiredServer (workflow#699 PR 1 task 7)" +``` + +**Rollback:** revert commit → comment changes restored (auto-tightening of type-assert remains via Task 5). + +--- + +### Task 8: Tighten wftest/bdd + delete obsolete test coverage + +**Files:** +- Modify: `wftest/bdd/strict_iac.go` (drop `Apply` row from `iacServiceChecks`) +- Modify: `cmd/wfctl/iac_loader_gate_test.go` (drop v1 dispatch coverage) +- Modify: `cmd/wfctl/plugin_audit_iac_test.go` (drop v1 dispatch coverage) +- Modify: `cmd/wfctl/plugin_audit.go` (drop v1 dispatch coverage) +- Modify: `plugin/external/proto/iac_proto_test.go` (delete `pb.ApplyResult`-using tests) + +**Step 1: Audit test files** + +```bash +grep -rn '\.Apply(\|pb.ApplyResult\|pb.ApplyRequest\|pb.ApplyResponse' cmd/wfctl/ wftest/ plugin/external/proto/ --include='*.go' +``` + +Capture line numbers per file. For each match: if it's a v1 Apply call OR a deleted-proto-message reference, delete the test function entirely (don't try to refactor; the v2 coverage in `iac/wfctlhelpers/apply*_test.go` is comprehensive). + +**Step 2: Edit each file** + +Delete the matched tests / drop the `Apply` row from the `iacServiceChecks` slice/map literal in `wftest/bdd/strict_iac.go`. + +**Step 3: Run full test suite** + +```bash +go test ./wftest/... ./cmd/wfctl/... ./plugin/external/... +``` + +Expected: green (or skip-marker if any test needs a follow-up; halt and address). + +**Step 4: Commit** + +```bash +git add wftest/bdd/strict_iac.go cmd/wfctl/iac_loader_gate_test.go cmd/wfctl/plugin_audit_iac_test.go cmd/wfctl/plugin_audit.go plugin/external/proto/iac_proto_test.go +git commit -m "test: drop v1 Apply coverage + iacServiceChecks row (workflow#699 PR 1 task 8)" +``` + +**Rollback:** revert commit → tests restored (but they'd fail against the trimmed interface). + +--- + +### Task 9: Delete cmd/iac-codemod + run full build/vet + tag rc1 + +**Files:** +- Delete: `cmd/iac-codemod/` (entire directory) +- Modify: `CHANGELOG.md` + +**Step 1: Verify codemod usage** + +```bash +grep -rln 'cmd/iac-codemod\|iac-codemod' --include='*.go' --include='*.md' --include='*.yaml' . | grep -v _worktrees | grep -v .claude/worktrees +``` + +Expected: only references inside `cmd/iac-codemod/` itself OR design docs. No production callers. + +**Step 2: Delete the directory** + +```bash +git rm -r cmd/iac-codemod/ +``` + +**Step 3: Run full build + vet pre-merge gate** + +```bash +go build ./... && go vet ./... && go test ./... +``` + +Expected: green across the board. + +**Step 4: Write CHANGELOG entry** + +Append to `CHANGELOG.md`: + +```markdown +## [Unreleased] + +### Breaking changes (workflow#699) + +- `interfaces.IaCProvider.Apply` removed. Plugins must implement v2 dispatch (declare `CapabilitiesResponse.compute_plan_version="v2"`) and drop their `Apply` method. +- `pb.IaCProviderRequired.Apply` RPC removed; `ApplyRequest`/`ApplyResponse`/`ApplyResult`/`ActionResult` proto messages deleted. +- `iac/wfctlhelpers/dispatch.go` package deleted (`ComputePlanVersionDeclarer`, `DispatchVersionFor`, `DispatchVersionV2`); v2 is the only supported dispatch. +- `cmd/iac-codemod` deleted (migration tool no longer needed). +- Load-time enforcement: `discoverAndLoadIaCProvider` now calls `Capabilities` at plugin handshake and rejects providers whose `ComputePlanVersion != "v2"`. +- Minimum plugin versions: aws v2.0.0+, gcp v2.0.0+, azure v2.0.0+, digitalocean v2.0.0+. +``` + +**Step 5: Commit** + +```bash +git add -A +git commit -m "feat(workflow): delete cmd/iac-codemod (dead post-cutover) + CHANGELOG (workflow#699 PR 1 task 9)" +``` + +**Step 6: Tag rc1** + +```bash +git push -u origin feat/699-iac-apply-removal-rc +gh pr create --title "workflow#699: IaCProvider.Apply hard-removal (v0.56.0-rc1)" --body "$(cat <<'EOF' +## Summary +- Hard-delete IaCProvider.Apply across interface + proto + wfctl dispatch +- Load-time Capabilities-RPC gate replaces parse-time plugin.json switch +- Delete cmd/iac-codemod (migration tool obsolete) +- See docs/plans/2026-05-17-iac-provider-apply-removal-design.md for full design + +## Test plan +- [ ] CI green (proto regen + lint + tests) +- [ ] Manual: build each plugin v2.0.0-rc1 against this rc; verify load-gate accepts v2 + rejects v1/empty +- [ ] Tag v0.56.0-rc1 on merge +EOF +)" +``` + +After merge: tag `v0.56.0-rc1` against the merged commit. + +**Rollback:** revert PR → codemod restored, CHANGELOG entry removed. + +--- + +## PR 2 — workflow-plugin-digitalocean rc1 + +**Repo:** `workflow-plugin-digitalocean` +**Branch:** `feat/699-drop-apply` +**Final tag:** `v2.0.0-rc1` + +### Task 10: Bump workflow SDK pin to v0.56.0-rc1 + drop DOProvider.Apply + ErrApplyV1Removed + +**Files:** +- Modify: `go.mod` (`github.com/GoCodeAlone/workflow` pin) +- Modify: `internal/provider.go` (delete `DOProvider.Apply` method + `ErrApplyV1Removed` constant) +- Delete: `internal/provider_apply_stub_test.go` (sentinel regression test obsolete) + +**Step 1: Bump go.mod** + +```bash +go get github.com/GoCodeAlone/workflow@v0.56.0-rc1 +go mod tidy +``` + +If `v0.56.0-rc1` isn't on proxy.golang.org yet: use `GOFLAGS='-mod=mod' go mod edit -replace github.com/GoCodeAlone/workflow=github.com/GoCodeAlone/workflow@` or `go.work` replace. + +**Step 2: Delete DOProvider.Apply + ErrApplyV1Removed** + +In `internal/provider.go`, delete the `func (p *DOProvider) Apply(...)` block (it returns `ErrApplyV1Removed`) and the `var ErrApplyV1Removed = ...` declaration. + +```bash +git rm internal/provider_apply_stub_test.go +``` + +**Step 3: Run build + tests** + +```bash +go build ./... && go test ./internal/... +``` + +Expected: green (the v1 stub was unreachable post-Phase-2.5). + +**Step 4: Commit** + +```bash +git add go.mod go.sum internal/provider.go +git rm internal/provider_apply_stub_test.go 2>/dev/null # already done +git commit -m "feat: drop DOProvider.Apply + ErrApplyV1Removed; bump workflow v0.56.0-rc1 (workflow#699 PR 2 task 10)" +``` + +**Rollback:** revert commit → Apply stub + sentinel restored; SDK pin reverted. + +--- + +### Task 11: Delete doIaCServer.Apply RPC handler + applyResultToPB helpers + +**Files:** +- Modify: `internal/iacserver.go:263-277` (`doIaCServer.Apply` RPC handler) +- Modify: `internal/iacserver.go` (`applyResultToPB` helper — locate via grep) + +**Step 1: Delete the RPC handler** + +In `internal/iacserver.go`, delete the `func (s *doIaCServer) Apply(...)` function (~lines 263-277). + +**Step 2: Delete applyResultToPB + helpers** + +```bash +grep -n 'func applyResultToPB\|func actionsToPB\|func actionToPB' internal/iacserver.go +``` + +For each match: delete the function. They're now unreachable. + +**Step 3: Run build** + +```bash +go build ./... +``` + +Expected: green (the gRPC server no longer needs to handle the deleted RPC). + +**Step 4: Commit** + +```bash +git add internal/iacserver.go +git commit -m "feat: delete doIaCServer.Apply RPC handler + applyResultToPB helpers (workflow#699 PR 2 task 11)" +``` + +**Rollback:** revert commit → handler + helpers restored. + +--- + +### Task 12: Bump plugin.json + tag rc1 + +**Files:** +- Modify: `plugin.json` (`version: 2.0.0-rc1`, `minEngineVersion: 0.56.0`) +- Modify: `CHANGELOG.md` + +**Step 1: Bump manifest** + +Edit `plugin.json`: set `"version": "2.0.0-rc1"` and `"minEngineVersion": "0.56.0"`. + +**Step 2: CHANGELOG entry** + +```markdown +## [2.0.0-rc1] - 2026-05-17 + +### Breaking changes +- Removed `DOProvider.Apply` and the `ErrApplyV1Removed` sentinel; v2 dispatch is the only path (per workflow#699). +- Removed `doIaCServer.Apply` gRPC handler. +- Requires workflow v0.56.0+. + +### Reason +- Eliminates the runtime-failure surface from v1.4.0 sentinel-stub per ADR 0024 compile-time-safety mandate. +``` + +**Step 3: Verify with iacserver_test smoke** + +```bash +go test ./internal/iacserver_test.go -v +``` + +Expected: PASS — Capabilities RPC still returns `ComputePlanVersion: "v2"`. + +**Step 4: Commit, push, PR, tag** + +```bash +git add plugin.json CHANGELOG.md +git commit -m "release: workflow-plugin-digitalocean v2.0.0-rc1 (workflow#699)" +git push -u origin feat/699-drop-apply +gh pr create --title "workflow#699: drop DOProvider.Apply (v2.0.0-rc1)" --body "Requires workflow v0.56.0-rc1+. See workflow#699." +``` + +After merge: tag `v2.0.0-rc1`. + +**Rollback:** revert PR → DOProvider.Apply restored; tag yanked. + +--- + +## PR 3 — workflow-plugin-aws rc1 + +**Repo:** `workflow-plugin-aws` +**Branch:** `feat/699-drop-apply` +**Final tag:** `v2.0.0-rc1` + +### Task 13: Bump SDK pin + drop AWSProvider.Apply + +**Files:** +- Modify: `go.mod` +- Modify: `provider/provider.go:237-300` (delete `AWSProvider.Apply` function) + +**Steps:** mirror PR 2 Task 10, swap `DOProvider` for `AWSProvider`. + +**Verification:** `go build ./... && go test ./provider/...` green. + +**Commit:** `feat: drop AWSProvider.Apply; bump workflow v0.56.0-rc1 (workflow#699 PR 3 task 13)` + +**Rollback:** revert commit. + +--- + +### Task 14: Delete awsIaCServer.Apply RPC handler + applyResultToPB + +**Files:** +- Modify: `internal/iacserver.go:148-...` (delete `awsIaCServer.Apply`) +- Modify: `internal/iacserver.go:679-...` (delete `applyResultToPB` + helpers) + +**Steps:** mirror PR 2 Task 11. + +**Commit:** `feat: delete awsIaCServer.Apply RPC handler + applyResultToPB (workflow#699 PR 3 task 14)` + +**Rollback:** revert commit. + +--- + +### Task 15: Bump plugin.json + tag rc1 + +**Files:** +- Modify: `plugin.json` (`version: 2.0.0-rc1`, `minEngineVersion: 0.56.0`) +- Modify: `CHANGELOG.md` + +**Steps:** mirror PR 2 Task 12. + +**Commit:** `release: workflow-plugin-aws v2.0.0-rc1 (workflow#699)` + +After merge: tag `v2.0.0-rc1`. + +**Rollback:** revert PR. + +--- + +## PR 4 — workflow-plugin-gcp rc1 + +**Repo:** `workflow-plugin-gcp` +**Branch:** `feat/699-drop-apply` +**Final tag:** `v2.0.0-rc1` + +### Task 16: File sync-plugin-version followup issue + +**Step 1:** From any directory: + +```bash +gh issue create -R GoCodeAlone/workflow-plugin-gcp \ + --title "sync-plugin-version workflow: plugin.json (1.1.0) lags live tag (v1.2.0)" \ + --body "Discovered during workflow#699 cascade. plugin.json on main shows version 1.1.0 but live tag is v1.2.0. The sync-plugin-version GitHub Action should bump plugin.json on tag — verify wiring. Cycle-3 adversarial-review finding m-NEW-1 from docs/plans/2026-05-17-iac-provider-apply-removal-design.md." +``` + +Capture the issue number; patch into PR description + design doc. + +**Step 2:** (No code change.) Move on. + +--- + +### Task 17: Bump SDK pin + drop GCPProvider.Apply + +Mirror PR 2 Task 10, swap `DOProvider` → `GCPProvider`. File path: `provider/provider.go:226-...`. + +**Commit:** `feat: drop GCPProvider.Apply; bump workflow v0.56.0-rc1 (workflow#699 PR 4 task 17)` + +--- + +### Task 18: Delete gcpIaCServer.Apply RPC handler + applyResultToPB + +Mirror PR 3 Task 14. File: `internal/iacserver.go:148-...` + `:682-...`. + +**Commit:** `feat: delete gcpIaCServer.Apply RPC handler + applyResultToPB (workflow#699 PR 4 task 18)` + +--- + +### Task 19: Bump plugin.json + tag rc1 + +Mirror PR 2 Task 12. Bump plugin.json `1.1.0` → `2.0.0-rc1`. + +After merge: tag `v2.0.0-rc1`. + +--- + +## PR 5 — workflow-plugin-azure rc1 + +**Repo:** `workflow-plugin-azure` +**Branch:** `feat/699-drop-apply` +**Final tag:** `v2.0.0-rc1` + +### Task 20: Bump SDK pin + drop AzureProvider.Apply + +Mirror PR 2 Task 10. File path: `internal/provider.go:138-...`. + +**Commit:** `feat: drop AzureProvider.Apply; bump workflow v0.56.0-rc1 (workflow#699 PR 5 task 20)` + +--- + +### Task 21: Delete azureIaCServer.Apply RPC handler + applyResultToPB + +Mirror PR 3 Task 14. File: `internal/iacserver.go:149-...` + `:680-...`. + +**Commit:** `feat: delete azureIaCServer.Apply RPC handler + applyResultToPB (workflow#699 PR 5 task 21)` + +--- + +### Task 22: Bump plugin.json + tag rc1 + +Mirror PR 2 Task 12. + +After merge: tag `v2.0.0-rc1`. + +--- + +## PR 6 — workflow conformance + final tag + +**Repo:** `workflow` +**Branch:** `feat/699-conformance-final` +**Final tag:** `v0.56.0` + +### Task 23: Add plugin-conformance matrix to CI + +**Files:** +- Modify: `.github/workflows/ci.yaml` (or equivalent) — add matrix step + +**Step 1: Add CI step** + +Append to the test job in `.github/workflows/ci.yaml`: + +```yaml + iac-plugin-conformance: + runs-on: ubuntu-latest + strategy: + matrix: + plugin: [aws, gcp, azure, digitalocean] + steps: + - uses: actions/checkout@v4 + with: + repository: GoCodeAlone/workflow-plugin-${{ matrix.plugin }} + ref: v2.0.0-rc1 + path: plugin-${{ matrix.plugin }} + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Pin workflow SDK to PR commit + run: | + cd plugin-${{ matrix.plugin }} + go mod edit -replace github.com/GoCodeAlone/workflow=${{ github.workspace }} + go mod tidy + - name: Build plugin + run: cd plugin-${{ matrix.plugin }} && go build ./... + - name: Run iacserver smoke + run: cd plugin-${{ matrix.plugin }} && go test ./internal/iacserver_test.go -v +``` + +**Step 2: Push branch + open PR** + +```bash +git checkout -b feat/699-conformance-final +git add .github/workflows/ci.yaml +git commit -m "ci: plugin-conformance matrix for IaC providers (workflow#699 PR 6 task 23)" +git push -u origin feat/699-conformance-final +gh pr create --title "workflow#699: conformance matrix + final v0.56.0 tag" --body "Validates all 4 plugin v2.0.0-rc1 tags build against this branch." +``` + +**Step 3: Wait for matrix to go green** + +If any plugin fails: halt cascade. Investigate. Likely cause: a plugin still references a deleted symbol; fix in that plugin's repo before merging this PR. + +**Rollback:** revert PR → matrix step removed. + +--- + +### Task 24: Tag v0.56.0 final + +**Step 1:** After PR 6 merges to main: + +```bash +git checkout main && git pull +git tag v0.56.0 +git push --tags +``` + +**Step 2:** Verify GoReleaser publishes: + +```bash +gh release view v0.56.0 +``` + +Expected: release notes auto-generated from CHANGELOG. + +--- + +### Task 25: Update workflow memory + tracker + +**Step 1:** Update `/Users/jon/.claude/projects/-Users-jon-workspace/memory/project_open_followup_queue.md`: + +Mark workflow#699 done; add link to v0.56.0 release. + +**Step 2:** Update `/Users/jon/.claude/projects/-Users-jon-workspace/memory/MEMORY.md` plugin inventory: + +- workflow v0.56.0 (was v0.55.x) + +**Step 3:** Create new memory file `/Users/jon/.claude/projects/-Users-jon-workspace/memory/project_workflow_699_apply_removal_shipped.md` documenting the cascade outcome. + +(No git commit — memory files live in the user's local `~/.claude/`.) + +--- + +## PR 7 — workflow-plugin-digitalocean final v2.0.0 + registry + +**Repos:** `workflow-plugin-digitalocean` + `workflow-registry` +**Branches:** `feat/699-final` (plugin) + `feat/699-do-manifest` (registry) +**Final tag:** `v2.0.0` (plugin) + +### Task 26: Bump SDK pin v0.56.0-rc1 → v0.56.0 + plugin.json v2.0.0 + +**Files:** +- Modify: `go.mod` +- Modify: `plugin.json` +- Modify: `CHANGELOG.md` + +```bash +go get github.com/GoCodeAlone/workflow@v0.56.0 +go mod tidy +# edit plugin.json: version: "2.0.0" +# edit CHANGELOG.md: promote rc1 entry to 2.0.0 +git add -A +git commit -m "release: workflow-plugin-digitalocean v2.0.0 (workflow#699)" +git push -u origin feat/699-final +gh pr create --title "workflow#699: workflow-plugin-digitalocean v2.0.0" --body "Final tag bump after workflow v0.56.0." +``` + +After merge: tag `v2.0.0`. + +**Rollback:** revert PR → v1.4.0 restored as recommended live tag. + +--- + +### Task 27: Update workflow-registry DO manifest + +**Files:** +- Modify: `workflow-registry/v1/plugins/digitalocean/manifest.json` (bump `version: 1.0.12` → `2.0.0`, `minEngineVersion: 0.51.0` → `0.56.0`) + +**Step 1:** Edit the file: + +```json +{ + "name": "workflow-plugin-digitalocean", + "version": "2.0.0", + "minEngineVersion": "0.56.0", + ... +} +``` + +**Step 2:** Open PR: + +```bash +cd /Users/jon/workspace/workflow-registry +git checkout -b feat/699-do-manifest +git add v1/plugins/digitalocean/manifest.json +git commit -m "feat(do): bump workflow-plugin-digitalocean to v2.0.0 (workflow#699)" +git push -u origin feat/699-do-manifest +gh pr create --title "workflow#699: bump DO plugin to v2.0.0" --body "Registry pin catches up from 1.0.12 → 2.0.0. Rollback floor: v1.4.0 (live tag before this cascade)." +``` + +After merge: registry is live immediately (no tag axis). + +**Rollback:** revert PR → registry pin reverts to v1.0.12 (operators on pre-v2 install path); recommend `wfctl plugin install --force` and pin to v1.4.0 explicitly. + +--- + +### Task 28: Smoke-validate DO v2.0.0 against workflow v0.56.0 + +**Step 1:** From a clean checkout: + +```bash +mkdir -p /tmp/699-smoke && cd /tmp/699-smoke +git clone --depth 1 --branch v0.56.0 https://github.com/GoCodeAlone/workflow.git +git clone --depth 1 --branch v2.0.0 https://github.com/GoCodeAlone/workflow-plugin-digitalocean.git +cd workflow-plugin-digitalocean +go build -o ../do-plugin ./cmd/... +cd ../workflow +go build -o wfctl ./cmd/wfctl +./wfctl plugin info digitalocean --plugin-dir /tmp/699-smoke +``` + +Expected: plugin loads, `ComputePlanVersion=v2` accepted, no v1 dispatch warnings. + +**Step 2:** No commit — record transcript at `docs/runtime-validation/2026-05-17-do-v2-smoke.md` in the workflow repo (separate housekeeping commit). + +--- + +## PR 8 — workflow-plugin-aws final v2.0.0 + registry + +### Task 29: Bump SDK pin + plugin.json v2.0.0 + +Mirror PR 7 Task 26. **Rollback floor:** aws v1.2.1. + +### Task 30: Update workflow-registry aws manifest + +Mirror PR 7 Task 27. File: `workflow-registry/v1/plugins/aws/manifest.json`. Pin currently `0.1.2` → bump to `2.0.0`. + +### Task 31: Smoke-validate aws v2.0.0 + +Mirror PR 7 Task 28 (substitute `aws` for `digitalocean`). + +--- + +## PR 9 — workflow-plugin-gcp final v2.0.0 + registry + +### Task 32: Bump SDK pin + plugin.json v2.0.0 + +Mirror PR 7 Task 26. **Rollback floor:** gcp v1.2.0. + +### Task 33: Update workflow-registry gcp manifest + +Mirror PR 7 Task 27. File: `workflow-registry/v1/plugins/gcp/manifest.json`. Pin currently `0.1.3` → bump to `2.0.0`. + +### Task 34: Smoke-validate gcp v2.0.0 + +Mirror PR 7 Task 28. + +--- + +## PR 10 — workflow-plugin-azure final v2.0.0 + registry + +### Task 35: Bump SDK pin + plugin.json v2.0.0 + +Mirror PR 7 Task 26. **Rollback floor:** azure v1.2.1. + +### Task 36: Update workflow-registry azure manifest + +Mirror PR 7 Task 27. File: `workflow-registry/v1/plugins/azure/manifest.json`. Pin currently `0.1.2` → bump to `2.0.0`. + +(Smoke-validate azure v2.0.0 is rolled into Task 36 to reduce task count without losing the validation step — same procedure as PR 7 Task 28.) From d09213970e0d5b6af1b0f26500d29648f994db47 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 14:16:52 -0400 Subject: [PATCH 06/22] plan: revise per cycle-1 plan-review (4 Crit + 8 Imp addressed) --- .../2026-05-17-iac-provider-apply-removal.md | 84 ++++++++++++++----- 1 file changed, 63 insertions(+), 21 deletions(-) diff --git a/docs/plans/2026-05-17-iac-provider-apply-removal.md b/docs/plans/2026-05-17-iac-provider-apply-removal.md index ff9c2790..4a3d1045 100644 --- a/docs/plans/2026-05-17-iac-provider-apply-removal.md +++ b/docs/plans/2026-05-17-iac-provider-apply-removal.md @@ -73,14 +73,14 @@ Before PR 1 starts, the executing agent MUST: grep -n usedV2Dispatch cmd/wfctl/infra_apply.go ``` -Expected: 5 lines (467, 472, 536, 1662, 1664, 1711) — both functions have the v1/v2 dispatch fork. +Expected: 6 lines (467, 472, 536, 1662, 1664, 1711) — both functions have the v1/v2 dispatch fork. **Step 2: Write the failing test for runInfraApply collapsed path** `cmd/wfctl/infra_apply_v2_only_test.go` (new file): ```go -package wfctl +package main import ( "context" @@ -267,7 +267,7 @@ git commit -m "feat(iac): delete wfctlhelpers/dispatch.go — v2 is sole dispatc `cmd/wfctl/deploy_providers_load_gate_test.go`: ```go -package wfctl +package main import ( "strings" @@ -356,17 +356,17 @@ if err := verifyComputePlanVersionV2(capsResp.GetComputePlanVersion(), pluginNam } ``` -Modify `findIaCPluginDir` switch at `:162-170` — leave the `case "", "v1", "v2"` accept clause unchanged. Add inside the `case "", "v1":` arm (split it from `v2`): +Leave `findIaCPluginDir`'s switch at `:162-170` UNCHANGED (it already accepts `"", "v1", "v2"`). Move the deprecation log emission to `discoverAndLoadIaCProvider` (cycle-2 plan-review Finding #7 fix — `findIaCPluginDir` may be called multiple times per wfctl invocation, but `discoverAndLoadIaCProvider` is called exactly once per resolve): ```go +// Inside discoverAndLoadIaCProvider, AFTER findIaCPluginDir returns + BEFORE the Capabilities gate. +// computePlanVersion is the value findIaCPluginDir returned. +switch computePlanVersion { case "": log.Printf("plugin %q: deprecation — manifest iacProvider.computePlanVersion is empty; declare \"v2\" explicitly (workflow#699)", pluginName) case "v1": log.Printf("plugin %q: deprecation — manifest iacProvider.computePlanVersion=\"v1\"; load-time gate will reject this (workflow#699)", pluginName) -case "v2": - // accept -default: - return "", "", false, fmt.Errorf(...) // unchanged +} ``` **Step 4: Run test** @@ -397,13 +397,14 @@ git commit -m "feat(wfctl): load-time Capabilities-RPC gate enforces ComputePlan - Modify: `plugin/external/proto/iac_grpc.pb.go` (regenerated) - Modify: `Makefile` (add lint step) -**Step 1: Inspect the proto messages slated for deletion** +**Step 1: Inspect the proto messages slated for deletion + verify ActionResult has zero external consumers (cycle-2 plan-review Finding #6 fix)** ```bash grep -n 'message ApplyRequest\|message ApplyResponse\|message ApplyResult\|message ActionResult\|rpc Apply' plugin/external/proto/iac.proto +grep -n 'ActionResult\b' plugin/external/proto/iac.proto ``` -Expected: 5 line matches. +Expected: first grep returns ≥5 line matches. Second grep MUST show `ActionResult` referenced ONLY inside `message ActionResult { ... }` block AND inside `message ApplyResult.actions = 7` field. If any OTHER reference exists (e.g., from FinalizeApply or hook telemetry), HALT and re-design — ActionResult cannot be deleted. **Step 2: Edit iac.proto** @@ -429,12 +430,21 @@ Expected: `iac.pb.go` and `iac_grpc.pb.go` shrink (no `ApplyRequest`/`Response`/ **Step 4: Add Makefile lint step** -In `Makefile`, find the `lint:` (or `ci:`) target and append: +In `Makefile`, locate the existing `lint:` target (single-line `golangci-lint run --timeout=5m` per cycle-2 plan-review I-NEW finding). Convert it to a multi-line recipe and append the proto guard: ```makefile - @grep -q 'rpc Apply' plugin/external/proto/iac.proto && (echo "workflow#699: rpc Apply re-introduced in iac.proto; see decisions/0024-iac-typed-force-cutover.md" && exit 1) || echo "workflow#699 guard: rpc Apply correctly absent" +lint: + golangci-lint run --timeout=5m + @if grep -q 'rpc Apply' plugin/external/proto/iac.proto; then \ + echo "workflow#699: rpc Apply re-introduced in iac.proto; see decisions/0024-iac-typed-force-cutover.md"; \ + exit 1; \ + else \ + echo "workflow#699 guard: rpc Apply correctly absent"; \ + fi ``` +If `buf` is not installed in the environment running this PR: install via `go install github.com/bufbuild/buf/cmd/buf@latest`. The repo's `buf.gen.yaml` is the generation config; no separate `proto-gen` Makefile target exists today. + **Step 5: Run build (expect FAIL — interface still has Apply, plugins haven't dropped their handlers yet)** ```bash @@ -524,14 +534,16 @@ git commit -m "feat(sdk): align iacserver type-assert with trimmed pb.IaCProvide --- -### Task 8: Tighten wftest/bdd + delete obsolete test coverage +### Task 8: Tighten wftest/bdd + iactest fakeprovider + delete obsolete test coverage **Files:** +- Modify: `iac/iactest/fakeprovider.go:42-46` (delete `DispatchVersion` field) + `:69-72` (delete `ComputePlanVersion()` method) — cycle-2 plan-review C2 fix; this stub is consumed by 8+ `cmd/wfctl/*_test.go` files and will break `go build ./...` in Task 9 if not cleaned up here. - Modify: `wftest/bdd/strict_iac.go` (drop `Apply` row from `iacServiceChecks`) - Modify: `cmd/wfctl/iac_loader_gate_test.go` (drop v1 dispatch coverage) - Modify: `cmd/wfctl/plugin_audit_iac_test.go` (drop v1 dispatch coverage) - Modify: `cmd/wfctl/plugin_audit.go` (drop v1 dispatch coverage) - Modify: `plugin/external/proto/iac_proto_test.go` (delete `pb.ApplyResult`-using tests) +- Modify: `iac/iactest/fakeprovider_test.go` (if it exists; verify with `ls iac/iactest/`) — drop any `DispatchVersion`/`ComputePlanVersion` coverage. Update consumer tests in `cmd/wfctl/` that set `iactest.NoopProvider{DispatchVersion: "v2"}` — remove the field. **Step 1: Audit test files** @@ -564,10 +576,12 @@ git commit -m "test: drop v1 Apply coverage + iacServiceChecks row (workflow#699 --- -### Task 9: Delete cmd/iac-codemod + run full build/vet + tag rc1 +### Task 9: Delete cmd/iac-codemod + Makefile cleanup + run full build/vet + tag rc1 **Files:** - Delete: `cmd/iac-codemod/` (entire directory) +- Modify: `Makefile` — delete `.PHONY` entries `build-iac-codemod` and `migrate-providers` (line 1); delete `build-iac-codemod:` target block (~lines 86-91); delete `migrate-providers:` target block (~lines 113-125); remove `iac-codemod` from any `clean:` rule (~line 131). Verify with `grep -c iac-codemod Makefile` → 0 after edit. +- Modify: `docs/migrations/2026-05-16-v2-lifecycle-phase1-inventory.md` (if it exists and references iac-codemod) — strike codemod section as completed-and-removed. - Modify: `CHANGELOG.md` **Step 1: Verify codemod usage** @@ -799,9 +813,17 @@ After merge: tag `v2.0.0-rc1`. ### Task 14: Delete awsIaCServer.Apply RPC handler + applyResultToPB -**Files:** -- Modify: `internal/iacserver.go:148-...` (delete `awsIaCServer.Apply`) -- Modify: `internal/iacserver.go:679-...` (delete `applyResultToPB` + helpers) +**Step 1: Verify symbol locations (cycle-2 plan-review Finding #8 fix)** + +```bash +grep -n 'func (s \*awsIaCServer) Apply\b\|func applyResultToPB\|func actionsToPB\|func actionToPB' internal/iacserver.go +``` + +Capture line numbers from output; use those (not the plan's approximate values). + +**Files (line numbers verified in Step 1):** +- Modify: `internal/iacserver.go` (delete `awsIaCServer.Apply`) +- Modify: `internal/iacserver.go` (delete `applyResultToPB` + helpers) **Steps:** mirror PR 2 Task 11. @@ -859,7 +881,7 @@ Mirror PR 2 Task 10, swap `DOProvider` → `GCPProvider`. File path: `provider/p ### Task 18: Delete gcpIaCServer.Apply RPC handler + applyResultToPB -Mirror PR 3 Task 14. File: `internal/iacserver.go:148-...` + `:682-...`. +Mirror PR 3 Task 14 — including the Step 1 grep verification of symbol locations. Do NOT assume line numbers; verify per-plugin. **Commit:** `feat: delete gcpIaCServer.Apply RPC handler + applyResultToPB (workflow#699 PR 4 task 18)` @@ -889,7 +911,7 @@ Mirror PR 2 Task 10. File path: `internal/provider.go:138-...`. ### Task 21: Delete azureIaCServer.Apply RPC handler + applyResultToPB -Mirror PR 3 Task 14. File: `internal/iacserver.go:149-...` + `:680-...`. +Mirror PR 3 Task 14 — including the Step 1 grep verification of symbol locations. Do NOT assume line numbers; verify per-plugin. **Commit:** `feat: delete azureIaCServer.Apply RPC handler + applyResultToPB (workflow#699 PR 5 task 21)` @@ -911,8 +933,17 @@ After merge: tag `v2.0.0-rc1`. ### Task 23: Add plugin-conformance matrix to CI +**Pre-flight gate (run BEFORE opening PR 6):** + +```bash +for p in aws gcp azure digitalocean; do + gh release view v2.0.0-rc1 -R GoCodeAlone/workflow-plugin-$p > /dev/null || { echo "MISSING: workflow-plugin-$p v2.0.0-rc1"; exit 1; } +done +echo "All 4 plugin v2.0.0-rc1 tags present." +``` + **Files:** -- Modify: `.github/workflows/ci.yaml` (or equivalent) — add matrix step +- Modify: `.github/workflows/ci.yml` (verified path; the repo uses `.yml` not `.yaml`) — add matrix step **Step 1: Add CI step** @@ -1124,4 +1155,15 @@ Mirror PR 7 Task 26. **Rollback floor:** azure v1.2.1. Mirror PR 7 Task 27. File: `workflow-registry/v1/plugins/azure/manifest.json`. Pin currently `0.1.2` → bump to `2.0.0`. -(Smoke-validate azure v2.0.0 is rolled into Task 36 to reduce task count without losing the validation step — same procedure as PR 7 Task 28.) +**Explicit smoke-validate step (cycle-2 plan-review Finding #12 fix):** + +```bash +mkdir -p /tmp/699-smoke-azure && cd /tmp/699-smoke-azure +git clone --depth 1 --branch v0.56.0 https://github.com/GoCodeAlone/workflow.git +git clone --depth 1 --branch v2.0.0 https://github.com/GoCodeAlone/workflow-plugin-azure.git +cd workflow-plugin-azure && go build -o ../azure-plugin ./cmd/... && cd .. +cd workflow && go build -o wfctl ./cmd/wfctl +./wfctl plugin info azure --plugin-dir /tmp/699-smoke-azure +``` + +Expected: plugin loads; `ComputePlanVersion=v2` accepted; no v1 dispatch warnings. From 5c20eb29be86507d8f7cc67d24250a282e5f0036 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 14:25:37 -0400 Subject: [PATCH 07/22] plan: revise per cycle-2 plan-review (2 Crit + 5 Imp addressed; N1 consumer fanout, N2 v1 else-block, N3 smoke command, N4 wiring, N5 integration test, N6 regex tighten, N7 EffectiveComputePlanVersion) --- .../2026-05-17-iac-provider-apply-removal.md | 96 +++++++++++++++---- 1 file changed, 75 insertions(+), 21 deletions(-) diff --git a/docs/plans/2026-05-17-iac-provider-apply-removal.md b/docs/plans/2026-05-17-iac-provider-apply-removal.md index 4a3d1045..12a5e486 100644 --- a/docs/plans/2026-05-17-iac-provider-apply-removal.md +++ b/docs/plans/2026-05-17-iac-provider-apply-removal.md @@ -132,7 +132,7 @@ go test -run TestInfraApply_V2OnlyDispatch_NoV1Branch ./cmd/wfctl/ Expected: compile error — `*stubV2OnlyProvider does not implement interfaces.IaCProvider (missing method Apply)`. This proves the interface still declares Apply. -**Step 4: Edit runInfraApply (lines 465-540) — collapse v1/v2 branch** +**Step 4: Edit runInfraApply (lines 465-589) — collapse v1/v2 branch + delete v1 else-block (cycle-2 N2 fix)** In `cmd/wfctl/infra_apply.go`, replace the block at lines 465-487 (the `if wfctlhelpers.DispatchVersionFor(provider) == wfctlhelpers.DispatchVersionV2 { ... } else { result, err = provider.Apply(ctx, &plan) }` block) with: @@ -145,11 +145,19 @@ if result != nil { } ``` -Remove the `usedV2Dispatch` variable declaration and any references in the surrounding error/result handling at line 536 (replace `if usedV2Dispatch { ... }` with the body unconditionally). +ALSO delete the v1 post-processing else-block at lines ~547-589 (the `if usedV2Dispatch { ... } else { rejectSensitiveOutputsWithoutProvider + persist loop + result.Errors handler }` shape — verify exact lines via `grep -n 'if usedV2Dispatch' cmd/wfctl/infra_apply.go`). Collapse to the v2-only body unconditionally. -**Step 5: Edit applyPrecomputedPlanWithStore (lines 1660-1730) — same collapse** +**Sub-step: after collapsing, identify newly-orphaned helpers:** -Apply the identical collapse pattern to the block at lines 1660-1722. Remove `usedV2Dispatch` variable at `:1662`, conditional at `:1711`. +```bash +grep -rn 'rejectSensitiveOutputsWithoutProvider\|successfulDeleteNames\|planActionForOutput' --include='*.go' cmd/wfctl/ | grep -v _test.go | grep -v _worktrees +``` + +For each helper with zero non-test callers post-collapse: delete it. Add to Task 1's commit. + +**Step 5: Edit applyPrecomputedPlanWithStore (lines 1660-~1750) — same collapse + v1 else-block delete** + +Apply the identical collapse pattern to the block at lines 1660-1722. Remove `usedV2Dispatch` variable at `:1662`, conditional at `:1711`. ALSO delete the v1 else-block at lines ~1723-1750 (the parallel v1 post-processing that mirrors the runInfraApply pattern; verify via `grep -n 'if usedV2Dispatch' cmd/wfctl/infra_apply.go`). **Step 6: Verify both collapses removed all 5 `usedV2Dispatch` references** @@ -315,7 +323,7 @@ go test -run TestDiscoverAndLoadIaCProvider_LoadGate ./cmd/wfctl/ Expected: FAIL — `undefined: verifyComputePlanVersionV2`. -**Step 3: Implement `verifyComputePlanVersionV2` helper + wire into `discoverAndLoadIaCProvider`** +**Step 3: Implement `verifyComputePlanVersionV2` helper + add CapabilitiesWithContext + wire gate (cycle-2 N4 fix — actual field paths)** Append to `cmd/wfctl/deploy_providers.go`: @@ -337,17 +345,27 @@ func verifyComputePlanVersionV2(cpv, pluginName string) error { } ``` -Modify `discoverAndLoadIaCProvider` (locate the line right after `typedIaCAdapter` is constructed and before it is returned). Add: +Add a method on `typedIaCAdapter` in `cmd/wfctl/iac_typed_adapter.go` (the `required` field is unexported per `iac_typed_adapter.go:169`; the load gate must go through the adapter, not reach into its internals): + +```go +// CapabilitiesWithContext returns CapabilitiesResponse with caller-supplied +// context. Bypasses fetchCapabilities's adapter-lifetime cache — used by +// the load-time workflow#699 gate which must not poison the cache on +// transient failure. +func (a *typedIaCAdapter) CapabilitiesWithContext(ctx context.Context) (*pb.CapabilitiesResponse, error) { + return a.required.Capabilities(ctx, &pb.CapabilitiesRequest{}) +} +``` + +In `discoverAndLoadIaCProvider`, after `typed` (`*typedIaCAdapter`) is constructed and before returning it: ```go -// Per workflow#699: gate provider load on the typed -// CapabilitiesResponse.compute_plan_version field. The 10s timeout -// bounds a hung plugin handshake; the call is NOT shared with the -// long-lived fetchCapabilities cache (transient failures must not -// poison the adapter for the entire invocation). +// Per workflow#699: gate provider load on typed CapabilitiesResponse.compute_plan_version. +// 10s timeout bounds hung handshake; bypasses the lifetime-cached fetchCapabilities so +// transient errors don't poison the adapter. capsCtx, capsCancel := context.WithTimeout(ctx, 10*time.Second) defer capsCancel() -capsResp, capsErr := adapter.required.Capabilities(capsCtx, &pb.CapabilitiesRequest{}) +capsResp, capsErr := typed.CapabilitiesWithContext(capsCtx) if capsErr != nil { return nil, nil, fmt.Errorf("plugin %q: Capabilities RPC failed: %w (see workflow#699)", pluginName, capsErr) } @@ -369,7 +387,7 @@ case "v1": } ``` -**Step 4: Run test** +**Step 4: Run unit test + add integration test for wiring (cycle-2 N5 fix)** ```bash go test -run TestDiscoverAndLoadIaCProvider_LoadGate ./cmd/wfctl/ -v @@ -377,6 +395,23 @@ go test -run TestDiscoverAndLoadIaCProvider_LoadGate ./cmd/wfctl/ -v Expected: PASS on all 3 sub-tests. +Add an integration test that wires a fake adapter through `discoverAndLoadIaCProvider` (use existing `iacAdapterAccessor` seam at `deploy_providers.go:235`): + +```go +// TestDiscoverAndLoadIaCProvider_LoadGate_WiredIntoDiscovery asserts the +// helper is actually called by discoverAndLoadIaCProvider — not just +// independently tested. Regression-gates against future refactor that +// removes the gate from the discovery code-path. +func TestDiscoverAndLoadIaCProvider_LoadGate_WiredIntoDiscovery(t *testing.T) { + // fake adapter whose CapabilitiesWithContext returns compute_plan_version="v1" + // MUST trigger the workflow#699 error from discoverAndLoadIaCProvider, not + // from verifyComputePlanVersionV2 in isolation. + // ... (use existing test scaffolding patterns from iac_loader_gate_test.go) +} +``` + +Run: `go test -run TestDiscoverAndLoadIaCProvider_LoadGate_WiredIntoDiscovery ./cmd/wfctl/ -v` → PASS. + **Step 5: Commit** ```bash @@ -435,7 +470,7 @@ In `Makefile`, locate the existing `lint:` target (single-line `golangci-lint ru ```makefile lint: golangci-lint run --timeout=5m - @if grep -q 'rpc Apply' plugin/external/proto/iac.proto; then \ + @if grep -qE '^\s*rpc Apply\s*\(' plugin/external/proto/iac.proto; then \ echo "workflow#699: rpc Apply re-introduced in iac.proto; see decisions/0024-iac-typed-force-cutover.md"; \ exit 1; \ else \ @@ -536,14 +571,29 @@ git commit -m "feat(sdk): align iacserver type-assert with trimmed pb.IaCProvide ### Task 8: Tighten wftest/bdd + iactest fakeprovider + delete obsolete test coverage -**Files:** -- Modify: `iac/iactest/fakeprovider.go:42-46` (delete `DispatchVersion` field) + `:69-72` (delete `ComputePlanVersion()` method) — cycle-2 plan-review C2 fix; this stub is consumed by 8+ `cmd/wfctl/*_test.go` files and will break `go build ./...` in Task 9 if not cleaned up here. +**Files (per cycle-2 plan-review N1 fanout):** +- Modify: `iac/iactest/fakeprovider.go:42-46` (delete `DispatchVersion` field) + `:69-72` (delete `ComputePlanVersion()` method) - Modify: `wftest/bdd/strict_iac.go` (drop `Apply` row from `iacServiceChecks`) - Modify: `cmd/wfctl/iac_loader_gate_test.go` (drop v1 dispatch coverage) - Modify: `cmd/wfctl/plugin_audit_iac_test.go` (drop v1 dispatch coverage) - Modify: `cmd/wfctl/plugin_audit.go` (drop v1 dispatch coverage) - Modify: `plugin/external/proto/iac_proto_test.go` (delete `pb.ApplyResult`-using tests) -- Modify: `iac/iactest/fakeprovider_test.go` (if it exists; verify with `ls iac/iactest/`) — drop any `DispatchVersion`/`ComputePlanVersion` coverage. Update consumer tests in `cmd/wfctl/` that set `iactest.NoopProvider{DispatchVersion: "v2"}` — remove the field. +- Modify: `cmd/wfctl/infra_apply_allow_replace_test.go:240` — drop `DispatchVersion: "v2"` field literal (default OK post-cleanup) +- Modify: `cmd/wfctl/infra_apply_plan_test.go:412` — same +- Modify: `cmd/wfctl/infra_apply_v2_test.go:71,157,201,582` — drop `DispatchVersion:` literals; DELETE the v1-empty-decl test at `:582` (path eliminated) +- Modify: `cmd/wfctl/infra_apply_v2_loader_test.go:249` — delete `_ wfctlhelpers.ComputePlanVersionDeclarer = ...` interface assertion (package gone) +- Modify: `cmd/wfctl/infra_apply_jit_loader_test.go:138` — same interface assertion deletion +- Modify: `iac/conformance/scenarios_test.go:494` — drop `DispatchVersion: "v2"` literal +- Modify: `iac/iactest/fakeprovider_test.go` (if exists; `ls iac/iactest/` to verify) +- Modify: `plugin/sdk/manifest.go:60-72` — remove `EffectiveComputePlanVersion` helper OR change default from "v1" to "v2" (cycle-2 N7: post-cutover, "v1" is no longer a valid runtime value) + +**Step 1 audit grep (expanded per N1):** + +```bash +grep -rn 'DispatchVersion:\|iactest.NoopProvider{\|wfctlhelpers.ComputePlanVersionDeclarer\|EffectiveComputePlanVersion' --include='*.go' . | grep -v _worktrees | grep -v .claude/worktrees +``` + +Expected: only files listed above. Any extra match → halt and add to the list. **Step 1: Audit test files** @@ -1104,10 +1154,12 @@ cd workflow-plugin-digitalocean go build -o ../do-plugin ./cmd/... cd ../workflow go build -o wfctl ./cmd/wfctl -./wfctl plugin info digitalocean --plugin-dir /tmp/699-smoke +./wfctl infra plan example/minimal-do-plan.yaml --plugin-dir /tmp/699-smoke 2>&1 | tee smoke.log ``` -Expected: plugin loads, `ComputePlanVersion=v2` accepted, no v1 dispatch warnings. +(`plugin info` does NOT load via gRPC; `infra plan` exercises `discoverAndLoadIaCProvider` and thus the workflow#699 gate — cycle-2 N3 fix.) + +Expected: smoke.log shows plugin loads via gRPC, `ComputePlanVersion=v2` accepted, plan computation completes without v1 dispatch warnings. **Step 2:** No commit — record transcript at `docs/runtime-validation/2026-05-17-do-v2-smoke.md` in the workflow repo (separate housekeeping commit). @@ -1163,7 +1215,9 @@ git clone --depth 1 --branch v0.56.0 https://github.com/GoCodeAlone/workflow.git git clone --depth 1 --branch v2.0.0 https://github.com/GoCodeAlone/workflow-plugin-azure.git cd workflow-plugin-azure && go build -o ../azure-plugin ./cmd/... && cd .. cd workflow && go build -o wfctl ./cmd/wfctl -./wfctl plugin info azure --plugin-dir /tmp/699-smoke-azure +./wfctl infra plan example/minimal-azure-plan.yaml --plugin-dir /tmp/699-smoke-azure 2>&1 | tee smoke.log ``` -Expected: plugin loads; `ComputePlanVersion=v2` accepted; no v1 dispatch warnings. +(Per cycle-2 N3 fix: `infra plan` exercises `discoverAndLoadIaCProvider` and thus the workflow#699 gate; `plugin info` does not.) + +Expected: smoke.log shows plugin loads via gRPC; `ComputePlanVersion=v2` accepted; plan computation completes without v1 dispatch warnings. From 585f3e047bf54e0d63840fcf4cce9a2f255ef94e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 14:32:11 -0400 Subject: [PATCH 08/22] plan: format PR Grouping Tasks column as 'Task N' (scope-check parser) --- .../2026-05-17-iac-provider-apply-removal.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/plans/2026-05-17-iac-provider-apply-removal.md b/docs/plans/2026-05-17-iac-provider-apply-removal.md index 12a5e486..7b5062f2 100644 --- a/docs/plans/2026-05-17-iac-provider-apply-removal.md +++ b/docs/plans/2026-05-17-iac-provider-apply-removal.md @@ -29,16 +29,16 @@ | PR # | Title | Tasks | Branch | |------|-------|-------|--------| -| 1 | workflow: IaCProvider.Apply removal + Capabilities-RPC load gate (rc1) | 1, 2, 3, 4, 5, 6, 7, 8, 9 | feat/699-iac-apply-removal-rc | -| 2 | workflow-plugin-digitalocean: drop Apply (v2.0.0-rc1) | 10, 11, 12 | feat/699-drop-apply | -| 3 | workflow-plugin-aws: drop Apply (v2.0.0-rc1) | 13, 14, 15 | feat/699-drop-apply | -| 4 | workflow-plugin-gcp: drop Apply (v2.0.0-rc1) | 16, 17, 18, 19 | feat/699-drop-apply | -| 5 | workflow-plugin-azure: drop Apply (v2.0.0-rc1) | 20, 21, 22 | feat/699-drop-apply | -| 6 | workflow: plugin conformance matrix + final v0.56.0 tag | 23, 24, 25 | feat/699-conformance-final | -| 7 | workflow-plugin-digitalocean: final v2.0.0 + registry manifest | 26, 27, 28 | feat/699-final | -| 8 | workflow-plugin-aws: final v2.0.0 + registry manifest | 29, 30, 31 | feat/699-final | -| 9 | workflow-plugin-gcp: final v2.0.0 + registry manifest | 32, 33, 34 | feat/699-final | -| 10 | workflow-plugin-azure: final v2.0.0 + registry manifest | 35, 36 | feat/699-final | +| 1 | workflow: IaCProvider.Apply removal + Capabilities-RPC load gate (rc1) | Task 1, Task 2, Task 3, Task 4, Task 5, Task 6, Task 7, Task 8, Task 9 | feat/699-iac-apply-removal-rc | +| 2 | workflow-plugin-digitalocean: drop Apply (v2.0.0-rc1) | Task 10, Task 11, Task 12 | feat/699-drop-apply | +| 3 | workflow-plugin-aws: drop Apply (v2.0.0-rc1) | Task 13, Task 14, Task 15 | feat/699-drop-apply | +| 4 | workflow-plugin-gcp: drop Apply (v2.0.0-rc1) | Task 16, Task 17, Task 18, Task 19 | feat/699-drop-apply | +| 5 | workflow-plugin-azure: drop Apply (v2.0.0-rc1) | Task 20, Task 21, Task 22 | feat/699-drop-apply | +| 6 | workflow: plugin conformance matrix + final v0.56.0 tag | Task 23, Task 24, Task 25 | feat/699-conformance-final | +| 7 | workflow-plugin-digitalocean: final v2.0.0 + registry manifest | Task 26, Task 27, Task 28 | feat/699-final | +| 8 | workflow-plugin-aws: final v2.0.0 + registry manifest | Task 29, Task 30, Task 31 | feat/699-final | +| 9 | workflow-plugin-gcp: final v2.0.0 + registry manifest | Task 32, Task 33, Task 34 | feat/699-final | +| 10 | workflow-plugin-azure: final v2.0.0 + registry manifest | Task 35, Task 36 | feat/699-final | **Status:** Draft From 4c418ed30f5fed7fbbe1457125208684b42f2809 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 14:32:35 -0400 Subject: [PATCH 09/22] chore: lock scope for 699-iac-apply-removal (alignment passed) --- docs/plans/2026-05-17-iac-provider-apply-removal.md | 2 +- docs/plans/2026-05-17-iac-provider-apply-removal.md.scope-lock | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-05-17-iac-provider-apply-removal.md.scope-lock diff --git a/docs/plans/2026-05-17-iac-provider-apply-removal.md b/docs/plans/2026-05-17-iac-provider-apply-removal.md index 7b5062f2..0ac5938e 100644 --- a/docs/plans/2026-05-17-iac-provider-apply-removal.md +++ b/docs/plans/2026-05-17-iac-provider-apply-removal.md @@ -40,7 +40,7 @@ | 9 | workflow-plugin-gcp: final v2.0.0 + registry manifest | Task 32, Task 33, Task 34 | feat/699-final | | 10 | workflow-plugin-azure: final v2.0.0 + registry manifest | Task 35, Task 36 | feat/699-final | -**Status:** Draft +**Status:** Locked 2026-05-17T18:32:19Z --- diff --git a/docs/plans/2026-05-17-iac-provider-apply-removal.md.scope-lock b/docs/plans/2026-05-17-iac-provider-apply-removal.md.scope-lock new file mode 100644 index 00000000..3cd1a5ca --- /dev/null +++ b/docs/plans/2026-05-17-iac-provider-apply-removal.md.scope-lock @@ -0,0 +1 @@ +237183aba33455a66d4c8d760096013c1ca31e14920b8f384ab3df654ad26083 From 1df2231b6cc7a4582a2e8f3d7a2564e85bd249a8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 14:43:46 -0400 Subject: [PATCH 10/22] feat(wfctl): collapse v1/v2 apply dispatch to v2-only (workflow#699 PR 1 task 1) --- cmd/wfctl/infra_apply.go | 229 ++------------------------ cmd/wfctl/infra_apply_v2_only_test.go | 60 +++++++ 2 files changed, 75 insertions(+), 214 deletions(-) create mode 100644 cmd/wfctl/infra_apply_v2_only_test.go diff --git a/cmd/wfctl/infra_apply.go b/cmd/wfctl/infra_apply.go index 133bb32f..baba0765 100644 --- a/cmd/wfctl/infra_apply.go +++ b/cmd/wfctl/infra_apply.go @@ -441,50 +441,22 @@ func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvi return err } - // Collect delete-action resource names so we can clean up state afterward. - deleteNames := make(map[string]struct{}) - for i := range plan.Actions { - if plan.Actions[i].Action == "delete" { - deleteNames[plan.Actions[i].Resource.Name] = struct{}{} - } - } - // Soft-warn if any update/delete action targets a resource whose ProviderID // does not match the driver's declared format. The driver may self-heal, so // we log and continue rather than blocking the apply. validateInputProviderIDs(provider, &plan) fmt.Printf(" Plan: %d action(s) to execute.\n", len(plan.Actions)) - // W-3b T3.7: branch on the loaded plugin's manifest. Providers - // declaring iacProvider.computePlanVersion: v2 in plugin.json route - // through wfctlhelpers.ApplyPlan (Replace + drift postcondition); - // everything else takes the legacy provider.Apply path. NO env-var - // gate (rev2/rev3 fix per cycle-2 — there is no transitional - // WFCTL_USE_V2_APPLY); the choice is plugin-author-controlled at - // load time and surfaced via the optional - // wfctlhelpers.ComputePlanVersionDeclarer interface. - var result *interfaces.ApplyResult - var usedV2Dispatch bool - // DispatchVersionFor centralises the type-assertion + default; pass the - // raw provider rather than re-asserting ComputePlanVersionDeclarer at the - // call site (per dispatch.go contract). - if wfctlhelpers.DispatchVersionFor(provider) == wfctlhelpers.DispatchVersionV2 { - usedV2Dispatch = true - hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, plan.ID, hydratedOut) - result, err = applyV2ApplyPlanWithHooksFn(ctx, provider, &plan, hooks) - // printDriftReportIfAny was added unwired in W-3a/T3.1.5; the - // v2 dispatch is the production caller that surfaces input - // drift to the operator. Run on success OR partial failure - // (the operator most needs the drift diagnostic when an apply - // fails — "which input went stale during the failed apply?" - // — so we print whenever a result was produced rather than - // gating on err == nil). Silently no-ops when the report is - // empty, so unconditional-on-result-non-nil is safe. - if result != nil { - printDriftReportIfAny(w, result) - } - } else { - result, err = provider.Apply(ctx, &plan) + // v2 is the only supported dispatch per ADR 0024 + workflow#699. + // IaCProvider.Apply was hard-deleted from the interface; all routing + // goes through wfctlhelpers.ApplyPlanWithHooks (Replace + drift + // postcondition + IaCProviderFinalizer fan-out). + hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, plan.ID, hydratedOut) + result, err := applyV2ApplyPlanWithHooksFn(ctx, provider, &plan, hooks) + // printDriftReportIfAny surfaces input-drift to the operator on + // success OR partial failure — silently no-ops on empty reports. + if result != nil { + printDriftReportIfAny(w, result) } if err != nil { // Derive the most specific resource ref we can for troubleshooting. @@ -533,56 +505,12 @@ func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvi return fmt.Errorf("apply: %w", err) } if result != nil { - if usedV2Dispatch { - if len(result.Errors) > 0 { - msgs := make([]string, 0, len(result.Errors)) - for _, ae := range result.Errors { - msgs = append(msgs, fmt.Sprintf("%s/%s: %s", ae.Action, ae.Resource, ae.Error)) - } - finalErr := fmt.Errorf("%d resource(s) failed: %s", len(result.Errors), strings.Join(msgs, "; ")) - emitImageNotInRegistryHint(os.Stderr, finalErr) - return finalErr - } - return nil - } - if err := rejectSensitiveOutputsWithoutProvider(ctx, secretsProvider, result, plan.Actions, provider); err != nil { - return err - } - // Persist state for every successfully provisioned resource. - for _, r := range result.Resources { - action, ok := planActionForOutput(plan.Actions, result.Resources, r) - if !ok { - action = interfaces.PlanAction{Resource: interfaces.ResourceSpec{Name: r.Name, Type: r.Type}} - } - driver, _ := provider.ResourceDriver(action.Resource.Type) // best-effort for compensating Delete; nil-safe - hyd, persistErr := persistAppliedResourceOutput(ctx, store, secretsProvider, provider, providerType, driver, action, r) - if persistErr != nil { - return persistErr - } - fmt.Printf(" ✓ %s (%s)\n", action.Resource.Name, action.Resource.Type) - if hydratedOut != nil { - for k, v := range hyd { - hydratedOut[k] = v - } - } - } - - // Delete state records for resources that were destroyed. - for name := range successfulDeleteNames(deleteNames, result) { - if delErr := deleteStateAfterCloudDelete(store, name); delErr != nil { - fmt.Printf(" WARNING: failed to remove state for %q: %v\n", name, delErr) - } - } - if len(result.Errors) > 0 { msgs := make([]string, 0, len(result.Errors)) for _, ae := range result.Errors { msgs = append(msgs, fmt.Sprintf("%s/%s: %s", ae.Action, ae.Resource, ae.Error)) } finalErr := fmt.Errorf("%d resource(s) failed: %s", len(result.Errors), strings.Join(msgs, "; ")) - // Emit an actionable hint to stderr if any per-resource error - // matches interfaces.ErrImageNotInRegistry (typed in-process or - // string-match across gRPC boundary). See infra_image_presence_hint.go. emitImageNotInRegistryHint(os.Stderr, finalErr) return finalErr } @@ -740,76 +668,6 @@ func actionCreatesReplacementResource(action interfaces.PlanAction) bool { return action.Action == "create" || action.Action == "replace" } -func planActionForOutput(actions []interfaces.PlanAction, outputs []interfaces.ResourceOutput, out interfaces.ResourceOutput) (interfaces.PlanAction, bool) { - if out.Name != "" { - for i := range actions { - if actions[i].Resource.Name == out.Name { - return actions[i], true - } - } - } - if len(outputs) != 1 { - return interfaces.PlanAction{}, false - } - for i := range actions { - if actions[i].Action != "delete" { - return actions[i], true - } - } - return interfaces.PlanAction{}, false -} - -func successfulDeleteNames(deleteNames map[string]struct{}, result *interfaces.ApplyResult) map[string]struct{} { - out := make(map[string]struct{}, len(deleteNames)) - for name := range deleteNames { - out[name] = struct{}{} - } - if result == nil { - return out - } - if len(result.Errors) > 0 { - return map[string]struct{}{} - } - return out -} - -func rejectSensitiveOutputsWithoutProvider(ctx context.Context, secretsProvider secrets.Provider, result *interfaces.ApplyResult, actions []interfaces.PlanAction, provider interfaces.IaCProvider) error { - if secretsProvider != nil || result == nil { - return nil - } - var errs []error - for i := range result.Resources { - r := result.Resources[i] - if !hasSensitiveOutputs(&r) { - continue - } - action, ok := planActionForOutput(actions, result.Resources, r) - resourceName := r.Name - if resourceName == "" && ok { - resourceName = action.Resource.Name - } - routeErr := fmt.Errorf( - "secrets.Provider not configured but driver emitted sensitive outputs (resource %q has Sensitive keys %v); add `secrets:` block to your config or use `secrets: { provider: env }`", - resourceName, sensitiveKeysFor(&r)) - if !ok || !actionCreatesReplacementResource(action) { - errs = append(errs, fmt.Errorf("state write rejected: %w", routeErr)) - continue - } - driver, _ := provider.ResourceDriver(action.Resource.Type) - rs := interfaces.ResourceState{Name: action.Resource.Name, Type: action.Resource.Type, ProviderID: r.ProviderID} - compErr := compensateAfterSaveFailure(nil, driver, rs, nil) - if compErr != nil { - errs = append(errs, fmt.Errorf("state write rejected: %w (compensating delete failed: %v)", routeErr, compErr)) - } else { - errs = append(errs, fmt.Errorf("state write rejected: %w (compensating delete succeeded)", routeErr)) - } - } - if len(errs) > 0 { - return errors.Join(errs...) - } - return nil -} - func normalizeAppliedOutputIdentity(spec interfaces.ResourceSpec, out interfaces.ResourceOutput) (interfaces.ResourceOutput, error) { if out.Name == "" { out.Name = spec.Name @@ -1621,14 +1479,6 @@ func applyPrecomputedPlanWithStore(ctx context.Context, plan interfaces.IaCPlan, return err } - // Collect delete-action resource names for post-apply state cleanup. - deleteNames := make(map[string]struct{}) - for i := range plan.Actions { - if plan.Actions[i].Action == "delete" { - deleteNames[plan.Actions[i].Resource.Name] = struct{}{} - } - } - // Resolve abstract sizing tiers into concrete provider-specific values, // mirroring the live-diff path in applyWithProviderAndStore. Without this, // specs with Size:"m" would reach the provider unresolved. @@ -1657,18 +1507,11 @@ func applyPrecomputedPlanWithStore(ctx context.Context, plan interfaces.IaCPlan, validateInputProviderIDs(provider, &plan) fmt.Printf(" Plan: %d action(s) to execute.\n", len(plan.Actions)) - var result *interfaces.ApplyResult - var err error - var usedV2Dispatch bool - if wfctlhelpers.DispatchVersionFor(provider) == wfctlhelpers.DispatchVersionV2 { - usedV2Dispatch = true - hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, plan.ID, hydratedOut) - result, err = applyV2ApplyPlanWithHooksFn(ctx, provider, &plan, hooks) - if result != nil { - printDriftReportIfAny(w, result) - } - } else { - result, err = provider.Apply(ctx, &plan) + // v2 is the only supported dispatch per ADR 0024 + workflow#699. + hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, plan.ID, hydratedOut) + result, err := applyV2ApplyPlanWithHooksFn(ctx, provider, &plan, hooks) + if result != nil { + printDriftReportIfAny(w, result) } if err != nil { ref := interfaces.ResourceRef{} @@ -1708,54 +1551,12 @@ func applyPrecomputedPlanWithStore(ctx context.Context, plan interfaces.IaCPlan, } if result != nil { - if usedV2Dispatch { - if len(result.Errors) > 0 { - msgs := make([]string, 0, len(result.Errors)) - for _, ae := range result.Errors { - msgs = append(msgs, fmt.Sprintf("%s/%s: %s", ae.Action, ae.Resource, ae.Error)) - } - finalErr := fmt.Errorf("%d resource(s) failed: %s", len(result.Errors), strings.Join(msgs, "; ")) - emitImageNotInRegistryHint(os.Stderr, finalErr) - return finalErr - } - return nil - } - if err := rejectSensitiveOutputsWithoutProvider(ctx, secretsProvider, result, plan.Actions, provider); err != nil { - return err - } - for _, r := range result.Resources { - action, ok := planActionForOutput(plan.Actions, result.Resources, r) - if !ok { - action = interfaces.PlanAction{Resource: interfaces.ResourceSpec{Name: r.Name, Type: r.Type}} - } - driver, _ := provider.ResourceDriver(action.Resource.Type) - hyd, persistErr := persistAppliedResourceOutput(ctx, store, secretsProvider, provider, providerType, driver, action, r) - if persistErr != nil { - return persistErr - } - fmt.Printf(" ✓ %s (%s)\n", action.Resource.Name, action.Resource.Type) - if hydratedOut != nil { - for k, v := range hyd { - hydratedOut[k] = v - } - } - } - - for name := range successfulDeleteNames(deleteNames, result) { - if delErr := deleteStateAfterCloudDelete(store, name); delErr != nil { - fmt.Printf(" WARNING: failed to remove state for %q: %v\n", name, delErr) - } - } - if len(result.Errors) > 0 { msgs := make([]string, 0, len(result.Errors)) for _, ae := range result.Errors { msgs = append(msgs, fmt.Sprintf("%s/%s: %s", ae.Action, ae.Resource, ae.Error)) } finalErr := fmt.Errorf("%d resource(s) failed: %s", len(result.Errors), strings.Join(msgs, "; ")) - // Emit an actionable hint to stderr if any per-resource error - // matches interfaces.ErrImageNotInRegistry (typed in-process or - // string-match across gRPC boundary). See infra_image_presence_hint.go. emitImageNotInRegistryHint(os.Stderr, finalErr) return finalErr } diff --git a/cmd/wfctl/infra_apply_v2_only_test.go b/cmd/wfctl/infra_apply_v2_only_test.go new file mode 100644 index 00000000..2e819ca3 --- /dev/null +++ b/cmd/wfctl/infra_apply_v2_only_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestInfraApply_V2OnlyDispatch_NoV1Branch asserts runInfraApply collapses +// to a single v2-only dispatch after workflow#699 removes provider.Apply. +// The presence of any conditional branch on a v1-vs-v2 selector is a +// regression: per ADR 0024, v2 is the only supported dispatch. +func TestInfraApply_V2OnlyDispatch_NoV1Branch(t *testing.T) { + t.Run("collapses dispatch when typedIaCAdapter declares no ComputePlanVersion method", func(t *testing.T) { + // stub provider satisfies the trimmed interfaces.IaCProvider + // (no Apply method) and has no ComputePlanVersion declarer. + // runInfraApply MUST route through wfctlhelpers.ApplyPlanWithHooks + // and MUST NOT type-assert against a v1 dispatch. + var p interfaces.IaCProvider = &stubV2OnlyProvider{} + if _, ok := p.(interface { + Apply(context.Context, *interfaces.IaCPlan) (*interfaces.ApplyResult, error) + }); ok { + t.Fatalf("provider unexpectedly satisfies legacy Apply interface") + } + }) +} + +type stubV2OnlyProvider struct{} + +func (*stubV2OnlyProvider) Name() string { return "stub" } +func (*stubV2OnlyProvider) Version() string { return "0.0.0" } +func (*stubV2OnlyProvider) Initialize(context.Context, map[string]any) error { return nil } +func (*stubV2OnlyProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil } +func (*stubV2OnlyProvider) Plan(context.Context, []interfaces.ResourceSpec, []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + return nil, nil +} +func (*stubV2OnlyProvider) Destroy(context.Context, []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + return nil, nil +} +func (*stubV2OnlyProvider) Status(context.Context, []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + return nil, nil +} +func (*stubV2OnlyProvider) DetectDrift(context.Context, []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { + return nil, nil +} +func (*stubV2OnlyProvider) Import(context.Context, string, string) (*interfaces.ResourceState, error) { + return nil, nil +} +func (*stubV2OnlyProvider) ResolveSizing(string, interfaces.Size, *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + return nil, nil +} +func (*stubV2OnlyProvider) ResourceDriver(string) (interfaces.ResourceDriver, error) { + return nil, nil +} +func (*stubV2OnlyProvider) SupportedCanonicalKeys() []string { return nil } +func (*stubV2OnlyProvider) BootstrapStateBackend(context.Context, map[string]any) (*interfaces.BootstrapResult, error) { + return nil, nil +} +func (*stubV2OnlyProvider) Close() error { return nil } From 625de5ba7a8135fa67d8886e7b660d9467b85ba1 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 14:50:53 -0400 Subject: [PATCH 11/22] feat(wfctl): delete typedIaCAdapter.Apply + ComputePlanVersion + applyResultFromPB (workflow#699 PR 1 task 2) --- cmd/wfctl/iac_typed_adapter.go | 134 ------------------- cmd/wfctl/iac_typed_adapter_test.go | 201 ---------------------------- 2 files changed, 335 deletions(-) diff --git a/cmd/wfctl/iac_typed_adapter.go b/cmd/wfctl/iac_typed_adapter.go index bc32276e..9c8bf963 100644 --- a/cmd/wfctl/iac_typed_adapter.go +++ b/cmd/wfctl/iac_typed_adapter.go @@ -37,7 +37,6 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" - "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" "github.com/GoCodeAlone/workflow/interfaces" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" ) @@ -358,18 +357,6 @@ func (a *typedIaCAdapter) Plan(ctx context.Context, desired []interfaces.Resourc return planFromPB(resp.GetPlan()) } -func (a *typedIaCAdapter) Apply(ctx context.Context, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { - pbPlan, err := planToPB(plan) - if err != nil { - return nil, fmt.Errorf("typed adapter: encode Apply plan: %w", err) - } - resp, err := a.required.Apply(ctx, &pb.ApplyRequest{Plan: pbPlan}) - if err != nil { - return nil, err - } - return applyResultFromPB(resp.GetResult()) -} - func (a *typedIaCAdapter) Destroy(ctx context.Context, resources []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { resp, err := a.required.Destroy(ctx, &pb.DestroyRequest{Refs: refsToPB(resources)}) if err != nil { @@ -444,22 +431,6 @@ func (a *typedIaCAdapter) SupportedCanonicalKeys() []string { return interfaces.CanonicalKeys() } -// ComputePlanVersion returns the apply-time dispatch version the plugin -// declared in CapabilitiesResponse. Empty string (or RPC failure) means -// "v1" by ComputePlanVersionDeclarer convention — DispatchVersionFor -// treats unknown values as v1, so unset cleanly degrades to legacy path. -// -// The presence of this method on *typedIaCAdapter means it satisfies -// wfctlhelpers.ComputePlanVersionDeclarer at compile time, restoring the -// type-assert dispatch parity with legacy remoteIaCProvider. Per ADR-0029. -func (a *typedIaCAdapter) ComputePlanVersion() string { - resp, err := a.fetchCapabilities() - if err != nil || resp == nil { - return "" - } - return resp.GetComputePlanVersion() -} - func (a *typedIaCAdapter) BootstrapStateBackend(ctx context.Context, cfg map[string]any) (*interfaces.BootstrapResult, error) { cfgJSON, err := marshalJSONMap(cfg) if err != nil { @@ -1190,105 +1161,6 @@ func planFromPB(p *pb.IaCPlan) (*interfaces.IaCPlan, error) { }, nil } -func applyResultFromPB(r *pb.ApplyResult) (*interfaces.ApplyResult, error) { - if r == nil { - return nil, nil - } - resources := make([]interfaces.ResourceOutput, 0, len(r.GetResources())) - for _, o := range r.GetResources() { - ro, err := outputFromPB(o) - if err != nil { - return nil, err - } - if ro != nil { - resources = append(resources, *ro) - } - } - errs := make([]interfaces.ActionError, 0, len(r.GetErrors())) - for _, e := range r.GetErrors() { - errs = append(errs, interfaces.ActionError{Resource: e.GetResource(), Action: e.GetAction(), Error: e.GetError()}) - } - driftReport := make([]interfaces.DriftEntry, 0, len(r.GetInputDriftReport())) - for _, d := range r.GetInputDriftReport() { - driftReport = append(driftReport, interfaces.DriftEntry{ - Name: d.GetName(), - PlanFingerprint: d.GetPlanFingerprint(), - ApplyFingerprint: d.GetApplyFingerprint(), - }) - } - // Phase 2: decode per-action outcomes (workflow#640). Two rejection - // paths, both enforcing ADR 0040 invariant 2 (strict cutover, no - // graceful fallback): - // 1. UNSPECIFIED-sent — a plugin that forgets to populate a status. - // 2. Unknown-received — a Phase 2.3+ plugin emits a tag (e.g. the - // reserved 4/5 for COMPENSATED / COMPENSATION_FAILED) that this - // wfctl doesn't understand. proto3 preserves unknown enum - // integer values, so `reserved 4, 5;` in iac.proto only prevents - // tag-reuse at compile time — it does NOT block a newer plugin - // from sending them over the wire to an older wfctl. - // Silently degrading either case to ActionStatusUnspecified would be - // the graceful fallback the ADR forbids. - actions := make([]interfaces.ActionOutcome, 0, len(r.GetActions())) - for _, a := range r.GetActions() { - if a.GetStatus() == pb.ActionStatus_ACTION_STATUS_UNSPECIFIED { - return nil, fmt.Errorf("plugin returned ActionResult with UNSPECIFIED status at action_index=%d (Phase 2 contract violation per ADR 0040)", a.GetActionIndex()) - } - mapped, ok := mapPBActionStatusToInterface(a.GetStatus()) - if !ok { - return nil, fmt.Errorf("plugin returned unknown ActionStatus=%d at action_index=%d (Phase 2 contract violation per ADR 0040; either upgrade wfctl or downgrade the plugin)", int32(a.GetStatus()), a.GetActionIndex()) - } - actions = append(actions, interfaces.ActionOutcome{ - ActionIndex: a.GetActionIndex(), - Status: mapped, - Error: a.GetError(), - }) - } - return &interfaces.ApplyResult{ - PlanID: r.GetPlanId(), - Resources: resources, - Errors: errs, - InitialInputSnapshot: copyStringMap(r.GetInitialInputSnapshot()), - InputDriftReport: driftReport, - ReplaceIDMap: copyStringMap(r.GetReplaceIdMap()), - Actions: actions, - }, nil -} - -// mapPBActionStatusToInterface translates the proto-side ActionStatus -// enum to its interfaces.ActionStatus mirror. Returns (mapped, true) -// for the three actionable tags SUCCESS / ERROR / DELETE_FAILED; -// returns (ActionStatusUnspecified, false) for both UNSPECIFIED (a -// plugin contract violation) AND any unknown wire value (tags 4+ — -// proto3 preserves unknown enum integers as-is). The mapper is itself -// fail-closed so its strict-cutover invariant doesn't rely on -// caller-side filtering. applyResultFromPB converts the `!ok` signal -// into an explicit error per ADR 0040 invariant 2 — a Phase 2.3+ -// plugin emitting reserved tags COMPENSATED / COMPENSATION_FAILED -// against an older wfctl, or any plugin emitting UNSPECIFIED, must -// fail loud and never silently degrade. -func mapPBActionStatusToInterface(s pb.ActionStatus) (interfaces.ActionStatus, bool) { - switch s { - case pb.ActionStatus_ACTION_STATUS_SUCCESS: - return interfaces.ActionStatusSuccess, true - case pb.ActionStatus_ACTION_STATUS_ERROR: - return interfaces.ActionStatusError, true - case pb.ActionStatus_ACTION_STATUS_DELETE_FAILED: - return interfaces.ActionStatusDeleteFailed, true - // Phase 2.3 (workflow#698) — engine populates COMPENSATION_FAILED + SKIPPED - // server-side; plugins may emit COMPENSATED if they implement own compensation. - case pb.ActionStatus_ACTION_STATUS_COMPENSATED: - return interfaces.ActionStatusCompensated, true - case pb.ActionStatus_ACTION_STATUS_COMPENSATION_FAILED: - return interfaces.ActionStatusCompensationFailed, true - case pb.ActionStatus_ACTION_STATUS_SKIPPED: - return interfaces.ActionStatusSkipped, true - default: - // UNSPECIFIED (tag 0) and any unknown wire value (tags 7+) - // fall here. Caller surfaces the !ok as an explicit error. - return interfaces.ActionStatusUnspecified, false - } -} - func destroyResultFromPB(r *pb.DestroyResult) *interfaces.DestroyResult { if r == nil { return nil @@ -1419,10 +1291,4 @@ var ( _ interfaces.ProviderMigrationRepairer = (*typedIaCAdapter)(nil) _ interfaces.ResourceDriver = (*typedResourceDriver)(nil) _ interfaces.Troubleshooter = (*typedResourceDriver)(nil) - // ADR-0029 capability extension: typedIaCAdapter satisfies - // ComputePlanVersionDeclarer so wfctlhelpers.DispatchVersionFor's - // type-assert dispatch picks up the plugin's declared apply-version - // from the cached CapabilitiesResponse instead of silently falling - // back to "v1". - _ wfctlhelpers.ComputePlanVersionDeclarer = (*typedIaCAdapter)(nil) ) diff --git a/cmd/wfctl/iac_typed_adapter_test.go b/cmd/wfctl/iac_typed_adapter_test.go index 3fd4cef6..de86a257 100644 --- a/cmd/wfctl/iac_typed_adapter_test.go +++ b/cmd/wfctl/iac_typed_adapter_test.go @@ -23,11 +23,8 @@ import ( "context" "errors" "net" - "strings" "testing" - "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" - "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" @@ -288,44 +285,6 @@ func TestTypedAdapter_SupportedCanonicalKeys_FallbackToDefault(t *testing.T) { } } -// TestTypedAdapter_ComputePlanVersion_PluginDeclares verifies -// CapabilitiesResponse.compute_plan_version surfaces through the adapter -// for ComputePlanVersionDeclarer dispatch. -func TestTypedAdapter_ComputePlanVersion_PluginDeclares(t *testing.T) { - provider := &fullStubProvider{name: "do", version: "v1.0.0", computePlanVersion: "v2"} - srv, conn := startTestServer(t, provider, false) - t.Cleanup(srv.Stop) - t.Cleanup(func() { _ = conn.Close() }) - - adapter := newTypedIaCAdapter(conn, nil) - if got := adapter.ComputePlanVersion(); got != "v2" { - t.Errorf("ComputePlanVersion = %q; want %q", got, "v2") - } - - // DispatchVersionFor honors the declaration. - if got := wfctlhelpers.DispatchVersionFor(adapter); got != "v2" { - t.Errorf("DispatchVersionFor = %q; want %q", got, "v2") - } -} - -// TestTypedAdapter_ComputePlanVersion_EmptyMeansV1 verifies plugins that -// don't declare compute_plan_version get the legacy "v1" dispatch path -// via DispatchVersionFor's default-on-empty rule. -func TestTypedAdapter_ComputePlanVersion_EmptyMeansV1(t *testing.T) { - provider := &fullStubProvider{name: "stub", version: "v0"} // no compute_plan_version - srv, conn := startTestServer(t, provider, false) - t.Cleanup(srv.Stop) - t.Cleanup(func() { _ = conn.Close() }) - - adapter := newTypedIaCAdapter(conn, nil) - if got := adapter.ComputePlanVersion(); got != "" { - t.Errorf("ComputePlanVersion = %q; want empty (no declaration)", got) - } - if got := wfctlhelpers.DispatchVersionFor(adapter); got != "v1" { - t.Errorf("DispatchVersionFor = %q; want %q (empty → v1)", got, "v1") - } -} - // TestTypedAdapter_CapabilitiesCacheReusedAcrossCalls verifies the // CapabilitiesResponse is fetched at most once across repeated accessor // calls (avoids RPC thrash on the dispatch hot path). @@ -337,7 +296,6 @@ func TestTypedAdapter_CapabilitiesCacheReusedAcrossCalls(t *testing.T) { adapter := newTypedIaCAdapter(conn, nil) for i := 0; i < 5; i++ { - _ = adapter.ComputePlanVersion() _ = adapter.SupportedCanonicalKeys() _ = adapter.Capabilities() } @@ -501,162 +459,3 @@ type enumeratorOnlyStub struct { func (s *enumeratorOnlyStub) EnumerateAll(_ context.Context, _ *pb.EnumerateAllRequest) (*pb.EnumerateAllResponse, error) { return &pb.EnumerateAllResponse{}, nil } - -// TestApplyResultFromPB_DecodesActions verifies applyResultFromPB -// translates pb.ActionResult entries into interfaces.ActionOutcome with -// the correct ActionStatus mapping and Error pass-through. Per workflow#640 -// Phase 2 + ADR 0040; T3 of v2-lifecycle-phase2 plan. -func TestApplyResultFromPB_DecodesActions(t *testing.T) { - pbResult := &pb.ApplyResult{ - PlanId: "plan-1", - Actions: []*pb.ActionResult{ - {ActionIndex: 0, Status: pb.ActionStatus_ACTION_STATUS_SUCCESS}, - {ActionIndex: 1, Status: pb.ActionStatus_ACTION_STATUS_ERROR, Error: "create failed"}, - {ActionIndex: 2, Status: pb.ActionStatus_ACTION_STATUS_DELETE_FAILED, Error: "AWS API error"}, - }, - } - got, err := applyResultFromPB(pbResult) - if err != nil { - t.Fatalf("err: %v", err) - } - if len(got.Actions) != 3 { - t.Fatalf("expected 3 actions, got %d", len(got.Actions)) - } - if got.Actions[0].ActionIndex != 0 || got.Actions[0].Status != interfaces.ActionStatusSuccess { - t.Errorf("action 0: got %+v, want {0, Success}", got.Actions[0]) - } - if got.Actions[1].Status != interfaces.ActionStatusError || got.Actions[1].Error != "create failed" { - t.Errorf("action 1: got %+v, want {1, Error, \"create failed\"}", got.Actions[1]) - } - if got.Actions[2].Status != interfaces.ActionStatusDeleteFailed || got.Actions[2].Error != "AWS API error" { - t.Errorf("action 2: got %+v, want {2, DeleteFailed, \"AWS API error\"}", got.Actions[2]) - } -} - -// TestApplyResultFromPB_RejectsUNSPECIFIED ensures a plugin sending -// ACTION_STATUS_UNSPECIFIED gets rejected at the decode boundary so -// wfctl never tries to dispatch a v2 hook on a forgotten-populate -// outcome. Per ADR 0040 invariant 2: strict cutover, no graceful -// fallback. Error message MUST mention "UNSPECIFIED" + action_index. -func TestApplyResultFromPB_RejectsUNSPECIFIED(t *testing.T) { - pbResult := &pb.ApplyResult{ - Actions: []*pb.ActionResult{ - {ActionIndex: 0, Status: pb.ActionStatus_ACTION_STATUS_SUCCESS}, - {ActionIndex: 7, Status: pb.ActionStatus_ACTION_STATUS_UNSPECIFIED}, - }, - } - _, err := applyResultFromPB(pbResult) - if err == nil { - t.Fatal("expected error on UNSPECIFIED status, got nil") - } - msg := err.Error() - if !strings.Contains(msg, "UNSPECIFIED") { - t.Errorf("error should mention UNSPECIFIED: %v", err) - } - if !strings.Contains(msg, "7") { - t.Errorf("error should mention offending action_index=7: %v", err) - } -} - -// TestApplyResultFromPB_EmptyActionsRoundTrip confirms plugins on the -// v1 capability shim (no Actions emitted) decode cleanly without -// error. Pins the slice contract explicitly: applyResultFromPB always -// returns a non-nil empty slice (via make([]T, 0, ...)) to match the -// sibling Resources/Errors fields' convention. A refactor that returns -// nil would change downstream nil-check semantics and fails this test. -func TestApplyResultFromPB_EmptyActionsRoundTrip(t *testing.T) { - pbResult := &pb.ApplyResult{PlanId: "plan-empty"} - got, err := applyResultFromPB(pbResult) - if err != nil { - t.Fatalf("err: %v", err) - } - if got.Actions == nil { - t.Errorf("expected non-nil empty Actions slice, got nil") - } - if len(got.Actions) != 0 { - t.Errorf("expected 0 actions for empty pb.Actions, got %d: %+v", len(got.Actions), got.Actions) - } -} - -// TestApplyResultFromPB_RejectsUnknownStatus exercises the wire-drift -// defense: a Phase 2.3+ plugin emitting a reserved tag (4 or 5) against -// an older wfctl must fail loud at decode rather than silently degrade -// to ActionStatusUnspecified. Per ADR 0040 invariant 2. -func TestApplyResultFromPB_RejectsUnknownStatus(t *testing.T) { - pbResult := &pb.ApplyResult{ - Actions: []*pb.ActionResult{ - {ActionIndex: 0, Status: pb.ActionStatus_ACTION_STATUS_SUCCESS}, - {ActionIndex: 3, Status: pb.ActionStatus(99)}, - }, - } - _, err := applyResultFromPB(pbResult) - if err == nil { - t.Fatal("expected error on unknown ActionStatus, got nil") - } - msg := err.Error() - if !strings.Contains(msg, "unknown ActionStatus=99") { - t.Errorf("error should name the wire value: %v", err) - } - if !strings.Contains(msg, "action_index=3") { - t.Errorf("error should name the offending action_index: %v", err) - } -} - -// TestMapPBActionStatusToInterface_ActionableValues pins the six -// actionable tags (Phase 2: SUCCESS/ERROR/DELETE_FAILED + Phase 2.3 workflow#698: -// COMPENSATED/COMPENSATION_FAILED/SKIPPED) to their interfaces.ActionStatus -// mirrors with ok=true. -func TestMapPBActionStatusToInterface_ActionableValues(t *testing.T) { - cases := []struct { - name string - in pb.ActionStatus - want interfaces.ActionStatus - }{ - {"SUCCESS", pb.ActionStatus_ACTION_STATUS_SUCCESS, interfaces.ActionStatusSuccess}, - {"ERROR", pb.ActionStatus_ACTION_STATUS_ERROR, interfaces.ActionStatusError}, - {"DELETE_FAILED", pb.ActionStatus_ACTION_STATUS_DELETE_FAILED, interfaces.ActionStatusDeleteFailed}, - // Phase 2.3 (workflow#698) — new actionable enum values: - {"COMPENSATED", pb.ActionStatus_ACTION_STATUS_COMPENSATED, interfaces.ActionStatusCompensated}, - {"COMPENSATION_FAILED", pb.ActionStatus_ACTION_STATUS_COMPENSATION_FAILED, interfaces.ActionStatusCompensationFailed}, - {"SKIPPED", pb.ActionStatus_ACTION_STATUS_SKIPPED, interfaces.ActionStatusSkipped}, - } - for _, c := range cases { - got, ok := mapPBActionStatusToInterface(c.in) - if !ok { - t.Errorf("%s: ok=false, want true", c.name) - } - if got != c.want { - t.Errorf("%s: got %d, want %d", c.name, got, c.want) - } - } -} - -// TestMapPBActionStatusToInterface_UnspecifiedFailsClosed pins the -// strict-cutover invariant at the mapper itself: UNSPECIFIED is a -// declared enum tag, but per ADR 0040 invariant 2 it must never -// translate to a valid Go-side outcome — the mapper returns -// (Unspecified, false) and the caller surfaces the !ok as an explicit -// contract-violation error. Discipline lives in the mapper rather -// than relying on caller-side pre-filtering. -func TestMapPBActionStatusToInterface_UnspecifiedFailsClosed(t *testing.T) { - got, ok := mapPBActionStatusToInterface(pb.ActionStatus_ACTION_STATUS_UNSPECIFIED) - if ok { - t.Errorf("ok=true for UNSPECIFIED, want false") - } - if got != interfaces.ActionStatusUnspecified { - t.Errorf("UNSPECIFIED mapped to %d, want ActionStatusUnspecified (0)", got) - } -} - -// TestMapPBActionStatusToInterface_UnknownValueFailsClosed pins the -// fail-closed wire-drift defense at the helper level: any tag outside -// 0-3 returns (Unspecified, false). Per ADR 0040 invariant 2. -func TestMapPBActionStatusToInterface_UnknownValueFailsClosed(t *testing.T) { - got, ok := mapPBActionStatusToInterface(pb.ActionStatus(99)) - if ok { - t.Errorf("ok=true for unknown tag, want false") - } - if got != interfaces.ActionStatusUnspecified { - t.Errorf("unknown tag mapped to %d, want ActionStatusUnspecified (0)", got) - } -} From 5f0466ecdbac9791ca72beec8cee19b4b4a802fa Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 14:53:17 -0400 Subject: [PATCH 12/22] docs(wfctl): rewrite stale godoc/comments after Apply hard-removal (workflow#699 PR 1 task 1 fixup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-reviewer Important findings 1-5 on Task 1 (commit 1df2231b): 1. printDriftReportIfAny godoc — was 'NOT yet by any production caller'; now documents production drift-surfacing path on v2-only dispatch. 2. applyWithProviderAndStore godoc — was 'executes it via provider.Apply'; now names wfctlhelpers.ApplyPlanWithHooks + hooks-based persistence. 3. applyFromPrecomputedPlan godoc — was 'calls provider.Apply for each group'; now points at applyPrecomputedPlanWithStore. 4. applyPrecomputedPlanWithStore godoc — was 'executes via provider.Apply'; now names wfctlhelpers.ApplyPlanWithHooks + parity with sibling helper. 5. Twin inline comments at runInfraApply + applyPrecomputedPlanWithStore — was 'Provider.Apply can surface a top-level error'; now names applyV2ApplyPlanWithHooksFn and enumerates the failure sources. --- cmd/wfctl/infra_apply.go | 49 +++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/cmd/wfctl/infra_apply.go b/cmd/wfctl/infra_apply.go index baba0765..460339e9 100644 --- a/cmd/wfctl/infra_apply.go +++ b/cmd/wfctl/infra_apply.go @@ -28,12 +28,10 @@ import ( // nil or InputDriftReport is empty/nil — both yield a no-op so callers // don't need to defensively check the field before calling. // -// Wired in by W-3a/T3.1.5 as a standalone helper; the actual call site in -// applyWithProviderAndStore (or its successor) lands when W-3b/T3.7 -// switches the in-process apply path through wfctlhelpers.ApplyPlan for -// v2 plugins. Until then this helper is exercised solely by the -// in-process drift test, NOT yet by any production caller — preserving -// the W-3a "zero runtime change for v1 plugins" invariant. +// Production drift-surfacing path on the v2 (only) apply dispatch — per +// workflow#699, v2 via wfctlhelpers.ApplyPlanWithHooks is the sole code +// path, so this helper now runs unconditionally after every apply +// invocation in both applyWithProviderAndStore and applyPrecomputedPlanWithStore. func printDriftReportIfAny(w io.Writer, result *interfaces.ApplyResult) { if result == nil || len(result.InputDriftReport) == 0 { return @@ -357,11 +355,13 @@ func resourceSpecProviderRef(spec interfaces.ResourceSpec) string { } // applyWithProviderAndStore computes a diff plan for the given specs against -// the current state and executes it via provider.Apply. On success, each -// provisioned resource is persisted to store. Save failures abort the command -// so callers cannot miss a successful cloud mutation whose state was not -// recorded. Deleted resources are removed from store after a successful destroy -// action. +// the current state and executes it via wfctlhelpers.ApplyPlanWithHooks (the +// v2-only dispatch per workflow#699; routed through the +// applyV2ApplyPlanWithHooksFn seam for test injection). On success, each +// provisioned resource is persisted to store via the OnResourceApplied hook. +// Save failures abort the command so callers cannot miss a successful cloud +// mutation whose state was not recorded. Deleted resources are removed from +// store via the OnResourceDeleted hook after a successful destroy action. // // providerType is used only as a label when constructing ResourceState records. // Callers pass a nil store (or noopStateStore) when state persistence is not @@ -433,7 +433,7 @@ func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvi // W-6/T6.1: gate replace and delete actions on `protected: true` // resources behind --allow-replace. Without an explicit per-resource - // opt-in, the apply errors before any provider Apply / wfctlhelpers + // opt-in, the apply errors before the wfctlhelpers.ApplyPlanWithHooks // dispatch — destructive actions on protected infrastructure must // be intentional. T6.2 swaps this fail-fast for an aggregated // multi-blocker report. @@ -496,8 +496,10 @@ func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvi }); sumErr != nil { log.Printf("step summary: %v (ignored)", sumErr) } - // Provider.Apply can surface a top-level error (vs. populating - // result.Errors[]). Emit the actionable hint here too if the + // applyV2ApplyPlanWithHooksFn can surface a top-level error + // (gRPC transport failure, plugin-side sentinel bubble, or + // local pre-Replace failure) distinct from per-resource entries + // in result.Errors[]. Emit the actionable hint here too if the // top-level error is/wraps interfaces.ErrImageNotInRegistry — // otherwise plugin paths that bubble the sentinel via err escape // the result.Errors[]-only hint below. See infra_image_presence_hint.go. @@ -1335,8 +1337,9 @@ func loadPlanFromFile(path string) (interfaces.IaCPlan, error) { // applyFromPrecomputedPlan dispatches a pre-computed plan without calling // ComputePlan. It loads IaCProvider plugins from cfgFile, groups plan actions -// by iac.provider module, and calls provider.Apply for each group. State is -// persisted exactly as in the live-diff path. +// by iac.provider module, and invokes applyPrecomputedPlanWithStore (which +// routes through wfctlhelpers.ApplyPlanWithHooks per workflow#699) for each +// group. State is persisted exactly as in the live-diff path. // // The same-process hydrated routed-secret map (sensitive.Route output) is // accumulated across provider groups and returned to the caller so the @@ -1456,9 +1459,11 @@ func applyFromPrecomputedPlan(ctx context.Context, plan interfaces.IaCPlan, cfgF } // applyPrecomputedPlanWithStore executes a pre-computed plan group via -// provider.Apply and persists state for each provisioned resource. It is the -// precomputed-plan counterpart of applyWithProviderAndStore, skipping -// ComputePlan / adoptExistingResources entirely. +// wfctlhelpers.ApplyPlanWithHooks (the v2-only dispatch per workflow#699) +// and persists state through the same OnResourceApplied / OnResourceDeleted +// hooks as applyWithProviderAndStore. It is the precomputed-plan counterpart +// of applyWithProviderAndStore, skipping ComputePlan / adoptExistingResources +// entirely. func applyPrecomputedPlanWithStore(ctx context.Context, plan interfaces.IaCPlan, provider interfaces.IaCProvider, providerType string, store infraStateStore, w io.Writer, envName string, cfgFile string, hydratedOut map[string]string) error { if store == nil { store = &noopStateStore{} @@ -1541,8 +1546,10 @@ func applyPrecomputedPlanWithStore(ctx context.Context, plan interfaces.IaCPlan, }); sumErr != nil { log.Printf("step summary: %v (ignored)", sumErr) } - // Provider.Apply can surface a top-level error (vs. populating - // result.Errors[]). Emit the actionable hint here too if the + // applyV2ApplyPlanWithHooksFn can surface a top-level error + // (gRPC transport failure, plugin-side sentinel bubble, or + // local pre-Replace failure) distinct from per-resource entries + // in result.Errors[]. Emit the actionable hint here too if the // top-level error is/wraps interfaces.ErrImageNotInRegistry — // otherwise plugin paths that bubble the sentinel via err escape // the result.Errors[]-only hint below. See infra_image_presence_hint.go. From 4c038421f872b9322ff5669b08320bdb153abd8e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 14:56:13 -0400 Subject: [PATCH 13/22] =?UTF-8?q?feat(iac):=20delete=20wfctlhelpers/dispat?= =?UTF-8?q?ch.go=20=E2=80=94=20v2=20is=20sole=20dispatch=20path=20(workflo?= =?UTF-8?q?w#699=20PR=201=20task=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- iac/wfctlhelpers/dispatch.go | 59 ------------------------------------ 1 file changed, 59 deletions(-) delete mode 100644 iac/wfctlhelpers/dispatch.go diff --git a/iac/wfctlhelpers/dispatch.go b/iac/wfctlhelpers/dispatch.go deleted file mode 100644 index 46ad143d..00000000 --- a/iac/wfctlhelpers/dispatch.go +++ /dev/null @@ -1,59 +0,0 @@ -package wfctlhelpers - -// ComputePlanVersionDeclarer is the optional interface a loaded -// interfaces.IaCProvider satisfies when wfctl has read its plugin.json -// SDK manifest's iacProvider.computePlanVersion field. wfctl's apply -// path type-asserts this interface to choose between the v1 (legacy -// in-provider Apply) and v2 (wfctlhelpers.ApplyPlan + drift -// postcondition) dispatch paths. -// -// The dispatch contract is rev2/rev3-locked: there is NO -// WFCTL_USE_V2_APPLY env var, NO operator-flippable gate. The v1/v2 -// routing is plugin-author-controlled via the manifest field. A -// provider that does not satisfy this interface defaults to v1 (legacy -// dispatch); a provider that returns "v2" routes through ApplyPlan; any -// other return value is treated as "v1" so a typo in the manifest -// silently degrades to the safe legacy path. -// -// NOTE on validation: when the manifest is loaded via plugin/sdk.ParseManifest, -// schema validation rejects unknown values at parse time. However, some -// loader paths in wfctl (e.g. cmd/wfctl/deploy_providers.go's -// findIaCPluginDir) currently use a minimal json.Unmarshal without -// schema validation, so unknown values CAN reach DispatchVersionFor at -// runtime. The default-to-v1 behavior is the safety net for those -// paths — DO NOT rely on the manifest-validation guarantee in callers. -type ComputePlanVersionDeclarer interface { - ComputePlanVersion() string -} - -// DispatchVersionV2 is the manifest value that routes apply through -// wfctlhelpers.ApplyPlan. Exported so callers don't string-literal it -// at every dispatch site. -const DispatchVersionV2 = "v2" - -// DispatchVersionFor returns the apply-time dispatch version for p. -// Providers that don't implement ComputePlanVersionDeclarer, or that -// return anything other than "v2", get "v1" (the legacy -// provider.Apply path). Centralizing the type assertion + default -// keeps the dispatch decision in one place — call sites pass the raw -// provider value (typed as interfaces.IaCProvider or any concrete -// provider type) rather than type-asserting at every dispatch site. -// -// Param is `any` rather than interfaces.IaCProvider so this package -// stays import-free of the engine's interfaces package (and so -// non-engine call sites such as tests can pass concrete provider -// stubs without an extra adapter). The contract is identical: pass -// the loaded provider; receive "v1" or "v2". -func DispatchVersionFor(p any) string { - if p == nil { - return "v1" - } - d, ok := p.(ComputePlanVersionDeclarer) - if !ok { - return "v1" - } - if v := d.ComputePlanVersion(); v == DispatchVersionV2 { - return DispatchVersionV2 - } - return "v1" -} From a081d4a288fbf50096d433062db70d1abc5569a9 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 15:01:54 -0400 Subject: [PATCH 14/22] feat(wfctl): load-time Capabilities-RPC gate enforces ComputePlanVersion=v2 (workflow#699 PR 1 task 4) --- cmd/wfctl/deploy_providers.go | 70 ++++++++++++- cmd/wfctl/deploy_providers_load_gate_test.go | 104 +++++++++++++++++++ cmd/wfctl/iac_typed_adapter.go | 8 ++ 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 cmd/wfctl/deploy_providers_load_gate_test.go diff --git a/cmd/wfctl/deploy_providers.go b/cmd/wfctl/deploy_providers.go index 098fcd62..b6a16618 100644 --- a/cmd/wfctl/deploy_providers.go +++ b/cmd/wfctl/deploy_providers.go @@ -189,6 +189,14 @@ func findIaCPluginDir(pluginDir, providerName string) (name, computePlanVersion // InvokeService string-dispatch surface is removed entirely — plugins // that do not register the typed IaCProviderRequired service are // rejected at load time with an actionable upgrade message. +// +// Per workflow#699 (v0.56.0+): after the typed adapter is constructed, +// the loader calls Capabilities via the typed RPC with a bounded +// (10s) context and rejects providers whose +// CapabilitiesResponse.compute_plan_version != "v2". The gate bypasses +// the typedIaCAdapter's fetchCapabilities cache (via +// 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 == "" { @@ -198,7 +206,7 @@ func discoverAndLoadIaCProvider(ctx context.Context, providerName string, cfg ma pluginDir = "./data/plugins" } - pName, _, hasBinary, findErr := findIaCPluginDir(pluginDir, providerName) + pName, manifestCPV, hasBinary, findErr := findIaCPluginDir(pluginDir, providerName) if findErr != nil { return nil, nil, fmt.Errorf("resolve IaC provider %q: %w", providerName, findErr) } @@ -209,6 +217,19 @@ func discoverAndLoadIaCProvider(ctx context.Context, providerName string, cfg ma return nil, nil, fmt.Errorf("plugin %q declares provider %q but binary is missing — run: wfctl plugin install %s", pName, providerName, pName) } + // Defense-in-depth deprecation warning (per workflow#699 cycle-2 + // Finding #7). The authoritative enforcement is the typed + // CapabilitiesResponse gate below; the manifest field is only an + // advisory hint. Emitted from discoverAndLoadIaCProvider (called + // exactly once per resolve) rather than findIaCPluginDir (which + // may be called multiple times per invocation). + switch manifestCPV { + case "": + log.Printf("plugin %q: deprecation — manifest iacProvider.computePlanVersion is empty; declare \"v2\" explicitly (workflow#699)", pName) + case "v1": + log.Printf("plugin %q: deprecation — manifest iacProvider.computePlanVersion=\"v1\"; load-time gate will reject this (workflow#699)", pName) + } + mgr := external.NewExternalPluginManager(pluginDir, nil) adapter, loadErr := mgr.LoadPlugin(pName) if loadErr != nil { @@ -225,6 +246,45 @@ func discoverAndLoadIaCProvider(ctx context.Context, providerName string, cfg ma return typed, closer, nil } +// capabilitiesWithContexter is the seam typedIaCAdapter satisfies for the +// load-time workflow#699 gate. Defined as an interface so unit + integration +// tests can substitute an in-memory stub without standing up a real gRPC +// adapter. +type capabilitiesWithContexter interface { + CapabilitiesWithContext(ctx context.Context) (*pb.CapabilitiesResponse, error) +} + +// enforceCapabilitiesV2Gate calls Capabilities on the typed adapter with a +// bounded (10s) context and rejects providers whose +// compute_plan_version != "v2". Exposed as a package-private seam (var) so +// integration tests can substitute a stub adapter — see +// TestDiscoverAndLoadIaCProvider_LoadGate_WiredIntoDiscovery. +var enforceCapabilitiesV2Gate = func(ctx context.Context, p capabilitiesWithContexter, pluginName string) error { + capsCtx, capsCancel := context.WithTimeout(ctx, 10*time.Second) + defer capsCancel() + capsResp, capsErr := p.CapabilitiesWithContext(capsCtx) + if capsErr != nil { + return fmt.Errorf("plugin %q: Capabilities RPC failed: %w (see workflow#699)", pluginName, capsErr) + } + return verifyComputePlanVersionV2(capsResp.GetComputePlanVersion(), pluginName) +} + +// verifyComputePlanVersionV2 rejects a plugin whose +// CapabilitiesResponse.compute_plan_version is not "v2". Called from +// discoverAndLoadIaCProvider after the typed adapter handshake; the +// rejection error is operator-facing — it MUST name the plugin and +// point at workflow#699. +func verifyComputePlanVersionV2(cpv, pluginName string) error { + if cpv == "v2" { + return nil + } + return fmt.Errorf( + "plugin %q declares CapabilitiesResponse.compute_plan_version = %q; "+ + "workflow v0.56.0+ requires \"v2\" (see workflow#699 — upgrade plugin to v2.0.0 or higher)", + pluginName, cpv, + ) +} + // iacAdapterAccessor is the slice of *external.ExternalPluginAdapter the // typed-IaC loader needs after a successful LoadPlugin. Extracted as an // interface so buildTypedIaCAdapterFrom is unit-testable against an @@ -268,6 +328,14 @@ func buildTypedIaCAdapterFrom(ctx context.Context, providerName, pName string, c if initErr := typed.Initialize(ctx, cfg); initErr != nil { return nil, fmt.Errorf("initialize provider %q: %w", providerName, initErr) } + // workflow#699 load-time gate: enforce ComputePlanVersion="v2" via the + // typed CapabilitiesResponse. 10s timeout bounds a hung handshake; + // CapabilitiesWithContext bypasses the lifetime-cached fetchCapabilities + // so a transient RPC failure here does not poison the adapter for + // later well-formed calls (cycle-3 I-NEW-6). + if gateErr := enforceCapabilitiesV2Gate(ctx, typed, pName); gateErr != nil { + return nil, gateErr + } return typed, nil } diff --git a/cmd/wfctl/deploy_providers_load_gate_test.go b/cmd/wfctl/deploy_providers_load_gate_test.go new file mode 100644 index 00000000..48a46be5 --- /dev/null +++ b/cmd/wfctl/deploy_providers_load_gate_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +// TestDiscoverAndLoadIaCProvider_LoadGate_RejectsV1 asserts a plugin that +// returns CapabilitiesResponse.ComputePlanVersion="v1" (or empty) is +// rejected at load time with an actionable error pointing to workflow#699. +func TestDiscoverAndLoadIaCProvider_LoadGate_RejectsV1(t *testing.T) { + cases := []struct { + name string + cpv string + wantInErr string + }{ + {name: "empty", cpv: "", wantInErr: "workflow#699"}, + {name: "v1", cpv: "v1", wantInErr: "workflow#699"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := verifyComputePlanVersionV2(tc.cpv, "plugin-x") + if err == nil { + t.Fatalf("expected reject for cpv=%q; got nil", tc.cpv) + } + if !strings.Contains(err.Error(), tc.wantInErr) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantInErr) + } + }) + } +} + +// TestDiscoverAndLoadIaCProvider_LoadGate_AcceptsV2 — happy path. +func TestDiscoverAndLoadIaCProvider_LoadGate_AcceptsV2(t *testing.T) { + if err := verifyComputePlanVersionV2("v2", "plugin-x"); err != nil { + t.Fatalf("expected accept for cpv=v2; got %v", err) + } +} + +// fakeCapabilitiesWithContext is a stub satisfying capabilitiesWithContexter +// for the integration test. resp / err mirror what a real plugin's typed +// Capabilities RPC would return. +type fakeCapabilitiesWithContext struct { + resp *pb.CapabilitiesResponse + err error +} + +func (f *fakeCapabilitiesWithContext) CapabilitiesWithContext(_ context.Context) (*pb.CapabilitiesResponse, error) { + return f.resp, f.err +} + +// TestDiscoverAndLoadIaCProvider_LoadGate_WiredIntoDiscovery asserts the +// helper is actually called by the discovery code-path — not just +// independently tested. Regression-gates against future refactor that +// removes the gate from the discovery code-path. Per plan cycle-2 N5 fix. +// +// Exercises the enforceCapabilitiesV2Gate var-seam (which is what +// buildTypedIaCAdapterFrom calls after the typed adapter is constructed). +// A real-RPC integration test would need an in-process gRPC server — +// covered separately by the conformance matrix in PR 6. +func TestDiscoverAndLoadIaCProvider_LoadGate_WiredIntoDiscovery(t *testing.T) { + t.Run("v1 plugin → workflow#699 error", func(t *testing.T) { + stub := &fakeCapabilitiesWithContext{ + resp: &pb.CapabilitiesResponse{ComputePlanVersion: "v1"}, + } + err := enforceCapabilitiesV2Gate(context.Background(), stub, "plugin-x") + if err == nil { + t.Fatal("expected reject for v1 plugin; got nil") + } + if !strings.Contains(err.Error(), "workflow#699") { + t.Errorf("error %q does not point at workflow#699", err.Error()) + } + if !strings.Contains(err.Error(), "plugin-x") { + t.Errorf("error %q does not name the plugin", err.Error()) + } + }) + + t.Run("v2 plugin → accept", func(t *testing.T) { + stub := &fakeCapabilitiesWithContext{ + resp: &pb.CapabilitiesResponse{ComputePlanVersion: "v2"}, + } + if err := enforceCapabilitiesV2Gate(context.Background(), stub, "plugin-x"); err != nil { + t.Fatalf("expected accept for v2 plugin; got %v", err) + } + }) + + t.Run("RPC failure → wrapped error", func(t *testing.T) { + stub := &fakeCapabilitiesWithContext{err: errors.New("transport reset")} + err := enforceCapabilitiesV2Gate(context.Background(), stub, "plugin-x") + if err == nil { + t.Fatal("expected error when RPC fails; got nil") + } + if !strings.Contains(err.Error(), "Capabilities RPC failed") { + t.Errorf("error %q does not mention Capabilities RPC failure", err.Error()) + } + if !strings.Contains(err.Error(), "transport reset") { + t.Errorf("error %q does not wrap the underlying RPC error", err.Error()) + } + }) +} diff --git a/cmd/wfctl/iac_typed_adapter.go b/cmd/wfctl/iac_typed_adapter.go index 9c8bf963..840aa808 100644 --- a/cmd/wfctl/iac_typed_adapter.go +++ b/cmd/wfctl/iac_typed_adapter.go @@ -235,6 +235,14 @@ func (a *typedIaCAdapter) Finalizer() pb.IaCProviderFinalizerClient { return a.finalizer } +// CapabilitiesWithContext returns CapabilitiesResponse with caller-supplied +// context. Bypasses fetchCapabilities's adapter-lifetime cache — used by +// the load-time workflow#699 gate which must not poison the cache on +// transient failure (cycle-3 I-NEW-6). +func (a *typedIaCAdapter) CapabilitiesWithContext(ctx context.Context) (*pb.CapabilitiesResponse, error) { + return a.required.Capabilities(ctx, &pb.CapabilitiesRequest{}) +} + // translateRPCErr converts a gRPC Unimplemented status (the wire signal a // plugin emits when an optional method is not supported) into the stable // interfaces.ErrProviderMethodUnimplemented sentinel callers iterate on From c231daf15322c93c9b18c80003ba3e05044068d9 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 15:07:35 -0400 Subject: [PATCH 15/22] feat(proto): delete rpc Apply + ApplyRequest/Response/Result/ActionResult; CI lint guard (workflow#699 PR 1 task 5) --- Makefile | 10 +- plugin/external/proto/iac.pb.go | 1205 ++++++++++---------------- plugin/external/proto/iac.proto | 32 +- plugin/external/proto/iac_grpc.pb.go | 38 - 4 files changed, 472 insertions(+), 813 deletions(-) diff --git a/Makefile b/Makefile index 1fe2ea02..0b5c6fab 100644 --- a/Makefile +++ b/Makefile @@ -40,9 +40,17 @@ bench-compare: go test $(BENCH_FLAGS) -count=6 ./... | tee current-bench.txt benchstat baseline-bench.txt current-bench.txt -# Run golangci-lint +# Run golangci-lint + workflow#699 proto guard (re-introduction of rpc +# Apply on the IaCProviderRequired service is a regression — guarded by +# CI so a future PR can't silently restore the deleted dispatch path). lint: golangci-lint run --timeout=5m + @if grep -qE '^\s*rpc Apply\s*\(' plugin/external/proto/iac.proto; then \ + echo "workflow#699: rpc Apply re-introduced in iac.proto; see decisions/0024-iac-typed-force-cutover.md"; \ + exit 1; \ + else \ + echo "workflow#699 guard: rpc Apply correctly absent"; \ + fi # Format code fmt: diff --git a/plugin/external/proto/iac.pb.go b/plugin/external/proto/iac.pb.go index 7ca2e1fa..030f3552 100644 --- a/plugin/external/proto/iac.pb.go +++ b/plugin/external/proto/iac.pb.go @@ -1570,163 +1570,6 @@ func (x *ActionError) GetError() string { return "" } -// ActionResult is the per-action outcome surfacing for Phase 2 v2 hooks. -// Per ADR 0040 invariant 1. output_keys field DROPPED per cycle-2 review -// (hook firing only needs action_index + status; per-resource outputs -// already in ApplyResult.resources). -type ActionResult struct { - state protoimpl.MessageState `protogen:"open.v1"` - ActionIndex uint32 `protobuf:"varint,1,opt,name=action_index,json=actionIndex,proto3" json:"action_index,omitempty"` - Status ActionStatus `protobuf:"varint,2,opt,name=status,proto3,enum=workflow.plugin.external.iac.ActionStatus" json:"status,omitempty"` - Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ActionResult) Reset() { - *x = ActionResult{} - mi := &file_iac_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ActionResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ActionResult) ProtoMessage() {} - -func (x *ActionResult) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[18] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ActionResult.ProtoReflect.Descriptor instead. -func (*ActionResult) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{18} -} - -func (x *ActionResult) GetActionIndex() uint32 { - if x != nil { - return x.ActionIndex - } - return 0 -} - -func (x *ActionResult) GetStatus() ActionStatus { - if x != nil { - return x.Status - } - return ActionStatus_ACTION_STATUS_UNSPECIFIED -} - -func (x *ActionResult) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -// ApplyResult mirrors interfaces.ApplyResult. -type ApplyResult struct { - state protoimpl.MessageState `protogen:"open.v1"` - PlanId string `protobuf:"bytes,1,opt,name=plan_id,json=planId,proto3" json:"plan_id,omitempty"` - Resources []*ResourceOutput `protobuf:"bytes,2,rep,name=resources,proto3" json:"resources,omitempty"` - Errors []*ActionError `protobuf:"bytes,3,rep,name=errors,proto3" json:"errors,omitempty"` - InitialInputSnapshot map[string]string `protobuf:"bytes,4,rep,name=initial_input_snapshot,json=initialInputSnapshot,proto3" json:"initial_input_snapshot,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - InputDriftReport []*DriftEntry `protobuf:"bytes,5,rep,name=input_drift_report,json=inputDriftReport,proto3" json:"input_drift_report,omitempty"` - ReplaceIdMap map[string]string `protobuf:"bytes,6,rep,name=replace_id_map,json=replaceIdMap,proto3" json:"replace_id_map,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - Actions []*ActionResult `protobuf:"bytes,7,rep,name=actions,proto3" json:"actions,omitempty"` // NEW Phase 2 (workflow#640) - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ApplyResult) Reset() { - *x = ApplyResult{} - mi := &file_iac_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ApplyResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ApplyResult) ProtoMessage() {} - -func (x *ApplyResult) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[19] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ApplyResult.ProtoReflect.Descriptor instead. -func (*ApplyResult) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{19} -} - -func (x *ApplyResult) GetPlanId() string { - if x != nil { - return x.PlanId - } - return "" -} - -func (x *ApplyResult) GetResources() []*ResourceOutput { - if x != nil { - return x.Resources - } - return nil -} - -func (x *ApplyResult) GetErrors() []*ActionError { - if x != nil { - return x.Errors - } - return nil -} - -func (x *ApplyResult) GetInitialInputSnapshot() map[string]string { - if x != nil { - return x.InitialInputSnapshot - } - return nil -} - -func (x *ApplyResult) GetInputDriftReport() []*DriftEntry { - if x != nil { - return x.InputDriftReport - } - return nil -} - -func (x *ApplyResult) GetReplaceIdMap() map[string]string { - if x != nil { - return x.ReplaceIdMap - } - return nil -} - -func (x *ApplyResult) GetActions() []*ActionResult { - if x != nil { - return x.Actions - } - return nil -} - // DestroyResult mirrors interfaces.DestroyResult. type DestroyResult struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1738,7 +1581,7 @@ type DestroyResult struct { func (x *DestroyResult) Reset() { *x = DestroyResult{} - mi := &file_iac_proto_msgTypes[20] + mi := &file_iac_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1750,7 +1593,7 @@ func (x *DestroyResult) String() string { func (*DestroyResult) ProtoMessage() {} func (x *DestroyResult) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[20] + mi := &file_iac_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1763,7 +1606,7 @@ func (x *DestroyResult) ProtoReflect() protoreflect.Message { // Deprecated: Use DestroyResult.ProtoReflect.Descriptor instead. func (*DestroyResult) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{20} + return file_iac_proto_rawDescGZIP(), []int{18} } func (x *DestroyResult) GetDestroyed() []string { @@ -1793,7 +1636,7 @@ type BootstrapResult struct { func (x *BootstrapResult) Reset() { *x = BootstrapResult{} - mi := &file_iac_proto_msgTypes[21] + mi := &file_iac_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1805,7 +1648,7 @@ func (x *BootstrapResult) String() string { func (*BootstrapResult) ProtoMessage() {} func (x *BootstrapResult) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[21] + mi := &file_iac_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1818,7 +1661,7 @@ func (x *BootstrapResult) ProtoReflect() protoreflect.Message { // Deprecated: Use BootstrapResult.ProtoReflect.Descriptor instead. func (*BootstrapResult) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{21} + return file_iac_proto_rawDescGZIP(), []int{19} } func (x *BootstrapResult) GetBucket() string { @@ -1871,7 +1714,7 @@ type MigrationRepairRequest struct { func (x *MigrationRepairRequest) Reset() { *x = MigrationRepairRequest{} - mi := &file_iac_proto_msgTypes[22] + mi := &file_iac_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1883,7 +1726,7 @@ func (x *MigrationRepairRequest) String() string { func (*MigrationRepairRequest) ProtoMessage() {} func (x *MigrationRepairRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[22] + mi := &file_iac_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1896,7 +1739,7 @@ func (x *MigrationRepairRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MigrationRepairRequest.ProtoReflect.Descriptor instead. func (*MigrationRepairRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{22} + return file_iac_proto_rawDescGZIP(), []int{20} } func (x *MigrationRepairRequest) GetAppResourceName() string { @@ -1992,7 +1835,7 @@ type MigrationRepairResult struct { func (x *MigrationRepairResult) Reset() { *x = MigrationRepairResult{} - mi := &file_iac_proto_msgTypes[23] + mi := &file_iac_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2004,7 +1847,7 @@ func (x *MigrationRepairResult) String() string { func (*MigrationRepairResult) ProtoMessage() {} func (x *MigrationRepairResult) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[23] + mi := &file_iac_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2017,7 +1860,7 @@ func (x *MigrationRepairResult) ProtoReflect() protoreflect.Message { // Deprecated: Use MigrationRepairResult.ProtoReflect.Descriptor instead. func (*MigrationRepairResult) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{23} + return file_iac_proto_rawDescGZIP(), []int{21} } func (x *MigrationRepairResult) GetProviderJobId() string { @@ -2065,7 +1908,7 @@ type InitializeRequest struct { func (x *InitializeRequest) Reset() { *x = InitializeRequest{} - mi := &file_iac_proto_msgTypes[24] + mi := &file_iac_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2077,7 +1920,7 @@ func (x *InitializeRequest) String() string { func (*InitializeRequest) ProtoMessage() {} func (x *InitializeRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[24] + mi := &file_iac_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2090,7 +1933,7 @@ func (x *InitializeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InitializeRequest.ProtoReflect.Descriptor instead. func (*InitializeRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{24} + return file_iac_proto_rawDescGZIP(), []int{22} } func (x *InitializeRequest) GetConfigJson() []byte { @@ -2108,7 +1951,7 @@ type InitializeResponse struct { func (x *InitializeResponse) Reset() { *x = InitializeResponse{} - mi := &file_iac_proto_msgTypes[25] + mi := &file_iac_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2120,7 +1963,7 @@ func (x *InitializeResponse) String() string { func (*InitializeResponse) ProtoMessage() {} func (x *InitializeResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[25] + mi := &file_iac_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2133,7 +1976,7 @@ func (x *InitializeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InitializeResponse.ProtoReflect.Descriptor instead. func (*InitializeResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{25} + return file_iac_proto_rawDescGZIP(), []int{23} } type NameRequest struct { @@ -2144,7 +1987,7 @@ type NameRequest struct { func (x *NameRequest) Reset() { *x = NameRequest{} - mi := &file_iac_proto_msgTypes[26] + mi := &file_iac_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2156,7 +1999,7 @@ func (x *NameRequest) String() string { func (*NameRequest) ProtoMessage() {} func (x *NameRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[26] + mi := &file_iac_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2169,7 +2012,7 @@ func (x *NameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use NameRequest.ProtoReflect.Descriptor instead. func (*NameRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{26} + return file_iac_proto_rawDescGZIP(), []int{24} } type NameResponse struct { @@ -2181,7 +2024,7 @@ type NameResponse struct { func (x *NameResponse) Reset() { *x = NameResponse{} - mi := &file_iac_proto_msgTypes[27] + mi := &file_iac_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2193,7 +2036,7 @@ func (x *NameResponse) String() string { func (*NameResponse) ProtoMessage() {} func (x *NameResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[27] + mi := &file_iac_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2206,7 +2049,7 @@ func (x *NameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use NameResponse.ProtoReflect.Descriptor instead. func (*NameResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{27} + return file_iac_proto_rawDescGZIP(), []int{25} } func (x *NameResponse) GetName() string { @@ -2224,7 +2067,7 @@ type VersionRequest struct { func (x *VersionRequest) Reset() { *x = VersionRequest{} - mi := &file_iac_proto_msgTypes[28] + mi := &file_iac_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2236,7 +2079,7 @@ func (x *VersionRequest) String() string { func (*VersionRequest) ProtoMessage() {} func (x *VersionRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[28] + mi := &file_iac_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2249,7 +2092,7 @@ func (x *VersionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use VersionRequest.ProtoReflect.Descriptor instead. func (*VersionRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{28} + return file_iac_proto_rawDescGZIP(), []int{26} } type VersionResponse struct { @@ -2261,7 +2104,7 @@ type VersionResponse struct { func (x *VersionResponse) Reset() { *x = VersionResponse{} - mi := &file_iac_proto_msgTypes[29] + mi := &file_iac_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2273,7 +2116,7 @@ func (x *VersionResponse) String() string { func (*VersionResponse) ProtoMessage() {} func (x *VersionResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[29] + mi := &file_iac_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2286,7 +2129,7 @@ func (x *VersionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use VersionResponse.ProtoReflect.Descriptor instead. func (*VersionResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{29} + return file_iac_proto_rawDescGZIP(), []int{27} } func (x *VersionResponse) GetVersion() string { @@ -2304,7 +2147,7 @@ type CapabilitiesRequest struct { func (x *CapabilitiesRequest) Reset() { *x = CapabilitiesRequest{} - mi := &file_iac_proto_msgTypes[30] + mi := &file_iac_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2316,7 +2159,7 @@ func (x *CapabilitiesRequest) String() string { func (*CapabilitiesRequest) ProtoMessage() {} func (x *CapabilitiesRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[30] + mi := &file_iac_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2329,7 +2172,7 @@ func (x *CapabilitiesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CapabilitiesRequest.ProtoReflect.Descriptor instead. func (*CapabilitiesRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{30} + return file_iac_proto_rawDescGZIP(), []int{28} } type CapabilitiesResponse struct { @@ -2355,7 +2198,7 @@ type CapabilitiesResponse struct { func (x *CapabilitiesResponse) Reset() { *x = CapabilitiesResponse{} - mi := &file_iac_proto_msgTypes[31] + mi := &file_iac_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2367,7 +2210,7 @@ func (x *CapabilitiesResponse) String() string { func (*CapabilitiesResponse) ProtoMessage() {} func (x *CapabilitiesResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[31] + mi := &file_iac_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2380,7 +2223,7 @@ func (x *CapabilitiesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CapabilitiesResponse.ProtoReflect.Descriptor instead. func (*CapabilitiesResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{31} + return file_iac_proto_rawDescGZIP(), []int{29} } func (x *CapabilitiesResponse) GetCapabilities() []*IaCCapabilityDeclaration { @@ -2414,7 +2257,7 @@ type PlanRequest struct { func (x *PlanRequest) Reset() { *x = PlanRequest{} - mi := &file_iac_proto_msgTypes[32] + mi := &file_iac_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2426,7 +2269,7 @@ func (x *PlanRequest) String() string { func (*PlanRequest) ProtoMessage() {} func (x *PlanRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[32] + mi := &file_iac_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2439,7 +2282,7 @@ func (x *PlanRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PlanRequest.ProtoReflect.Descriptor instead. func (*PlanRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{32} + return file_iac_proto_rawDescGZIP(), []int{30} } func (x *PlanRequest) GetDesired() []*ResourceSpec { @@ -2465,7 +2308,7 @@ type PlanResponse struct { func (x *PlanResponse) Reset() { *x = PlanResponse{} - mi := &file_iac_proto_msgTypes[33] + mi := &file_iac_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2477,7 +2320,7 @@ func (x *PlanResponse) String() string { func (*PlanResponse) ProtoMessage() {} func (x *PlanResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[33] + mi := &file_iac_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2490,7 +2333,7 @@ func (x *PlanResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PlanResponse.ProtoReflect.Descriptor instead. func (*PlanResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{33} + return file_iac_proto_rawDescGZIP(), []int{31} } func (x *PlanResponse) GetPlan() *IaCPlan { @@ -2500,94 +2343,6 @@ func (x *PlanResponse) GetPlan() *IaCPlan { return nil } -type ApplyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Plan *IaCPlan `protobuf:"bytes,1,opt,name=plan,proto3" json:"plan,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ApplyRequest) Reset() { - *x = ApplyRequest{} - mi := &file_iac_proto_msgTypes[34] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ApplyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ApplyRequest) ProtoMessage() {} - -func (x *ApplyRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[34] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ApplyRequest.ProtoReflect.Descriptor instead. -func (*ApplyRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{34} -} - -func (x *ApplyRequest) GetPlan() *IaCPlan { - if x != nil { - return x.Plan - } - return nil -} - -type ApplyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Result *ApplyResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ApplyResponse) Reset() { - *x = ApplyResponse{} - mi := &file_iac_proto_msgTypes[35] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ApplyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ApplyResponse) ProtoMessage() {} - -func (x *ApplyResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[35] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ApplyResponse.ProtoReflect.Descriptor instead. -func (*ApplyResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{35} -} - -func (x *ApplyResponse) GetResult() *ApplyResult { - if x != nil { - return x.Result - } - return nil -} - type DestroyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Refs []*ResourceRef `protobuf:"bytes,1,rep,name=refs,proto3" json:"refs,omitempty"` @@ -2597,7 +2352,7 @@ type DestroyRequest struct { func (x *DestroyRequest) Reset() { *x = DestroyRequest{} - mi := &file_iac_proto_msgTypes[36] + mi := &file_iac_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2609,7 +2364,7 @@ func (x *DestroyRequest) String() string { func (*DestroyRequest) ProtoMessage() {} func (x *DestroyRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[36] + mi := &file_iac_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2622,7 +2377,7 @@ func (x *DestroyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DestroyRequest.ProtoReflect.Descriptor instead. func (*DestroyRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{36} + return file_iac_proto_rawDescGZIP(), []int{32} } func (x *DestroyRequest) GetRefs() []*ResourceRef { @@ -2641,7 +2396,7 @@ type DestroyResponse struct { func (x *DestroyResponse) Reset() { *x = DestroyResponse{} - mi := &file_iac_proto_msgTypes[37] + mi := &file_iac_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2653,7 +2408,7 @@ func (x *DestroyResponse) String() string { func (*DestroyResponse) ProtoMessage() {} func (x *DestroyResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[37] + mi := &file_iac_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2666,7 +2421,7 @@ func (x *DestroyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DestroyResponse.ProtoReflect.Descriptor instead. func (*DestroyResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{37} + return file_iac_proto_rawDescGZIP(), []int{33} } func (x *DestroyResponse) GetResult() *DestroyResult { @@ -2685,7 +2440,7 @@ type StatusRequest struct { func (x *StatusRequest) Reset() { *x = StatusRequest{} - mi := &file_iac_proto_msgTypes[38] + mi := &file_iac_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2697,7 +2452,7 @@ func (x *StatusRequest) String() string { func (*StatusRequest) ProtoMessage() {} func (x *StatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[38] + mi := &file_iac_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2710,7 +2465,7 @@ func (x *StatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StatusRequest.ProtoReflect.Descriptor instead. func (*StatusRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{38} + return file_iac_proto_rawDescGZIP(), []int{34} } func (x *StatusRequest) GetRefs() []*ResourceRef { @@ -2729,7 +2484,7 @@ type StatusResponse struct { func (x *StatusResponse) Reset() { *x = StatusResponse{} - mi := &file_iac_proto_msgTypes[39] + mi := &file_iac_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2741,7 +2496,7 @@ func (x *StatusResponse) String() string { func (*StatusResponse) ProtoMessage() {} func (x *StatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[39] + mi := &file_iac_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2754,7 +2509,7 @@ func (x *StatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StatusResponse.ProtoReflect.Descriptor instead. func (*StatusResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{39} + return file_iac_proto_rawDescGZIP(), []int{35} } func (x *StatusResponse) GetStatuses() []*ResourceStatus { @@ -2774,7 +2529,7 @@ type ImportRequest struct { func (x *ImportRequest) Reset() { *x = ImportRequest{} - mi := &file_iac_proto_msgTypes[40] + mi := &file_iac_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2786,7 +2541,7 @@ func (x *ImportRequest) String() string { func (*ImportRequest) ProtoMessage() {} func (x *ImportRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[40] + mi := &file_iac_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2799,7 +2554,7 @@ func (x *ImportRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ImportRequest.ProtoReflect.Descriptor instead. func (*ImportRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{40} + return file_iac_proto_rawDescGZIP(), []int{36} } func (x *ImportRequest) GetProviderId() string { @@ -2825,7 +2580,7 @@ type ImportResponse struct { func (x *ImportResponse) Reset() { *x = ImportResponse{} - mi := &file_iac_proto_msgTypes[41] + mi := &file_iac_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2837,7 +2592,7 @@ func (x *ImportResponse) String() string { func (*ImportResponse) ProtoMessage() {} func (x *ImportResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[41] + mi := &file_iac_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2850,7 +2605,7 @@ func (x *ImportResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ImportResponse.ProtoReflect.Descriptor instead. func (*ImportResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{41} + return file_iac_proto_rawDescGZIP(), []int{37} } func (x *ImportResponse) GetState() *ResourceState { @@ -2871,7 +2626,7 @@ type ResolveSizingRequest struct { func (x *ResolveSizingRequest) Reset() { *x = ResolveSizingRequest{} - mi := &file_iac_proto_msgTypes[42] + mi := &file_iac_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2883,7 +2638,7 @@ func (x *ResolveSizingRequest) String() string { func (*ResolveSizingRequest) ProtoMessage() {} func (x *ResolveSizingRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[42] + mi := &file_iac_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2896,7 +2651,7 @@ func (x *ResolveSizingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResolveSizingRequest.ProtoReflect.Descriptor instead. func (*ResolveSizingRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{42} + return file_iac_proto_rawDescGZIP(), []int{38} } func (x *ResolveSizingRequest) GetResourceType() string { @@ -2929,7 +2684,7 @@ type ResolveSizingResponse struct { func (x *ResolveSizingResponse) Reset() { *x = ResolveSizingResponse{} - mi := &file_iac_proto_msgTypes[43] + mi := &file_iac_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2941,7 +2696,7 @@ func (x *ResolveSizingResponse) String() string { func (*ResolveSizingResponse) ProtoMessage() {} func (x *ResolveSizingResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[43] + mi := &file_iac_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2954,7 +2709,7 @@ func (x *ResolveSizingResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResolveSizingResponse.ProtoReflect.Descriptor instead. func (*ResolveSizingResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{43} + return file_iac_proto_rawDescGZIP(), []int{39} } func (x *ResolveSizingResponse) GetSizing() *ProviderSizing { @@ -2974,7 +2729,7 @@ type BootstrapStateBackendRequest struct { func (x *BootstrapStateBackendRequest) Reset() { *x = BootstrapStateBackendRequest{} - mi := &file_iac_proto_msgTypes[44] + mi := &file_iac_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2986,7 +2741,7 @@ func (x *BootstrapStateBackendRequest) String() string { func (*BootstrapStateBackendRequest) ProtoMessage() {} func (x *BootstrapStateBackendRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[44] + mi := &file_iac_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2999,7 +2754,7 @@ func (x *BootstrapStateBackendRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BootstrapStateBackendRequest.ProtoReflect.Descriptor instead. func (*BootstrapStateBackendRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{44} + return file_iac_proto_rawDescGZIP(), []int{40} } func (x *BootstrapStateBackendRequest) GetConfigJson() []byte { @@ -3018,7 +2773,7 @@ type BootstrapStateBackendResponse struct { func (x *BootstrapStateBackendResponse) Reset() { *x = BootstrapStateBackendResponse{} - mi := &file_iac_proto_msgTypes[45] + mi := &file_iac_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3030,7 +2785,7 @@ func (x *BootstrapStateBackendResponse) String() string { func (*BootstrapStateBackendResponse) ProtoMessage() {} func (x *BootstrapStateBackendResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[45] + mi := &file_iac_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3043,7 +2798,7 @@ func (x *BootstrapStateBackendResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BootstrapStateBackendResponse.ProtoReflect.Descriptor instead. func (*BootstrapStateBackendResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{45} + return file_iac_proto_rawDescGZIP(), []int{41} } func (x *BootstrapStateBackendResponse) GetResult() *BootstrapResult { @@ -3065,7 +2820,7 @@ type EnumerateAllRequest struct { func (x *EnumerateAllRequest) Reset() { *x = EnumerateAllRequest{} - mi := &file_iac_proto_msgTypes[46] + mi := &file_iac_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3077,7 +2832,7 @@ func (x *EnumerateAllRequest) String() string { func (*EnumerateAllRequest) ProtoMessage() {} func (x *EnumerateAllRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[46] + mi := &file_iac_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3090,7 +2845,7 @@ func (x *EnumerateAllRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EnumerateAllRequest.ProtoReflect.Descriptor instead. func (*EnumerateAllRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{46} + return file_iac_proto_rawDescGZIP(), []int{42} } func (x *EnumerateAllRequest) GetResourceType() string { @@ -3109,7 +2864,7 @@ type EnumerateAllResponse struct { func (x *EnumerateAllResponse) Reset() { *x = EnumerateAllResponse{} - mi := &file_iac_proto_msgTypes[47] + mi := &file_iac_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3121,7 +2876,7 @@ func (x *EnumerateAllResponse) String() string { func (*EnumerateAllResponse) ProtoMessage() {} func (x *EnumerateAllResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[47] + mi := &file_iac_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3134,7 +2889,7 @@ func (x *EnumerateAllResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EnumerateAllResponse.ProtoReflect.Descriptor instead. func (*EnumerateAllResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{47} + return file_iac_proto_rawDescGZIP(), []int{43} } func (x *EnumerateAllResponse) GetOutputs() []*ResourceOutput { @@ -3153,7 +2908,7 @@ type EnumerateByTagRequest struct { func (x *EnumerateByTagRequest) Reset() { *x = EnumerateByTagRequest{} - mi := &file_iac_proto_msgTypes[48] + mi := &file_iac_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3165,7 +2920,7 @@ func (x *EnumerateByTagRequest) String() string { func (*EnumerateByTagRequest) ProtoMessage() {} func (x *EnumerateByTagRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[48] + mi := &file_iac_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3178,7 +2933,7 @@ func (x *EnumerateByTagRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EnumerateByTagRequest.ProtoReflect.Descriptor instead. func (*EnumerateByTagRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{48} + return file_iac_proto_rawDescGZIP(), []int{44} } func (x *EnumerateByTagRequest) GetTag() string { @@ -3197,7 +2952,7 @@ type EnumerateByTagResponse struct { func (x *EnumerateByTagResponse) Reset() { *x = EnumerateByTagResponse{} - mi := &file_iac_proto_msgTypes[49] + mi := &file_iac_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3209,7 +2964,7 @@ func (x *EnumerateByTagResponse) String() string { func (*EnumerateByTagResponse) ProtoMessage() {} func (x *EnumerateByTagResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[49] + mi := &file_iac_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3222,7 +2977,7 @@ func (x *EnumerateByTagResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EnumerateByTagResponse.ProtoReflect.Descriptor instead. func (*EnumerateByTagResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{49} + return file_iac_proto_rawDescGZIP(), []int{45} } func (x *EnumerateByTagResponse) GetRefs() []*ResourceRef { @@ -3244,7 +2999,7 @@ type DetectDriftRequest struct { func (x *DetectDriftRequest) Reset() { *x = DetectDriftRequest{} - mi := &file_iac_proto_msgTypes[50] + mi := &file_iac_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3256,7 +3011,7 @@ func (x *DetectDriftRequest) String() string { func (*DetectDriftRequest) ProtoMessage() {} func (x *DetectDriftRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[50] + mi := &file_iac_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3269,7 +3024,7 @@ func (x *DetectDriftRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DetectDriftRequest.ProtoReflect.Descriptor instead. func (*DetectDriftRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{50} + return file_iac_proto_rawDescGZIP(), []int{46} } func (x *DetectDriftRequest) GetRefs() []*ResourceRef { @@ -3288,7 +3043,7 @@ type DetectDriftResponse struct { func (x *DetectDriftResponse) Reset() { *x = DetectDriftResponse{} - mi := &file_iac_proto_msgTypes[51] + mi := &file_iac_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3300,7 +3055,7 @@ func (x *DetectDriftResponse) String() string { func (*DetectDriftResponse) ProtoMessage() {} func (x *DetectDriftResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[51] + mi := &file_iac_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3313,7 +3068,7 @@ func (x *DetectDriftResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DetectDriftResponse.ProtoReflect.Descriptor instead. func (*DetectDriftResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{51} + return file_iac_proto_rawDescGZIP(), []int{47} } func (x *DetectDriftResponse) GetDrifts() []*DriftResult { @@ -3335,7 +3090,7 @@ type DetectDriftWithSpecsRequest struct { func (x *DetectDriftWithSpecsRequest) Reset() { *x = DetectDriftWithSpecsRequest{} - mi := &file_iac_proto_msgTypes[52] + mi := &file_iac_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3347,7 +3102,7 @@ func (x *DetectDriftWithSpecsRequest) String() string { func (*DetectDriftWithSpecsRequest) ProtoMessage() {} func (x *DetectDriftWithSpecsRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[52] + mi := &file_iac_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3360,7 +3115,7 @@ func (x *DetectDriftWithSpecsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DetectDriftWithSpecsRequest.ProtoReflect.Descriptor instead. func (*DetectDriftWithSpecsRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{52} + return file_iac_proto_rawDescGZIP(), []int{48} } func (x *DetectDriftWithSpecsRequest) GetRefs() []*ResourceRef { @@ -3386,7 +3141,7 @@ type DetectDriftWithSpecsResponse struct { func (x *DetectDriftWithSpecsResponse) Reset() { *x = DetectDriftWithSpecsResponse{} - mi := &file_iac_proto_msgTypes[53] + mi := &file_iac_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3398,7 +3153,7 @@ func (x *DetectDriftWithSpecsResponse) String() string { func (*DetectDriftWithSpecsResponse) ProtoMessage() {} func (x *DetectDriftWithSpecsResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[53] + mi := &file_iac_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3411,7 +3166,7 @@ func (x *DetectDriftWithSpecsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DetectDriftWithSpecsResponse.ProtoReflect.Descriptor instead. func (*DetectDriftWithSpecsResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{53} + return file_iac_proto_rawDescGZIP(), []int{49} } func (x *DetectDriftWithSpecsResponse) GetDrifts() []*DriftResult { @@ -3436,7 +3191,7 @@ type RevokeProviderCredentialRequest struct { func (x *RevokeProviderCredentialRequest) Reset() { *x = RevokeProviderCredentialRequest{} - mi := &file_iac_proto_msgTypes[54] + mi := &file_iac_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3448,7 +3203,7 @@ func (x *RevokeProviderCredentialRequest) String() string { func (*RevokeProviderCredentialRequest) ProtoMessage() {} func (x *RevokeProviderCredentialRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[54] + mi := &file_iac_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3461,7 +3216,7 @@ func (x *RevokeProviderCredentialRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeProviderCredentialRequest.ProtoReflect.Descriptor instead. func (*RevokeProviderCredentialRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{54} + return file_iac_proto_rawDescGZIP(), []int{50} } func (x *RevokeProviderCredentialRequest) GetSource() string { @@ -3486,7 +3241,7 @@ type RevokeProviderCredentialResponse struct { func (x *RevokeProviderCredentialResponse) Reset() { *x = RevokeProviderCredentialResponse{} - mi := &file_iac_proto_msgTypes[55] + mi := &file_iac_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3498,7 +3253,7 @@ func (x *RevokeProviderCredentialResponse) String() string { func (*RevokeProviderCredentialResponse) ProtoMessage() {} func (x *RevokeProviderCredentialResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[55] + mi := &file_iac_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3511,7 +3266,7 @@ func (x *RevokeProviderCredentialResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeProviderCredentialResponse.ProtoReflect.Descriptor instead. func (*RevokeProviderCredentialResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{55} + return file_iac_proto_rawDescGZIP(), []int{51} } // ───────────────────────────────────────────────────────────────────────────── @@ -3528,7 +3283,7 @@ type FinalizeApplyRequest struct { func (x *FinalizeApplyRequest) Reset() { *x = FinalizeApplyRequest{} - mi := &file_iac_proto_msgTypes[56] + mi := &file_iac_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3540,7 +3295,7 @@ func (x *FinalizeApplyRequest) String() string { func (*FinalizeApplyRequest) ProtoMessage() {} func (x *FinalizeApplyRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[56] + mi := &file_iac_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3553,7 +3308,7 @@ func (x *FinalizeApplyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeApplyRequest.ProtoReflect.Descriptor instead. func (*FinalizeApplyRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{56} + return file_iac_proto_rawDescGZIP(), []int{52} } func (x *FinalizeApplyRequest) GetPlanId() string { @@ -3588,7 +3343,7 @@ type FinalizeApplyResponse struct { func (x *FinalizeApplyResponse) Reset() { *x = FinalizeApplyResponse{} - mi := &file_iac_proto_msgTypes[57] + mi := &file_iac_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3600,7 +3355,7 @@ func (x *FinalizeApplyResponse) String() string { func (*FinalizeApplyResponse) ProtoMessage() {} func (x *FinalizeApplyResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[57] + mi := &file_iac_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3613,7 +3368,7 @@ func (x *FinalizeApplyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FinalizeApplyResponse.ProtoReflect.Descriptor instead. func (*FinalizeApplyResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{57} + return file_iac_proto_rawDescGZIP(), []int{53} } func (x *FinalizeApplyResponse) GetErrors() []*ActionError { @@ -3635,7 +3390,7 @@ type RepairDirtyMigrationRequest struct { func (x *RepairDirtyMigrationRequest) Reset() { *x = RepairDirtyMigrationRequest{} - mi := &file_iac_proto_msgTypes[58] + mi := &file_iac_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3647,7 +3402,7 @@ func (x *RepairDirtyMigrationRequest) String() string { func (*RepairDirtyMigrationRequest) ProtoMessage() {} func (x *RepairDirtyMigrationRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[58] + mi := &file_iac_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3660,7 +3415,7 @@ func (x *RepairDirtyMigrationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RepairDirtyMigrationRequest.ProtoReflect.Descriptor instead. func (*RepairDirtyMigrationRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{58} + return file_iac_proto_rawDescGZIP(), []int{54} } func (x *RepairDirtyMigrationRequest) GetRequest() *MigrationRepairRequest { @@ -3679,7 +3434,7 @@ type RepairDirtyMigrationResponse struct { func (x *RepairDirtyMigrationResponse) Reset() { *x = RepairDirtyMigrationResponse{} - mi := &file_iac_proto_msgTypes[59] + mi := &file_iac_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3691,7 +3446,7 @@ func (x *RepairDirtyMigrationResponse) String() string { func (*RepairDirtyMigrationResponse) ProtoMessage() {} func (x *RepairDirtyMigrationResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[59] + mi := &file_iac_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3704,7 +3459,7 @@ func (x *RepairDirtyMigrationResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RepairDirtyMigrationResponse.ProtoReflect.Descriptor instead. func (*RepairDirtyMigrationResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{59} + return file_iac_proto_rawDescGZIP(), []int{55} } func (x *RepairDirtyMigrationResponse) GetResult() *MigrationRepairResult { @@ -3726,7 +3481,7 @@ type ValidatePlanRequest struct { func (x *ValidatePlanRequest) Reset() { *x = ValidatePlanRequest{} - mi := &file_iac_proto_msgTypes[60] + mi := &file_iac_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3738,7 +3493,7 @@ func (x *ValidatePlanRequest) String() string { func (*ValidatePlanRequest) ProtoMessage() {} func (x *ValidatePlanRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[60] + mi := &file_iac_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3751,7 +3506,7 @@ func (x *ValidatePlanRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidatePlanRequest.ProtoReflect.Descriptor instead. func (*ValidatePlanRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{60} + return file_iac_proto_rawDescGZIP(), []int{56} } func (x *ValidatePlanRequest) GetPlan() *IaCPlan { @@ -3770,7 +3525,7 @@ type ValidatePlanResponse struct { func (x *ValidatePlanResponse) Reset() { *x = ValidatePlanResponse{} - mi := &file_iac_proto_msgTypes[61] + mi := &file_iac_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3782,7 +3537,7 @@ func (x *ValidatePlanResponse) String() string { func (*ValidatePlanResponse) ProtoMessage() {} func (x *ValidatePlanResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[61] + mi := &file_iac_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3795,7 +3550,7 @@ func (x *ValidatePlanResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidatePlanResponse.ProtoReflect.Descriptor instead. func (*ValidatePlanResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{61} + return file_iac_proto_rawDescGZIP(), []int{57} } func (x *ValidatePlanResponse) GetDiagnostics() []*PlanDiagnostic { @@ -3821,7 +3576,7 @@ type DetectDriftConfigRequest struct { func (x *DetectDriftConfigRequest) Reset() { *x = DetectDriftConfigRequest{} - mi := &file_iac_proto_msgTypes[62] + mi := &file_iac_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3833,7 +3588,7 @@ func (x *DetectDriftConfigRequest) String() string { func (*DetectDriftConfigRequest) ProtoMessage() {} func (x *DetectDriftConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[62] + mi := &file_iac_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3846,7 +3601,7 @@ func (x *DetectDriftConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DetectDriftConfigRequest.ProtoReflect.Descriptor instead. func (*DetectDriftConfigRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{62} + return file_iac_proto_rawDescGZIP(), []int{58} } func (x *DetectDriftConfigRequest) GetRefs() []*ResourceRef { @@ -3872,7 +3627,7 @@ type DetectDriftConfigResponse struct { func (x *DetectDriftConfigResponse) Reset() { *x = DetectDriftConfigResponse{} - mi := &file_iac_proto_msgTypes[63] + mi := &file_iac_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3884,7 +3639,7 @@ func (x *DetectDriftConfigResponse) String() string { func (*DetectDriftConfigResponse) ProtoMessage() {} func (x *DetectDriftConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[63] + mi := &file_iac_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3897,7 +3652,7 @@ func (x *DetectDriftConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DetectDriftConfigResponse.ProtoReflect.Descriptor instead. func (*DetectDriftConfigResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{63} + return file_iac_proto_rawDescGZIP(), []int{59} } func (x *DetectDriftConfigResponse) GetDrifts() []*DriftResult { @@ -3923,7 +3678,7 @@ type ResourceCreateRequest struct { func (x *ResourceCreateRequest) Reset() { *x = ResourceCreateRequest{} - mi := &file_iac_proto_msgTypes[64] + mi := &file_iac_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3935,7 +3690,7 @@ func (x *ResourceCreateRequest) String() string { func (*ResourceCreateRequest) ProtoMessage() {} func (x *ResourceCreateRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[64] + mi := &file_iac_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3948,7 +3703,7 @@ func (x *ResourceCreateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceCreateRequest.ProtoReflect.Descriptor instead. func (*ResourceCreateRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{64} + return file_iac_proto_rawDescGZIP(), []int{60} } func (x *ResourceCreateRequest) GetResourceType() string { @@ -3974,7 +3729,7 @@ type ResourceCreateResponse struct { func (x *ResourceCreateResponse) Reset() { *x = ResourceCreateResponse{} - mi := &file_iac_proto_msgTypes[65] + mi := &file_iac_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3986,7 +3741,7 @@ func (x *ResourceCreateResponse) String() string { func (*ResourceCreateResponse) ProtoMessage() {} func (x *ResourceCreateResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[65] + mi := &file_iac_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3999,7 +3754,7 @@ func (x *ResourceCreateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceCreateResponse.ProtoReflect.Descriptor instead. func (*ResourceCreateResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{65} + return file_iac_proto_rawDescGZIP(), []int{61} } func (x *ResourceCreateResponse) GetOutput() *ResourceOutput { @@ -4019,7 +3774,7 @@ type ResourceReadRequest struct { func (x *ResourceReadRequest) Reset() { *x = ResourceReadRequest{} - mi := &file_iac_proto_msgTypes[66] + mi := &file_iac_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4031,7 +3786,7 @@ func (x *ResourceReadRequest) String() string { func (*ResourceReadRequest) ProtoMessage() {} func (x *ResourceReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[66] + mi := &file_iac_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4044,7 +3799,7 @@ func (x *ResourceReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceReadRequest.ProtoReflect.Descriptor instead. func (*ResourceReadRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{66} + return file_iac_proto_rawDescGZIP(), []int{62} } func (x *ResourceReadRequest) GetResourceType() string { @@ -4070,7 +3825,7 @@ type ResourceReadResponse struct { func (x *ResourceReadResponse) Reset() { *x = ResourceReadResponse{} - mi := &file_iac_proto_msgTypes[67] + mi := &file_iac_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4082,7 +3837,7 @@ func (x *ResourceReadResponse) String() string { func (*ResourceReadResponse) ProtoMessage() {} func (x *ResourceReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[67] + mi := &file_iac_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4095,7 +3850,7 @@ func (x *ResourceReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceReadResponse.ProtoReflect.Descriptor instead. func (*ResourceReadResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{67} + return file_iac_proto_rawDescGZIP(), []int{63} } func (x *ResourceReadResponse) GetOutput() *ResourceOutput { @@ -4116,7 +3871,7 @@ type ResourceUpdateRequest struct { func (x *ResourceUpdateRequest) Reset() { *x = ResourceUpdateRequest{} - mi := &file_iac_proto_msgTypes[68] + mi := &file_iac_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4128,7 +3883,7 @@ func (x *ResourceUpdateRequest) String() string { func (*ResourceUpdateRequest) ProtoMessage() {} func (x *ResourceUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[68] + mi := &file_iac_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4141,7 +3896,7 @@ func (x *ResourceUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceUpdateRequest.ProtoReflect.Descriptor instead. func (*ResourceUpdateRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{68} + return file_iac_proto_rawDescGZIP(), []int{64} } func (x *ResourceUpdateRequest) GetResourceType() string { @@ -4174,7 +3929,7 @@ type ResourceUpdateResponse struct { func (x *ResourceUpdateResponse) Reset() { *x = ResourceUpdateResponse{} - mi := &file_iac_proto_msgTypes[69] + mi := &file_iac_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4186,7 +3941,7 @@ func (x *ResourceUpdateResponse) String() string { func (*ResourceUpdateResponse) ProtoMessage() {} func (x *ResourceUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[69] + mi := &file_iac_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4199,7 +3954,7 @@ func (x *ResourceUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceUpdateResponse.ProtoReflect.Descriptor instead. func (*ResourceUpdateResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{69} + return file_iac_proto_rawDescGZIP(), []int{65} } func (x *ResourceUpdateResponse) GetOutput() *ResourceOutput { @@ -4219,7 +3974,7 @@ type ResourceDeleteRequest struct { func (x *ResourceDeleteRequest) Reset() { *x = ResourceDeleteRequest{} - mi := &file_iac_proto_msgTypes[70] + mi := &file_iac_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4231,7 +3986,7 @@ func (x *ResourceDeleteRequest) String() string { func (*ResourceDeleteRequest) ProtoMessage() {} func (x *ResourceDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[70] + mi := &file_iac_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4244,7 +3999,7 @@ func (x *ResourceDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceDeleteRequest.ProtoReflect.Descriptor instead. func (*ResourceDeleteRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{70} + return file_iac_proto_rawDescGZIP(), []int{66} } func (x *ResourceDeleteRequest) GetResourceType() string { @@ -4269,7 +4024,7 @@ type ResourceDeleteResponse struct { func (x *ResourceDeleteResponse) Reset() { *x = ResourceDeleteResponse{} - mi := &file_iac_proto_msgTypes[71] + mi := &file_iac_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4281,7 +4036,7 @@ func (x *ResourceDeleteResponse) String() string { func (*ResourceDeleteResponse) ProtoMessage() {} func (x *ResourceDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[71] + mi := &file_iac_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4294,7 +4049,7 @@ func (x *ResourceDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceDeleteResponse.ProtoReflect.Descriptor instead. func (*ResourceDeleteResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{71} + return file_iac_proto_rawDescGZIP(), []int{67} } type ResourceDiffRequest struct { @@ -4308,7 +4063,7 @@ type ResourceDiffRequest struct { func (x *ResourceDiffRequest) Reset() { *x = ResourceDiffRequest{} - mi := &file_iac_proto_msgTypes[72] + mi := &file_iac_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4320,7 +4075,7 @@ func (x *ResourceDiffRequest) String() string { func (*ResourceDiffRequest) ProtoMessage() {} func (x *ResourceDiffRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[72] + mi := &file_iac_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4333,7 +4088,7 @@ func (x *ResourceDiffRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceDiffRequest.ProtoReflect.Descriptor instead. func (*ResourceDiffRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{72} + return file_iac_proto_rawDescGZIP(), []int{68} } func (x *ResourceDiffRequest) GetResourceType() string { @@ -4366,7 +4121,7 @@ type ResourceDiffResponse struct { func (x *ResourceDiffResponse) Reset() { *x = ResourceDiffResponse{} - mi := &file_iac_proto_msgTypes[73] + mi := &file_iac_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4378,7 +4133,7 @@ func (x *ResourceDiffResponse) String() string { func (*ResourceDiffResponse) ProtoMessage() {} func (x *ResourceDiffResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[73] + mi := &file_iac_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4391,7 +4146,7 @@ func (x *ResourceDiffResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceDiffResponse.ProtoReflect.Descriptor instead. func (*ResourceDiffResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{73} + return file_iac_proto_rawDescGZIP(), []int{69} } func (x *ResourceDiffResponse) GetResult() *DiffResult { @@ -4412,7 +4167,7 @@ type ResourceScaleRequest struct { func (x *ResourceScaleRequest) Reset() { *x = ResourceScaleRequest{} - mi := &file_iac_proto_msgTypes[74] + mi := &file_iac_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4424,7 +4179,7 @@ func (x *ResourceScaleRequest) String() string { func (*ResourceScaleRequest) ProtoMessage() {} func (x *ResourceScaleRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[74] + mi := &file_iac_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4437,7 +4192,7 @@ func (x *ResourceScaleRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceScaleRequest.ProtoReflect.Descriptor instead. func (*ResourceScaleRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{74} + return file_iac_proto_rawDescGZIP(), []int{70} } func (x *ResourceScaleRequest) GetResourceType() string { @@ -4470,7 +4225,7 @@ type ResourceScaleResponse struct { func (x *ResourceScaleResponse) Reset() { *x = ResourceScaleResponse{} - mi := &file_iac_proto_msgTypes[75] + mi := &file_iac_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4482,7 +4237,7 @@ func (x *ResourceScaleResponse) String() string { func (*ResourceScaleResponse) ProtoMessage() {} func (x *ResourceScaleResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[75] + mi := &file_iac_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4495,7 +4250,7 @@ func (x *ResourceScaleResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceScaleResponse.ProtoReflect.Descriptor instead. func (*ResourceScaleResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{75} + return file_iac_proto_rawDescGZIP(), []int{71} } func (x *ResourceScaleResponse) GetOutput() *ResourceOutput { @@ -4515,7 +4270,7 @@ type ResourceHealthCheckRequest struct { func (x *ResourceHealthCheckRequest) Reset() { *x = ResourceHealthCheckRequest{} - mi := &file_iac_proto_msgTypes[76] + mi := &file_iac_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4527,7 +4282,7 @@ func (x *ResourceHealthCheckRequest) String() string { func (*ResourceHealthCheckRequest) ProtoMessage() {} func (x *ResourceHealthCheckRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[76] + mi := &file_iac_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4540,7 +4295,7 @@ func (x *ResourceHealthCheckRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceHealthCheckRequest.ProtoReflect.Descriptor instead. func (*ResourceHealthCheckRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{76} + return file_iac_proto_rawDescGZIP(), []int{72} } func (x *ResourceHealthCheckRequest) GetResourceType() string { @@ -4566,7 +4321,7 @@ type ResourceHealthCheckResponse struct { func (x *ResourceHealthCheckResponse) Reset() { *x = ResourceHealthCheckResponse{} - mi := &file_iac_proto_msgTypes[77] + mi := &file_iac_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4578,7 +4333,7 @@ func (x *ResourceHealthCheckResponse) String() string { func (*ResourceHealthCheckResponse) ProtoMessage() {} func (x *ResourceHealthCheckResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[77] + mi := &file_iac_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4591,7 +4346,7 @@ func (x *ResourceHealthCheckResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceHealthCheckResponse.ProtoReflect.Descriptor instead. func (*ResourceHealthCheckResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{77} + return file_iac_proto_rawDescGZIP(), []int{73} } func (x *ResourceHealthCheckResponse) GetResult() *HealthResult { @@ -4610,7 +4365,7 @@ type SensitiveKeysRequest struct { func (x *SensitiveKeysRequest) Reset() { *x = SensitiveKeysRequest{} - mi := &file_iac_proto_msgTypes[78] + mi := &file_iac_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4622,7 +4377,7 @@ func (x *SensitiveKeysRequest) String() string { func (*SensitiveKeysRequest) ProtoMessage() {} func (x *SensitiveKeysRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[78] + mi := &file_iac_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4635,7 +4390,7 @@ func (x *SensitiveKeysRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SensitiveKeysRequest.ProtoReflect.Descriptor instead. func (*SensitiveKeysRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{78} + return file_iac_proto_rawDescGZIP(), []int{74} } func (x *SensitiveKeysRequest) GetResourceType() string { @@ -4654,7 +4409,7 @@ type SensitiveKeysResponse struct { func (x *SensitiveKeysResponse) Reset() { *x = SensitiveKeysResponse{} - mi := &file_iac_proto_msgTypes[79] + mi := &file_iac_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4666,7 +4421,7 @@ func (x *SensitiveKeysResponse) String() string { func (*SensitiveKeysResponse) ProtoMessage() {} func (x *SensitiveKeysResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[79] + mi := &file_iac_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4679,7 +4434,7 @@ func (x *SensitiveKeysResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SensitiveKeysResponse.ProtoReflect.Descriptor instead. func (*SensitiveKeysResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{79} + return file_iac_proto_rawDescGZIP(), []int{75} } func (x *SensitiveKeysResponse) GetKeys() []string { @@ -4700,7 +4455,7 @@ type TroubleshootRequest struct { func (x *TroubleshootRequest) Reset() { *x = TroubleshootRequest{} - mi := &file_iac_proto_msgTypes[80] + mi := &file_iac_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4712,7 +4467,7 @@ func (x *TroubleshootRequest) String() string { func (*TroubleshootRequest) ProtoMessage() {} func (x *TroubleshootRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[80] + mi := &file_iac_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4725,7 +4480,7 @@ func (x *TroubleshootRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TroubleshootRequest.ProtoReflect.Descriptor instead. func (*TroubleshootRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{80} + return file_iac_proto_rawDescGZIP(), []int{76} } func (x *TroubleshootRequest) GetResourceType() string { @@ -4758,7 +4513,7 @@ type TroubleshootResponse struct { func (x *TroubleshootResponse) Reset() { *x = TroubleshootResponse{} - mi := &file_iac_proto_msgTypes[81] + mi := &file_iac_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4770,7 +4525,7 @@ func (x *TroubleshootResponse) String() string { func (*TroubleshootResponse) ProtoMessage() {} func (x *TroubleshootResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[81] + mi := &file_iac_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4783,7 +4538,7 @@ func (x *TroubleshootResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TroubleshootResponse.ProtoReflect.Descriptor instead. func (*TroubleshootResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{81} + return file_iac_proto_rawDescGZIP(), []int{77} } func (x *TroubleshootResponse) GetDiagnostics() []*Diagnostic { @@ -4817,7 +4572,7 @@ type IaCState struct { func (x *IaCState) Reset() { *x = IaCState{} - mi := &file_iac_proto_msgTypes[82] + mi := &file_iac_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4829,7 +4584,7 @@ func (x *IaCState) String() string { func (*IaCState) ProtoMessage() {} func (x *IaCState) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[82] + mi := &file_iac_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4842,7 +4597,7 @@ func (x *IaCState) ProtoReflect() protoreflect.Message { // Deprecated: Use IaCState.ProtoReflect.Descriptor instead. func (*IaCState) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{82} + return file_iac_proto_rawDescGZIP(), []int{78} } func (x *IaCState) GetResourceId() string { @@ -4950,7 +4705,7 @@ type ConfigureRequest struct { func (x *ConfigureRequest) Reset() { *x = ConfigureRequest{} - mi := &file_iac_proto_msgTypes[83] + mi := &file_iac_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4962,7 +4717,7 @@ func (x *ConfigureRequest) String() string { func (*ConfigureRequest) ProtoMessage() {} func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[83] + mi := &file_iac_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4975,7 +4730,7 @@ func (x *ConfigureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigureRequest.ProtoReflect.Descriptor instead. func (*ConfigureRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{83} + return file_iac_proto_rawDescGZIP(), []int{79} } func (x *ConfigureRequest) GetBackendName() string { @@ -5000,7 +4755,7 @@ type ConfigureResponse struct { func (x *ConfigureResponse) Reset() { *x = ConfigureResponse{} - mi := &file_iac_proto_msgTypes[84] + mi := &file_iac_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5012,7 +4767,7 @@ func (x *ConfigureResponse) String() string { func (*ConfigureResponse) ProtoMessage() {} func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[84] + mi := &file_iac_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5025,7 +4780,7 @@ func (x *ConfigureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigureResponse.ProtoReflect.Descriptor instead. func (*ConfigureResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{84} + return file_iac_proto_rawDescGZIP(), []int{80} } type GetStateRequest struct { @@ -5037,7 +4792,7 @@ type GetStateRequest struct { func (x *GetStateRequest) Reset() { *x = GetStateRequest{} - mi := &file_iac_proto_msgTypes[85] + mi := &file_iac_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5049,7 +4804,7 @@ func (x *GetStateRequest) String() string { func (*GetStateRequest) ProtoMessage() {} func (x *GetStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[85] + mi := &file_iac_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5062,7 +4817,7 @@ func (x *GetStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetStateRequest.ProtoReflect.Descriptor instead. func (*GetStateRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{85} + return file_iac_proto_rawDescGZIP(), []int{81} } func (x *GetStateRequest) GetResourceId() string { @@ -5082,7 +4837,7 @@ type GetStateResponse struct { func (x *GetStateResponse) Reset() { *x = GetStateResponse{} - mi := &file_iac_proto_msgTypes[86] + mi := &file_iac_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5094,7 +4849,7 @@ func (x *GetStateResponse) String() string { func (*GetStateResponse) ProtoMessage() {} func (x *GetStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[86] + mi := &file_iac_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5107,7 +4862,7 @@ func (x *GetStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetStateResponse.ProtoReflect.Descriptor instead. func (*GetStateResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{86} + return file_iac_proto_rawDescGZIP(), []int{82} } func (x *GetStateResponse) GetState() *IaCState { @@ -5133,7 +4888,7 @@ type SaveStateRequest struct { func (x *SaveStateRequest) Reset() { *x = SaveStateRequest{} - mi := &file_iac_proto_msgTypes[87] + mi := &file_iac_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5145,7 +4900,7 @@ func (x *SaveStateRequest) String() string { func (*SaveStateRequest) ProtoMessage() {} func (x *SaveStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[87] + mi := &file_iac_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5158,7 +4913,7 @@ func (x *SaveStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SaveStateRequest.ProtoReflect.Descriptor instead. func (*SaveStateRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{87} + return file_iac_proto_rawDescGZIP(), []int{83} } func (x *SaveStateRequest) GetState() *IaCState { @@ -5176,7 +4931,7 @@ type SaveStateResponse struct { func (x *SaveStateResponse) Reset() { *x = SaveStateResponse{} - mi := &file_iac_proto_msgTypes[88] + mi := &file_iac_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5188,7 +4943,7 @@ func (x *SaveStateResponse) String() string { func (*SaveStateResponse) ProtoMessage() {} func (x *SaveStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[88] + mi := &file_iac_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5201,7 +4956,7 @@ func (x *SaveStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SaveStateResponse.ProtoReflect.Descriptor instead. func (*SaveStateResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{88} + return file_iac_proto_rawDescGZIP(), []int{84} } type ListStatesRequest struct { @@ -5213,7 +4968,7 @@ type ListStatesRequest struct { func (x *ListStatesRequest) Reset() { *x = ListStatesRequest{} - mi := &file_iac_proto_msgTypes[89] + mi := &file_iac_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5225,7 +4980,7 @@ func (x *ListStatesRequest) String() string { func (*ListStatesRequest) ProtoMessage() {} func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[89] + mi := &file_iac_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5238,7 +4993,7 @@ func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesRequest.ProtoReflect.Descriptor instead. func (*ListStatesRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{89} + return file_iac_proto_rawDescGZIP(), []int{85} } func (x *ListStatesRequest) GetFilter() map[string]string { @@ -5257,7 +5012,7 @@ type ListStatesResponse struct { func (x *ListStatesResponse) Reset() { *x = ListStatesResponse{} - mi := &file_iac_proto_msgTypes[90] + mi := &file_iac_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5269,7 +5024,7 @@ func (x *ListStatesResponse) String() string { func (*ListStatesResponse) ProtoMessage() {} func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[90] + mi := &file_iac_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5282,7 +5037,7 @@ func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesResponse.ProtoReflect.Descriptor instead. func (*ListStatesResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{90} + return file_iac_proto_rawDescGZIP(), []int{86} } func (x *ListStatesResponse) GetStates() []*IaCState { @@ -5301,7 +5056,7 @@ type DeleteStateRequest struct { func (x *DeleteStateRequest) Reset() { *x = DeleteStateRequest{} - mi := &file_iac_proto_msgTypes[91] + mi := &file_iac_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5313,7 +5068,7 @@ func (x *DeleteStateRequest) String() string { func (*DeleteStateRequest) ProtoMessage() {} func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[91] + mi := &file_iac_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5326,7 +5081,7 @@ func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateRequest.ProtoReflect.Descriptor instead. func (*DeleteStateRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{91} + return file_iac_proto_rawDescGZIP(), []int{87} } func (x *DeleteStateRequest) GetResourceId() string { @@ -5344,7 +5099,7 @@ type DeleteStateResponse struct { func (x *DeleteStateResponse) Reset() { *x = DeleteStateResponse{} - mi := &file_iac_proto_msgTypes[92] + mi := &file_iac_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5356,7 +5111,7 @@ func (x *DeleteStateResponse) String() string { func (*DeleteStateResponse) ProtoMessage() {} func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[92] + mi := &file_iac_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5369,7 +5124,7 @@ func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateResponse.ProtoReflect.Descriptor instead. func (*DeleteStateResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{92} + return file_iac_proto_rawDescGZIP(), []int{88} } type LockRequest struct { @@ -5381,7 +5136,7 @@ type LockRequest struct { func (x *LockRequest) Reset() { *x = LockRequest{} - mi := &file_iac_proto_msgTypes[93] + mi := &file_iac_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5393,7 +5148,7 @@ func (x *LockRequest) String() string { func (*LockRequest) ProtoMessage() {} func (x *LockRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[93] + mi := &file_iac_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5406,7 +5161,7 @@ func (x *LockRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LockRequest.ProtoReflect.Descriptor instead. func (*LockRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{93} + return file_iac_proto_rawDescGZIP(), []int{89} } func (x *LockRequest) GetResourceId() string { @@ -5424,7 +5179,7 @@ type LockResponse struct { func (x *LockResponse) Reset() { *x = LockResponse{} - mi := &file_iac_proto_msgTypes[94] + mi := &file_iac_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5436,7 +5191,7 @@ func (x *LockResponse) String() string { func (*LockResponse) ProtoMessage() {} func (x *LockResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[94] + mi := &file_iac_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5449,7 +5204,7 @@ func (x *LockResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LockResponse.ProtoReflect.Descriptor instead. func (*LockResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{94} + return file_iac_proto_rawDescGZIP(), []int{90} } type UnlockRequest struct { @@ -5461,7 +5216,7 @@ type UnlockRequest struct { func (x *UnlockRequest) Reset() { *x = UnlockRequest{} - mi := &file_iac_proto_msgTypes[95] + mi := &file_iac_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5473,7 +5228,7 @@ func (x *UnlockRequest) String() string { func (*UnlockRequest) ProtoMessage() {} func (x *UnlockRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[95] + mi := &file_iac_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5486,7 +5241,7 @@ func (x *UnlockRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UnlockRequest.ProtoReflect.Descriptor instead. func (*UnlockRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{95} + return file_iac_proto_rawDescGZIP(), []int{91} } func (x *UnlockRequest) GetResourceId() string { @@ -5504,7 +5259,7 @@ type UnlockResponse struct { func (x *UnlockResponse) Reset() { *x = UnlockResponse{} - mi := &file_iac_proto_msgTypes[96] + mi := &file_iac_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5516,7 +5271,7 @@ func (x *UnlockResponse) String() string { func (*UnlockResponse) ProtoMessage() {} func (x *UnlockResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[96] + mi := &file_iac_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5529,7 +5284,7 @@ func (x *UnlockResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UnlockResponse.ProtoReflect.Descriptor instead. func (*UnlockResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{96} + return file_iac_proto_rawDescGZIP(), []int{92} } // ListBackendNames lets the engine ask a loaded plugin which iac.state backend @@ -5543,7 +5298,7 @@ type ListBackendNamesRequest struct { func (x *ListBackendNamesRequest) Reset() { *x = ListBackendNamesRequest{} - mi := &file_iac_proto_msgTypes[97] + mi := &file_iac_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5555,7 +5310,7 @@ func (x *ListBackendNamesRequest) String() string { func (*ListBackendNamesRequest) ProtoMessage() {} func (x *ListBackendNamesRequest) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[97] + mi := &file_iac_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5568,7 +5323,7 @@ func (x *ListBackendNamesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListBackendNamesRequest.ProtoReflect.Descriptor instead. func (*ListBackendNamesRequest) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{97} + return file_iac_proto_rawDescGZIP(), []int{93} } type ListBackendNamesResponse struct { @@ -5580,7 +5335,7 @@ type ListBackendNamesResponse struct { func (x *ListBackendNamesResponse) Reset() { *x = ListBackendNamesResponse{} - mi := &file_iac_proto_msgTypes[98] + mi := &file_iac_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5592,7 +5347,7 @@ func (x *ListBackendNamesResponse) String() string { func (*ListBackendNamesResponse) ProtoMessage() {} func (x *ListBackendNamesResponse) ProtoReflect() protoreflect.Message { - mi := &file_iac_proto_msgTypes[98] + mi := &file_iac_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5605,7 +5360,7 @@ func (x *ListBackendNamesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListBackendNamesResponse.ProtoReflect.Descriptor instead. func (*ListBackendNamesResponse) Descriptor() ([]byte, []int) { - return file_iac_proto_rawDescGZIP(), []int{98} + return file_iac_proto_rawDescGZIP(), []int{94} } func (x *ListBackendNamesResponse) GetBackendNames() []string { @@ -5746,25 +5501,7 @@ const file_iac_proto_rawDesc = "" + "\vActionError\x12\x1a\n" + "\bresource\x18\x01 \x01(\tR\bresource\x12\x16\n" + "\x06action\x18\x02 \x01(\tR\x06action\x12\x14\n" + - "\x05error\x18\x03 \x01(\tR\x05error\"\x8b\x01\n" + - "\fActionResult\x12!\n" + - "\faction_index\x18\x01 \x01(\rR\vactionIndex\x12B\n" + - "\x06status\x18\x02 \x01(\x0e2*.workflow.plugin.external.iac.ActionStatusR\x06status\x12\x14\n" + - "\x05error\x18\x03 \x01(\tR\x05error\"\xbb\x05\n" + - "\vApplyResult\x12\x17\n" + - "\aplan_id\x18\x01 \x01(\tR\x06planId\x12J\n" + - "\tresources\x18\x02 \x03(\v2,.workflow.plugin.external.iac.ResourceOutputR\tresources\x12A\n" + - "\x06errors\x18\x03 \x03(\v2).workflow.plugin.external.iac.ActionErrorR\x06errors\x12y\n" + - "\x16initial_input_snapshot\x18\x04 \x03(\v2C.workflow.plugin.external.iac.ApplyResult.InitialInputSnapshotEntryR\x14initialInputSnapshot\x12V\n" + - "\x12input_drift_report\x18\x05 \x03(\v2(.workflow.plugin.external.iac.DriftEntryR\x10inputDriftReport\x12a\n" + - "\x0ereplace_id_map\x18\x06 \x03(\v2;.workflow.plugin.external.iac.ApplyResult.ReplaceIdMapEntryR\freplaceIdMap\x12D\n" + - "\aactions\x18\a \x03(\v2*.workflow.plugin.external.iac.ActionResultR\aactions\x1aG\n" + - "\x19InitialInputSnapshotEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a?\n" + - "\x11ReplaceIdMapEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"p\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"p\n" + "\rDestroyResult\x12\x1c\n" + "\tdestroyed\x18\x01 \x03(\tR\tdestroyed\x12A\n" + "\x06errors\x18\x02 \x03(\v2).workflow.plugin.external.iac.ActionErrorR\x06errors\"\xf0\x01\n" + @@ -5818,11 +5555,7 @@ const file_iac_proto_rawDesc = "" + "\adesired\x18\x01 \x03(\v2*.workflow.plugin.external.iac.ResourceSpecR\adesired\x12E\n" + "\acurrent\x18\x02 \x03(\v2+.workflow.plugin.external.iac.ResourceStateR\acurrent\"I\n" + "\fPlanResponse\x129\n" + - "\x04plan\x18\x01 \x01(\v2%.workflow.plugin.external.iac.IaCPlanR\x04plan\"I\n" + - "\fApplyRequest\x129\n" + - "\x04plan\x18\x01 \x01(\v2%.workflow.plugin.external.iac.IaCPlanR\x04plan\"R\n" + - "\rApplyResponse\x12A\n" + - "\x06result\x18\x01 \x01(\v2).workflow.plugin.external.iac.ApplyResultR\x06result\"O\n" + + "\x04plan\x18\x01 \x01(\v2%.workflow.plugin.external.iac.IaCPlanR\x04plan\"O\n" + "\x0eDestroyRequest\x12=\n" + "\x04refs\x18\x01 \x03(\v2).workflow.plugin.external.iac.ResourceRefR\x04refs\"V\n" + "\x0fDestroyResponse\x12C\n" + @@ -6016,15 +5749,14 @@ const file_iac_proto_rawDesc = "" + "\x1bACTION_STATUS_DELETE_FAILED\x10\x03\x12\x1d\n" + "\x19ACTION_STATUS_COMPENSATED\x10\x04\x12%\n" + "!ACTION_STATUS_COMPENSATION_FAILED\x10\x05\x12\x19\n" + - "\x15ACTION_STATUS_SKIPPED\x10\x062\xc4\t\n" + + "\x15ACTION_STATUS_SKIPPED\x10\x062\xe2\b\n" + "\x13IaCProviderRequired\x12o\n" + "\n" + "Initialize\x12/.workflow.plugin.external.iac.InitializeRequest\x1a0.workflow.plugin.external.iac.InitializeResponse\x12]\n" + "\x04Name\x12).workflow.plugin.external.iac.NameRequest\x1a*.workflow.plugin.external.iac.NameResponse\x12f\n" + "\aVersion\x12,.workflow.plugin.external.iac.VersionRequest\x1a-.workflow.plugin.external.iac.VersionResponse\x12u\n" + "\fCapabilities\x121.workflow.plugin.external.iac.CapabilitiesRequest\x1a2.workflow.plugin.external.iac.CapabilitiesResponse\x12]\n" + - "\x04Plan\x12).workflow.plugin.external.iac.PlanRequest\x1a*.workflow.plugin.external.iac.PlanResponse\x12`\n" + - "\x05Apply\x12*.workflow.plugin.external.iac.ApplyRequest\x1a+.workflow.plugin.external.iac.ApplyResponse\x12f\n" + + "\x04Plan\x12).workflow.plugin.external.iac.PlanRequest\x1a*.workflow.plugin.external.iac.PlanResponse\x12f\n" + "\aDestroy\x12,.workflow.plugin.external.iac.DestroyRequest\x1a-.workflow.plugin.external.iac.DestroyResponse\x12c\n" + "\x06Status\x12+.workflow.plugin.external.iac.StatusRequest\x1a,.workflow.plugin.external.iac.StatusResponse\x12c\n" + "\x06Import\x12+.workflow.plugin.external.iac.ImportRequest\x1a,.workflow.plugin.external.iac.ImportResponse\x12x\n" + @@ -6080,7 +5812,7 @@ func file_iac_proto_rawDescGZIP() []byte { } var file_iac_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_iac_proto_msgTypes = make([]protoimpl.MessageInfo, 108) +var file_iac_proto_msgTypes = make([]protoimpl.MessageInfo, 102) var file_iac_proto_goTypes = []any{ (DriftClass)(0), // 0: workflow.plugin.external.iac.DriftClass (PlanDiagnosticSeverity)(0), // 1: workflow.plugin.external.iac.PlanDiagnosticSeverity @@ -6103,256 +5835,239 @@ var file_iac_proto_goTypes = []any{ (*PlanAction)(nil), // 18: workflow.plugin.external.iac.PlanAction (*IaCPlan)(nil), // 19: workflow.plugin.external.iac.IaCPlan (*ActionError)(nil), // 20: workflow.plugin.external.iac.ActionError - (*ActionResult)(nil), // 21: workflow.plugin.external.iac.ActionResult - (*ApplyResult)(nil), // 22: workflow.plugin.external.iac.ApplyResult - (*DestroyResult)(nil), // 23: workflow.plugin.external.iac.DestroyResult - (*BootstrapResult)(nil), // 24: workflow.plugin.external.iac.BootstrapResult - (*MigrationRepairRequest)(nil), // 25: workflow.plugin.external.iac.MigrationRepairRequest - (*MigrationRepairResult)(nil), // 26: workflow.plugin.external.iac.MigrationRepairResult - (*InitializeRequest)(nil), // 27: workflow.plugin.external.iac.InitializeRequest - (*InitializeResponse)(nil), // 28: workflow.plugin.external.iac.InitializeResponse - (*NameRequest)(nil), // 29: workflow.plugin.external.iac.NameRequest - (*NameResponse)(nil), // 30: workflow.plugin.external.iac.NameResponse - (*VersionRequest)(nil), // 31: workflow.plugin.external.iac.VersionRequest - (*VersionResponse)(nil), // 32: workflow.plugin.external.iac.VersionResponse - (*CapabilitiesRequest)(nil), // 33: workflow.plugin.external.iac.CapabilitiesRequest - (*CapabilitiesResponse)(nil), // 34: workflow.plugin.external.iac.CapabilitiesResponse - (*PlanRequest)(nil), // 35: workflow.plugin.external.iac.PlanRequest - (*PlanResponse)(nil), // 36: workflow.plugin.external.iac.PlanResponse - (*ApplyRequest)(nil), // 37: workflow.plugin.external.iac.ApplyRequest - (*ApplyResponse)(nil), // 38: workflow.plugin.external.iac.ApplyResponse - (*DestroyRequest)(nil), // 39: workflow.plugin.external.iac.DestroyRequest - (*DestroyResponse)(nil), // 40: workflow.plugin.external.iac.DestroyResponse - (*StatusRequest)(nil), // 41: workflow.plugin.external.iac.StatusRequest - (*StatusResponse)(nil), // 42: workflow.plugin.external.iac.StatusResponse - (*ImportRequest)(nil), // 43: workflow.plugin.external.iac.ImportRequest - (*ImportResponse)(nil), // 44: workflow.plugin.external.iac.ImportResponse - (*ResolveSizingRequest)(nil), // 45: workflow.plugin.external.iac.ResolveSizingRequest - (*ResolveSizingResponse)(nil), // 46: workflow.plugin.external.iac.ResolveSizingResponse - (*BootstrapStateBackendRequest)(nil), // 47: workflow.plugin.external.iac.BootstrapStateBackendRequest - (*BootstrapStateBackendResponse)(nil), // 48: workflow.plugin.external.iac.BootstrapStateBackendResponse - (*EnumerateAllRequest)(nil), // 49: workflow.plugin.external.iac.EnumerateAllRequest - (*EnumerateAllResponse)(nil), // 50: workflow.plugin.external.iac.EnumerateAllResponse - (*EnumerateByTagRequest)(nil), // 51: workflow.plugin.external.iac.EnumerateByTagRequest - (*EnumerateByTagResponse)(nil), // 52: workflow.plugin.external.iac.EnumerateByTagResponse - (*DetectDriftRequest)(nil), // 53: workflow.plugin.external.iac.DetectDriftRequest - (*DetectDriftResponse)(nil), // 54: workflow.plugin.external.iac.DetectDriftResponse - (*DetectDriftWithSpecsRequest)(nil), // 55: workflow.plugin.external.iac.DetectDriftWithSpecsRequest - (*DetectDriftWithSpecsResponse)(nil), // 56: workflow.plugin.external.iac.DetectDriftWithSpecsResponse - (*RevokeProviderCredentialRequest)(nil), // 57: workflow.plugin.external.iac.RevokeProviderCredentialRequest - (*RevokeProviderCredentialResponse)(nil), // 58: workflow.plugin.external.iac.RevokeProviderCredentialResponse - (*FinalizeApplyRequest)(nil), // 59: workflow.plugin.external.iac.FinalizeApplyRequest - (*FinalizeApplyResponse)(nil), // 60: workflow.plugin.external.iac.FinalizeApplyResponse - (*RepairDirtyMigrationRequest)(nil), // 61: workflow.plugin.external.iac.RepairDirtyMigrationRequest - (*RepairDirtyMigrationResponse)(nil), // 62: workflow.plugin.external.iac.RepairDirtyMigrationResponse - (*ValidatePlanRequest)(nil), // 63: workflow.plugin.external.iac.ValidatePlanRequest - (*ValidatePlanResponse)(nil), // 64: workflow.plugin.external.iac.ValidatePlanResponse - (*DetectDriftConfigRequest)(nil), // 65: workflow.plugin.external.iac.DetectDriftConfigRequest - (*DetectDriftConfigResponse)(nil), // 66: workflow.plugin.external.iac.DetectDriftConfigResponse - (*ResourceCreateRequest)(nil), // 67: workflow.plugin.external.iac.ResourceCreateRequest - (*ResourceCreateResponse)(nil), // 68: workflow.plugin.external.iac.ResourceCreateResponse - (*ResourceReadRequest)(nil), // 69: workflow.plugin.external.iac.ResourceReadRequest - (*ResourceReadResponse)(nil), // 70: workflow.plugin.external.iac.ResourceReadResponse - (*ResourceUpdateRequest)(nil), // 71: workflow.plugin.external.iac.ResourceUpdateRequest - (*ResourceUpdateResponse)(nil), // 72: workflow.plugin.external.iac.ResourceUpdateResponse - (*ResourceDeleteRequest)(nil), // 73: workflow.plugin.external.iac.ResourceDeleteRequest - (*ResourceDeleteResponse)(nil), // 74: workflow.plugin.external.iac.ResourceDeleteResponse - (*ResourceDiffRequest)(nil), // 75: workflow.plugin.external.iac.ResourceDiffRequest - (*ResourceDiffResponse)(nil), // 76: workflow.plugin.external.iac.ResourceDiffResponse - (*ResourceScaleRequest)(nil), // 77: workflow.plugin.external.iac.ResourceScaleRequest - (*ResourceScaleResponse)(nil), // 78: workflow.plugin.external.iac.ResourceScaleResponse - (*ResourceHealthCheckRequest)(nil), // 79: workflow.plugin.external.iac.ResourceHealthCheckRequest - (*ResourceHealthCheckResponse)(nil), // 80: workflow.plugin.external.iac.ResourceHealthCheckResponse - (*SensitiveKeysRequest)(nil), // 81: workflow.plugin.external.iac.SensitiveKeysRequest - (*SensitiveKeysResponse)(nil), // 82: workflow.plugin.external.iac.SensitiveKeysResponse - (*TroubleshootRequest)(nil), // 83: workflow.plugin.external.iac.TroubleshootRequest - (*TroubleshootResponse)(nil), // 84: workflow.plugin.external.iac.TroubleshootResponse - (*IaCState)(nil), // 85: workflow.plugin.external.iac.IaCState - (*ConfigureRequest)(nil), // 86: workflow.plugin.external.iac.ConfigureRequest - (*ConfigureResponse)(nil), // 87: workflow.plugin.external.iac.ConfigureResponse - (*GetStateRequest)(nil), // 88: workflow.plugin.external.iac.GetStateRequest - (*GetStateResponse)(nil), // 89: workflow.plugin.external.iac.GetStateResponse - (*SaveStateRequest)(nil), // 90: workflow.plugin.external.iac.SaveStateRequest - (*SaveStateResponse)(nil), // 91: workflow.plugin.external.iac.SaveStateResponse - (*ListStatesRequest)(nil), // 92: workflow.plugin.external.iac.ListStatesRequest - (*ListStatesResponse)(nil), // 93: workflow.plugin.external.iac.ListStatesResponse - (*DeleteStateRequest)(nil), // 94: workflow.plugin.external.iac.DeleteStateRequest - (*DeleteStateResponse)(nil), // 95: workflow.plugin.external.iac.DeleteStateResponse - (*LockRequest)(nil), // 96: workflow.plugin.external.iac.LockRequest - (*LockResponse)(nil), // 97: workflow.plugin.external.iac.LockResponse - (*UnlockRequest)(nil), // 98: workflow.plugin.external.iac.UnlockRequest - (*UnlockResponse)(nil), // 99: workflow.plugin.external.iac.UnlockResponse - (*ListBackendNamesRequest)(nil), // 100: workflow.plugin.external.iac.ListBackendNamesRequest - (*ListBackendNamesResponse)(nil), // 101: workflow.plugin.external.iac.ListBackendNamesResponse - nil, // 102: workflow.plugin.external.iac.ResourceOutput.SensitiveEntry - nil, // 103: workflow.plugin.external.iac.IaCPlan.InputSnapshotEntry - nil, // 104: workflow.plugin.external.iac.ApplyResult.InitialInputSnapshotEntry - nil, // 105: workflow.plugin.external.iac.ApplyResult.ReplaceIdMapEntry - nil, // 106: workflow.plugin.external.iac.BootstrapResult.EnvVarsEntry - nil, // 107: workflow.plugin.external.iac.MigrationRepairRequest.EnvEntry - nil, // 108: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry - nil, // 109: workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry - nil, // 110: workflow.plugin.external.iac.ListStatesRequest.FilterEntry - (*timestamppb.Timestamp)(nil), // 111: google.protobuf.Timestamp + (*DestroyResult)(nil), // 21: workflow.plugin.external.iac.DestroyResult + (*BootstrapResult)(nil), // 22: workflow.plugin.external.iac.BootstrapResult + (*MigrationRepairRequest)(nil), // 23: workflow.plugin.external.iac.MigrationRepairRequest + (*MigrationRepairResult)(nil), // 24: workflow.plugin.external.iac.MigrationRepairResult + (*InitializeRequest)(nil), // 25: workflow.plugin.external.iac.InitializeRequest + (*InitializeResponse)(nil), // 26: workflow.plugin.external.iac.InitializeResponse + (*NameRequest)(nil), // 27: workflow.plugin.external.iac.NameRequest + (*NameResponse)(nil), // 28: workflow.plugin.external.iac.NameResponse + (*VersionRequest)(nil), // 29: workflow.plugin.external.iac.VersionRequest + (*VersionResponse)(nil), // 30: workflow.plugin.external.iac.VersionResponse + (*CapabilitiesRequest)(nil), // 31: workflow.plugin.external.iac.CapabilitiesRequest + (*CapabilitiesResponse)(nil), // 32: workflow.plugin.external.iac.CapabilitiesResponse + (*PlanRequest)(nil), // 33: workflow.plugin.external.iac.PlanRequest + (*PlanResponse)(nil), // 34: workflow.plugin.external.iac.PlanResponse + (*DestroyRequest)(nil), // 35: workflow.plugin.external.iac.DestroyRequest + (*DestroyResponse)(nil), // 36: workflow.plugin.external.iac.DestroyResponse + (*StatusRequest)(nil), // 37: workflow.plugin.external.iac.StatusRequest + (*StatusResponse)(nil), // 38: workflow.plugin.external.iac.StatusResponse + (*ImportRequest)(nil), // 39: workflow.plugin.external.iac.ImportRequest + (*ImportResponse)(nil), // 40: workflow.plugin.external.iac.ImportResponse + (*ResolveSizingRequest)(nil), // 41: workflow.plugin.external.iac.ResolveSizingRequest + (*ResolveSizingResponse)(nil), // 42: workflow.plugin.external.iac.ResolveSizingResponse + (*BootstrapStateBackendRequest)(nil), // 43: workflow.plugin.external.iac.BootstrapStateBackendRequest + (*BootstrapStateBackendResponse)(nil), // 44: workflow.plugin.external.iac.BootstrapStateBackendResponse + (*EnumerateAllRequest)(nil), // 45: workflow.plugin.external.iac.EnumerateAllRequest + (*EnumerateAllResponse)(nil), // 46: workflow.plugin.external.iac.EnumerateAllResponse + (*EnumerateByTagRequest)(nil), // 47: workflow.plugin.external.iac.EnumerateByTagRequest + (*EnumerateByTagResponse)(nil), // 48: workflow.plugin.external.iac.EnumerateByTagResponse + (*DetectDriftRequest)(nil), // 49: workflow.plugin.external.iac.DetectDriftRequest + (*DetectDriftResponse)(nil), // 50: workflow.plugin.external.iac.DetectDriftResponse + (*DetectDriftWithSpecsRequest)(nil), // 51: workflow.plugin.external.iac.DetectDriftWithSpecsRequest + (*DetectDriftWithSpecsResponse)(nil), // 52: workflow.plugin.external.iac.DetectDriftWithSpecsResponse + (*RevokeProviderCredentialRequest)(nil), // 53: workflow.plugin.external.iac.RevokeProviderCredentialRequest + (*RevokeProviderCredentialResponse)(nil), // 54: workflow.plugin.external.iac.RevokeProviderCredentialResponse + (*FinalizeApplyRequest)(nil), // 55: workflow.plugin.external.iac.FinalizeApplyRequest + (*FinalizeApplyResponse)(nil), // 56: workflow.plugin.external.iac.FinalizeApplyResponse + (*RepairDirtyMigrationRequest)(nil), // 57: workflow.plugin.external.iac.RepairDirtyMigrationRequest + (*RepairDirtyMigrationResponse)(nil), // 58: workflow.plugin.external.iac.RepairDirtyMigrationResponse + (*ValidatePlanRequest)(nil), // 59: workflow.plugin.external.iac.ValidatePlanRequest + (*ValidatePlanResponse)(nil), // 60: workflow.plugin.external.iac.ValidatePlanResponse + (*DetectDriftConfigRequest)(nil), // 61: workflow.plugin.external.iac.DetectDriftConfigRequest + (*DetectDriftConfigResponse)(nil), // 62: workflow.plugin.external.iac.DetectDriftConfigResponse + (*ResourceCreateRequest)(nil), // 63: workflow.plugin.external.iac.ResourceCreateRequest + (*ResourceCreateResponse)(nil), // 64: workflow.plugin.external.iac.ResourceCreateResponse + (*ResourceReadRequest)(nil), // 65: workflow.plugin.external.iac.ResourceReadRequest + (*ResourceReadResponse)(nil), // 66: workflow.plugin.external.iac.ResourceReadResponse + (*ResourceUpdateRequest)(nil), // 67: workflow.plugin.external.iac.ResourceUpdateRequest + (*ResourceUpdateResponse)(nil), // 68: workflow.plugin.external.iac.ResourceUpdateResponse + (*ResourceDeleteRequest)(nil), // 69: workflow.plugin.external.iac.ResourceDeleteRequest + (*ResourceDeleteResponse)(nil), // 70: workflow.plugin.external.iac.ResourceDeleteResponse + (*ResourceDiffRequest)(nil), // 71: workflow.plugin.external.iac.ResourceDiffRequest + (*ResourceDiffResponse)(nil), // 72: workflow.plugin.external.iac.ResourceDiffResponse + (*ResourceScaleRequest)(nil), // 73: workflow.plugin.external.iac.ResourceScaleRequest + (*ResourceScaleResponse)(nil), // 74: workflow.plugin.external.iac.ResourceScaleResponse + (*ResourceHealthCheckRequest)(nil), // 75: workflow.plugin.external.iac.ResourceHealthCheckRequest + (*ResourceHealthCheckResponse)(nil), // 76: workflow.plugin.external.iac.ResourceHealthCheckResponse + (*SensitiveKeysRequest)(nil), // 77: workflow.plugin.external.iac.SensitiveKeysRequest + (*SensitiveKeysResponse)(nil), // 78: workflow.plugin.external.iac.SensitiveKeysResponse + (*TroubleshootRequest)(nil), // 79: workflow.plugin.external.iac.TroubleshootRequest + (*TroubleshootResponse)(nil), // 80: workflow.plugin.external.iac.TroubleshootResponse + (*IaCState)(nil), // 81: workflow.plugin.external.iac.IaCState + (*ConfigureRequest)(nil), // 82: workflow.plugin.external.iac.ConfigureRequest + (*ConfigureResponse)(nil), // 83: workflow.plugin.external.iac.ConfigureResponse + (*GetStateRequest)(nil), // 84: workflow.plugin.external.iac.GetStateRequest + (*GetStateResponse)(nil), // 85: workflow.plugin.external.iac.GetStateResponse + (*SaveStateRequest)(nil), // 86: workflow.plugin.external.iac.SaveStateRequest + (*SaveStateResponse)(nil), // 87: workflow.plugin.external.iac.SaveStateResponse + (*ListStatesRequest)(nil), // 88: workflow.plugin.external.iac.ListStatesRequest + (*ListStatesResponse)(nil), // 89: workflow.plugin.external.iac.ListStatesResponse + (*DeleteStateRequest)(nil), // 90: workflow.plugin.external.iac.DeleteStateRequest + (*DeleteStateResponse)(nil), // 91: workflow.plugin.external.iac.DeleteStateResponse + (*LockRequest)(nil), // 92: workflow.plugin.external.iac.LockRequest + (*LockResponse)(nil), // 93: workflow.plugin.external.iac.LockResponse + (*UnlockRequest)(nil), // 94: workflow.plugin.external.iac.UnlockRequest + (*UnlockResponse)(nil), // 95: workflow.plugin.external.iac.UnlockResponse + (*ListBackendNamesRequest)(nil), // 96: workflow.plugin.external.iac.ListBackendNamesRequest + (*ListBackendNamesResponse)(nil), // 97: workflow.plugin.external.iac.ListBackendNamesResponse + nil, // 98: workflow.plugin.external.iac.ResourceOutput.SensitiveEntry + nil, // 99: workflow.plugin.external.iac.IaCPlan.InputSnapshotEntry + nil, // 100: workflow.plugin.external.iac.BootstrapResult.EnvVarsEntry + nil, // 101: workflow.plugin.external.iac.MigrationRepairRequest.EnvEntry + nil, // 102: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry + nil, // 103: workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry + nil, // 104: workflow.plugin.external.iac.ListStatesRequest.FilterEntry + (*timestamppb.Timestamp)(nil), // 105: google.protobuf.Timestamp } var file_iac_proto_depIdxs = []int32{ 5, // 0: workflow.plugin.external.iac.ResourceSpec.hints:type_name -> workflow.plugin.external.iac.ResourceHints - 111, // 1: workflow.plugin.external.iac.ResourceState.created_at:type_name -> google.protobuf.Timestamp - 111, // 2: workflow.plugin.external.iac.ResourceState.updated_at:type_name -> google.protobuf.Timestamp - 111, // 3: workflow.plugin.external.iac.ResourceState.last_drift_check:type_name -> google.protobuf.Timestamp - 102, // 4: workflow.plugin.external.iac.ResourceOutput.sensitive:type_name -> workflow.plugin.external.iac.ResourceOutput.SensitiveEntry + 105, // 1: workflow.plugin.external.iac.ResourceState.created_at:type_name -> google.protobuf.Timestamp + 105, // 2: workflow.plugin.external.iac.ResourceState.updated_at:type_name -> google.protobuf.Timestamp + 105, // 3: workflow.plugin.external.iac.ResourceState.last_drift_check:type_name -> google.protobuf.Timestamp + 98, // 4: workflow.plugin.external.iac.ResourceOutput.sensitive:type_name -> workflow.plugin.external.iac.ResourceOutput.SensitiveEntry 11, // 5: workflow.plugin.external.iac.DiffResult.changes:type_name -> workflow.plugin.external.iac.FieldChange 0, // 6: workflow.plugin.external.iac.DriftResult.class:type_name -> workflow.plugin.external.iac.DriftClass - 111, // 7: workflow.plugin.external.iac.Diagnostic.at:type_name -> google.protobuf.Timestamp + 105, // 7: workflow.plugin.external.iac.Diagnostic.at:type_name -> google.protobuf.Timestamp 1, // 8: workflow.plugin.external.iac.PlanDiagnostic.severity:type_name -> workflow.plugin.external.iac.PlanDiagnosticSeverity 3, // 9: workflow.plugin.external.iac.PlanAction.resource:type_name -> workflow.plugin.external.iac.ResourceSpec 8, // 10: workflow.plugin.external.iac.PlanAction.current:type_name -> workflow.plugin.external.iac.ResourceState 11, // 11: workflow.plugin.external.iac.PlanAction.changes:type_name -> workflow.plugin.external.iac.FieldChange 18, // 12: workflow.plugin.external.iac.IaCPlan.actions:type_name -> workflow.plugin.external.iac.PlanAction - 111, // 13: workflow.plugin.external.iac.IaCPlan.created_at:type_name -> google.protobuf.Timestamp - 103, // 14: workflow.plugin.external.iac.IaCPlan.input_snapshot:type_name -> workflow.plugin.external.iac.IaCPlan.InputSnapshotEntry - 2, // 15: workflow.plugin.external.iac.ActionResult.status:type_name -> workflow.plugin.external.iac.ActionStatus - 9, // 16: workflow.plugin.external.iac.ApplyResult.resources:type_name -> workflow.plugin.external.iac.ResourceOutput - 20, // 17: workflow.plugin.external.iac.ApplyResult.errors:type_name -> workflow.plugin.external.iac.ActionError - 104, // 18: workflow.plugin.external.iac.ApplyResult.initial_input_snapshot:type_name -> workflow.plugin.external.iac.ApplyResult.InitialInputSnapshotEntry - 14, // 19: workflow.plugin.external.iac.ApplyResult.input_drift_report:type_name -> workflow.plugin.external.iac.DriftEntry - 105, // 20: workflow.plugin.external.iac.ApplyResult.replace_id_map:type_name -> workflow.plugin.external.iac.ApplyResult.ReplaceIdMapEntry - 21, // 21: workflow.plugin.external.iac.ApplyResult.actions:type_name -> workflow.plugin.external.iac.ActionResult - 20, // 22: workflow.plugin.external.iac.DestroyResult.errors:type_name -> workflow.plugin.external.iac.ActionError - 106, // 23: workflow.plugin.external.iac.BootstrapResult.env_vars:type_name -> workflow.plugin.external.iac.BootstrapResult.EnvVarsEntry - 107, // 24: workflow.plugin.external.iac.MigrationRepairRequest.env:type_name -> workflow.plugin.external.iac.MigrationRepairRequest.EnvEntry - 16, // 25: workflow.plugin.external.iac.MigrationRepairResult.diagnostics:type_name -> workflow.plugin.external.iac.Diagnostic - 7, // 26: workflow.plugin.external.iac.CapabilitiesResponse.capabilities:type_name -> workflow.plugin.external.iac.IaCCapabilityDeclaration - 3, // 27: workflow.plugin.external.iac.PlanRequest.desired:type_name -> workflow.plugin.external.iac.ResourceSpec - 8, // 28: workflow.plugin.external.iac.PlanRequest.current:type_name -> workflow.plugin.external.iac.ResourceState - 19, // 29: workflow.plugin.external.iac.PlanResponse.plan:type_name -> workflow.plugin.external.iac.IaCPlan - 19, // 30: workflow.plugin.external.iac.ApplyRequest.plan:type_name -> workflow.plugin.external.iac.IaCPlan - 22, // 31: workflow.plugin.external.iac.ApplyResponse.result:type_name -> workflow.plugin.external.iac.ApplyResult - 4, // 32: workflow.plugin.external.iac.DestroyRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef - 23, // 33: workflow.plugin.external.iac.DestroyResponse.result:type_name -> workflow.plugin.external.iac.DestroyResult - 4, // 34: workflow.plugin.external.iac.StatusRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef - 10, // 35: workflow.plugin.external.iac.StatusResponse.statuses:type_name -> workflow.plugin.external.iac.ResourceStatus - 8, // 36: workflow.plugin.external.iac.ImportResponse.state:type_name -> workflow.plugin.external.iac.ResourceState - 5, // 37: workflow.plugin.external.iac.ResolveSizingRequest.hints:type_name -> workflow.plugin.external.iac.ResourceHints - 6, // 38: workflow.plugin.external.iac.ResolveSizingResponse.sizing:type_name -> workflow.plugin.external.iac.ProviderSizing - 24, // 39: workflow.plugin.external.iac.BootstrapStateBackendResponse.result:type_name -> workflow.plugin.external.iac.BootstrapResult - 9, // 40: workflow.plugin.external.iac.EnumerateAllResponse.outputs:type_name -> workflow.plugin.external.iac.ResourceOutput - 4, // 41: workflow.plugin.external.iac.EnumerateByTagResponse.refs:type_name -> workflow.plugin.external.iac.ResourceRef - 4, // 42: workflow.plugin.external.iac.DetectDriftRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef - 13, // 43: workflow.plugin.external.iac.DetectDriftResponse.drifts:type_name -> workflow.plugin.external.iac.DriftResult - 4, // 44: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef - 108, // 45: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.specs:type_name -> workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry - 13, // 46: workflow.plugin.external.iac.DetectDriftWithSpecsResponse.drifts:type_name -> workflow.plugin.external.iac.DriftResult - 20, // 47: workflow.plugin.external.iac.FinalizeApplyResponse.errors:type_name -> workflow.plugin.external.iac.ActionError - 25, // 48: workflow.plugin.external.iac.RepairDirtyMigrationRequest.request:type_name -> workflow.plugin.external.iac.MigrationRepairRequest - 26, // 49: workflow.plugin.external.iac.RepairDirtyMigrationResponse.result:type_name -> workflow.plugin.external.iac.MigrationRepairResult - 19, // 50: workflow.plugin.external.iac.ValidatePlanRequest.plan:type_name -> workflow.plugin.external.iac.IaCPlan - 17, // 51: workflow.plugin.external.iac.ValidatePlanResponse.diagnostics:type_name -> workflow.plugin.external.iac.PlanDiagnostic - 4, // 52: workflow.plugin.external.iac.DetectDriftConfigRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef - 109, // 53: workflow.plugin.external.iac.DetectDriftConfigRequest.specs:type_name -> workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry - 13, // 54: workflow.plugin.external.iac.DetectDriftConfigResponse.drifts:type_name -> workflow.plugin.external.iac.DriftResult - 3, // 55: workflow.plugin.external.iac.ResourceCreateRequest.spec:type_name -> workflow.plugin.external.iac.ResourceSpec - 9, // 56: workflow.plugin.external.iac.ResourceCreateResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput - 4, // 57: workflow.plugin.external.iac.ResourceReadRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef - 9, // 58: workflow.plugin.external.iac.ResourceReadResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput - 4, // 59: workflow.plugin.external.iac.ResourceUpdateRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef - 3, // 60: workflow.plugin.external.iac.ResourceUpdateRequest.spec:type_name -> workflow.plugin.external.iac.ResourceSpec - 9, // 61: workflow.plugin.external.iac.ResourceUpdateResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput - 4, // 62: workflow.plugin.external.iac.ResourceDeleteRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef - 3, // 63: workflow.plugin.external.iac.ResourceDiffRequest.desired:type_name -> workflow.plugin.external.iac.ResourceSpec - 9, // 64: workflow.plugin.external.iac.ResourceDiffRequest.current:type_name -> workflow.plugin.external.iac.ResourceOutput - 12, // 65: workflow.plugin.external.iac.ResourceDiffResponse.result:type_name -> workflow.plugin.external.iac.DiffResult - 4, // 66: workflow.plugin.external.iac.ResourceScaleRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef - 9, // 67: workflow.plugin.external.iac.ResourceScaleResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput - 4, // 68: workflow.plugin.external.iac.ResourceHealthCheckRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef - 15, // 69: workflow.plugin.external.iac.ResourceHealthCheckResponse.result:type_name -> workflow.plugin.external.iac.HealthResult - 4, // 70: workflow.plugin.external.iac.TroubleshootRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef - 16, // 71: workflow.plugin.external.iac.TroubleshootResponse.diagnostics:type_name -> workflow.plugin.external.iac.Diagnostic - 85, // 72: workflow.plugin.external.iac.GetStateResponse.state:type_name -> workflow.plugin.external.iac.IaCState - 85, // 73: workflow.plugin.external.iac.SaveStateRequest.state:type_name -> workflow.plugin.external.iac.IaCState - 110, // 74: workflow.plugin.external.iac.ListStatesRequest.filter:type_name -> workflow.plugin.external.iac.ListStatesRequest.FilterEntry - 85, // 75: workflow.plugin.external.iac.ListStatesResponse.states:type_name -> workflow.plugin.external.iac.IaCState - 3, // 76: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry.value:type_name -> workflow.plugin.external.iac.ResourceSpec - 3, // 77: workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry.value:type_name -> workflow.plugin.external.iac.ResourceSpec - 27, // 78: workflow.plugin.external.iac.IaCProviderRequired.Initialize:input_type -> workflow.plugin.external.iac.InitializeRequest - 29, // 79: workflow.plugin.external.iac.IaCProviderRequired.Name:input_type -> workflow.plugin.external.iac.NameRequest - 31, // 80: workflow.plugin.external.iac.IaCProviderRequired.Version:input_type -> workflow.plugin.external.iac.VersionRequest - 33, // 81: workflow.plugin.external.iac.IaCProviderRequired.Capabilities:input_type -> workflow.plugin.external.iac.CapabilitiesRequest - 35, // 82: workflow.plugin.external.iac.IaCProviderRequired.Plan:input_type -> workflow.plugin.external.iac.PlanRequest - 37, // 83: workflow.plugin.external.iac.IaCProviderRequired.Apply:input_type -> workflow.plugin.external.iac.ApplyRequest - 39, // 84: workflow.plugin.external.iac.IaCProviderRequired.Destroy:input_type -> workflow.plugin.external.iac.DestroyRequest - 41, // 85: workflow.plugin.external.iac.IaCProviderRequired.Status:input_type -> workflow.plugin.external.iac.StatusRequest - 43, // 86: workflow.plugin.external.iac.IaCProviderRequired.Import:input_type -> workflow.plugin.external.iac.ImportRequest - 45, // 87: workflow.plugin.external.iac.IaCProviderRequired.ResolveSizing:input_type -> workflow.plugin.external.iac.ResolveSizingRequest - 47, // 88: workflow.plugin.external.iac.IaCProviderRequired.BootstrapStateBackend:input_type -> workflow.plugin.external.iac.BootstrapStateBackendRequest - 49, // 89: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateAll:input_type -> workflow.plugin.external.iac.EnumerateAllRequest - 51, // 90: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateByTag:input_type -> workflow.plugin.external.iac.EnumerateByTagRequest - 53, // 91: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDrift:input_type -> workflow.plugin.external.iac.DetectDriftRequest - 55, // 92: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDriftWithSpecs:input_type -> workflow.plugin.external.iac.DetectDriftWithSpecsRequest - 57, // 93: workflow.plugin.external.iac.IaCProviderCredentialRevoker.RevokeProviderCredential:input_type -> workflow.plugin.external.iac.RevokeProviderCredentialRequest - 59, // 94: workflow.plugin.external.iac.IaCProviderFinalizer.FinalizeApply:input_type -> workflow.plugin.external.iac.FinalizeApplyRequest - 61, // 95: workflow.plugin.external.iac.IaCProviderMigrationRepairer.RepairDirtyMigration:input_type -> workflow.plugin.external.iac.RepairDirtyMigrationRequest - 63, // 96: workflow.plugin.external.iac.IaCProviderValidator.ValidatePlan:input_type -> workflow.plugin.external.iac.ValidatePlanRequest - 65, // 97: workflow.plugin.external.iac.IaCProviderDriftConfigDetector.DetectDriftConfig:input_type -> workflow.plugin.external.iac.DetectDriftConfigRequest - 67, // 98: workflow.plugin.external.iac.ResourceDriver.Create:input_type -> workflow.plugin.external.iac.ResourceCreateRequest - 69, // 99: workflow.plugin.external.iac.ResourceDriver.Read:input_type -> workflow.plugin.external.iac.ResourceReadRequest - 71, // 100: workflow.plugin.external.iac.ResourceDriver.Update:input_type -> workflow.plugin.external.iac.ResourceUpdateRequest - 73, // 101: workflow.plugin.external.iac.ResourceDriver.Delete:input_type -> workflow.plugin.external.iac.ResourceDeleteRequest - 75, // 102: workflow.plugin.external.iac.ResourceDriver.Diff:input_type -> workflow.plugin.external.iac.ResourceDiffRequest - 77, // 103: workflow.plugin.external.iac.ResourceDriver.Scale:input_type -> workflow.plugin.external.iac.ResourceScaleRequest - 79, // 104: workflow.plugin.external.iac.ResourceDriver.HealthCheck:input_type -> workflow.plugin.external.iac.ResourceHealthCheckRequest - 81, // 105: workflow.plugin.external.iac.ResourceDriver.SensitiveKeys:input_type -> workflow.plugin.external.iac.SensitiveKeysRequest - 83, // 106: workflow.plugin.external.iac.ResourceDriver.Troubleshoot:input_type -> workflow.plugin.external.iac.TroubleshootRequest - 86, // 107: workflow.plugin.external.iac.IaCStateBackend.Configure:input_type -> workflow.plugin.external.iac.ConfigureRequest - 88, // 108: workflow.plugin.external.iac.IaCStateBackend.GetState:input_type -> workflow.plugin.external.iac.GetStateRequest - 90, // 109: workflow.plugin.external.iac.IaCStateBackend.SaveState:input_type -> workflow.plugin.external.iac.SaveStateRequest - 92, // 110: workflow.plugin.external.iac.IaCStateBackend.ListStates:input_type -> workflow.plugin.external.iac.ListStatesRequest - 94, // 111: workflow.plugin.external.iac.IaCStateBackend.DeleteState:input_type -> workflow.plugin.external.iac.DeleteStateRequest - 96, // 112: workflow.plugin.external.iac.IaCStateBackend.Lock:input_type -> workflow.plugin.external.iac.LockRequest - 98, // 113: workflow.plugin.external.iac.IaCStateBackend.Unlock:input_type -> workflow.plugin.external.iac.UnlockRequest - 100, // 114: workflow.plugin.external.iac.IaCStateBackend.ListBackendNames:input_type -> workflow.plugin.external.iac.ListBackendNamesRequest - 28, // 115: workflow.plugin.external.iac.IaCProviderRequired.Initialize:output_type -> workflow.plugin.external.iac.InitializeResponse - 30, // 116: workflow.plugin.external.iac.IaCProviderRequired.Name:output_type -> workflow.plugin.external.iac.NameResponse - 32, // 117: workflow.plugin.external.iac.IaCProviderRequired.Version:output_type -> workflow.plugin.external.iac.VersionResponse - 34, // 118: workflow.plugin.external.iac.IaCProviderRequired.Capabilities:output_type -> workflow.plugin.external.iac.CapabilitiesResponse - 36, // 119: workflow.plugin.external.iac.IaCProviderRequired.Plan:output_type -> workflow.plugin.external.iac.PlanResponse - 38, // 120: workflow.plugin.external.iac.IaCProviderRequired.Apply:output_type -> workflow.plugin.external.iac.ApplyResponse - 40, // 121: workflow.plugin.external.iac.IaCProviderRequired.Destroy:output_type -> workflow.plugin.external.iac.DestroyResponse - 42, // 122: workflow.plugin.external.iac.IaCProviderRequired.Status:output_type -> workflow.plugin.external.iac.StatusResponse - 44, // 123: workflow.plugin.external.iac.IaCProviderRequired.Import:output_type -> workflow.plugin.external.iac.ImportResponse - 46, // 124: workflow.plugin.external.iac.IaCProviderRequired.ResolveSizing:output_type -> workflow.plugin.external.iac.ResolveSizingResponse - 48, // 125: workflow.plugin.external.iac.IaCProviderRequired.BootstrapStateBackend:output_type -> workflow.plugin.external.iac.BootstrapStateBackendResponse - 50, // 126: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateAll:output_type -> workflow.plugin.external.iac.EnumerateAllResponse - 52, // 127: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateByTag:output_type -> workflow.plugin.external.iac.EnumerateByTagResponse - 54, // 128: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDrift:output_type -> workflow.plugin.external.iac.DetectDriftResponse - 56, // 129: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDriftWithSpecs:output_type -> workflow.plugin.external.iac.DetectDriftWithSpecsResponse - 58, // 130: workflow.plugin.external.iac.IaCProviderCredentialRevoker.RevokeProviderCredential:output_type -> workflow.plugin.external.iac.RevokeProviderCredentialResponse - 60, // 131: workflow.plugin.external.iac.IaCProviderFinalizer.FinalizeApply:output_type -> workflow.plugin.external.iac.FinalizeApplyResponse - 62, // 132: workflow.plugin.external.iac.IaCProviderMigrationRepairer.RepairDirtyMigration:output_type -> workflow.plugin.external.iac.RepairDirtyMigrationResponse - 64, // 133: workflow.plugin.external.iac.IaCProviderValidator.ValidatePlan:output_type -> workflow.plugin.external.iac.ValidatePlanResponse - 66, // 134: workflow.plugin.external.iac.IaCProviderDriftConfigDetector.DetectDriftConfig:output_type -> workflow.plugin.external.iac.DetectDriftConfigResponse - 68, // 135: workflow.plugin.external.iac.ResourceDriver.Create:output_type -> workflow.plugin.external.iac.ResourceCreateResponse - 70, // 136: workflow.plugin.external.iac.ResourceDriver.Read:output_type -> workflow.plugin.external.iac.ResourceReadResponse - 72, // 137: workflow.plugin.external.iac.ResourceDriver.Update:output_type -> workflow.plugin.external.iac.ResourceUpdateResponse - 74, // 138: workflow.plugin.external.iac.ResourceDriver.Delete:output_type -> workflow.plugin.external.iac.ResourceDeleteResponse - 76, // 139: workflow.plugin.external.iac.ResourceDriver.Diff:output_type -> workflow.plugin.external.iac.ResourceDiffResponse - 78, // 140: workflow.plugin.external.iac.ResourceDriver.Scale:output_type -> workflow.plugin.external.iac.ResourceScaleResponse - 80, // 141: workflow.plugin.external.iac.ResourceDriver.HealthCheck:output_type -> workflow.plugin.external.iac.ResourceHealthCheckResponse - 82, // 142: workflow.plugin.external.iac.ResourceDriver.SensitiveKeys:output_type -> workflow.plugin.external.iac.SensitiveKeysResponse - 84, // 143: workflow.plugin.external.iac.ResourceDriver.Troubleshoot:output_type -> workflow.plugin.external.iac.TroubleshootResponse - 87, // 144: workflow.plugin.external.iac.IaCStateBackend.Configure:output_type -> workflow.plugin.external.iac.ConfigureResponse - 89, // 145: workflow.plugin.external.iac.IaCStateBackend.GetState:output_type -> workflow.plugin.external.iac.GetStateResponse - 91, // 146: workflow.plugin.external.iac.IaCStateBackend.SaveState:output_type -> workflow.plugin.external.iac.SaveStateResponse - 93, // 147: workflow.plugin.external.iac.IaCStateBackend.ListStates:output_type -> workflow.plugin.external.iac.ListStatesResponse - 95, // 148: workflow.plugin.external.iac.IaCStateBackend.DeleteState:output_type -> workflow.plugin.external.iac.DeleteStateResponse - 97, // 149: workflow.plugin.external.iac.IaCStateBackend.Lock:output_type -> workflow.plugin.external.iac.LockResponse - 99, // 150: workflow.plugin.external.iac.IaCStateBackend.Unlock:output_type -> workflow.plugin.external.iac.UnlockResponse - 101, // 151: workflow.plugin.external.iac.IaCStateBackend.ListBackendNames:output_type -> workflow.plugin.external.iac.ListBackendNamesResponse - 115, // [115:152] is the sub-list for method output_type - 78, // [78:115] is the sub-list for method input_type - 78, // [78:78] is the sub-list for extension type_name - 78, // [78:78] is the sub-list for extension extendee - 0, // [0:78] is the sub-list for field type_name + 105, // 13: workflow.plugin.external.iac.IaCPlan.created_at:type_name -> google.protobuf.Timestamp + 99, // 14: workflow.plugin.external.iac.IaCPlan.input_snapshot:type_name -> workflow.plugin.external.iac.IaCPlan.InputSnapshotEntry + 20, // 15: workflow.plugin.external.iac.DestroyResult.errors:type_name -> workflow.plugin.external.iac.ActionError + 100, // 16: workflow.plugin.external.iac.BootstrapResult.env_vars:type_name -> workflow.plugin.external.iac.BootstrapResult.EnvVarsEntry + 101, // 17: workflow.plugin.external.iac.MigrationRepairRequest.env:type_name -> workflow.plugin.external.iac.MigrationRepairRequest.EnvEntry + 16, // 18: workflow.plugin.external.iac.MigrationRepairResult.diagnostics:type_name -> workflow.plugin.external.iac.Diagnostic + 7, // 19: workflow.plugin.external.iac.CapabilitiesResponse.capabilities:type_name -> workflow.plugin.external.iac.IaCCapabilityDeclaration + 3, // 20: workflow.plugin.external.iac.PlanRequest.desired:type_name -> workflow.plugin.external.iac.ResourceSpec + 8, // 21: workflow.plugin.external.iac.PlanRequest.current:type_name -> workflow.plugin.external.iac.ResourceState + 19, // 22: workflow.plugin.external.iac.PlanResponse.plan:type_name -> workflow.plugin.external.iac.IaCPlan + 4, // 23: workflow.plugin.external.iac.DestroyRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef + 21, // 24: workflow.plugin.external.iac.DestroyResponse.result:type_name -> workflow.plugin.external.iac.DestroyResult + 4, // 25: workflow.plugin.external.iac.StatusRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef + 10, // 26: workflow.plugin.external.iac.StatusResponse.statuses:type_name -> workflow.plugin.external.iac.ResourceStatus + 8, // 27: workflow.plugin.external.iac.ImportResponse.state:type_name -> workflow.plugin.external.iac.ResourceState + 5, // 28: workflow.plugin.external.iac.ResolveSizingRequest.hints:type_name -> workflow.plugin.external.iac.ResourceHints + 6, // 29: workflow.plugin.external.iac.ResolveSizingResponse.sizing:type_name -> workflow.plugin.external.iac.ProviderSizing + 22, // 30: workflow.plugin.external.iac.BootstrapStateBackendResponse.result:type_name -> workflow.plugin.external.iac.BootstrapResult + 9, // 31: workflow.plugin.external.iac.EnumerateAllResponse.outputs:type_name -> workflow.plugin.external.iac.ResourceOutput + 4, // 32: workflow.plugin.external.iac.EnumerateByTagResponse.refs:type_name -> workflow.plugin.external.iac.ResourceRef + 4, // 33: workflow.plugin.external.iac.DetectDriftRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef + 13, // 34: workflow.plugin.external.iac.DetectDriftResponse.drifts:type_name -> workflow.plugin.external.iac.DriftResult + 4, // 35: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef + 102, // 36: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.specs:type_name -> workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry + 13, // 37: workflow.plugin.external.iac.DetectDriftWithSpecsResponse.drifts:type_name -> workflow.plugin.external.iac.DriftResult + 20, // 38: workflow.plugin.external.iac.FinalizeApplyResponse.errors:type_name -> workflow.plugin.external.iac.ActionError + 23, // 39: workflow.plugin.external.iac.RepairDirtyMigrationRequest.request:type_name -> workflow.plugin.external.iac.MigrationRepairRequest + 24, // 40: workflow.plugin.external.iac.RepairDirtyMigrationResponse.result:type_name -> workflow.plugin.external.iac.MigrationRepairResult + 19, // 41: workflow.plugin.external.iac.ValidatePlanRequest.plan:type_name -> workflow.plugin.external.iac.IaCPlan + 17, // 42: workflow.plugin.external.iac.ValidatePlanResponse.diagnostics:type_name -> workflow.plugin.external.iac.PlanDiagnostic + 4, // 43: workflow.plugin.external.iac.DetectDriftConfigRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef + 103, // 44: workflow.plugin.external.iac.DetectDriftConfigRequest.specs:type_name -> workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry + 13, // 45: workflow.plugin.external.iac.DetectDriftConfigResponse.drifts:type_name -> workflow.plugin.external.iac.DriftResult + 3, // 46: workflow.plugin.external.iac.ResourceCreateRequest.spec:type_name -> workflow.plugin.external.iac.ResourceSpec + 9, // 47: workflow.plugin.external.iac.ResourceCreateResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput + 4, // 48: workflow.plugin.external.iac.ResourceReadRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef + 9, // 49: workflow.plugin.external.iac.ResourceReadResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput + 4, // 50: workflow.plugin.external.iac.ResourceUpdateRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef + 3, // 51: workflow.plugin.external.iac.ResourceUpdateRequest.spec:type_name -> workflow.plugin.external.iac.ResourceSpec + 9, // 52: workflow.plugin.external.iac.ResourceUpdateResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput + 4, // 53: workflow.plugin.external.iac.ResourceDeleteRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef + 3, // 54: workflow.plugin.external.iac.ResourceDiffRequest.desired:type_name -> workflow.plugin.external.iac.ResourceSpec + 9, // 55: workflow.plugin.external.iac.ResourceDiffRequest.current:type_name -> workflow.plugin.external.iac.ResourceOutput + 12, // 56: workflow.plugin.external.iac.ResourceDiffResponse.result:type_name -> workflow.plugin.external.iac.DiffResult + 4, // 57: workflow.plugin.external.iac.ResourceScaleRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef + 9, // 58: workflow.plugin.external.iac.ResourceScaleResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput + 4, // 59: workflow.plugin.external.iac.ResourceHealthCheckRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef + 15, // 60: workflow.plugin.external.iac.ResourceHealthCheckResponse.result:type_name -> workflow.plugin.external.iac.HealthResult + 4, // 61: workflow.plugin.external.iac.TroubleshootRequest.ref:type_name -> workflow.plugin.external.iac.ResourceRef + 16, // 62: workflow.plugin.external.iac.TroubleshootResponse.diagnostics:type_name -> workflow.plugin.external.iac.Diagnostic + 81, // 63: workflow.plugin.external.iac.GetStateResponse.state:type_name -> workflow.plugin.external.iac.IaCState + 81, // 64: workflow.plugin.external.iac.SaveStateRequest.state:type_name -> workflow.plugin.external.iac.IaCState + 104, // 65: workflow.plugin.external.iac.ListStatesRequest.filter:type_name -> workflow.plugin.external.iac.ListStatesRequest.FilterEntry + 81, // 66: workflow.plugin.external.iac.ListStatesResponse.states:type_name -> workflow.plugin.external.iac.IaCState + 3, // 67: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry.value:type_name -> workflow.plugin.external.iac.ResourceSpec + 3, // 68: workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry.value:type_name -> workflow.plugin.external.iac.ResourceSpec + 25, // 69: workflow.plugin.external.iac.IaCProviderRequired.Initialize:input_type -> workflow.plugin.external.iac.InitializeRequest + 27, // 70: workflow.plugin.external.iac.IaCProviderRequired.Name:input_type -> workflow.plugin.external.iac.NameRequest + 29, // 71: workflow.plugin.external.iac.IaCProviderRequired.Version:input_type -> workflow.plugin.external.iac.VersionRequest + 31, // 72: workflow.plugin.external.iac.IaCProviderRequired.Capabilities:input_type -> workflow.plugin.external.iac.CapabilitiesRequest + 33, // 73: workflow.plugin.external.iac.IaCProviderRequired.Plan:input_type -> workflow.plugin.external.iac.PlanRequest + 35, // 74: workflow.plugin.external.iac.IaCProviderRequired.Destroy:input_type -> workflow.plugin.external.iac.DestroyRequest + 37, // 75: workflow.plugin.external.iac.IaCProviderRequired.Status:input_type -> workflow.plugin.external.iac.StatusRequest + 39, // 76: workflow.plugin.external.iac.IaCProviderRequired.Import:input_type -> workflow.plugin.external.iac.ImportRequest + 41, // 77: workflow.plugin.external.iac.IaCProviderRequired.ResolveSizing:input_type -> workflow.plugin.external.iac.ResolveSizingRequest + 43, // 78: workflow.plugin.external.iac.IaCProviderRequired.BootstrapStateBackend:input_type -> workflow.plugin.external.iac.BootstrapStateBackendRequest + 45, // 79: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateAll:input_type -> workflow.plugin.external.iac.EnumerateAllRequest + 47, // 80: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateByTag:input_type -> workflow.plugin.external.iac.EnumerateByTagRequest + 49, // 81: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDrift:input_type -> workflow.plugin.external.iac.DetectDriftRequest + 51, // 82: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDriftWithSpecs:input_type -> workflow.plugin.external.iac.DetectDriftWithSpecsRequest + 53, // 83: workflow.plugin.external.iac.IaCProviderCredentialRevoker.RevokeProviderCredential:input_type -> workflow.plugin.external.iac.RevokeProviderCredentialRequest + 55, // 84: workflow.plugin.external.iac.IaCProviderFinalizer.FinalizeApply:input_type -> workflow.plugin.external.iac.FinalizeApplyRequest + 57, // 85: workflow.plugin.external.iac.IaCProviderMigrationRepairer.RepairDirtyMigration:input_type -> workflow.plugin.external.iac.RepairDirtyMigrationRequest + 59, // 86: workflow.plugin.external.iac.IaCProviderValidator.ValidatePlan:input_type -> workflow.plugin.external.iac.ValidatePlanRequest + 61, // 87: workflow.plugin.external.iac.IaCProviderDriftConfigDetector.DetectDriftConfig:input_type -> workflow.plugin.external.iac.DetectDriftConfigRequest + 63, // 88: workflow.plugin.external.iac.ResourceDriver.Create:input_type -> workflow.plugin.external.iac.ResourceCreateRequest + 65, // 89: workflow.plugin.external.iac.ResourceDriver.Read:input_type -> workflow.plugin.external.iac.ResourceReadRequest + 67, // 90: workflow.plugin.external.iac.ResourceDriver.Update:input_type -> workflow.plugin.external.iac.ResourceUpdateRequest + 69, // 91: workflow.plugin.external.iac.ResourceDriver.Delete:input_type -> workflow.plugin.external.iac.ResourceDeleteRequest + 71, // 92: workflow.plugin.external.iac.ResourceDriver.Diff:input_type -> workflow.plugin.external.iac.ResourceDiffRequest + 73, // 93: workflow.plugin.external.iac.ResourceDriver.Scale:input_type -> workflow.plugin.external.iac.ResourceScaleRequest + 75, // 94: workflow.plugin.external.iac.ResourceDriver.HealthCheck:input_type -> workflow.plugin.external.iac.ResourceHealthCheckRequest + 77, // 95: workflow.plugin.external.iac.ResourceDriver.SensitiveKeys:input_type -> workflow.plugin.external.iac.SensitiveKeysRequest + 79, // 96: workflow.plugin.external.iac.ResourceDriver.Troubleshoot:input_type -> workflow.plugin.external.iac.TroubleshootRequest + 82, // 97: workflow.plugin.external.iac.IaCStateBackend.Configure:input_type -> workflow.plugin.external.iac.ConfigureRequest + 84, // 98: workflow.plugin.external.iac.IaCStateBackend.GetState:input_type -> workflow.plugin.external.iac.GetStateRequest + 86, // 99: workflow.plugin.external.iac.IaCStateBackend.SaveState:input_type -> workflow.plugin.external.iac.SaveStateRequest + 88, // 100: workflow.plugin.external.iac.IaCStateBackend.ListStates:input_type -> workflow.plugin.external.iac.ListStatesRequest + 90, // 101: workflow.plugin.external.iac.IaCStateBackend.DeleteState:input_type -> workflow.plugin.external.iac.DeleteStateRequest + 92, // 102: workflow.plugin.external.iac.IaCStateBackend.Lock:input_type -> workflow.plugin.external.iac.LockRequest + 94, // 103: workflow.plugin.external.iac.IaCStateBackend.Unlock:input_type -> workflow.plugin.external.iac.UnlockRequest + 96, // 104: workflow.plugin.external.iac.IaCStateBackend.ListBackendNames:input_type -> workflow.plugin.external.iac.ListBackendNamesRequest + 26, // 105: workflow.plugin.external.iac.IaCProviderRequired.Initialize:output_type -> workflow.plugin.external.iac.InitializeResponse + 28, // 106: workflow.plugin.external.iac.IaCProviderRequired.Name:output_type -> workflow.plugin.external.iac.NameResponse + 30, // 107: workflow.plugin.external.iac.IaCProviderRequired.Version:output_type -> workflow.plugin.external.iac.VersionResponse + 32, // 108: workflow.plugin.external.iac.IaCProviderRequired.Capabilities:output_type -> workflow.plugin.external.iac.CapabilitiesResponse + 34, // 109: workflow.plugin.external.iac.IaCProviderRequired.Plan:output_type -> workflow.plugin.external.iac.PlanResponse + 36, // 110: workflow.plugin.external.iac.IaCProviderRequired.Destroy:output_type -> workflow.plugin.external.iac.DestroyResponse + 38, // 111: workflow.plugin.external.iac.IaCProviderRequired.Status:output_type -> workflow.plugin.external.iac.StatusResponse + 40, // 112: workflow.plugin.external.iac.IaCProviderRequired.Import:output_type -> workflow.plugin.external.iac.ImportResponse + 42, // 113: workflow.plugin.external.iac.IaCProviderRequired.ResolveSizing:output_type -> workflow.plugin.external.iac.ResolveSizingResponse + 44, // 114: workflow.plugin.external.iac.IaCProviderRequired.BootstrapStateBackend:output_type -> workflow.plugin.external.iac.BootstrapStateBackendResponse + 46, // 115: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateAll:output_type -> workflow.plugin.external.iac.EnumerateAllResponse + 48, // 116: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateByTag:output_type -> workflow.plugin.external.iac.EnumerateByTagResponse + 50, // 117: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDrift:output_type -> workflow.plugin.external.iac.DetectDriftResponse + 52, // 118: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDriftWithSpecs:output_type -> workflow.plugin.external.iac.DetectDriftWithSpecsResponse + 54, // 119: workflow.plugin.external.iac.IaCProviderCredentialRevoker.RevokeProviderCredential:output_type -> workflow.plugin.external.iac.RevokeProviderCredentialResponse + 56, // 120: workflow.plugin.external.iac.IaCProviderFinalizer.FinalizeApply:output_type -> workflow.plugin.external.iac.FinalizeApplyResponse + 58, // 121: workflow.plugin.external.iac.IaCProviderMigrationRepairer.RepairDirtyMigration:output_type -> workflow.plugin.external.iac.RepairDirtyMigrationResponse + 60, // 122: workflow.plugin.external.iac.IaCProviderValidator.ValidatePlan:output_type -> workflow.plugin.external.iac.ValidatePlanResponse + 62, // 123: workflow.plugin.external.iac.IaCProviderDriftConfigDetector.DetectDriftConfig:output_type -> workflow.plugin.external.iac.DetectDriftConfigResponse + 64, // 124: workflow.plugin.external.iac.ResourceDriver.Create:output_type -> workflow.plugin.external.iac.ResourceCreateResponse + 66, // 125: workflow.plugin.external.iac.ResourceDriver.Read:output_type -> workflow.plugin.external.iac.ResourceReadResponse + 68, // 126: workflow.plugin.external.iac.ResourceDriver.Update:output_type -> workflow.plugin.external.iac.ResourceUpdateResponse + 70, // 127: workflow.plugin.external.iac.ResourceDriver.Delete:output_type -> workflow.plugin.external.iac.ResourceDeleteResponse + 72, // 128: workflow.plugin.external.iac.ResourceDriver.Diff:output_type -> workflow.plugin.external.iac.ResourceDiffResponse + 74, // 129: workflow.plugin.external.iac.ResourceDriver.Scale:output_type -> workflow.plugin.external.iac.ResourceScaleResponse + 76, // 130: workflow.plugin.external.iac.ResourceDriver.HealthCheck:output_type -> workflow.plugin.external.iac.ResourceHealthCheckResponse + 78, // 131: workflow.plugin.external.iac.ResourceDriver.SensitiveKeys:output_type -> workflow.plugin.external.iac.SensitiveKeysResponse + 80, // 132: workflow.plugin.external.iac.ResourceDriver.Troubleshoot:output_type -> workflow.plugin.external.iac.TroubleshootResponse + 83, // 133: workflow.plugin.external.iac.IaCStateBackend.Configure:output_type -> workflow.plugin.external.iac.ConfigureResponse + 85, // 134: workflow.plugin.external.iac.IaCStateBackend.GetState:output_type -> workflow.plugin.external.iac.GetStateResponse + 87, // 135: workflow.plugin.external.iac.IaCStateBackend.SaveState:output_type -> workflow.plugin.external.iac.SaveStateResponse + 89, // 136: workflow.plugin.external.iac.IaCStateBackend.ListStates:output_type -> workflow.plugin.external.iac.ListStatesResponse + 91, // 137: workflow.plugin.external.iac.IaCStateBackend.DeleteState:output_type -> workflow.plugin.external.iac.DeleteStateResponse + 93, // 138: workflow.plugin.external.iac.IaCStateBackend.Lock:output_type -> workflow.plugin.external.iac.LockResponse + 95, // 139: workflow.plugin.external.iac.IaCStateBackend.Unlock:output_type -> workflow.plugin.external.iac.UnlockResponse + 97, // 140: workflow.plugin.external.iac.IaCStateBackend.ListBackendNames:output_type -> workflow.plugin.external.iac.ListBackendNamesResponse + 105, // [105:141] is the sub-list for method output_type + 69, // [69:105] is the sub-list for method input_type + 69, // [69:69] is the sub-list for extension type_name + 69, // [69:69] is the sub-list for extension extendee + 0, // [0:69] is the sub-list for field type_name } func init() { file_iac_proto_init() } @@ -6366,7 +6081,7 @@ func file_iac_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_iac_proto_rawDesc), len(file_iac_proto_rawDesc)), NumEnums: 3, - NumMessages: 108, + NumMessages: 102, NumExtensions: 0, NumServices: 10, }, diff --git a/plugin/external/proto/iac.proto b/plugin/external/proto/iac.proto index b6d7925c..5b71d7c9 100644 --- a/plugin/external/proto/iac.proto +++ b/plugin/external/proto/iac.proto @@ -31,12 +31,14 @@ service IaCProviderRequired { rpc Version(VersionRequest) returns (VersionResponse); rpc Capabilities(CapabilitiesRequest) returns (CapabilitiesResponse); rpc Plan(PlanRequest) returns (PlanResponse); - rpc Apply(ApplyRequest) returns (ApplyResponse); rpc Destroy(DestroyRequest) returns (DestroyResponse); rpc Status(StatusRequest) returns (StatusResponse); rpc Import(ImportRequest) returns (ImportResponse); rpc ResolveSizing(ResolveSizingRequest) returns (ResolveSizingResponse); rpc BootstrapStateBackend(BootstrapStateBackendRequest) returns (BootstrapStateBackendResponse); + // Method "Apply" was removed per workflow#699 (2026-05-17); v2 dispatch + // routes through ResourceDriver per-action + IaCProviderFinalizer. + // Do not re-introduce. CI lint guards re-appearance (see Makefile). } // ───────────────────────────────────────────────────────────────────────────── @@ -321,27 +323,6 @@ enum ActionStatus { ACTION_STATUS_SKIPPED = 6; // action never attempted at driver (ctx-cancel pre-dispatch, JIT-substitution-fail, driver-resolve-fail); cloud-side state unchanged } -// ActionResult is the per-action outcome surfacing for Phase 2 v2 hooks. -// Per ADR 0040 invariant 1. output_keys field DROPPED per cycle-2 review -// (hook firing only needs action_index + status; per-resource outputs -// already in ApplyResult.resources). -message ActionResult { - uint32 action_index = 1; - ActionStatus status = 2; - string error = 3; -} - -// ApplyResult mirrors interfaces.ApplyResult. -message ApplyResult { - string plan_id = 1; - repeated ResourceOutput resources = 2; - repeated ActionError errors = 3; - map initial_input_snapshot = 4; - repeated DriftEntry input_drift_report = 5; - map replace_id_map = 6; - repeated ActionResult actions = 7; // NEW Phase 2 (workflow#640) -} - // DestroyResult mirrors interfaces.DestroyResult. message DestroyResult { repeated string destroyed = 1; @@ -431,13 +412,6 @@ message PlanResponse { IaCPlan plan = 1; } -message ApplyRequest { - IaCPlan plan = 1; -} -message ApplyResponse { - ApplyResult result = 1; -} - message DestroyRequest { repeated ResourceRef refs = 1; } diff --git a/plugin/external/proto/iac_grpc.pb.go b/plugin/external/proto/iac_grpc.pb.go index 3adbd4d0..d752daae 100644 --- a/plugin/external/proto/iac_grpc.pb.go +++ b/plugin/external/proto/iac_grpc.pb.go @@ -40,7 +40,6 @@ const ( IaCProviderRequired_Version_FullMethodName = "/workflow.plugin.external.iac.IaCProviderRequired/Version" IaCProviderRequired_Capabilities_FullMethodName = "/workflow.plugin.external.iac.IaCProviderRequired/Capabilities" IaCProviderRequired_Plan_FullMethodName = "/workflow.plugin.external.iac.IaCProviderRequired/Plan" - IaCProviderRequired_Apply_FullMethodName = "/workflow.plugin.external.iac.IaCProviderRequired/Apply" IaCProviderRequired_Destroy_FullMethodName = "/workflow.plugin.external.iac.IaCProviderRequired/Destroy" IaCProviderRequired_Status_FullMethodName = "/workflow.plugin.external.iac.IaCProviderRequired/Status" IaCProviderRequired_Import_FullMethodName = "/workflow.plugin.external.iac.IaCProviderRequired/Import" @@ -62,7 +61,6 @@ type IaCProviderRequiredClient interface { Version(ctx context.Context, in *VersionRequest, opts ...grpc.CallOption) (*VersionResponse, error) Capabilities(ctx context.Context, in *CapabilitiesRequest, opts ...grpc.CallOption) (*CapabilitiesResponse, error) Plan(ctx context.Context, in *PlanRequest, opts ...grpc.CallOption) (*PlanResponse, error) - Apply(ctx context.Context, in *ApplyRequest, opts ...grpc.CallOption) (*ApplyResponse, error) Destroy(ctx context.Context, in *DestroyRequest, opts ...grpc.CallOption) (*DestroyResponse, error) Status(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error) Import(ctx context.Context, in *ImportRequest, opts ...grpc.CallOption) (*ImportResponse, error) @@ -128,16 +126,6 @@ func (c *iaCProviderRequiredClient) Plan(ctx context.Context, in *PlanRequest, o return out, nil } -func (c *iaCProviderRequiredClient) Apply(ctx context.Context, in *ApplyRequest, opts ...grpc.CallOption) (*ApplyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ApplyResponse) - err := c.cc.Invoke(ctx, IaCProviderRequired_Apply_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - func (c *iaCProviderRequiredClient) Destroy(ctx context.Context, in *DestroyRequest, opts ...grpc.CallOption) (*DestroyResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DestroyResponse) @@ -202,7 +190,6 @@ type IaCProviderRequiredServer interface { Version(context.Context, *VersionRequest) (*VersionResponse, error) Capabilities(context.Context, *CapabilitiesRequest) (*CapabilitiesResponse, error) Plan(context.Context, *PlanRequest) (*PlanResponse, error) - Apply(context.Context, *ApplyRequest) (*ApplyResponse, error) Destroy(context.Context, *DestroyRequest) (*DestroyResponse, error) Status(context.Context, *StatusRequest) (*StatusResponse, error) Import(context.Context, *ImportRequest) (*ImportResponse, error) @@ -233,9 +220,6 @@ func (UnimplementedIaCProviderRequiredServer) Capabilities(context.Context, *Cap func (UnimplementedIaCProviderRequiredServer) Plan(context.Context, *PlanRequest) (*PlanResponse, error) { return nil, status.Error(codes.Unimplemented, "method Plan not implemented") } -func (UnimplementedIaCProviderRequiredServer) Apply(context.Context, *ApplyRequest) (*ApplyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Apply not implemented") -} func (UnimplementedIaCProviderRequiredServer) Destroy(context.Context, *DestroyRequest) (*DestroyResponse, error) { return nil, status.Error(codes.Unimplemented, "method Destroy not implemented") } @@ -362,24 +346,6 @@ func _IaCProviderRequired_Plan_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } -func _IaCProviderRequired_Apply_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ApplyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(IaCProviderRequiredServer).Apply(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: IaCProviderRequired_Apply_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(IaCProviderRequiredServer).Apply(ctx, req.(*ApplyRequest)) - } - return interceptor(ctx, in, info, handler) -} - func _IaCProviderRequired_Destroy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DestroyRequest) if err := dec(in); err != nil { @@ -497,10 +463,6 @@ var IaCProviderRequired_ServiceDesc = grpc.ServiceDesc{ MethodName: "Plan", Handler: _IaCProviderRequired_Plan_Handler, }, - { - MethodName: "Apply", - Handler: _IaCProviderRequired_Apply_Handler, - }, { MethodName: "Destroy", Handler: _IaCProviderRequired_Destroy_Handler, From 75bcbd5b65d025133f0b902029704a5e5ceacf58 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 15:09:47 -0400 Subject: [PATCH 16/22] test(wfctl): add v1-reject wiring test + extend stub Capabilities for load gate (workflow#699 PR 1 task 4 fixup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-reviewer Task-4 findings on commit a081d4a2: Critical: requiredOnlyServer in discover_typed_loader_test.go inherited UnimplementedIaCProviderRequiredServer's Capabilities default. Post-Task-6 (when the missing-Apply cascade resolves and CI compiles this package), TestDiscoverAndLoadIaCProvider_ReturnsTypedClient would have failed because the new gate would receive code.Unimplemented from the stub. Fix: add Capabilities method to requiredOnlyServer returning a configurable ComputePlanVersion (default "v2" → accept). startInProcessTypedServer now takes the cpv as a param so per-test variants can flip between accept/reject. Important: TestDiscoverAndLoadIaCProvider_LoadGate_WiredIntoDiscovery exercised the enforceCapabilitiesV2Gate var-seam directly — not the wiring through buildTypedIaCAdapterFrom. A refactor that removed the gate call site would have left the test green. Fix: new TestBuildTypedIaCAdapterFrom_LoadGate_RejectsV1Plugin drives a v1 plugin through the full buildTypedIaCAdapterFrom chain (Conn / ContractRegistry / newTypedIaCAdapter / Initialize / gate) and asserts the operator-facing workflow#699 error. Renamed the existing helper-only test to TestEnforceCapabilitiesV2Gate_HelperBehavior with honest framing. --- cmd/wfctl/deploy_providers_load_gate_test.go | 18 +++-- cmd/wfctl/discover_typed_loader_test.go | 75 +++++++++++++++++--- 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/cmd/wfctl/deploy_providers_load_gate_test.go b/cmd/wfctl/deploy_providers_load_gate_test.go index 48a46be5..abe536cc 100644 --- a/cmd/wfctl/deploy_providers_load_gate_test.go +++ b/cmd/wfctl/deploy_providers_load_gate_test.go @@ -53,16 +53,14 @@ func (f *fakeCapabilitiesWithContext) CapabilitiesWithContext(_ context.Context) return f.resp, f.err } -// TestDiscoverAndLoadIaCProvider_LoadGate_WiredIntoDiscovery asserts the -// helper is actually called by the discovery code-path — not just -// independently tested. Regression-gates against future refactor that -// removes the gate from the discovery code-path. Per plan cycle-2 N5 fix. -// -// Exercises the enforceCapabilitiesV2Gate var-seam (which is what -// buildTypedIaCAdapterFrom calls after the typed adapter is constructed). -// A real-RPC integration test would need an in-process gRPC server — -// covered separately by the conformance matrix in PR 6. -func TestDiscoverAndLoadIaCProvider_LoadGate_WiredIntoDiscovery(t *testing.T) { +// TestEnforceCapabilitiesV2Gate_HelperBehavior unit-tests the +// enforceCapabilitiesV2Gate var-seam: 10s-timeout RPC + status-check +// + error-wrapping. Pairs with TestBuildTypedIaCAdapterFrom_LoadGate_RejectsV1Plugin +// in discover_typed_loader_test.go — that test proves the gate is +// actually wired into buildTypedIaCAdapterFrom; THIS test proves the +// gate's three sub-paths (accept / reject-v1 / RPC-failure-wrap) +// behave correctly when invoked. +func TestEnforceCapabilitiesV2Gate_HelperBehavior(t *testing.T) { t.Run("v1 plugin → workflow#699 error", func(t *testing.T) { stub := &fakeCapabilitiesWithContext{ resp: &pb.CapabilitiesResponse{ComputePlanVersion: "v1"}, diff --git a/cmd/wfctl/discover_typed_loader_test.go b/cmd/wfctl/discover_typed_loader_test.go index 34cbc893..92e2dc41 100644 --- a/cmd/wfctl/discover_typed_loader_test.go +++ b/cmd/wfctl/discover_typed_loader_test.go @@ -40,28 +40,41 @@ func (s *stubIaCAdapter) ContractRegistry() *pb.ContractRegistry { return s.regi func (s *stubIaCAdapter) ContractRegistryError() error { return s.regErr } // requiredOnlyServer satisfies pb.IaCProviderRequiredServer with the -// absolute minimum: only Initialize responds (every other method left -// to UnimplementedIaCProviderRequiredServer's defaults). Initialize is -// the one method buildTypedIaCAdapterFrom calls during loader path. +// minimum surface buildTypedIaCAdapterFrom touches during the loader +// path: Initialize AND Capabilities. Per workflow#699, the load-time +// gate calls Capabilities right after Initialize via the typed RPC, so +// a stub that defaults Capabilities to UnimplementedIaCProviderRequiredServer +// would fail the gate with `code = Unimplemented`. The +// computePlanVersion field lets per-test variants flip between v2 +// (default — accept path) and "v1" / "" (reject path) without spinning +// up a second server type. type requiredOnlyServer struct { pb.UnimplementedIaCProviderRequiredServer + computePlanVersion string // empty default → v1 reject; "v2" → accept } func (s *requiredOnlyServer) Initialize(_ context.Context, _ *pb.InitializeRequest) (*pb.InitializeResponse, error) { return &pb.InitializeResponse{}, nil } +func (s *requiredOnlyServer) Capabilities(_ context.Context, _ *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) { + return &pb.CapabilitiesResponse{ComputePlanVersion: s.computePlanVersion}, nil +} + // startInProcessTypedServer spins up an in-process gRPC server that // registers the typed IaCProviderRequired service and returns a -// dial-back conn the test can hand to a stubIaCAdapter. -func startInProcessTypedServer(t *testing.T) (*grpc.Server, *grpc.ClientConn) { +// dial-back conn the test can hand to a stubIaCAdapter. computePlanVersion +// configures the server's Capabilities response — pass "v2" for the +// happy path (load-time gate accepts) or "v1" / "" to drive the +// rejection path. +func startInProcessTypedServer(t *testing.T, computePlanVersion string) (*grpc.Server, *grpc.ClientConn) { t.Helper() lis, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("net.Listen: %v", err) } srv := grpc.NewServer() - pb.RegisterIaCProviderRequiredServer(srv, &requiredOnlyServer{}) + pb.RegisterIaCProviderRequiredServer(srv, &requiredOnlyServer{computePlanVersion: computePlanVersion}) go func() { _ = srv.Serve(lis) }() conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { @@ -75,7 +88,7 @@ func startInProcessTypedServer(t *testing.T) (*grpc.Server, *grpc.ClientConn) { // loader's post-LoadPlugin path returns the typed adapter // (*typedIaCAdapter) — the cutover invariant. Per Spec Step 1. func TestDiscoverAndLoadIaCProvider_ReturnsTypedClient(t *testing.T) { - srv, conn := startInProcessTypedServer(t) + srv, conn := startInProcessTypedServer(t, "v2") defer srv.Stop() defer conn.Close() @@ -115,7 +128,7 @@ func TestDiscoverAndLoadIaCProvider_ReturnsTypedClient(t *testing.T) { // invariant. Plugins that haven't migrated to the typed protocol // fail loud at load time with a `wfctl plugin update` hint. func TestDiscoverAndLoadIaCProvider_RejectsMissingRequiredService(t *testing.T) { - srv, conn := startInProcessTypedServer(t) + srv, conn := startInProcessTypedServer(t, "v2") defer srv.Stop() defer conn.Close() @@ -147,7 +160,7 @@ func TestDiscoverAndLoadIaCProvider_RejectsMissingRequiredService(t *testing.T) // the underlying error rather than masked by the generic "does not // register the required service" message — per Copilot finding on PR #609. func TestDiscoverAndLoadIaCProvider_SurfacesContractRegistryError(t *testing.T) { - srv, conn := startInProcessTypedServer(t) + srv, conn := startInProcessTypedServer(t, "v2") defer srv.Stop() defer conn.Close() @@ -168,3 +181,47 @@ func TestDiscoverAndLoadIaCProvider_SurfacesContractRegistryError(t *testing.T) t.Errorf("expected RPC-failure framing in error; got %q", err.Error()) } } + +// TestBuildTypedIaCAdapterFrom_LoadGate_RejectsV1Plugin proves the +// workflow#699 load-time gate is actually wired into the loader's +// post-Initialize path — not just unit-tested in isolation. +// +// Drives a v1 plugin through the full buildTypedIaCAdapterFrom chain +// (Conn/ContractRegistry → newTypedIaCAdapter → Initialize → gate). The +// in-process stub's Capabilities returns `ComputePlanVersion: "v1"`, so +// the post-Initialize gate must reject with the operator-facing +// workflow#699 error. +// +// A refactor that deletes the `enforceCapabilitiesV2Gate(ctx, typed, +// pName)` call site from buildTypedIaCAdapterFrom would silently +// regress this test even if verifyComputePlanVersionV2 is unchanged. +// Cycle-2 N5 regression-gate: pins gate placement at the wiring layer, +// not just the helper. +func TestBuildTypedIaCAdapterFrom_LoadGate_RejectsV1Plugin(t *testing.T) { + srv, conn := startInProcessTypedServer(t, "v1") // v1 → gate must reject + defer srv.Stop() + defer conn.Close() + + stub := &stubIaCAdapter{ + conn: conn, + registry: &pb.ContractRegistry{ + Contracts: []*pb.ContractDescriptor{ + { + Kind: pb.ContractKind_CONTRACT_KIND_SERVICE, + ServiceName: iacServiceRequired, + }, + }, + }, + } + + _, err := buildTypedIaCAdapterFrom(context.Background(), "stub-provider", "workflow-plugin-stub", map[string]any{}, stub) + if err == nil { + t.Fatal("expected reject for v1 plugin; got nil") + } + msg := err.Error() + for _, want := range []string{"workflow#699", "workflow-plugin-stub", "v0.56.0+"} { + if !strings.Contains(msg, want) { + t.Errorf("error message %q missing expected substring %q", msg, want) + } + } +} From 33f35da8d83abb4cc4ffeb20c4dbfe9f2cf894da Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 15:12:20 -0400 Subject: [PATCH 17/22] feat(interfaces): delete IaCProvider.Apply method (workflow#699 PR 1 task 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the intermediate "missing method Apply" cascade that Task 2 introduced when typedIaCAdapter.Apply was deleted. From task-2 commit (625de5ba) through task-5 commit (c231daf1), `go build ./cmd/wfctl/` and `go vet ./cmd/wfctl/...` failed at ~8 type-assertion / interface- assignment sites because the IaCProvider interface still declared Apply while no implementer (in-process stubs OR the typed adapter) carried the method. This is by design — the plan sequences proto + adapter + helper deletions BEFORE the interface trim so each layer's removal is self-contained and reviewable. Bisecting commits 625de5ba..c231daf1 will encounter the cascade; the resolution lands here. Plan reference: docs/plans/2026-05-17-iac-provider-apply-removal.md Task 6 + Task 2 commit body ("compile may not be green yet — sequential PR within single branch"). --- interfaces/iac_provider.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/interfaces/iac_provider.go b/interfaces/iac_provider.go index b9c58056..3475665a 100644 --- a/interfaces/iac_provider.go +++ b/interfaces/iac_provider.go @@ -14,7 +14,12 @@ type IaCProvider interface { // Lifecycle Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) - Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) + // Apply was removed per workflow#699 (2026-05-17). v2 dispatch + // routes through wfctlhelpers.ApplyPlanWithHooks (ResourceDriver + // per-action + IaCProviderFinalizer.FinalizeApply post-loop). The + // load-time Capabilities-RPC gate in cmd/wfctl/deploy_providers.go + // rejects plugins whose CapabilitiesResponse.compute_plan_version + // is not "v2". Destroy(ctx context.Context, resources []ResourceRef) (*DestroyResult, error) // Observability From 5e586c0fef2d2d13a0708c79ea90593159b5930e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 15:14:46 -0400 Subject: [PATCH 18/22] feat(sdk): align iacserver type-assert with trimmed pb.IaCProviderRequiredServer (workflow#699 PR 1 task 7) --- plugin/external/sdk/iacserver.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugin/external/sdk/iacserver.go b/plugin/external/sdk/iacserver.go index ee149c78..d4d22f6c 100644 --- a/plugin/external/sdk/iacserver.go +++ b/plugin/external/sdk/iacserver.go @@ -127,6 +127,13 @@ func registerIaCServicesOnly(s *grpc.Server, provider any) error { if rv := reflect.ValueOf(provider); rv.Kind() == reflect.Pointer && rv.IsNil() { return fmt.Errorf("RegisterAllIaCProviderServices: provider is a typed-nil %T pointer", provider) } + // Per workflow#699 (Task 5): pb.IaCProviderRequiredServer no longer + // declares Apply, so this type-assertion auto-tightens to the trimmed + // required surface. Plugins that retain a Go-level Apply method still + // satisfy the interface (extra methods are permitted); plugins missing + // any of the still-required methods (Initialize/Name/Version/ + // Capabilities/Plan/Destroy/Status/Import/ResolveSizing/ + // BootstrapStateBackend) fail-loud here as before. required, ok := provider.(pb.IaCProviderRequiredServer) if !ok { return fmt.Errorf( From 049a92023a8c26be4b08663f549883e443aa07ca Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 16:00:48 -0400 Subject: [PATCH 19/22] test: drop v1 Apply coverage + iacServiceChecks row (workflow#699 PR 1 task 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-file v1-cleanup pass: - iac/iactest/fakeprovider.go — delete DispatchVersion field + the now-orphaned ComputePlanVersion method (ComputePlanVersionDeclarer is gone with Task 3). - plugin/sdk/manifest.go — delete EffectiveComputePlanVersion accessor (cycle-2 N7: post-cutover "v1" is not a valid runtime value; authoritative gate is the typed CapabilitiesResponse check in deploy_providers.go). - plugin/external/proto/iac_proto_test.go — delete TestApplyResultActionsRoundTrip (uses deleted pb.ApplyResult/ActionResult); update iacRequiredMethodsCheck interface to drop Apply method. - cmd/wfctl/{infra_apply_allow_replace,plan,v2}_test.go + iac/conformance/scenarios_test.go — drop DispatchVersion: "v2" literals (the field is gone). - cmd/wfctl/infra_apply_v2_loader_test.go + infra_apply_jit_loader_test.go — delete the now-undefined assertions + the obsolete ComputePlanVersion+Apply methods on the loader-seam stubs. - cmd/wfctl/infra_apply_v2_test.go — delete TestApplyWithProviderAndStore_V1FallsThroughToProviderApply + TestApplyWithProviderAndStore_V1Path_DeclarerReturnsEmpty + v1RecordingProvider + dead ComputePlanVersion method on v2DriverProvider. - cmd/wfctl/infra_apply_plan_test.go — rewrite TestInfraApplyConsumesPlan to capture the plan via applyV2ApplyPlanWithHooksFn seam; delete TestInfraApplyPrecomputedPlan_PersistsState + TestInfraApplyPrecomputedPlan_FailedDeleteKeepsState + applyCaptureFull (v1-dispatch- specific; v2 equivalent covered by TestInfraApplyPrecomputedPlan_V2PersistsStateThroughHooks). - cmd/wfctl/plugin_audit_iac_test.go — drop "IaCProvider.Apply" from classifier test data (no post-cutover plugin can advertise this). - cmd/wfctl/deploy_providers.go — reword stale wfctlhelpers.DispatchVersion comments in findIaCPluginDir (deferred from Task 3 — comment-only). - interfaces/iac_state.go + iac_state_test.go + iac/wfctlhelpers/apply.go + plugin/external/proto/iac_proto_test.go — reword stale godoc references to deleted pb.ActionResult / applyResultFromPB (per code-reviewer Task 5 Minor advisories). Test seam (installAsV2Dispatch) added to applyCapture / stateReturningProvider / sizingCapture / applyFailProvider / plainFailProvider / recordingProvider / readBackedFailingApplyProvider so v1-era tests can route through the v2 dispatch seam to the type's preserved .Apply method + synthesize the OnResourceApplied/OnResourceDeleted/OnPlanComplete hook lifecycle for state-persistence assertions. Build + vet + full test suite green. The intentional nested go-test failure (TestRunCIRunTestFallsBackToGoTestWhenNoConfiguredTests's inner TestFallbackRuns) remains as designed — outer test asserts that fallback path errors. Parallel cascade narrative (per code-reviewer Task 5 Minor advisory): commits c231daf1 (Task 5) → 33f35da8 (Task 6) left plugin/external/proto/iac_proto_test.go in a non-compiling state due to references to deleted pb.ApplyResult / pb.ActionResult / pb.ApplyRequest. Same shape as the cmd/wfctl Task-2 cascade (see commit 33f35da8 body); resolved by this commit. --- cmd/wfctl/deploy_providers.go | 18 +- cmd/wfctl/infra_apply_allow_replace_test.go | 2 +- cmd/wfctl/infra_apply_jit_loader_test.go | 9 +- cmd/wfctl/infra_apply_plan_test.go | 177 +++++--------- cmd/wfctl/infra_apply_test.go | 230 +++++++++++++++--- cmd/wfctl/infra_apply_troubleshoot_test.go | 35 +++ cmd/wfctl/infra_apply_v2_loader_test.go | 23 +- cmd/wfctl/infra_apply_v2_test.go | 145 +---------- .../infra_plan_apply_equivalence_test.go | 18 ++ cmd/wfctl/infra_state_store_test.go | 1 + cmd/wfctl/plugin_audit_iac_test.go | 7 +- iac/conformance/scenarios_test.go | 2 +- iac/iactest/fakeprovider.go | 11 - iac/wfctlhelpers/apply.go | 6 +- interfaces/iac_state.go | 18 +- interfaces/iac_state_test.go | 10 +- plugin/external/proto/iac_proto_test.go | 57 +---- plugin/sdk/manifest.go | 17 +- plugin/sdk/manifest_test.go | 35 ++- 19 files changed, 402 insertions(+), 419 deletions(-) diff --git a/cmd/wfctl/deploy_providers.go b/cmd/wfctl/deploy_providers.go index b6a16618..a6c955f8 100644 --- a/cmd/wfctl/deploy_providers.go +++ b/cmd/wfctl/deploy_providers.go @@ -124,9 +124,10 @@ type iacPluginManifest struct { // plugin.json scan); kept in place to avoid that churn. // // Until the follow-up: callers receive the value but discard it. The raw -// string is unconstrained — schema-validated values are {"", "v1", "v2"} -// per wfctlhelpers.DispatchVersionV2, but this loader path performs only -// minimal json.Unmarshal so MUST NOT assume. +// string is unconstrained — SDK schema-validated values are {"", "v1", +// "v2"}, but this loader path performs only minimal json.Unmarshal so +// 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) if err != nil { @@ -154,11 +155,12 @@ func findIaCPluginDir(pluginDir, providerName string) (name, computePlanVersion // 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") - // would silently route through the v1 dispatch path via - // wfctlhelpers.DispatchVersionFor's empty/unknown default, - // breaking the Phase 2 hard-cutover contract per ADR 0024 + - // ADR 0040. Hard-fail so operators see the misconfiguration - // loudly instead of silently dispatching to the wrong path. + // would surface as the empty/unknown-bucket default, masking + // operator intent — hard-fail so the misconfiguration shows + // up loudly. Per workflow#699 the authoritative enforcement + // is the typed CapabilitiesResponse gate in + // discoverAndLoadIaCProvider; this parse-time check is the + // pre-load schema sanity (defense-in-depth). switch m.IaCProvider.ComputePlanVersion { case "", "v1", "v2": // valid diff --git a/cmd/wfctl/infra_apply_allow_replace_test.go b/cmd/wfctl/infra_apply_allow_replace_test.go index 6447e86c..5595f59b 100644 --- a/cmd/wfctl/infra_apply_allow_replace_test.go +++ b/cmd/wfctl/infra_apply_allow_replace_test.go @@ -237,7 +237,7 @@ func TestApplyWithProviderAndStore_ProtectedReplace_WithoutAllowReplace_Errors(t // via applyV2ApplyPlanWithHooksFn) so a sentinel error proves the call site // reached dispatch — i.e. the gate did not short-circuit. func TestApplyWithProviderAndStore_ProtectedReplace_WithAllowReplace_Proceeds(t *testing.T) { - provider := &iactest.NoopProvider{ProviderName: "allow-replace-stub", DispatchVersion: "v2"} + provider := &iactest.NoopProvider{ProviderName: "allow-replace-stub"} origCompute := computeInfraPlan computeInfraPlan = func(_ context.Context, _ interfaces.IaCProvider, specs []interfaces.ResourceSpec, _ []interfaces.ResourceState) (interfaces.IaCPlan, error) { diff --git a/cmd/wfctl/infra_apply_jit_loader_test.go b/cmd/wfctl/infra_apply_jit_loader_test.go index 976a17bb..ea859ae5 100644 --- a/cmd/wfctl/infra_apply_jit_loader_test.go +++ b/cmd/wfctl/infra_apply_jit_loader_test.go @@ -2,14 +2,12 @@ package main import ( "context" - "errors" "io" "os" "path/filepath" "sync" "testing" - "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -134,21 +132,16 @@ type jitLoaderStubProvider struct { } var ( - _ interfaces.IaCProvider = (*jitLoaderStubProvider)(nil) - _ wfctlhelpers.ComputePlanVersionDeclarer = (*jitLoaderStubProvider)(nil) + _ interfaces.IaCProvider = (*jitLoaderStubProvider)(nil) ) func (p *jitLoaderStubProvider) Name() string { return "stub" } func (p *jitLoaderStubProvider) Version() string { return "0.0.1-jit-loader-seam" } -func (p *jitLoaderStubProvider) ComputePlanVersion() string { return "v2" } func (p *jitLoaderStubProvider) Initialize(_ context.Context, _ map[string]any) error { return nil } func (p *jitLoaderStubProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil } func (p *jitLoaderStubProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { return nil, nil } -func (p *jitLoaderStubProvider) Apply(_ context.Context, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { - return nil, errors.New("v2 path must route through wfctlhelpers.ApplyPlan, not provider.Apply") -} func (p *jitLoaderStubProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { return nil, nil } diff --git a/cmd/wfctl/infra_apply_plan_test.go b/cmd/wfctl/infra_apply_plan_test.go index 2cea85c6..3977bbd6 100644 --- a/cmd/wfctl/infra_apply_plan_test.go +++ b/cmd/wfctl/infra_apply_plan_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -29,8 +30,14 @@ func fingerprintForTest(value string) string { // TestInfraApplyConsumesPlan verifies that wfctl infra apply --plan : // 1. Reads actions from the plan file without calling ComputePlan. -// 2. Calls provider.Apply with exactly the plan from the file (identified by plan ID). +// 2. Dispatches via wfctlhelpers.ApplyPlanWithHooks (v2-only post +// workflow#699) with exactly the plan from the file (identified by +// plan ID). // 3. Does NOT recompute a fresh plan from the config diff. +// +// Captures the plan via the applyV2ApplyPlanWithHooksFn seam — the +// stubbed dispatch records the plan reference without invoking the +// per-action driver layer. func TestInfraApplyConsumesPlan(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "infra.yaml") @@ -81,7 +88,8 @@ modules: t.Fatalf("write plan: %v", err) } - // Mock provider: records the plan passed to Apply. + // Mock provider: satisfies the interface; the v2 seam captures the plan + // before per-action dispatch reaches any driver. fake := &applyCapture{} origResolve := resolveIaCProvider resolveIaCProvider = func(_ context.Context, providerType string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { @@ -89,33 +97,50 @@ modules: } defer func() { resolveIaCProvider = origResolve }() + // Capture the plan via the v2 dispatch seam (post workflow#699 — there + // is no longer a provider.Apply to assert against). + var ( + mu sync.Mutex + applyCalled bool + appliedPlan *interfaces.IaCPlan + ) + origApply := applyV2ApplyPlanWithHooksFn + applyV2ApplyPlanWithHooksFn = func(_ context.Context, _ interfaces.IaCProvider, p *interfaces.IaCPlan, _ wfctlhelpers.ApplyPlanHooks) (*interfaces.ApplyResult, error) { + mu.Lock() + defer mu.Unlock() + applyCalled = true + appliedPlan = p + return &interfaces.ApplyResult{PlanID: p.ID}, nil + } + defer func() { applyV2ApplyPlanWithHooksFn = origApply }() + // Run apply with --plan flag. if err := runInfraApply([]string{"--auto-approve", "--config", cfgPath, "--plan", planPath}); err != nil { t.Fatalf("runInfraApply: %v", err) } - fake.mu.Lock() - defer fake.mu.Unlock() + mu.Lock() + defer mu.Unlock() - // Apply must have been called. - if !fake.applyCalled { - t.Fatal("provider.Apply was not called") + // Dispatch must have been invoked. + if !applyCalled { + t.Fatal("v2 dispatch (applyV2ApplyPlanWithHooksFn) was not called") } - if fake.appliedPlan == nil { + if appliedPlan == nil { t.Fatal("appliedPlan is nil") } // Verify the plan came from the file (not recomputed). // ComputePlan generates a fresh ID ("plan-"); our file has a fixed ID. - if fake.appliedPlan.ID != planID { - t.Errorf("plan ID: want %q (from file), got %q (recomputed?)", planID, fake.appliedPlan.ID) + if appliedPlan.ID != planID { + t.Errorf("plan ID: want %q (from file), got %q (recomputed?)", planID, appliedPlan.ID) } // Exactly one create action for my-db. - if got := len(fake.appliedPlan.Actions); got != 1 { + if got := len(appliedPlan.Actions); got != 1 { t.Fatalf("plan actions: want 1, got %d", got) } - a := fake.appliedPlan.Actions[0] + a := appliedPlan.Actions[0] if a.Action != "create" { t.Errorf("action: want create, got %q", a.Action) } @@ -298,84 +323,11 @@ modules: } } -// applyCaptureFull is a mock provider that returns a real ApplyResult with -// provisioned resources, enabling state-persistence path testing. -type applyCaptureFull struct { - applyCapture - resources []interfaces.ResourceOutput -} - -func (f *applyCaptureFull) Apply(_ context.Context, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { - f.mu.Lock() - defer f.mu.Unlock() - f.applyCalled = true - f.appliedPlan = plan - return &interfaces.ApplyResult{Resources: f.resources}, nil -} - -// TestInfraApplyPrecomputedPlan_PersistsState verifies that applyPrecomputedPlanWithStore -// writes ResourceState records to the store after a successful apply, with correct -// metadata fields (ProviderID, ProviderRef, ConfigHash, Dependencies). -func TestInfraApplyPrecomputedPlan_PersistsState(t *testing.T) { - stateDir := t.TempDir() - store := &fsWfctlStateStore{dir: stateDir} - - spec := interfaces.ResourceSpec{ - Name: "my-db", - Type: "infra.database", - Config: map[string]any{ - "provider": "test-provider", - "engine": "postgres", - }, - DependsOn: []string{"some-vpc"}, - } - plan := interfaces.IaCPlan{ - ID: "persist-test", - Actions: []interfaces.PlanAction{{Action: "create", Resource: spec}}, - } - - provider := &applyCaptureFull{ - resources: []interfaces.ResourceOutput{ - {Name: "my-db", Type: "infra.database", ProviderID: "db-abc123"}, - }, - } - - err := applyPrecomputedPlanWithStore(context.Background(), plan, provider, "fake-cloud", store, io.Discard, "", "", nil) - if err != nil { - t.Fatalf("applyPrecomputedPlanWithStore: %v", err) - } - - // Verify the state was persisted. - all, err := store.ListResources(context.Background()) - if err != nil { - t.Fatalf("ListResources: %v", err) - } - var saved *interfaces.ResourceState - for i := range all { - if all[i].Name == "my-db" { - saved = &all[i] - break - } - } - if saved == nil { - t.Fatal("ResourceState for my-db not found in store") - } - if saved.ProviderID != "db-abc123" { - t.Errorf("ProviderID: want db-abc123, got %q", saved.ProviderID) - } - if saved.ProviderRef != "test-provider" { - t.Errorf("ProviderRef: want test-provider, got %q", saved.ProviderRef) - } - if saved.Provider != "fake-cloud" { - t.Errorf("Provider: want fake-cloud, got %q", saved.Provider) - } - if len(saved.Dependencies) != 1 || saved.Dependencies[0] != "some-vpc" { - t.Errorf("Dependencies: want [some-vpc], got %v", saved.Dependencies) - } - if saved.ConfigHash == "" { - t.Error("ConfigHash: want non-empty, got empty") - } -} +// TestInfraApplyPrecomputedPlan_PersistsState + applyCaptureFull were +// deleted per workflow#699: the v1 dispatch path +// (provider.Apply → caller persists state) is gone. State persistence +// now happens via the v2 OnResourceApplied hook, exercised by +// TestInfraApplyPrecomputedPlan_V2PersistsStateThroughHooks below. func TestInfraApplyPrecomputedPlan_V2PersistsStateThroughHooks(t *testing.T) { store := &fakeStateStore{} @@ -409,7 +361,7 @@ func TestInfraApplyPrecomputedPlan_V2PrintsDriftReport(t *testing.T) { ID: "v2-drift-test", Actions: []interfaces.PlanAction{{Action: "create", Resource: interfaces.ResourceSpec{Name: "x", Type: "infra.test"}}}, } - provider := &iactest.NoopProvider{ProviderName: "v2-stub", DispatchVersion: "v2"} + provider := &iactest.NoopProvider{ProviderName: "v2-stub"} driftEntries := []interfaces.DriftEntry{ {Name: "EXAMPLE_VAR", PlanFingerprint: "plan-fp", ApplyFingerprint: "apply-fp"}, } @@ -430,30 +382,12 @@ func TestInfraApplyPrecomputedPlan_V2PrintsDriftReport(t *testing.T) { } } -func TestInfraApplyPrecomputedPlan_FailedDeleteKeepsState(t *testing.T) { - current := interfaces.ResourceState{Name: "old", Type: "infra.test", ProviderID: "id-old"} - store := &fakeStateStore{saved: []interfaces.ResourceState{current}} - plan := interfaces.IaCPlan{Actions: []interfaces.PlanAction{{ - Action: "delete", - Resource: interfaces.ResourceSpec{Name: "old", Type: "infra.test"}, - Current: ¤t, - }}} - provider := &stateReturningProvider{ - applyResult: &interfaces.ApplyResult{ - Errors: []interfaces.ActionError{{Action: "delete", Resource: "old", Error: "delete failed"}}, - }, - } - - err := applyPrecomputedPlanWithStore(t.Context(), plan, provider, "fake-cloud", store, io.Discard, "", "", nil) - if err == nil { - t.Fatal("expected delete failure, got nil") - } - store.mu.Lock() - defer store.mu.Unlock() - if len(store.deleted) != 0 { - t.Fatalf("deleted state entries = %v, want none after failed delete", store.deleted) - } -} +// TestInfraApplyPrecomputedPlan_FailedDeleteKeepsState was deleted per +// workflow#699 — the test relied on the v1 provider.Apply path returning +// a preset ApplyResult with delete errors. Post-v2 cutover the +// equivalent assertion lives in TestInfraApplyPrecomputedPlan_V2PersistsStateThroughHooks +// (state-write through OnResourceApplied/OnResourceDeleted hooks) and in +// the per-driver delete-error coverage in iac/wfctlhelpers/apply_test.go. // TestApplyFromPrecomputedPlan_DeleteActionResolvesProvider verifies that delete // actions (which carry no Resource.Config from ComputePlan) correctly resolve @@ -525,6 +459,16 @@ modules: } defer func() { resolveIaCProvider = origResolve }() + // Stub the v2 dispatch seam — the test is asserting the provider- + // resolution stage (action.Current.ProviderRef → loaded provider) and + // MUST NOT cross into the per-driver dispatch layer (which the bare + // applyCapture lacks). Per workflow#699 v2 is the only dispatch. + origApply := applyV2ApplyPlanWithHooksFn + applyV2ApplyPlanWithHooksFn = func(_ context.Context, _ interfaces.IaCProvider, _ *interfaces.IaCPlan, _ wfctlhelpers.ApplyPlanHooks) (*interfaces.ApplyResult, error) { + return &interfaces.ApplyResult{}, nil + } + defer func() { applyV2ApplyPlanWithHooksFn = origApply }() + // With an empty config (delete-all scenario), hash matches because both // sides hash nil/empty spec slices the same way. // The key assertion: applyFromPrecomputedPlan must NOT error on the delete action. @@ -532,7 +476,8 @@ modules: _, err = applyFromPrecomputedPlan(context.Background(), plan, cfgPath, "") // The apply itself won't error even if the config has my-db (hash mismatch // would catch that) — we just want to confirm no "missing provider" error. - // With the delete action resolved via Current.ProviderRef, provider.Apply is called. + // With the delete action resolved via Current.ProviderRef, dispatch reaches + // the v2 seam. if err != nil && strings.Contains(err.Error(), "missing 'provider' field") { t.Errorf("delete action should resolve provider from Current, got: %v", err) } diff --git a/cmd/wfctl/infra_apply_test.go b/cmd/wfctl/infra_apply_test.go index 6842a286..a50c0ef2 100644 --- a/cmd/wfctl/infra_apply_test.go +++ b/cmd/wfctl/infra_apply_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/GoCodeAlone/workflow/iac/sensitive" + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -38,6 +39,12 @@ func (f *applyCapture) Plan(_ context.Context, desired []interfaces.ResourceSpec f.planSpecs = append(f.planSpecs, desired...) return nil, nil } + +// Apply is no longer on the interfaces.IaCProvider surface (workflow#699 +// removed it); kept as a concrete method so tests' applyCapture-based +// assertions (applyCalled / appliedPlan) survive. installAsV2Dispatch +// wires the method into the global v2 dispatch seam — call it at the +// top of every test that depends on the pre-v2 applyCalled semantics. func (f *applyCapture) Apply(_ context.Context, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { f.mu.Lock() defer f.mu.Unlock() @@ -45,6 +52,99 @@ func (f *applyCapture) Apply(_ context.Context, plan *interfaces.IaCPlan) (*inte f.appliedPlan = plan return &interfaces.ApplyResult{}, nil } + +// installAsV2Dispatch substitutes the global applyV2ApplyPlanWithHooksFn +// seam so the test's call to applyInfraModules / applyWithProviderAndStore / +// applyPrecomputedPlanWithStore routes through this stub's Apply method +// instead of the real wfctlhelpers.ApplyPlanWithHooks (which would +// dispatch per-action via ResourceDriver, a layer most v1-era tests don't +// stub). Also fires the appropriate OnResourceApplied / OnResourceDeleted +// hooks for each plan action so state-persistence assertions still pass +// without the per-action driver layer. Auto-restored on test cleanup. +// Per workflow#699 fixup. +func (f *applyCapture) installAsV2Dispatch(t testing.TB) { + t.Helper() + orig := applyV2ApplyPlanWithHooksFn + applyV2ApplyPlanWithHooksFn = func(ctx context.Context, p interfaces.IaCProvider, plan *interfaces.IaCPlan, hooks wfctlhelpers.ApplyPlanHooks) (*interfaces.ApplyResult, error) { + result, err := f.Apply(ctx, plan) + if hookErr := fireSyntheticHooks(ctx, p, plan, hooks, result); hookErr != nil && err == nil { + err = hookErr + } + return result, err + } + t.Cleanup(func() { applyV2ApplyPlanWithHooksFn = orig }) +} + +// fireSyntheticHooks invokes the v2 ApplyPlanHooks lifecycle for each +// plan action based on the recorded ApplyResult — bridging the +// per-action hook semantics of the production wfctlhelpers.ApplyPlanWithHooks +// path with the all-at-once shape of the test stub's Apply method. +// Successful per-resource entries in result.Resources fire +// OnResourceApplied (matching by name); delete actions absent from +// result.Errors fire OnResourceDeleted. OnPlanComplete fires once at +// the end. Returns the first non-nil error from the hooks so callers +// can surface state-persistence failures the way the production helper +// does. +func fireSyntheticHooks(ctx context.Context, p interfaces.IaCProvider, plan *interfaces.IaCPlan, hooks wfctlhelpers.ApplyPlanHooks, result *interfaces.ApplyResult) error { + // Build a per-name error index so we don't fire successful-path + // hooks for actions that errored. + errored := map[string]bool{} + if result != nil { + for _, e := range result.Errors { + errored[e.Resource] = true + } + } + for i := range plan.Actions { + action := plan.Actions[i] + if errored[action.Resource.Name] { + continue + } + switch action.Action { + case "create", "update", "replace": + if hooks.OnResourceApplied == nil { + continue + } + // Only fire OnResourceApplied for actions whose output is + // explicitly listed in result.Resources. The v1 contract + // was that an empty result.Resources meant "nothing + // persisted by the driver"; mirroring it here keeps the + // v1-era tests' single-save assertions intact while the + // real v2 production path persists via the same hook on + // real driver outputs. + var out interfaces.ResourceOutput + var matched bool + if result != nil { + for _, r := range result.Resources { + if r.Name == action.Resource.Name { + out = r + matched = true + break + } + } + } + if !matched { + continue + } + driver, _ := p.ResourceDriver(action.Resource.Type) + if err := hooks.OnResourceApplied(ctx, driver, action, out); err != nil { + return err + } + case "delete": + if hooks.OnResourceDeleted == nil { + continue + } + if err := hooks.OnResourceDeleted(ctx, action); err != nil { + return err + } + } + } + if hooks.OnPlanComplete != nil { + if err := hooks.OnPlanComplete(ctx); err != nil { + return err + } + } + return nil +} func (f *applyCapture) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { return nil, nil } @@ -198,6 +298,25 @@ func (p *readBackedFailingApplyProvider) Apply(_ context.Context, plan *interfac return nil, p.applyErr } +// installAsV2Dispatch shadows applyCapture's promoted method so the +// failing-apply variant's Apply (which returns p.applyErr) is the one +// the v2 seam invokes. Promotion would otherwise capture +// applyCapture.Apply, which returns a clean ApplyResult and never +// surfaces applyErr. Fires synthetic v2 hooks for state-persistence +// assertions. +func (p *readBackedFailingApplyProvider) installAsV2Dispatch(t testing.TB) { + t.Helper() + orig := applyV2ApplyPlanWithHooksFn + applyV2ApplyPlanWithHooksFn = func(ctx context.Context, prov interfaces.IaCProvider, plan *interfaces.IaCPlan, hooks wfctlhelpers.ApplyPlanHooks) (*interfaces.ApplyResult, error) { + result, err := p.Apply(ctx, plan) + if hookErr := fireSyntheticHooks(ctx, prov, plan, hooks, result); hookErr != nil && err == nil { + err = hookErr + } + return result, err + } + t.Cleanup(func() { applyV2ApplyPlanWithHooksFn = orig }) +} + type noDriverApplyProvider struct { applyCapture } @@ -242,6 +361,7 @@ modules: } fake := &applyCapture{} + fake.installAsV2Dispatch(t) orig := resolveIaCProvider resolveIaCProvider = func(_ context.Context, providerType string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { if providerType != "fake-cloud" { @@ -258,9 +378,9 @@ modules: fake.mu.Lock() defer fake.mu.Unlock() - // Apply must have been called. + // Dispatch (v2 seam) must have been called. if !fake.applyCalled { - t.Fatal("provider.Apply was not called") + t.Fatal("v2 dispatch was not called") } // Plan must contain exactly 2 create actions (no current state → all creates). @@ -550,6 +670,7 @@ modules: } fake := &applyCapture{} + fake.installAsV2Dispatch(t) orig := resolveIaCProvider resolveIaCProvider = func(_ context.Context, providerType string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { if providerType != "fake-cloud" { @@ -711,6 +832,7 @@ func TestApplyWithProvider_NoChanges(t *testing.T) { }} fake := &applyCapture{} + fake.installAsV2Dispatch(t) if err := applyWithProviderAndStore(context.Background(), fake, "fake-cloud", []interfaces.ResourceSpec{spec}, current, nil, io.Discard, "", "", nil); err != nil { t.Fatalf("applyWithProviderAndStore: %v", err) } @@ -751,6 +873,7 @@ func TestApplyWithProvider_AdoptsExistingDNSBeforeComputePlan(t *testing.T) { }, } provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) store := &fakeStateStore{} if err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, store, io.Discard, "", "", nil); err != nil { @@ -831,6 +954,7 @@ func TestApplyWithProvider_AdoptionRoutesNewSensitiveOutputs(t *testing.T) { }, } provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) store := &fakeStateStore{} cfgPath := filepath.Join(t.TempDir(), "workflow.yaml") if err := os.WriteFile(cfgPath, []byte("secrets:\n provider: env\n"), 0o600); err != nil { @@ -882,6 +1006,7 @@ func TestAdoptExistingResources_AdoptionRoutingSaveFailureCleansSecretsOnly(t *t }, } provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) store := &fakeStateStore{saveErr: errors.New("state unavailable")} secretsProvider := newEnvTestProvider() @@ -929,6 +1054,7 @@ func TestApplyWithProvider_DNSAdoptionFailedUpdateKeepsLiveAppliedConfig(t *test readBackedProvider: readBackedProvider{driver: driver}, applyErr: errors.New("provider update failed"), } + provider.installAsV2Dispatch(t) store := &fakeStateStore{} err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, store, io.Discard, "", "", nil) @@ -969,6 +1095,7 @@ func TestApplyWithProvider_DNSAdoptionFallsBackToNameWhenDomainOmitted(t *testin }, } provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) if err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, &fakeStateStore{}, io.Discard, "", "", nil); err != nil { t.Fatalf("applyWithProviderAndStore: %v", err) @@ -997,6 +1124,7 @@ func TestApplyWithProvider_DNSAdoptionSaveFailureFailsBeforeApply(t *testing.T) }, } provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) store := &fakeStateStore{saveErr: errors.New("disk full")} err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, store, io.Discard, "", "", nil) @@ -1030,6 +1158,7 @@ func TestApplyWithProvider_DNSAdoptionRequiresWritableStateStore(t *testing.T) { }, } provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, nil, io.Discard, "", "", nil) if err == nil { @@ -1062,6 +1191,7 @@ func TestApplyWithProvider_AdoptsResourceThroughDriverLocator(t *testing.T) { }, } provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) store := &fakeStateStore{} err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, store, io.Discard, "", "", nil) @@ -1115,6 +1245,7 @@ func TestApplyWithProvider_AdoptsResourceWhenConfigAdoptExistingTrue(t *testing. }, } provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) store := &fakeStateStore{} if err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, store, io.Discard, "", "", nil); err != nil { @@ -1149,6 +1280,7 @@ func TestApplyWithProvider_ConfigAdoptionRejectsUnsupportedDriver(t *testing.T) } driver := &configAdoptDriver{} provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, &fakeStateStore{}, io.Discard, "", "", nil) if err == nil { @@ -1207,6 +1339,7 @@ func TestApplyWithProvider_DNSAdoptionPreservesBuiltInRefWithAdoptExisting(t *te }, } provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) if err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, &fakeStateStore{}, io.Discard, "", "", nil); err != nil { t.Fatalf("applyWithProviderAndStore: %v", err) @@ -1226,6 +1359,7 @@ func TestApplyWithProvider_SkipsAdoptionWhenAppDriverHasNoLocator(t *testing.T) Config: map[string]any{"image": "example/app:latest"}, } provider := &noDriverApplyProvider{} + provider.installAsV2Dispatch(t) err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, &fakeStateStore{}, io.Discard, "", "", nil) if err != nil { @@ -1234,7 +1368,7 @@ func TestApplyWithProvider_SkipsAdoptionWhenAppDriverHasNoLocator(t *testing.T) provider.mu.Lock() defer provider.mu.Unlock() if !provider.applyCalled { - t.Fatal("Apply should be called for normal create when app driver lacks adoption locator") + t.Fatal("v2 dispatch should be invoked for normal create when app driver lacks adoption locator") } if provider.appliedPlan == nil || len(provider.appliedPlan.Actions) != 1 || provider.appliedPlan.Actions[0].Action != "create" { t.Fatalf("applied plan = %#v, want one create", provider.appliedPlan) @@ -1258,6 +1392,7 @@ func TestApplyWithProvider_DNSAdoptionRejectsMalformedProviderID(t *testing.T) { }, } provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) store := &fakeStateStore{} err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, store, io.Discard, "", "", nil) @@ -1288,6 +1423,7 @@ func TestApplyWithProvider_DNSReadNotFoundKeepsCreateBehavior(t *testing.T) { } driver := &readDriver{readErr: interfaces.ErrResourceNotFound} provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) store := &fakeStateStore{} if err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, store, io.Discard, "", "", nil); err != nil { @@ -1318,6 +1454,7 @@ func TestApplyWithProvider_DNSReadErrorFailsBeforeApply(t *testing.T) { } driver := &readDriver{readErr: errors.New("provider API unavailable")} provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, nil, io.Discard, "", "", nil) if err == nil { @@ -1341,6 +1478,7 @@ func TestApplyWithProvider_DNSReadNilLiveOutputFailsBeforeApply(t *testing.T) { } driver := &readDriver{} provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, &fakeStateStore{}, io.Discard, "", "", nil) if err == nil { @@ -1370,6 +1508,7 @@ func TestApplyWithProvider_DNSReadEmptyProviderIDFailsBeforeApply(t *testing.T) }, } provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) store := &fakeStateStore{} err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, store, io.Discard, "", "", nil) @@ -1409,6 +1548,7 @@ func TestApplyWithProvider_DeletesRemovedResource(t *testing.T) { } fake := &applyCapture{} + fake.installAsV2Dispatch(t) store := &fakeStateStore{} if err := applyWithProviderAndStore(context.Background(), fake, "fake-cloud", specs, current, store, io.Discard, "", "", nil); err != nil { t.Fatalf("applyWithProviderAndStore: %v", err) @@ -1465,9 +1605,32 @@ func (p *stateReturningProvider) Capabilities() []interfaces.IaCCapabilityDeclar func (p *stateReturningProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { return nil, nil } + +// Apply is no longer on the interfaces.IaCProvider surface (workflow#699 +// removed it); kept as a concrete method so tests can preset applyResult / +// applyErr and observe the dispatch outcome. installAsV2Dispatch wires +// this concrete method into the global v2 dispatch seam. func (p *stateReturningProvider) Apply(_ context.Context, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { return p.applyResult, p.applyErr } + +// installAsV2Dispatch substitutes the global applyV2ApplyPlanWithHooksFn +// seam so tests that preset applyResult / applyErr observe the dispatch +// outcome without crossing into the per-driver layer. Also fires the v2 +// ApplyPlanHooks lifecycle so state-persistence assertions still pass. +// Auto-restored on test cleanup. Per workflow#699 fixup. +func (p *stateReturningProvider) installAsV2Dispatch(t testing.TB) { + t.Helper() + orig := applyV2ApplyPlanWithHooksFn + applyV2ApplyPlanWithHooksFn = func(ctx context.Context, prov interfaces.IaCProvider, plan *interfaces.IaCPlan, hooks wfctlhelpers.ApplyPlanHooks) (*interfaces.ApplyResult, error) { + result, err := p.Apply(ctx, plan) + if hookErr := fireSyntheticHooks(ctx, prov, plan, hooks, result); hookErr != nil && err == nil { + err = hookErr + } + return result, err + } + t.Cleanup(func() { applyV2ApplyPlanWithHooksFn = orig }) +} func (p *stateReturningProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { return nil, nil } @@ -1525,6 +1688,7 @@ func TestApplyWithProvider_SavesStateForSuccessfulResources(t *testing.T) { }, }, } + fake.installAsV2Dispatch(t) store := &fakeStateStore{} if err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", specs, nil, store, io.Discard, "", "", nil); err != nil { @@ -1581,6 +1745,7 @@ func TestApplyWithProvider_SavesStateOnPartialFailure(t *testing.T) { }, }, } + fake.installAsV2Dispatch(t) store := &fakeStateStore{} err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", specs, nil, store, io.Discard, "", "", nil) @@ -1607,6 +1772,7 @@ func TestApplyWithProvider_StoreSaveFailureFails(t *testing.T) { Resources: []interfaces.ResourceOutput{{Name: "r1", ProviderID: "vpc-1"}}, }, } + fake.installAsV2Dispatch(t) store := &fakeStateStore{saveErr: fmt.Errorf("disk full")} err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", specs, nil, store, io.Discard, "", "", nil) @@ -1740,6 +1906,7 @@ func TestApplyWithProvider_UpdateSensitiveRoutingFailureDoesNotDelete(t *testing }, driver: driver, } + fake.installAsV2Dispatch(t) store := &fakeStateStore{saved: []interfaces.ResourceState{current}} specs := []interfaces.ResourceSpec{{Name: "r1", Type: "infra.test", Config: desiredCfg}} @@ -1763,6 +1930,7 @@ func TestApplyWithProvider_FailedDeleteKeepsState(t *testing.T) { Errors: []interfaces.ActionError{{Action: "delete", Resource: "old", Error: "delete failed"}}, }, } + fake.installAsV2Dispatch(t) if err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", nil, []interfaces.ResourceState{current}, store, io.Discard, "", "", nil); err == nil { t.Fatal("expected delete failure, got nil") @@ -1774,33 +1942,14 @@ func TestApplyWithProvider_FailedDeleteKeepsState(t *testing.T) { } } -func TestApplyWithProvider_PartialFailureDoesNotInferDeleteSuccess(t *testing.T) { - desired := interfaces.ResourceSpec{Name: "new", Type: "infra.test", Config: map[string]any{"version": 1}} - current := interfaces.ResourceState{Name: "old", Type: "infra.test", ProviderID: "id-old"} - store := &fakeStateStore{saved: []interfaces.ResourceState{current}} - origCompute := computeInfraPlan - computeInfraPlan = func(context.Context, interfaces.IaCProvider, []interfaces.ResourceSpec, []interfaces.ResourceState) (interfaces.IaCPlan, error) { - return interfaces.IaCPlan{Actions: []interfaces.PlanAction{ - {Action: "create", Resource: desired}, - {Action: "delete", Resource: interfaces.ResourceSpec{Name: "old", Type: "infra.test"}, Current: ¤t}, - }}, nil - } - t.Cleanup(func() { computeInfraPlan = origCompute }) - fake := &stateReturningProvider{ - applyResult: &interfaces.ApplyResult{ - Errors: []interfaces.ActionError{{Action: "create", Resource: "new", Error: "create failed before delete"}}, - }, - } - - if err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", []interfaces.ResourceSpec{desired}, []interfaces.ResourceState{current}, store, io.Discard, "", "", nil); err == nil { - t.Fatal("expected partial failure, got nil") - } - store.mu.Lock() - defer store.mu.Unlock() - if len(store.deleted) != 0 { - t.Fatalf("deleted state entries = %v, want none when legacy result has errors", store.deleted) - } -} +// TestApplyWithProvider_PartialFailureDoesNotInferDeleteSuccess was +// deleted per workflow#699. The test pinned the v1 conservative +// "any error → no delete-state cleanup" short-circuit +// (successfulDeleteNames returning empty when result.Errors was +// non-empty). Post-v2 the dispatch fires per-action OnResourceDeleted +// hooks independently of other actions' outcomes; the v2 equivalent +// (TestApplyWithProviderAndStore_V2FailedDeleteKeepsState) asserts the +// correct per-action behavior. func TestApplyWithProvider_CreateMissingIdentitySaveFailureCompensates(t *testing.T) { driver := &v2SensitiveCreateDriver{} @@ -1899,6 +2048,7 @@ func TestApplyWithProvider_DeletePrunesStateAfterCancellation(t *testing.T) { stateReturningProvider: stateReturningProvider{applyResult: &interfaces.ApplyResult{}}, cancel: cancel, } + fake.installAsV2Dispatch(t) if err := applyWithProviderAndStore(ctx, fake, "fake-cloud", nil, []interfaces.ResourceState{current}, store, io.Discard, "", "", nil); err != nil { t.Fatalf("applyWithProviderAndStore: %v", err) @@ -2003,6 +2153,22 @@ func (s *sizingCapture) Apply(_ context.Context, plan *interfaces.IaCPlan) (*int return &interfaces.ApplyResult{}, nil } +// installAsV2Dispatch shadows applyCapture's promoted method so the +// sizing-specific Apply (which records appliedSpecs) is the one the v2 +// seam invokes. +func (s *sizingCapture) installAsV2Dispatch(t testing.TB) { + t.Helper() + orig := applyV2ApplyPlanWithHooksFn + applyV2ApplyPlanWithHooksFn = func(ctx context.Context, prov interfaces.IaCProvider, plan *interfaces.IaCPlan, hooks wfctlhelpers.ApplyPlanHooks) (*interfaces.ApplyResult, error) { + result, err := s.Apply(ctx, plan) + if hookErr := fireSyntheticHooks(ctx, prov, plan, hooks, result); hookErr != nil && err == nil { + err = hookErr + } + return result, err + } + t.Cleanup(func() { applyV2ApplyPlanWithHooksFn = orig }) +} + // TestApplyInfraModules_CallsResolveSizing_ForEachSpec verifies that // applyWithProviderAndStore invokes provider.ResolveSizing for each spec // that has a non-empty Size field, and that the resolved InstanceType and @@ -2020,6 +2186,7 @@ func TestApplyInfraModules_CallsResolveSizing_ForEachSpec(t *testing.T) { Specs: map[string]any{"memory_mb": 2048}, }, } + fake.installAsV2Dispatch(t) if err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", specs, nil, nil, io.Discard, "", "", nil); err != nil { t.Fatalf("applyWithProviderAndStore: %v", err) @@ -2125,6 +2292,7 @@ modules: // Override resolveIaCProvider to return a provider + error-producing closer. orig := resolveIaCProvider fake := &applyCapture{} + fake.installAsV2Dispatch(t) closerErr := "shutdown-sentinel-error" resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { return fake, &errCloser{msg: closerErr}, nil @@ -2183,6 +2351,7 @@ func TestApply_StateRecordsAppliedConfigSourceApply(t *testing.T) { }, }, } + fake.installAsV2Dispatch(t) store := &fakeStateStore{} if err := applyWithProviderAndStore(t.Context(), fake, "test", []interfaces.ResourceSpec{spec}, nil, store, io.Discard, "", "", nil); err != nil { @@ -2218,6 +2387,7 @@ func TestAdoption_StateRecordsAppliedConfigSourceAdoption(t *testing.T) { }, } provider := &readBackedProvider{driver: driver} + provider.installAsV2Dispatch(t) store := &fakeStateStore{} if err := applyWithProviderAndStore(t.Context(), provider, "digitalocean", []interfaces.ResourceSpec{spec}, nil, store, io.Discard, "", "", nil); err != nil { diff --git a/cmd/wfctl/infra_apply_troubleshoot_test.go b/cmd/wfctl/infra_apply_troubleshoot_test.go index 228c368b..5d8b888c 100644 --- a/cmd/wfctl/infra_apply_troubleshoot_test.go +++ b/cmd/wfctl/infra_apply_troubleshoot_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -51,6 +52,22 @@ func (p *applyFailProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, return nil, nil } +// installAsV2Dispatch shadows applyCapture's promoted method so the +// failing-apply variant's Apply (which returns p.applyErr) is the one +// the v2 seam invokes. +func (p *applyFailProvider) installAsV2Dispatch(t testing.TB) { + t.Helper() + orig := applyV2ApplyPlanWithHooksFn + applyV2ApplyPlanWithHooksFn = func(ctx context.Context, prov interfaces.IaCProvider, plan *interfaces.IaCPlan, hooks wfctlhelpers.ApplyPlanHooks) (*interfaces.ApplyResult, error) { + result, err := p.Apply(ctx, plan) + if hookErr := fireSyntheticHooks(ctx, prov, plan, hooks, result); hookErr != nil && err == nil { + err = hookErr + } + return result, err + } + t.Cleanup(func() { applyV2ApplyPlanWithHooksFn = orig }) +} + // plainFailProvider fails Apply and returns a ResourceDriver with no Troubleshoot. type plainFailProvider struct { applyCapture @@ -65,6 +82,21 @@ func (p *plainFailProvider) Apply(_ context.Context, plan *interfaces.IaCPlan) ( return nil, p.applyErr } +// installAsV2Dispatch shadows applyCapture's promoted method so the +// plain-failing-apply variant's Apply is the one the v2 seam invokes. +func (p *plainFailProvider) installAsV2Dispatch(t testing.TB) { + t.Helper() + orig := applyV2ApplyPlanWithHooksFn + applyV2ApplyPlanWithHooksFn = func(ctx context.Context, prov interfaces.IaCProvider, plan *interfaces.IaCPlan, hooks wfctlhelpers.ApplyPlanHooks) (*interfaces.ApplyResult, error) { + result, err := p.Apply(ctx, plan) + if hookErr := fireSyntheticHooks(ctx, prov, plan, hooks, result); hookErr != nil && err == nil { + err = hookErr + } + return result, err + } + t.Cleanup(func() { applyV2ApplyPlanWithHooksFn = orig }) +} + func TestInfraApply_EmitsDiagnosticsOnFailure(t *testing.T) { t.Setenv("GITHUB_ACTIONS", "true") @@ -76,6 +108,7 @@ func TestInfraApply_EmitsDiagnosticsOnFailure(t *testing.T) { applyErr: errors.New("API error"), tsDriver: &troubleshootingRD{diags: diags, tsCalls: &tsCalls}, } + provider.installAsV2Dispatch(t) infraApplyTroubleshootTimeout = 5 * time.Second defer func() { infraApplyTroubleshootTimeout = 30 * time.Second }() @@ -105,6 +138,7 @@ func TestInfraApply_EmitsDiagnosticsOnFailure(t *testing.T) { func TestInfraApply_NonTroubleshooterNocrash(t *testing.T) { // plainFailProvider.ResourceDriver returns (nil, nil); nil driver → no-op. provider := &plainFailProvider{applyErr: errors.New("boom")} + provider.installAsV2Dispatch(t) var diagBuf bytes.Buffer specs := []interfaces.ResourceSpec{{Name: "x", Type: "app_platform"}} err := applyWithProviderAndStore(context.Background(), provider, "digitalocean", specs, nil, nil, &diagBuf, "", "", nil) @@ -132,6 +166,7 @@ func TestInfraApply_WritesStepSummaryOnFailure(t *testing.T) { // This exercises the important branch where diagnostics are empty but the // summary is still written with the failure header and root cause. provider := &plainFailProvider{applyErr: errors.New("apply: resource quota exceeded")} + provider.installAsV2Dispatch(t) specs := []interfaces.ResourceSpec{{Name: "bmw-staging", Type: "app_platform"}} var diagBuf bytes.Buffer diff --git a/cmd/wfctl/infra_apply_v2_loader_test.go b/cmd/wfctl/infra_apply_v2_loader_test.go index ab9ed770..aec57e3c 100644 --- a/cmd/wfctl/infra_apply_v2_loader_test.go +++ b/cmd/wfctl/infra_apply_v2_loader_test.go @@ -2,7 +2,6 @@ package main import ( "context" - "errors" "io" "os" "path/filepath" @@ -113,8 +112,8 @@ modules: // Run the production apply entrypoint. The dispatcher must: // 1. Load the provider via resolveIaCProvider (seam) — succeeds. - // 2. Type-assert wfctlhelpers.ComputePlanVersionDeclarer → - // "v2" → route through wfctlhelpers.ApplyPlan. + // 2. Route through wfctlhelpers.ApplyPlan (v2 is the sole + // dispatch path per workflow#699; no type-assertion fork). // 3. ComputePlan dispatch driver.Diff via T3.6e errgroup, // observe NeedsReplace=true, emit "replace" action. // 4. wfctlhelpers.ApplyPlan decomposes replace into @@ -236,30 +235,28 @@ modules: } // v2LoaderStubProvider is the in-process equivalent of a v2 IaC -// plugin loaded via wfctl's discoverAndLoadIaCProvider. Implements -// interfaces.IaCProvider AND wfctlhelpers.ComputePlanVersionDeclarer -// (returning "v2") so the apply path's dispatch branch routes -// through wfctlhelpers.ApplyPlan. +// plugin loaded via wfctl's discoverAndLoadIaCProvider. Per workflow#699 +// v2 is the only supported dispatch, so satisfying interfaces.IaCProvider +// suffices; the in-process loader-seam bypasses the runtime +// CapabilitiesResponse.compute_plan_version gate (that gate fires inside +// buildTypedIaCAdapterFrom against a real gRPC plugin — see +// TestBuildTypedIaCAdapterFrom_LoadGate_RejectsV1Plugin in +// discover_typed_loader_test.go). type v2LoaderStubProvider struct { driver *v2LoaderStubDriver } var ( - _ interfaces.IaCProvider = (*v2LoaderStubProvider)(nil) - _ wfctlhelpers.ComputePlanVersionDeclarer = (*v2LoaderStubProvider)(nil) + _ interfaces.IaCProvider = (*v2LoaderStubProvider)(nil) ) func (p *v2LoaderStubProvider) Name() string { return "stub" } func (p *v2LoaderStubProvider) Version() string { return "0.0.1-loader-seam" } -func (p *v2LoaderStubProvider) ComputePlanVersion() string { return "v2" } func (p *v2LoaderStubProvider) Initialize(_ context.Context, _ map[string]any) error { return nil } func (p *v2LoaderStubProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil } func (p *v2LoaderStubProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { return nil, nil } -func (p *v2LoaderStubProvider) Apply(_ context.Context, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { - return nil, errors.New("v2 path must route through wfctlhelpers.ApplyPlan, not provider.Apply") -} func (p *v2LoaderStubProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { return nil, nil } diff --git a/cmd/wfctl/infra_apply_v2_test.go b/cmd/wfctl/infra_apply_v2_test.go index 289ca13b..0a8f1227 100644 --- a/cmd/wfctl/infra_apply_v2_test.go +++ b/cmd/wfctl/infra_apply_v2_test.go @@ -67,8 +67,7 @@ func TestApplyWithProviderAndStore_PassesLiveProviderToComputePlan(t *testing.T) // surfaced via the optional ComputePlanVersionDeclarer interface). func TestApplyWithProviderAndStore_V2RoutesThroughWfctlhelpers(t *testing.T) { v2Provider := &iactest.NoopProvider{ - ProviderName: "v2-stub", - DispatchVersion: "v2", + ProviderName: "v2-stub", } var v2Called atomic.Bool @@ -108,44 +107,12 @@ func TestApplyWithProviderAndStore_V2RoutesThroughWfctlhelpers(t *testing.T) { } } -// TestApplyWithProviderAndStore_V1FallsThroughToProviderApply -// verifies that a provider that does NOT declare v2 (via the optional -// interface) routes through the legacy provider.Apply path, not -// wfctlhelpers.ApplyPlan. Default behaviour for un-migrated plugins. -func TestApplyWithProviderAndStore_V1FallsThroughToProviderApply(t *testing.T) { - v1Provider := &v1RecordingProvider{} - - var v2Called atomic.Bool - origApply := applyV2ApplyPlanWithHooksFn - applyV2ApplyPlanWithHooksFn = func(_ context.Context, _ interfaces.IaCProvider, _ *interfaces.IaCPlan, _ wfctlhelpers.ApplyPlanHooks) (*interfaces.ApplyResult, error) { - v2Called.Store(true) - return nil, errors.New("v2 must not be invoked for v1 manifest") - } - t.Cleanup(func() { applyV2ApplyPlanWithHooksFn = origApply }) - - origCompute := computeInfraPlan - computeInfraPlan = func(_ context.Context, _ interfaces.IaCProvider, specs []interfaces.ResourceSpec, _ []interfaces.ResourceState) (interfaces.IaCPlan, error) { - actions := make([]interfaces.PlanAction, len(specs)) - for i, s := range specs { - actions[i] = interfaces.PlanAction{Action: "create", Resource: s} - } - return interfaces.IaCPlan{Actions: actions}, nil - } - t.Cleanup(func() { computeInfraPlan = origCompute }) - - specs := []interfaces.ResourceSpec{{Name: "vpc", Type: "infra.vpc"}} - - var w bytes.Buffer - if err := applyWithProviderAndStore(context.Background(), v1Provider, "stub", specs, nil, nil, &w, "test", "", nil); err != nil { - t.Fatalf("applyWithProviderAndStore: %v", err) - } - if !v1Provider.applyCalled.Load() { - t.Error("legacy provider.Apply was not invoked for v1 manifest") - } - if v2Called.Load() { - t.Error("v2 ApplyPlan was invoked for a v1 manifest — dispatch routed to wrong path") - } -} +// TestApplyWithProviderAndStore_V1FallsThroughToProviderApply + +// TestApplyWithProviderAndStore_V1Path_DeclarerReturnsEmpty + +// v1RecordingProvider stub were deleted per workflow#699 — v1 dispatch +// has no remaining surface to exercise; the runtime gate in +// discoverAndLoadIaCProvider rejects v1 plugins at load time and +// IaCProvider.Apply is gone from the interface. // TestApplyWithProviderAndStore_V2PrintsDriftReport verifies the // drift-report wiring: when wfctlhelpers.ApplyPlan returns a result @@ -154,7 +121,7 @@ func TestApplyWithProviderAndStore_V1FallsThroughToProviderApply(t *testing.T) { // production). Pre-T3.7 the helper existed but wasn't called from // any production path. func TestApplyWithProviderAndStore_V2PrintsDriftReport(t *testing.T) { - v2Provider := &iactest.NoopProvider{ProviderName: "drift-stub", DispatchVersion: "v2"} + v2Provider := &iactest.NoopProvider{ProviderName: "drift-stub"} driftResult := &interfaces.ApplyResult{ InputDriftReport: []interfaces.DriftEntry{ @@ -198,7 +165,7 @@ func TestApplyWithProviderAndStore_V2PrintsDriftReport(t *testing.T) { // disconnected. Pre-fix the gate was `if err == nil`, which dropped // drift on partial failure. func TestApplyWithProviderAndStore_V2PrintsDriftReportOnPartialFailure(t *testing.T) { - v2Provider := &iactest.NoopProvider{ProviderName: "drift-stub", DispatchVersion: "v2"} + v2Provider := &iactest.NoopProvider{ProviderName: "drift-stub"} driftResult := &interfaces.ApplyResult{ InputDriftReport: []interfaces.DriftEntry{ @@ -566,105 +533,11 @@ func TestApplyWithProviderAndStore_V2MismatchedOutputIdentityRollsBack(t *testin } } -// TestApplyWithProviderAndStore_V1Path_DeclarerReturnsEmpty pins the -// "Path B" v1 fallback (rev3 fix for T3.7 review IMPORTANT #3): a -// provider that DOES implement ComputePlanVersionDeclarer but -// returns "" (or any non-"v2" value) routes through the legacy -// provider.Apply path, not wfctlhelpers.ApplyPlan. This is the -// expected mid-transition state for v1 plugins after the SDK update -// lands but before they explicitly migrate. iactest.NoopProvider -// always implements the interface (the method exists on the type); -// leaving DispatchVersion empty exercises Path B specifically. Path -// A (provider doesn't implement the interface at all) is covered by -// TestApplyWithProviderAndStore_V1FallsThroughToProviderApply via -// v1RecordingProvider, which omits the method. -func TestApplyWithProviderAndStore_V1Path_DeclarerReturnsEmpty(t *testing.T) { - v1Provider := &iactest.NoopProvider{ProviderName: "v1-empty-decl", DispatchVersion: ""} - - var v2Called atomic.Bool - origApply := applyV2ApplyPlanWithHooksFn - applyV2ApplyPlanWithHooksFn = func(_ context.Context, _ interfaces.IaCProvider, _ *interfaces.IaCPlan, _ wfctlhelpers.ApplyPlanHooks) (*interfaces.ApplyResult, error) { - v2Called.Store(true) - return nil, errors.New("v2 must not be invoked when DispatchVersion is empty") - } - t.Cleanup(func() { applyV2ApplyPlanWithHooksFn = origApply }) - - origCompute := computeInfraPlan - computeInfraPlan = func(_ context.Context, _ interfaces.IaCProvider, specs []interfaces.ResourceSpec, _ []interfaces.ResourceState) (interfaces.IaCPlan, error) { - actions := make([]interfaces.PlanAction, len(specs)) - for i, s := range specs { - actions[i] = interfaces.PlanAction{Action: "create", Resource: s} - } - return interfaces.IaCPlan{Actions: actions}, nil - } - t.Cleanup(func() { computeInfraPlan = origCompute }) - - specs := []interfaces.ResourceSpec{{Name: "vpc", Type: "infra.vpc"}} - - var w bytes.Buffer - if err := applyWithProviderAndStore(context.Background(), v1Provider, "stub", specs, nil, nil, &w, "test", "", nil); err != nil { - t.Fatalf("applyWithProviderAndStore: %v", err) - } - if v2Called.Load() { - t.Error("v2 ApplyPlan was invoked when ComputePlanVersion() returned empty — dispatch routed to wrong path") - } -} - -// v1RecordingProvider is a minimal interfaces.IaCProvider that does -// NOT implement ComputePlanVersionDeclarer (the entire point of this -// fixture: prove the dispatch defaults to v1 for un-declared -// providers). Tracks Apply invocations so the v1-routing test can -// assert legacy dispatch fired. -type v1RecordingProvider struct { - applyCalled atomic.Bool -} - -var _ interfaces.IaCProvider = (*v1RecordingProvider)(nil) - -func (p *v1RecordingProvider) Name() string { return "v1-stub" } -func (p *v1RecordingProvider) Version() string { return "0.0.0" } -func (p *v1RecordingProvider) Initialize(_ context.Context, _ map[string]any) error { return nil } -func (p *v1RecordingProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { - return nil -} -func (p *v1RecordingProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { - return nil, nil -} -func (p *v1RecordingProvider) Apply(_ context.Context, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { - p.applyCalled.Store(true) - return &interfaces.ApplyResult{}, nil -} -func (p *v1RecordingProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { - return nil, nil -} -func (p *v1RecordingProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { - return nil, nil -} -func (p *v1RecordingProvider) DetectDrift(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { - return nil, nil -} -func (p *v1RecordingProvider) Import(_ context.Context, _ string, _ string) (*interfaces.ResourceState, error) { - return nil, nil -} -func (p *v1RecordingProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { - return nil, nil -} -func (p *v1RecordingProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { - return nil, nil -} -func (p *v1RecordingProvider) SupportedCanonicalKeys() []string { return nil } -func (p *v1RecordingProvider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) { - return nil, nil -} -func (p *v1RecordingProvider) Close() error { return nil } - type v2DriverProvider struct { iactest.NoopProvider driver interfaces.ResourceDriver } -func (p *v2DriverProvider) ComputePlanVersion() string { return "v2" } - func (p *v2DriverProvider) ResourceDriver(string) (interfaces.ResourceDriver, error) { return p.driver, nil } diff --git a/cmd/wfctl/infra_plan_apply_equivalence_test.go b/cmd/wfctl/infra_plan_apply_equivalence_test.go index 7330bb0c..b407fbec 100644 --- a/cmd/wfctl/infra_plan_apply_equivalence_test.go +++ b/cmd/wfctl/infra_plan_apply_equivalence_test.go @@ -8,6 +8,7 @@ import ( "reflect" "testing" + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -28,6 +29,22 @@ func (r *recordingProvider) Apply(_ context.Context, plan *interfaces.IaCPlan) ( return &interfaces.ApplyResult{}, nil } +// installAsV2Dispatch shadows applyCapture's promoted method so the +// recording variant's Apply (which captures action.Resource per spec) +// is the one the v2 seam invokes. +func (r *recordingProvider) installAsV2Dispatch(t testing.TB) { + t.Helper() + orig := applyV2ApplyPlanWithHooksFn + applyV2ApplyPlanWithHooksFn = func(ctx context.Context, prov interfaces.IaCProvider, plan *interfaces.IaCPlan, hooks wfctlhelpers.ApplyPlanHooks) (*interfaces.ApplyResult, error) { + result, err := r.Apply(ctx, plan) + if hookErr := fireSyntheticHooks(ctx, prov, plan, hooks, result); hookErr != nil && err == nil { + err = hookErr + } + return result, err + } + t.Cleanup(func() { applyV2ApplyPlanWithHooksFn = orig }) +} + // TestPlanApplyEquivalence_EnvOverrideNames is the regression gate for Bug #32 // and the class of env-override name divergences. It: // 1. Builds a BMW-shaped infra.yaml with env overrides that rename every resource. @@ -112,6 +129,7 @@ modules: // ── Step 2: what apply actually sends to the provider ──────────────────── rp := &recordingProvider{} + rp.installAsV2Dispatch(t) orig := resolveIaCProvider resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { return rp, nil, nil diff --git a/cmd/wfctl/infra_state_store_test.go b/cmd/wfctl/infra_state_store_test.go index 565933e5..950261aa 100644 --- a/cmd/wfctl/infra_state_store_test.go +++ b/cmd/wfctl/infra_state_store_test.go @@ -259,6 +259,7 @@ modules: }, } fake := &stateReturningProvider{applyResult: fakeResult} + fake.installAsV2Dispatch(t) orig := resolveIaCProvider resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { diff --git a/cmd/wfctl/plugin_audit_iac_test.go b/cmd/wfctl/plugin_audit_iac_test.go index 1b2e08b3..f3f5d067 100644 --- a/cmd/wfctl/plugin_audit_iac_test.go +++ b/cmd/wfctl/plugin_audit_iac_test.go @@ -26,7 +26,6 @@ func TestAuditPluginStrictContracts_IaCServiceMethodsAreNotRequired(t *testing.T "serviceMethods": [ "IaCProvider.Initialize", "IaCProvider.Plan", - "IaCProvider.Apply", "IaCProvider.EnumerateAll", "ResourceDriver.Create", "ResourceDriver.Read" @@ -98,10 +97,12 @@ func TestAuditPluginStrictContracts_NonIaCServiceMethodsStillRequire(t *testing. // methods added in iac.proto should be covered by this matcher. func TestIsIaCServiceMethod_Cases(t *testing.T) { cases := map[string]bool{ - // IaCProvider methods (legacy InvokeService dispatch shape). + // IaCProvider methods (legacy InvokeService dispatch shape; + // "IaCProvider.Apply" was retired per workflow#699 — no + // post-cutover plugin can advertise it, so the classifier no + // longer tests it explicitly). "IaCProvider.Initialize": true, "IaCProvider.Plan": true, - "IaCProvider.Apply": true, "IaCProvider.EnumerateAll": true, "IaCProvider.RepairDirtyMigration": true, // ResourceDriver methods (legacy InvokeService dispatch shape). diff --git a/iac/conformance/scenarios_test.go b/iac/conformance/scenarios_test.go index 570e03dc..f8e3ca19 100644 --- a/iac/conformance/scenarios_test.go +++ b/iac/conformance/scenarios_test.go @@ -491,7 +491,7 @@ func TestScenario_ReplaceCascadePreservesDependents(t *testing.T) { } cfg := Config{ Provider: func() interfaces.IaCProvider { - return &iactest.NoopProvider{Driver: &driver.NoopDriver, DispatchVersion: "v2"} + return &iactest.NoopProvider{Driver: &driver.NoopDriver} }, } // The cascade scenario calls wfctlhelpers.ApplyPlan; it routes diff --git a/iac/iactest/fakeprovider.go b/iac/iactest/fakeprovider.go index d22748b4..88a8dad0 100644 --- a/iac/iactest/fakeprovider.go +++ b/iac/iactest/fakeprovider.go @@ -38,12 +38,6 @@ type NoopProvider struct { // ProviderVersion overrides the Version() return value. Defaults // to "0.0.0-iactest" when empty. ProviderVersion string - - // DispatchVersion is the value returned from ComputePlanVersion(), - // satisfying iac/wfctlhelpers.ComputePlanVersionDeclarer. Tests - // driving the v2 apply path set this to "v2"; leaving it empty - // (the default) routes through the legacy v1 dispatch. - DispatchVersion string } // Compile-time interface conformance check — fails the build if @@ -66,11 +60,6 @@ func (p *NoopProvider) Version() string { return "0.0.0-iactest" } -// ComputePlanVersion satisfies iac/wfctlhelpers.ComputePlanVersionDeclarer. -// Returns DispatchVersion (which defaults to "" — treated as v1 by -// the dispatcher). -func (p *NoopProvider) ComputePlanVersion() string { return p.DispatchVersion } - // Initialize is a no-op. func (p *NoopProvider) Initialize(_ context.Context, _ map[string]any) error { return nil } diff --git a/iac/wfctlhelpers/apply.go b/iac/wfctlhelpers/apply.go index 1ff64e83..15f88e2f 100644 --- a/iac/wfctlhelpers/apply.go +++ b/iac/wfctlhelpers/apply.go @@ -452,8 +452,10 @@ func applyPlanWithEnvProviderAndHooks( // Phase 2 engine invariant (workflow#640 + ADR 0040 invariant 1): on // a normally-completed loop, len(result.Actions) MUST equal - // len(plan.Actions). Length validation lives engine-side here, not in - // applyResultFromPB (which is on the v1 plugin-dispatch path). + // len(plan.Actions). Length validation lives engine-side here, where + // it always has — the previous reference to a wfctl-side + // applyResultFromPB decoder is moot post-workflow#699 (the v1 + // plugin-Apply dispatch path is gone). if len(result.Actions) != len(plan.Actions) { return result, fmt.Errorf("internal: ApplyPlanWithHooks produced %d ActionOutcomes for %d plan actions (engine invariant violation per ADR 0040)", len(result.Actions), len(plan.Actions)) } diff --git a/interfaces/iac_state.go b/interfaces/iac_state.go index 6f947fbf..2031c0ac 100644 --- a/interfaces/iac_state.go +++ b/interfaces/iac_state.go @@ -154,9 +154,12 @@ type DriftEntry struct { type ActionStatus uint8 const ( - // ActionStatusUnspecified is the zero-value; T3's applyResultFromPB - // REJECTS this on decode so forgotten populates surface as errors - // rather than silent SUCCESS misreads. + // ActionStatusUnspecified is the zero-value. Engine-side population + // in wfctlhelpers.ApplyPlanWithHooks must replace this before + // returning; wfctl rejects ActionOutcome entries left at + // Unspecified per ADR 0040 invariant 2. Per workflow#699 the + // proto-side decode path (formerly applyResultFromPB) is gone — + // the only producer is now the engine's per-action populate. ActionStatusUnspecified ActionStatus = iota ActionStatusSuccess ActionStatusError @@ -167,9 +170,12 @@ const ( ActionStatusSkipped // action never attempted at driver (ctx-cancel pre-dispatch, JIT-substitution-fail, driver-resolve-fail); cloud-side state unchanged ) -// ActionOutcome mirrors pb.ActionResult. Engine populates one entry per -// PlanAction in ApplyResult.Actions; wfctl dispatches v2 hooks (Created / -// Deleted) by matching ActionIndex back to the planned action slice. +// ActionOutcome is the wfctl-side per-action surfacing for v2 hook +// dispatch. Engine populates one entry per PlanAction in +// ApplyResult.Actions; wfctl dispatches v2 hooks (Created / Deleted) by +// matching ActionIndex back to the planned action slice. Per +// workflow#699 the proto-side pb.ActionResult mirror was deleted — +// ActionOutcome is now the sole representation of per-action outcome. type ActionOutcome struct { ActionIndex uint32 `json:"action_index"` Status ActionStatus `json:"status"` diff --git a/interfaces/iac_state_test.go b/interfaces/iac_state_test.go index b5be6efa..c9074794 100644 --- a/interfaces/iac_state_test.go +++ b/interfaces/iac_state_test.go @@ -239,8 +239,9 @@ func TestResourceState_NewReaderTolerates_OldWriter(t *testing.T) { // TestActionStatus_ZeroValueIsUnspecified pins the zero-value semantics of // ActionStatus: an uninitialized status MUST be ActionStatusUnspecified so -// T3's applyResultFromPB reject path catches forgotten populates. Per -// workflow#640 Phase 2 + ADR 0040 invariant 2. +// the engine-side populate path catches forgotten populates. Per +// workflow#640 Phase 2 + ADR 0040 invariant 2. (The proto-side +// applyResultFromPB decode path was deleted per workflow#699.) func TestActionStatus_ZeroValueIsUnspecified(t *testing.T) { var s ActionStatus if s != ActionStatusUnspecified { @@ -249,8 +250,9 @@ func TestActionStatus_ZeroValueIsUnspecified(t *testing.T) { } // TestActionStatus_ConstantValues pins the wire tags 0/1/2/3 to the four -// declared constants. Mirrors pb.ActionStatus values; drift here would -// cause applyResultFromPB decode (T3) to silently mis-categorize. +// declared constants. Mirrors pb.ActionStatus values; drift would cause +// the engine-side populate to mis-categorize outcomes. (The proto-side +// applyResultFromPB decode path was deleted per workflow#699.) func TestActionStatus_ConstantValues(t *testing.T) { cases := []struct { name string diff --git a/plugin/external/proto/iac_proto_test.go b/plugin/external/proto/iac_proto_test.go index b03b81ac..7a320050 100644 --- a/plugin/external/proto/iac_proto_test.go +++ b/plugin/external/proto/iac_proto_test.go @@ -6,7 +6,6 @@ import ( "github.com/GoCodeAlone/workflow/interfaces" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" - "google.golang.org/protobuf/proto" ) // iacRequiredMethodsCheck is a locally-enumerated method-signature interface @@ -17,13 +16,15 @@ import ( // and surfaces the drop loudly. The previous (`var _ pb.X = stub{}`) form // would still compile because the regenerated stub would also lose the // method — that test silently followed the proto rather than guarding it. +// +// Apply was removed from iac.proto per workflow#699 (2026-05-17); the +// list below tracks the post-cutover required surface. type iacRequiredMethodsCheck interface { Initialize(context.Context, *pb.InitializeRequest) (*pb.InitializeResponse, error) Name(context.Context, *pb.NameRequest) (*pb.NameResponse, error) Version(context.Context, *pb.VersionRequest) (*pb.VersionResponse, error) Capabilities(context.Context, *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) Plan(context.Context, *pb.PlanRequest) (*pb.PlanResponse, error) - Apply(context.Context, *pb.ApplyRequest) (*pb.ApplyResponse, error) Destroy(context.Context, *pb.DestroyRequest) (*pb.DestroyResponse, error) Status(context.Context, *pb.StatusRequest) (*pb.StatusResponse, error) Import(context.Context, *pb.ImportRequest) (*pb.ImportResponse, error) @@ -97,53 +98,11 @@ func TestMigrationRepairConfirmationStringMatchesProtoComment(t *testing.T) { } } -// TestApplyResultActionsRoundTrip verifies the Phase 2 ActionResult+ -// ActionStatus additions to ApplyResult survive a proto marshal/unmarshal -// round trip with identical field values. Per ADR 0040 invariants 1-2 and -// the v2-lifecycle-phase2 plan T1. Guards against accidental field-tag -// drift and against re-ordering action_index / status / error. -// -// Uses proto.Equal for canonical comparison so adding a field to -// ActionResult later still gets checked without changing this test. -// Subcases include UNSPECIFIED (which T3 will REJECT on decode — wire -// layer must still encode/decode it losslessly) and nil/empty Actions -// (the dominant case for plugins on v1 capability shim). -func TestApplyResultActionsRoundTrip(t *testing.T) { - cases := []struct { - name string - actions []*pb.ActionResult - }{ - {"nil_actions", nil}, - {"empty_actions", []*pb.ActionResult{}}, - {"unspecified_status", []*pb.ActionResult{ - {ActionIndex: 0, Status: pb.ActionStatus_ACTION_STATUS_UNSPECIFIED, Error: ""}, - }}, - {"mixed_statuses", []*pb.ActionResult{ - {ActionIndex: 0, Status: pb.ActionStatus_ACTION_STATUS_SUCCESS, Error: ""}, - {ActionIndex: 1, Status: pb.ActionStatus_ACTION_STATUS_ERROR, Error: "boom"}, - {ActionIndex: 2, Status: pb.ActionStatus_ACTION_STATUS_DELETE_FAILED, Error: "still in use"}, - }}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - original := &pb.ApplyResult{ - PlanId: "plan-phase2-roundtrip", - Actions: c.actions, - } - wire, err := proto.Marshal(original) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - decoded := &pb.ApplyResult{} - if err := proto.Unmarshal(wire, decoded); err != nil { - t.Fatalf("Unmarshal: %v", err) - } - if !proto.Equal(decoded, original) { - t.Fatalf("round-trip mismatch:\n got: %v\nwant: %v", decoded, original) - } - }) - } -} +// TestApplyResultActionsRoundTrip was deleted per workflow#699: +// pb.ApplyResult + pb.ActionResult are gone from iac.proto (the +// per-action outcome surfacing now flows through engine-side hooks, +// not the Apply RPC's response). pb.ActionStatus enum survives — +// covered by TestActionStatusEnumValues below. // TestActionStatusEnumValues pins the wire-tag → constant mapping for // ActionStatus. Per plan T1: 0=UNSPECIFIED (rejected by wfctl), 1=SUCCESS, diff --git a/plugin/sdk/manifest.go b/plugin/sdk/manifest.go index d9f22bfd..b6c9c688 100644 --- a/plugin/sdk/manifest.go +++ b/plugin/sdk/manifest.go @@ -60,16 +60,13 @@ type IaCProvider struct { ComputePlanVersion string `json:"computePlanVersion,omitempty"` } -// EffectiveComputePlanVersion returns the dispatch version, defaulting to -// "v1" when the manifest omits the field. Callers should always go through -// this accessor rather than reading ComputePlanVersion directly so the -// default-v1 contract stays in one place. -func (p IaCProvider) EffectiveComputePlanVersion() string { - if p.ComputePlanVersion == "" { - return "v1" - } - return p.ComputePlanVersion -} +// EffectiveComputePlanVersion was removed per workflow#699 (2026-05-17): +// post-cutover "v1" is not a valid runtime value, so a default-to-v1 +// accessor would lie. The manifest field is now a parse-time-validated +// advisory only — the authoritative gate is the typed +// CapabilitiesResponse.compute_plan_version check in +// cmd/wfctl/deploy_providers.go's discoverAndLoadIaCProvider, which +// rejects any plugin not declaring "v2" at load time. // compiledSchema is the parsed manifest schema. It is compiled lazily on // first ParseManifest call and cached for the process lifetime; the schema diff --git a/plugin/sdk/manifest_test.go b/plugin/sdk/manifest_test.go index ea781d90..95c5b625 100644 --- a/plugin/sdk/manifest_test.go +++ b/plugin/sdk/manifest_test.go @@ -11,11 +11,15 @@ import ( "github.com/santhosh-tekuri/jsonschema/v6/kind" ) -// TestManifest_IaCProvider_ComputePlanVersion exercises the new -// iacProvider.computePlanVersion field. Cases: -// - default-v1: field omitted → EffectiveComputePlanVersion() == "v1" -// - explicit-v1: "v1" → "v1" -// - explicit-v2: "v2" → "v2" +// TestManifest_IaCProvider_ComputePlanVersion exercises the +// iacProvider.computePlanVersion field at the schema layer. Per +// workflow#699 the EffectiveComputePlanVersion accessor is gone (the +// authoritative gate is now the typed CapabilitiesResponse check in +// cmd/wfctl/deploy_providers.go); the manifest field remains as a +// parse-time validation surface. Cases: +// - omitted: accepted (manifest field is optional) +// - explicit-v1: accepted (advisory; the runtime gate rejects v1) +// - explicit-v2: accepted // - rejected: "v3" → ParseManifest returns an error (schema-rejected) func TestManifest_IaCProvider_ComputePlanVersion(t *testing.T) { cases := map[string]struct { @@ -23,7 +27,7 @@ func TestManifest_IaCProvider_ComputePlanVersion(t *testing.T) { want string wantErr bool }{ - "default-v1": {`{"name":"x","iacProvider":{}}`, "v1", false}, + "omitted": {`{"name":"x","iacProvider":{}}`, "", false}, "explicit-v1": {`{"name":"x","iacProvider":{"computePlanVersion":"v1"}}`, "v1", false}, "explicit-v2": {`{"name":"x","iacProvider":{"computePlanVersion":"v2"}}`, "v2", false}, "rejected": {`{"name":"x","iacProvider":{"computePlanVersion":"v3"}}`, "", true}, @@ -34,24 +38,13 @@ func TestManifest_IaCProvider_ComputePlanVersion(t *testing.T) { if (err != nil) != c.wantErr { t.Fatalf("err=%v wantErr=%v", err, c.wantErr) } - if !c.wantErr && m.IaCProvider.EffectiveComputePlanVersion() != c.want { - t.Errorf("got %q want %q", m.IaCProvider.EffectiveComputePlanVersion(), c.want) + if !c.wantErr && m.IaCProvider.ComputePlanVersion != c.want { + t.Errorf("got %q want %q", m.IaCProvider.ComputePlanVersion, c.want) } }) } } -// TestManifest_IaCProvider_ComputePlanVersion_ZeroValue verifies that an -// IaCProvider with the zero value (empty string) reports v1, matching the -// "default-v1" case but exercising the accessor on a Go-zero-valued struct -// (no JSON involved). -func TestManifest_IaCProvider_ComputePlanVersion_ZeroValue(t *testing.T) { - var p IaCProvider - if got := p.EffectiveComputePlanVersion(); got != "v1" { - t.Errorf("zero IaCProvider.EffectiveComputePlanVersion() = %q, want %q", got, "v1") - } -} - // TestManifest_IaCProvider_RejectsTypoKey verifies that a typo inside // iacProvider (e.g., the lowercase "computeplanversion") is rejected by // the schema rather than silently parsing to a zero-valued IaCProvider — @@ -83,8 +76,8 @@ func TestManifest_RootPermitsAdditionalProperties(t *testing.T) { if err != nil { t.Fatalf("expected pass; got %v", err) } - if m.IaCProvider.EffectiveComputePlanVersion() != "v2" { - t.Errorf("got %q want %q", m.IaCProvider.EffectiveComputePlanVersion(), "v2") + if m.IaCProvider.ComputePlanVersion != "v2" { + t.Errorf("got %q want %q", m.IaCProvider.ComputePlanVersion, "v2") } } From 6c804e2fd310bd12e393285d2560276f04cd60d4 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 16:11:18 -0400 Subject: [PATCH 20/22] feat(workflow): delete cmd/iac-codemod (dead post-cutover) + CHANGELOG (workflow#699 PR 1 task 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final PR-1 task — closes the IaCProvider.Apply hard-removal sequence: - cmd/iac-codemod/ — entire directory deleted (10 files: main.go, lint.go, refactor_apply.go, refactor_plan.go, add_validate_plan.go, main_test.go, lint_test.go, refactor_apply_test.go, refactor_plan_test.go, add_validate_plan_test.go). The codemod's reason-to-exist (migrate v1 Apply impls to v2 wfctlhelpers.ApplyPlan delegation) evaporated when IaCProvider.Apply was removed from the interface + proto in Tasks 5+6. - Makefile — deleted .PHONY entries build-iac-codemod + migrate-providers, the build-iac-codemod target, the migrate-providers target block, AWS/GCP/ AZURE override variables, and the iac-codemod entry in the clean rule. grep -c iac-codemod Makefile = 0 after edit. - CHANGELOG.md — Unreleased breaking-changes entry covering interface deletion, proto deletion, wfctlhelpers/dispatch deletion, codemod deletion, EffectiveComputePlanVersion deletion, load-time gate, CI lint guard, and minimum plugin versions (aws/gcp/azure/digitalocean v2.0.0+). - docs/migrations/2026-05-16-v2-lifecycle-phase1-inventory.md — strike iac-codemod tool-references section as completed-and-removed; reference workflow#699 supersession. Pre-merge gate GREEN: - go build ./... → clean - go vet ./... → clean (modernization hints only) - go test ./... → all suites pass (except the intentional nested go-test failure in TestRunCIRunTestFallsBackToGoTestWhenNoConfiguredTests which the outer test asserts must fail). This completes the workflow#699 PR 1 cascade resolution. The branch is ready for v0.56.0-rc1 tag after merge. --- CHANGELOG.md | 11 + Makefile | 44 +- cmd/iac-codemod/add_validate_plan.go | 982 ------------ cmd/iac-codemod/add_validate_plan_test.go | 391 ----- cmd/iac-codemod/lint.go | 1274 --------------- cmd/iac-codemod/lint_test.go | 843 ---------- cmd/iac-codemod/main.go | 207 --- cmd/iac-codemod/main_test.go | 331 ---- cmd/iac-codemod/refactor_apply.go | 1354 ---------------- cmd/iac-codemod/refactor_apply_test.go | 717 --------- cmd/iac-codemod/refactor_plan.go | 1404 ----------------- cmd/iac-codemod/refactor_plan_test.go | 575 ------- ...026-05-16-v2-lifecycle-phase1-inventory.md | 15 +- 13 files changed, 23 insertions(+), 8125 deletions(-) delete mode 100644 cmd/iac-codemod/add_validate_plan.go delete mode 100644 cmd/iac-codemod/add_validate_plan_test.go delete mode 100644 cmd/iac-codemod/lint.go delete mode 100644 cmd/iac-codemod/lint_test.go delete mode 100644 cmd/iac-codemod/main.go delete mode 100644 cmd/iac-codemod/main_test.go delete mode 100644 cmd/iac-codemod/refactor_apply.go delete mode 100644 cmd/iac-codemod/refactor_apply_test.go delete mode 100644 cmd/iac-codemod/refactor_plan.go delete mode 100644 cmd/iac-codemod/refactor_plan_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f8cd0e..4e20fa61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,17 @@ Configs that still reference the legacy types now fail to load with an actionabl ## [Unreleased] +### Breaking changes (workflow#699 — IaCProvider.Apply hard-removal) + +- `interfaces.IaCProvider.Apply` removed. Plugins must implement v2 dispatch (declare `CapabilitiesResponse.compute_plan_version="v2"` via the typed RPC) and drop their `Apply` Go method. +- `pb.IaCProviderRequired.Apply` RPC removed; `ApplyRequest`/`ApplyResponse`/`ApplyResult`/`ActionResult` proto messages deleted (`ActionStatus` enum survives — surfaced through engine-side `interfaces.ActionOutcome`). +- `iac/wfctlhelpers/dispatch.go` package deleted (`ComputePlanVersionDeclarer`, `DispatchVersionFor`, `DispatchVersionV2`); v2 is the only supported dispatch path. +- `cmd/iac-codemod` deleted (the v1→v2 migration tool no longer has a target). +- `plugin/sdk.IaCProvider.EffectiveComputePlanVersion()` accessor deleted (post-cutover, "v1" is not a valid runtime value); the manifest field remains as a parse-time validation surface. +- Load-time enforcement: `cmd/wfctl/deploy_providers.go`'s `discoverAndLoadIaCProvider` now calls the typed `Capabilities` RPC at plugin handshake (with a 10s bounded context that bypasses the adapter's lifetime cache) and rejects providers whose `compute_plan_version != "v2"`. +- Makefile lint guard added: `grep -qE '^\s*rpc Apply\s*\(' plugin/external/proto/iac.proto` runs as part of the `lint` target so a future PR cannot silently re-introduce the deleted RPC. +- Minimum plugin versions: aws v2.0.0+, gcp v2.0.0+, azure v2.0.0+, digitalocean v2.0.0+. + ### Fixed (issue #663 — follow-up) - **`*external.RemoteModule.Dependencies()` now returns the yaml-level `dependsOn:` keys** instead of always returning `nil`. The v0.51.8 fix (PR #664) only reordered the `cfg.Modules` slice — but modular's `app.Init()` then runs its own `DependencyAware`-driven sort over the registered modules, and `RemoteModule` (the wrapper used for every external-plugin module) returned `nil` from `Dependencies()`, so modular saw every external-plugin module as a root and sorted alphabetically. BMW PR #280 image-launch surfaced this as the same `bmw-eventbus`/`bmw-stream` ordering race that v0.51.8 was supposed to close. Engine `BuildFromConfig` now filters `modCfg.DependsOn` through `filterResolvableDeps` (drops empty strings + names not present in `cfg.Modules` — the same edge-set topoSortModules used for ordering) and calls `SetDependencies(filtered)` on each module that **implements** `interface{ SetDependencies([]string) }`, but **only when the filtered slice is non-empty**, immediately after the factory returns and before `app.RegisterModule`. (Modules with no resolvable dependsOn — empty yaml + transform-injected modules whose dependsOn is all empty/ghost — are skipped, so a constructor-time default isn't clobbered with `SetDependencies(nil)`.) `RemoteModule` implements that setter, defensively copies the slice, and modular's Init() walker then reads it via the existing `Dependencies()` contract. 7 unit tests cover the `RemoteModule` contract (default-nil, plumb, empty-slice, overwrite, defensive-copy aliasing, plus two type-assertion pins for `modular.DependencyAware` and the engine's `SetDependencies` interface) plus 4 engine-level `BuildFromConfig` tests covering the production path (basic plumb + defensive copy via raw-slice recorder + back-compat skip + real-modular Init order). Built-in modules can opt in by implementing the same setter; existing behaviour is unchanged for modules that don't. diff --git a/Makefile b/Makefile index 0b5c6fab..bade626d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-ui build-go test bench bench-baseline bench-compare lint fmt vet fix install-hooks clean ko-build build-wfctl build-iac-codemod migrate-providers +.PHONY: build build-ui build-go test bench bench-baseline bench-compare lint fmt vet fix install-hooks clean ko-build build-wfctl # Common benchmark flags BENCH_FLAGS = -bench=. -benchmem -run=^$$ -timeout=30m @@ -91,51 +91,9 @@ run-admin: build ko-build: KO_DOCKER_REPO=ko.local ko build ./cmd/server --bare --platform=linux/$(shell go env GOARCH) -# Build the iac-codemod CLI (W-8 / cmd/iac-codemod). GOWORK=off keeps -# the build self-contained: contributors with a workspace go.work file -# that doesn't include this module shouldn't have to amend their -# environment to run `make migrate-providers`. -build-iac-codemod: - GOWORK=off go build -o iac-codemod ./cmd/iac-codemod - -# Workspace-wide IaC migration runner (W-8 / T8.6). -# -# Runs `iac-codemod lint -dry-run` against the AWS, GCP, and Azure plugin -# repos as advisory-only checks. The plugins themselves stay un-migrated -# at v1 (per plan §W-8: "AWS/GCP/Azure plugins are run advisory-only (no -# `-fix`); their reports are filed as GitHub issues against the -# respective plugin repos for activation-time triage"). For DO, run the -# refactor-* modes manually with `-fix` against the workspace's DO -# checkout — that migration is the subject of P-DO and is intentionally -# excluded from this target's mechanical sweep. -# -# Provider paths are sibling-repo defaults; override on the command line: -# -# make migrate-providers AWS=/path/to/workflow-plugin-aws \ -# GCP=/path/to/workflow-plugin-gcp \ -# AZURE=/path/to/workflow-plugin-azure -AWS ?= ../workflow-plugin-aws -GCP ?= ../workflow-plugin-gcp -AZURE ?= ../workflow-plugin-azure - -migrate-providers: build-iac-codemod - @# iac-codemod lint exit-code semantics (review round-5 finding #7): - @# 0 = clean / 1 = advisory findings (continue) / 2 = parse errors (fail). - @# Naive `|| true` would swallow real execution failures alongside the - @# expected advisory findings; gate on exit code 1 specifically so a - @# parse-error or unknown-flag (>=2) still fails the target. - @echo "==> Running iac-codemod lint (advisory) against AWS plugin: $(AWS)" - @if [ -d "$(AWS)" ]; then ./iac-codemod lint -dry-run "$(AWS)"; ec=$$?; if [ $$ec -ne 0 ] && [ $$ec -ne 1 ]; then echo " iac-codemod lint failed (exit=$$ec)"; exit $$ec; fi; else echo " (skipping: $(AWS) not found)"; fi - @echo "==> Running iac-codemod lint (advisory) against GCP plugin: $(GCP)" - @if [ -d "$(GCP)" ]; then ./iac-codemod lint -dry-run "$(GCP)"; ec=$$?; if [ $$ec -ne 0 ] && [ $$ec -ne 1 ]; then echo " iac-codemod lint failed (exit=$$ec)"; exit $$ec; fi; else echo " (skipping: $(GCP) not found)"; fi - @echo "==> Running iac-codemod lint (advisory) against Azure plugin: $(AZURE)" - @if [ -d "$(AZURE)" ]; then ./iac-codemod lint -dry-run "$(AZURE)"; ec=$$?; if [ $$ec -ne 0 ] && [ $$ec -ne 1 ]; then echo " iac-codemod lint failed (exit=$$ec)"; exit $$ec; fi; else echo " (skipping: $(AZURE) not found)"; fi - @echo "==> migrate-providers complete (advisory-only; no files mutated)" - # Clean build artifacts clean: rm -f server rm -f wfctl - rm -f iac-codemod rm -f example/workflow-example rm -rf module/ui_dist/assets module/ui_dist/index.html module/ui_dist/vite.svg diff --git a/cmd/iac-codemod/add_validate_plan.go b/cmd/iac-codemod/add_validate_plan.go deleted file mode 100644 index 948bd7b5..00000000 --- a/cmd/iac-codemod/add_validate_plan.go +++ /dev/null @@ -1,982 +0,0 @@ -// Copyright (c) 2026 Jon Langevin -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "bytes" - "fmt" - "go/ast" - "go/format" - "go/parser" - "go/token" - "io" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" -) - -func init() { - modes["add-validate-plan"] = runAddValidatePlan -} - -// validatePlanClassification labels the disposition of a single -// provider receiver type with respect to the ValidatePlan stub -// injection. Drives both the report grouping and the mutation gate. -type validatePlanClassification int - -const ( - // validatePlanMissing: provider has Plan + Apply but no - // ValidatePlan; the stub will be injected on -fix. - validatePlanMissing validatePlanClassification = iota - // validatePlanAlreadyImplemented: provider already has - // ValidatePlan; idempotent no-op. - validatePlanAlreadyImplemented - // validatePlanSkipped: marker on the type decl or on Plan/Apply. - validatePlanSkipped -) - -func (c validatePlanClassification) String() string { - switch c { - case validatePlanMissing: - return "missing-validate-plan" - case validatePlanAlreadyImplemented: - return "already-implemented" - case validatePlanSkipped: - return "skipped" - default: - return "unknown" - } -} - -// validatePlanSite captures one provider-type site in the report. -type validatePlanSite struct { - Path string - Line int - Receiver string - Class validatePlanClassification - Inserted bool // set when -fix actually injected a stub -} - -type validatePlanReport struct { - sites []validatePlanSite - errors []string -} - -// runAddValidatePlan is the entry point for the add-validate-plan -// subcommand. It walks the supplied paths, classifies each provider -// receiver, and (under -fix) injects a no-op ValidatePlan stub on -// missing sites. -func runAddValidatePlan(args []string, opts *Options, stdout, stderr io.Writer) int { - if len(args) == 0 { - fmt.Fprintln(stderr, "iac-codemod add-validate-plan: at least one path is required") - usage(stderr) - return 2 - } - report := &validatePlanReport{} - for _, path := range args { - if err := addValidatePlanPath(path, opts, report); err != nil { - fmt.Fprintf(stderr, "iac-codemod add-validate-plan: %s: %v\n", path, err) - return 1 - } - } - report.print(stdout, opts) - if len(report.errors) > 0 { - return 1 - } - return 0 -} - -func addValidatePlanPath(path string, opts *Options, report *validatePlanReport) error { - info, err := stat(path) - if err != nil { - return err - } - if !info.IsDir() { - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return fmt.Errorf("not a Go source file (or is a _test.go): %s", path) - } - if err := addValidatePlanFile(path, opts, report); err != nil { - report.errors = append(report.errors, fmt.Sprintf("%s: %v", path, err)) - } - return nil - } - return filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - base := d.Name() - if shouldSkipDir(base) { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { - return nil - } - if err := addValidatePlanFile(p, opts, report); err != nil { - report.errors = append(report.errors, fmt.Sprintf("%s: %v", p, err)) - } - return nil - }) -} - -// addValidatePlanFile parses `path`, identifies provider-shaped -// receiver types, and (under -fix) appends a no-op ValidatePlan stub -// for each provider missing one. The stub uses an unqualified -// `*IaCPlan` and `[]PlanDiagnostic` so the substituted code compiles -// against whichever package alias the rest of the file uses. -// -// Insertion strategy: rather than synthesising the FuncDecl via -// AST nodes (which is brittle when the package's IaCPlan type is -// imported under an alias), we append the stub as raw text after -// printing the file. This keeps the rest of the file byte-identical -// for files that only need a stub appended, and avoids any risk of -// printer-induced reformatting elsewhere in the source. -func addValidatePlanFile(path string, opts *Options, report *validatePlanReport) error { - src, err := readFile(path) - if err != nil { - return err - } - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, path, src, parser.ParseComments) - if err != nil { - return err - } - - // Round-12 #4: skip files in a non-dominant package (same - // rationale as refactor-plan #2 / refactor-apply #3). - dominant := dominantPackageForDir(filepath.Dir(path)) - if dominant != "" && file.Name.Name != dominant { - return nil - } - provs, methodsByRecv, typeDecls := providerReceiversWithMethods(file) - // Widen `provs` AND `methodsByRecv` to the directory-wide method - // set so all per-receiver decisions (skip-marker check, - // hasValidatePlanMethod, receiver-kind inference) consult ALL - // methods of the type, not only the ones declared in this file. - // Review round-2 finding #9: rev1 only widened `provs`, leaving - // methodsByRecv file-local. A provider whose ValidatePlan was - // already implemented in a sibling file would still receive a - // duplicate stub here. Now methodsByRecv carries the package-wide - // view; stub injection still only fires when typeDecls[recv] is - // non-nil so we never APPEND to a sibling file. - if dirProvs, dirMethods := planLikeProviderMethodsInDir(filepath.Dir(path)); dirProvs != nil { - for recv := range dirProvs { - if _, ok := provs[recv]; !ok && typeDecls[recv] != nil { - provs[recv] = typeDecls[recv] - } - } - // Merge sibling methods into methodsByRecv. Per-recv slice is - // append-merged so any sibling ValidatePlan declaration is - // visible to hasValidatePlanMethod, and any sibling Plan/Apply - // is visible to providerReceiverConvention. - // - // Round-7 #10: rev3 deduped by method NAME only ("avoid - // double-counting" was the rationale, since the directory - // re-parser produces fresh *ast.FuncDecl values for the local - // file too). But name-dedupe drops a sibling-correct - // ValidatePlan when the local file has a wrong-signature - // shadow, leading to a duplicate stub injection. The fix: - // dedupe by (name, file-path) using fset.Position. A method - // from a sibling file always has a different file path than - // methods from `file`, so adding it never duplicates. - for recv, sibMethods := range dirMethods { - if _, ok := provs[recv]; !ok { - continue - } - for _, m := range sibMethods { - // Position uses the *separate* FileSet from - // planLikeProviderMethodsInDir. We can't compare - // directly to the primary fset's positions. The - // safest signal: is the FuncDecl's own *ast.FuncDecl - // pointer present in methodsByRecv[recv] (the local - // methods)? Pointer comparison handles the dedupe - // without name shadowing. - present := false - for _, lm := range methodsByRecv[recv] { - if lm == m { - present = true - break - } - } - if present { - continue - } - // Distinct *ast.FuncDecl: name+signature dedupe so a - // sibling Plan/Apply with identical signature to a - // local one (re-parsed) doesn't duplicate. ValidatePlan - // is INTENTIONALLY not deduped by name alone; if the - // local has wrong-signature ValidatePlan and sibling - // has correct, both are added so hasValidatePlanMethod - // can find the correct one (it ignores wrong shapes). - if isLocalDuplicate(m, methodsByRecv[recv]) { - continue - } - methodsByRecv[recv] = append(methodsByRecv[recv], m) - } - } - } - // Determine the qualifier for *IaCPlan / []PlanDiagnostic so the - // stub's signature matches whatever import-naming convention the - // file already uses (review round-1 finding #7). - // - // Round-4 #1: when the type declaration lives in a sibling file - // (no interfaces import in THIS file), fall back to the qualifier - // the package uses AND inject the import. - // - // Round-7 #4: rev3 fell back to "interfaces" if ANY sibling imports - // interfaces. That's wrong if the provider itself uses LOCAL - // IaCPlan types (e.g., a unit-test fixture in package `p` with - // local types, where an unrelated sibling imports interfaces for - // other reasons). The correct signal is per-receiver: inspect THIS - // PROVIDER's existing Plan/Apply parameter types (now visible via - // the directory-wide methodsByRecv merge from round-3 #1) to see - // what qualifier they use. Only fall back if the provider's own - // methods reference the qualified shape. - qualifier := interfacesQualifier(file) - needsInterfacesImport := false - // Captures the per-receiver qualifier set in the loop below, so the - // post-loop import injection (round-9 #4) can match the alias name - // the stub will reference. - injectedQualifier := "" - // Deterministic order for the report and for mutation: sort by - // declaration line. Round-7 finding #7 + #8: provs[recv] can be - // nil when the type declaration lives in a sibling file (round-3's - // directory-wide method-set scan supports this layout). Calling - // .Pos() on a nil *ast.TypeSpec panics. Default position to NoPos - // for nil specs; sort still works (NoPos sorts equal-to-zero). - type recvOrder struct { - Name string - Pos token.Pos - } - var ordered []recvOrder - for recv := range provs { - var pos token.Pos - if ts := provs[recv]; ts != nil { - pos = ts.Pos() - } - ordered = append(ordered, recvOrder{Name: recv, Pos: pos}) - } - sort.Slice(ordered, func(i, j int) bool { - if ordered[i].Pos != ordered[j].Pos { - return ordered[i].Pos < ordered[j].Pos - } - return ordered[i].Name < ordered[j].Name - }) - - // Directory-wide type-doc lookup so a skip-marker on a sibling - // file's type declaration is honored (round-7 #5). - siblingTypeDocs := receiverTypeDocsInDir(filepath.Dir(path), file) - - mutated := false - var pendingStubs []string - for _, rec := range ordered { - recv := rec.Name - methods := methodsByRecv[recv] - // Skip-marker check: the type decl (in this file OR a sibling - // file via the directory-wide doc lookup) OR any of the - // existing Plan/Apply methods (across files) carrying the - // marker suppresses the classification. - // - // Round-7 #5: rev3 only consulted typeDecls (this file's TypeSpec). - // When Plan/Apply are here but the provider type with - // `// wfctl:skip-iac-codemod` lives in a SIBLING file, the - // skip got ignored. siblingTypeDocs now provides the - // directory-wide view (matching the round-6 fix in refactor-*). - ts := typeDecls[recv] - skipped := false - if ts != nil && hasSkipMarkerOn(ts.Doc) { - skipped = true - } - if !skipped { - if doc, ok := siblingTypeDocs[recv]; ok && doc.carriesMarker() { - skipped = true - } - } - if !skipped { - // Round-8 #2: rev2 checked the marker on EVERY method, so - // a marker on Destroy/Status/etc. accidentally suppressed - // add-validate-plan for the whole provider. Restrict to - // Plan and Apply (the provider-defining methods that - // actually opt the type out of the migration). - for _, m := range methods { - if m.Name.Name != "Plan" && m.Name.Name != "Apply" { - continue - } - if hasSkipMarkerOn(m.Doc) { - skipped = true - break - } - } - } - // Also honor the parent GenDecl's doc for a `type Foo struct{}` - // declared in a single-spec block (current file only — - // receiverTypeDocsInDir's GenDeclDoc already covers siblings). - if !skipped { - if gd := genDeclFor(file, ts); gd != nil && hasSkipMarkerOn(gd.Doc) { - skipped = true - } - } - - var class validatePlanClassification - switch { - case skipped: - class = validatePlanSkipped - case hasValidatePlanMethod(methods): - class = validatePlanAlreadyImplemented - default: - // Round-11 #3 reverts round-10 #2's broad-suppress: ANY - // embedded field would suppress the missing diagnostic, - // but `sync.Mutex`, loggers, config mixins, etc. don't - // promote a `ValidatePlan` method, so real migration - // targets were silently missed. Without full type info we - // can't resolve promotion, so report missing - // unconditionally; maintainers whose providers actually - // satisfy ProviderValidator via promotion can suppress - // with the explicit `// wfctl:skip-iac-codemod` marker. - class = validatePlanMissing - } - - line := 0 - if ts != nil { - line = fset.Position(ts.Pos()).Line - } else if len(methods) > 0 { - line = fset.Position(methods[0].Pos()).Line - } - site := validatePlanSite{ - Path: path, - Line: line, - Receiver: recv, - Class: class, - } - if class == validatePlanMissing && opts != nil && opts.Fix { - // Round-10 #1: only inject the stub in the file that - // contains the receiver TYPE declaration. When the type is - // in a sibling file (`ts == nil` here because it wasn't - // found in the local file's typeDecls), skip injection; - // the sibling's own pass will inject the stub. Without - // this guard, both files write a `ValidatePlan` stub for - // the same receiver, producing duplicate method - // declarations in the package. - if ts == nil { - report.sites = append(report.sites, site) - continue - } - pointerRecv := providerReceiverConvention(methods) - // Per-receiver qualifier resolution. If THIS file has its - // own interfaces import, qualifier already reflects that - // (set above). Otherwise inspect this provider's existing - // Plan/Apply parameter types for the qualifier they use — - // round-7 #4: an unrelated sibling importing interfaces is - // not a reliable signal that THIS provider uses qualified - // types. - recvQualifier := qualifier - if recvQualifier == "" { - recvQualifier = qualifierFromProviderMethods(methods) - if recvQualifier != "" { - needsInterfacesImport = true - // Round-9 #4: capture for post-loop import-alias - // matching. If multiple receivers in the same file - // derive different aliases, the LAST one wins — - // rare in practice (a single file usually has one - // interfaces alias). - injectedQualifier = recvQualifier - } - } - pendingStubs = append(pendingStubs, validatePlanStubText(recv, recvQualifier, pointerRecv)) - site.Inserted = true - mutated = true - } - report.sites = append(report.sites, site) - } - - if mutated && opts != nil && opts.Fix { - baseSrc := src - // Round-4 finding #1: when the stub uses a qualified type but - // the file doesn't import interfaces, add the import via AST - // printing first so the qualified type resolves. - // - // Round-9 finding #4: if the qualifier we derived from a - // sibling method's signature is NOT "interfaces" (e.g. the - // sibling uses an alias like `iface "github.com/.../interfaces"`), - // the injected import must also use that alias so the stub's - // `iface.IaCPlan` resolves to the imported package. - if needsInterfacesImport { - injectedAlias := "" - if injectedQualifier != "" && injectedQualifier != "interfaces" { - injectedAlias = injectedQualifier - } - ensureImportAs(file, "github.com/GoCodeAlone/workflow/interfaces", injectedAlias) - var buf bytes.Buffer - if err := format.Node(&buf, fset, file); err != nil { - return fmt.Errorf("format %s: %w", path, err) - } - baseSrc = buf.Bytes() - } - // Append stubs as raw text. baseSrc is either the unmodified - // original (no interfaces import needed) or the AST-reprinted - // form with the interfaces import injected. - appended := append([]byte{}, baseSrc...) - if len(appended) == 0 || appended[len(appended)-1] != '\n' { - appended = append(appended, '\n') - } - for _, stub := range pendingStubs { - appended = append(appended, '\n') - appended = append(appended, stub...) - if !strings.HasSuffix(stub, "\n") { - appended = append(appended, '\n') - } - } - if err := writeFileAtomicBytes(path, appended); err != nil { - return fmt.Errorf("write %s: %w", path, err) - } - } - return nil -} - -// validatePlanStubText returns the source text for a no-op ValidatePlan -// stub on the named receiver type. `qualifier` is the package alias -// the source file uses for github.com/GoCodeAlone/workflow/interfaces -// (typically "interfaces", or "" if the file is itself in that package -// and uses unqualified names). `pointerReceiver` controls whether the -// stub uses `*T` or `T` as its receiver — set to match the existing -// receiver convention of the type's other methods. -// -// Review history: -// - rev0 (round 0): always emitted unqualified `*IaCPlan` / -// `[]PlanDiagnostic`, breaking compile in files importing -// interfaces. Fixed in round-1 (qualifier param added). -// - rev1 (round 2 finding #5): always used `(p *T)` even when the -// type's existing methods used value receivers. Method-set -// mismatch left the type failing the ProviderValidator type -// assertion. Fixed by threading pointerReceiver through the -// caller, which inspects the type's existing Plan/Apply -// receivers. -func validatePlanStubText(recv, qualifier string, pointerReceiver bool) string { - planType := "*IaCPlan" - diagsType := "[]PlanDiagnostic" - if qualifier != "" { - planType = "*" + qualifier + ".IaCPlan" - diagsType = "[]" + qualifier + ".PlanDiagnostic" - } - receiver := recv - if pointerReceiver { - receiver = "*" + recv - } - return fmt.Sprintf(`// ValidatePlan reports diagnostics for any plan-time concerns. The -// stub generated by iac-codemod returns no diagnostics; replace with -// real provider-specific checks (region constraints, quota limits, -// resource-type conflicts, etc.) before relying on it. -func (p %s) ValidatePlan(plan %s) %s { - return nil -} -`, receiver, planType, diagsType) -} - -// receiverIsPointer returns true if the receiver of fn is `*T` (i.e. -// a pointer receiver). Helps determine the convention to use when -// inserting a new ValidatePlan stub on the same type so the method-set -// matches the existing Plan/Apply (review round-2 #5). -func receiverIsPointer(fn *ast.FuncDecl) bool { - if fn == nil || fn.Recv == nil || len(fn.Recv.List) == 0 { - return false - } - _, ok := fn.Recv.List[0].Type.(*ast.StarExpr) - return ok -} - -// providerReceiverConvention reports whether the receiver type's -// Plan/Apply methods use a pointer receiver. The convention used by -// the existing Plan method takes precedence; if Plan is missing the -// Apply convention is used. Defaults to true (pointer receiver) when -// no Plan/Apply pair exists, matching the dominant Go style. -func providerReceiverConvention(methods []*ast.FuncDecl) bool { - for _, m := range methods { - if m.Name.Name == "Plan" { - return receiverIsPointer(m) - } - } - for _, m := range methods { - if m.Name.Name == "Apply" { - return receiverIsPointer(m) - } - } - return true -} - -// isLocalDuplicate returns true if `m` appears to be a re-parse of a -// FuncDecl already in `existing`. Round-8 #1: arity-only dedupe (rev2) -// still mistreated a correct ValidatePlan(plan *IaCPlan) -// []PlanDiagnostic as a duplicate of a wrong-signature -// ValidatePlan(name string) []PlanDiagnostic — same arity, different -// types. Now compares parameter and return TYPES via a structural -// fingerprint (typeFingerprint) so signatures with matching names but -// different types are correctly distinguished. -func isLocalDuplicate(m *ast.FuncDecl, existing []*ast.FuncDecl) bool { - mSig := signatureFingerprint(m.Type) - for _, lm := range existing { - if lm == m { - continue - } - if lm.Name.Name != m.Name.Name { - continue - } - if signatureFingerprint(lm.Type) == mSig { - return true - } - } - return false -} - -// signatureFingerprint returns a string fingerprint of a FuncType -// that's stable across distinct *ast.FuncDecl values (as produced by -// re-parsing the same file in planLikeProviderMethodsInDir). The -// fingerprint includes BOTH parameter and return type strings so -// same-name same-arity DIFFERENT-type methods (the wrong-signature -// shadow scenario) get distinct fingerprints (round-8 #1). -func signatureFingerprint(ft *ast.FuncType) string { - if ft == nil { - return "" - } - var b strings.Builder - b.WriteString("(") - if ft.Params != nil { - for i, p := range ft.Params.List { - if i > 0 { - b.WriteString(",") - } - b.WriteString(typeFingerprint(p.Type)) - } - } - b.WriteString(")") - if ft.Results != nil { - b.WriteString("(") - for i, r := range ft.Results.List { - if i > 0 { - b.WriteString(",") - } - b.WriteString(typeFingerprint(r.Type)) - } - b.WriteString(")") - } - return b.String() -} - -// typeFingerprint returns a structural string for an ast.Expr type. -// Conservative: covers the type shapes used by IaC provider methods -// (Ident, SelectorExpr, StarExpr, ArrayType, MapType, InterfaceType, -// FuncType, Ellipsis). Anything else returns "?", which still -// participates in fingerprint comparison correctly. -func typeFingerprint(expr ast.Expr) string { - switch e := expr.(type) { - case *ast.Ident: - return e.Name - case *ast.SelectorExpr: - return typeFingerprint(e.X) + "." + e.Sel.Name - case *ast.StarExpr: - return "*" + typeFingerprint(e.X) - case *ast.ArrayType: - return "[]" + typeFingerprint(e.Elt) - case *ast.MapType: - return "map[" + typeFingerprint(e.Key) + "]" + typeFingerprint(e.Value) - case *ast.InterfaceType: - return "interface{}" - case *ast.Ellipsis: - return "..." + typeFingerprint(e.Elt) - case *ast.FuncType: - return "func" + signatureFingerprint(e) - } - return "?" -} - -// qualifierFromProviderMethods inspects the parameter types of the -// supplied methods (the receiver's directory-wide method set per -// round-3 #1) and returns the qualifier used for the IaCPlan type if -// any method's signature references it qualified (e.g. *interfaces.IaCPlan). -// Returns "" if no method's signature uses a qualified IaCPlan. -// -// Round-7 #4: rev3 of add_validate_plan fell back to qualifier="interfaces" -// based on whether ANY sibling file in the directory imported -// interfaces. That signal is unreliable: if the provider itself uses -// LOCAL IaCPlan types (test fixtures, etc.) but an unrelated sibling -// imports interfaces for some other reason, the stub got a wrongly- -// qualified signature and broke compilation. Per-receiver inspection -// of the actual signatures the provider already uses is the -// trustworthy signal. -func qualifierFromProviderMethods(methods []*ast.FuncDecl) string { - for _, m := range methods { - switch m.Name.Name { - case "Plan", "Apply": - // continue - default: - continue - } - if m.Type == nil || m.Type.Params == nil { - continue - } - for _, p := range m.Type.Params.List { - // Look for *.IaCPlan or *IaCPlan. - star, ok := p.Type.(*ast.StarExpr) - if !ok { - // Slice form `[].ResourceSpec` etc. also - // indicates qualified usage; check. - if arr, ok := p.Type.(*ast.ArrayType); ok && arr.Len == nil { - if sel, ok := arr.Elt.(*ast.SelectorExpr); ok { - if id, ok := sel.X.(*ast.Ident); ok { - return id.Name - } - } - } - continue - } - if sel, ok := star.X.(*ast.SelectorExpr); ok && sel.Sel.Name == "IaCPlan" { - if id, ok := sel.X.(*ast.Ident); ok { - return id.Name - } - } - } - } - return "" -} - -// siblingUsesInterfacesImport returns true if any non-test .go file -// in dir (other than excludePath) imports -// github.com/GoCodeAlone/workflow/interfaces. Used to decide whether -// to inject an interfaces import into a file that doesn't have one -// when emitting a qualified ValidatePlan stub (review round-4 #1). -// -//nolint:unused -func siblingUsesInterfacesImport(dir, excludePath string) bool { - const wantPath = "github.com/GoCodeAlone/workflow/interfaces" - entries, err := os.ReadDir(dir) - if err != nil { - return false - } - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { - continue - } - fpath := filepath.Join(dir, name) - if fpath == excludePath { - continue - } - src, err := readFile(fpath) - if err != nil { - continue - } - fs := token.NewFileSet() - sib, err := parser.ParseFile(fs, fpath, src, parser.ImportsOnly) - if err != nil { - continue - } - for _, imp := range sib.Imports { - if imp.Path == nil { - continue - } - if strings.Trim(imp.Path.Value, `"`) == wantPath { - return true - } - } - } - return false -} - -// interfacesQualifier returns the package alias `file` uses for -// github.com/GoCodeAlone/workflow/interfaces. If the import is -// renamed (`alias "github.com/.../interfaces"`), the alias name is -// returned. If the file does not import interfaces at all, returns -// "" (the rare case of a file declaring providers entirely in -// terms of locally-defined types, e.g. unit-test fixtures). -func interfacesQualifier(file *ast.File) string { - const wantPath = "github.com/GoCodeAlone/workflow/interfaces" - for _, imp := range file.Imports { - if imp.Path == nil { - continue - } - v := strings.Trim(imp.Path.Value, `"`) - if v != wantPath { - continue - } - if imp.Name != nil { - if imp.Name.Name == "_" || imp.Name.Name == "." { - // Blank/dot imports — the latter would let the user - // reference IaCPlan unqualified. We can't safely - // disambiguate so we err on the side of qualifying - // (the file would not compile with a blank import - // of the types anyway). - continue - } - return imp.Name.Name - } - // Default-named import — the package's actual name is - // "interfaces" (verified by reading the package clause). - return "interfaces" - } - return "" -} - -// providerReceiversWithMethods returns three views of the file's -// receiver-type structure: -// - the set of receiver type names whose method set (in this file -// alone) looks like an IaCProvider (has Plan + Apply); -// - methodsByRecv: every method's *ast.FuncDecl indexed by receiver; -// - typeDecls: the *ast.TypeSpec for each struct receiver, used so -// the report can point at the type's declaration line and the -// skip-marker can be looked up on the type doc. -// -// Note: cross-file method sets are not supported in this single-file -// pass. A provider whose Plan and Apply live in different files will -// be missed; the codemod's spec scope is single-file (the four -// per-plugin Apply/Plan files in the workspace today are each -// self-contained). -func providerReceiversWithMethods(file *ast.File) ( - map[string]*ast.TypeSpec, // provs (key = recv name; value = its TypeSpec or nil) - map[string][]*ast.FuncDecl, // methodsByRecv - map[string]*ast.TypeSpec, // typeDecls -) { - methodsByRecv := make(map[string][]*ast.FuncDecl) - typeDecls := make(map[string]*ast.TypeSpec) - for _, decl := range file.Decls { - switch d := decl.(type) { - case *ast.FuncDecl: - if d.Recv == nil || len(d.Recv.List) == 0 { - continue - } - recv := receiverTypeName(d) - if recv == "" { - continue - } - methodsByRecv[recv] = append(methodsByRecv[recv], d) - case *ast.GenDecl: - if d.Tok != token.TYPE { - continue - } - for _, spec := range d.Specs { - ts, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } - if _, isStruct := ts.Type.(*ast.StructType); !isStruct { - continue - } - typeDecls[ts.Name.Name] = ts - } - } - } - provs := make(map[string]*ast.TypeSpec) - for recv, methods := range methodsByRecv { - if !looksLikeProvider(methods) { - continue - } - provs[recv] = typeDecls[recv] - } - return provs, methodsByRecv, typeDecls -} - -// hasValidatePlanMethod returns true if the method list contains a -// ValidatePlan method whose signature matches -// `ValidatePlan(*IaCPlan) []PlanDiagnostic` AND whose receiver kind -// matches the dominant receiver kind of the type's existing -// Plan/Apply methods. -// -// Review history: -// - round-1 #8: rev0 only checked the method name; a ValidatePlan -// with the wrong parameter or result type passed silently. Fixed -// by adding validatePlanSignatureMatches. -// - round-5 #3: rev1 ignored receiver kind; a value-receiver -// provider (Plan/Apply on `T`) with a pointer-receiver -// ValidatePlan on `*T` still failed the -// interfaces.ProviderValidator type assertion (method set on `T` -// does not include `*T` methods). hasValidatePlanMethod now -// accepts ValidatePlan only if its receiver kind matches the -// existing convention; otherwise the type is reported as missing. -func hasValidatePlanMethod(methods []*ast.FuncDecl) bool { - // Round-5 #3 added receiver-kind enforcement; round-9 #3 corrects - // the asymmetry: per Go spec, *T's method set includes both - // pointer- and value-receiver methods of T. So: - // - // - value-receiver provider (Plan/Apply on T): ValidatePlan - // MUST also be value-receiver, because T's method set excludes - // pointer methods. - // - pointer-receiver provider (Plan/Apply on *T): ValidatePlan - // can be EITHER value- or pointer-receiver; *T's method set - // includes both. - // - // Only the value-receiver provider case requires strict matching; - // pointer-receiver providers accept either kind. - providerWantsPointer := providerReceiverConvention(methods) - for _, m := range methods { - if m.Name.Name != "ValidatePlan" { - continue - } - if !validatePlanSignatureMatches(m.Type) { - continue - } - if !providerWantsPointer && receiverIsPointer(m) { - // Value-receiver provider can't satisfy ProviderValidator - // via a pointer-receiver ValidatePlan (T's method set - // excludes *T methods). - continue - } - return true - } - return false -} - -// validatePlanSignatureMatches returns true if ft has the canonical -// `func(*IaCPlan) []PlanDiagnostic` signature shape (qualified or -// unqualified). See hasValidatePlanMethod for the rationale. -func validatePlanSignatureMatches(ft *ast.FuncType) bool { - if ft == nil { - return false - } - if ft.Params == nil || len(ft.Params.List) != 1 { - return false - } - if ft.Results == nil || len(ft.Results.List) != 1 { - return false - } - // Param must be a pointer to a type whose name ends in "IaCPlan". - star, ok := ft.Params.List[0].Type.(*ast.StarExpr) - if !ok { - return false - } - if !typeNameTailMatches(star.X, "IaCPlan") { - return false - } - // Result must be a slice whose element name ends in "PlanDiagnostic". - arr, ok := ft.Results.List[0].Type.(*ast.ArrayType) - if !ok { - return false - } - if arr.Len != nil { - // Fixed-size array (e.g. [3]PlanDiagnostic) is not a slice. - return false - } - return typeNameTailMatches(arr.Elt, "PlanDiagnostic") -} - -// typeNameTailMatches returns true if expr is `.` or just -// `` (i.e. matches an unqualified or any-qualifier-qualified -// type name). -func typeNameTailMatches(expr ast.Expr, want string) bool { - switch e := expr.(type) { - case *ast.Ident: - return e.Name == want - case *ast.SelectorExpr: - return e.Sel.Name == want - } - return false -} - -// (typeHasEmbeddedFields was added in round-10 #2/#3 to suppress the -// missing-ValidatePlan diagnostic on providers with ANY embedded -// field, on the assumption embedding might promote ValidatePlan. -// Round-11 #3/#4 reverted that broad suppression because most -// embeddings — sync.Mutex, loggers, config mixins — don't promote -// ValidatePlan, so real targets were silently missed. The function -// is removed; maintainers whose providers ACTUALLY satisfy -// ProviderValidator via promotion suppress with the explicit -// `// wfctl:skip-iac-codemod` marker.) - -// genDeclFor returns the *ast.GenDecl wrapper for the given TypeSpec, -// which is where a doc comment placed before the `type` keyword -// (rather than between `type` and the type name) lives. AST attaches -// such comments to the GenDecl rather than the inner TypeSpec. -func genDeclFor(file *ast.File, ts *ast.TypeSpec) *ast.GenDecl { - if ts == nil { - return nil - } - for _, decl := range file.Decls { - gd, ok := decl.(*ast.GenDecl) - if !ok || gd.Tok != token.TYPE { - continue - } - for _, spec := range gd.Specs { - if spec == ts { - return gd - } - } - } - return nil -} - -// writeFileAtomicBytes is the bytes-input twin of writeFileAtomic. -// Round-11 #5: rev1 left the temp file at os.CreateTemp's default -// 0600 mode, so the rename clobbered the source's original -// permissions. Now delegates to writeFileAtomicBytesPreserveMode -// (defined in refactor_plan.go) which captures the original mode -// and chmods the temp file before rename. -func writeFileAtomicBytes(path string, data []byte) error { - return writeFileAtomicBytesPreserveMode(path, data) -} - -// ============================================================ -// Report rendering -// ============================================================ - -func (r *validatePlanReport) print(w io.Writer, opts *Options) { - sort.Slice(r.sites, func(i, j int) bool { - if r.sites[i].Path != r.sites[j].Path { - return r.sites[i].Path < r.sites[j].Path - } - return r.sites[i].Line < r.sites[j].Line - }) - fmt.Fprintln(w, "# iac-codemod add-validate-plan report") - fmt.Fprintln(w) - mode := "dry-run" - if opts != nil && opts.Fix { - mode = "fix" - } - fmt.Fprintf(w, "Mode: %s\n", mode) - fmt.Fprintf(w, "Sites: %d\n", len(r.sites)) - fmt.Fprintf(w, "Errors: %d\n", len(r.errors)) - fmt.Fprintln(w) - - groups := map[validatePlanClassification][]validatePlanSite{} - for _, s := range r.sites { - groups[s.Class] = append(groups[s.Class], s) - } - order := []validatePlanClassification{ - validatePlanMissing, - validatePlanAlreadyImplemented, - validatePlanSkipped, - } - headers := map[validatePlanClassification]string{ - validatePlanMissing: "Missing ValidatePlan (stub injection candidate)", - validatePlanAlreadyImplemented: "Already-implemented (no-op)", - validatePlanSkipped: "Skipped (// wfctl:skip-iac-codemod)", - } - for _, c := range order { - sites := groups[c] - if len(sites) == 0 { - continue - } - fmt.Fprintf(w, "## %s\n\n", headers[c]) - for _, s := range sites { - suffix := "" - if c == validatePlanMissing && s.Inserted { - suffix = " (stub inserted)" - } - fmt.Fprintf(w, "- %s:%d %s %s%s\n", s.Path, s.Line, s.Receiver, s.Class, suffix) - } - fmt.Fprintln(w) - } - - if len(r.errors) > 0 { - fmt.Fprintln(w, "## Errors") - fmt.Fprintln(w) - for _, e := range r.errors { - fmt.Fprintf(w, "- %s\n", e) - } - fmt.Fprintln(w) - } -} diff --git a/cmd/iac-codemod/add_validate_plan_test.go b/cmd/iac-codemod/add_validate_plan_test.go deleted file mode 100644 index 309be7ac..00000000 --- a/cmd/iac-codemod/add_validate_plan_test.go +++ /dev/null @@ -1,391 +0,0 @@ -// Copyright (c) 2026 Jon Langevin -// SPDX-License-Identifier: Apache-2.0 - -// Tests in this file MUST NOT call t.Parallel(). Same global-state -// constraint as main_test.go / lint_test.go / refactor_*_test.go. - -package main - -import ( - "bytes" - "os" - "strings" - "testing" - "time" -) - -// ============================================================ -// Source fixtures -// ============================================================ - -// avpProviderMissingValidatePlanSrc is a provider with both Plan and Apply -// but no ValidatePlan method. The codemod must insert a no-op stub. -const avpProviderMissingValidatePlanSrc = `package p - -import "context" - -type ResourceSpec struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} -type PlanDiagnostic struct{} - -type FooProvider struct{} - -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return &IaCPlan{}, nil -} - -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return &ApplyResult{}, nil -} -` - -// avpProviderWithValidatePlanSrc is the no-op idempotent case: ValidatePlan -// already exists; the codemod must NOT add another stub. -const avpProviderWithValidatePlanSrc = `package p - -import "context" - -type ResourceSpec struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} -type PlanDiagnostic struct{} - -type FooProvider struct{} - -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return &IaCPlan{}, nil -} - -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return &ApplyResult{}, nil -} - -func (p *FooProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -// avpProviderSkippedValidatePlanSrc carries the marker on the type decl — -// the codemod must NOT inject ValidatePlan and must list the site as -// skipped. (Plan rev2 line 2400: marker honored at type-doc level.) -const avpProviderSkippedValidatePlanSrc = `package p - -import "context" - -type ResourceSpec struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} -type PlanDiagnostic struct{} - -// FooProvider is intentionally without ValidatePlan; the constraint -// surface lives in a sibling type. -// -// wfctl:skip-iac-codemod sibling-validator pattern, see ADR-042 -type FooProvider struct{} - -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return &IaCPlan{}, nil -} - -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return &ApplyResult{}, nil -} -` - -// avpNonProviderSrc — has methods named Plan/Apply but on a non-provider -// type (insufficient signature shape). Must NOT be touched. -const avpNonProviderSrc = `package p - -import "context" - -type Settings struct{} - -func (s Settings) Plan(x int) error { return nil } -func (s Settings) Apply(y int) error { return nil } -` - -// ============================================================ -// Detection (dry-run) -// ============================================================ - -func TestAddValidatePlan_DryRun_DetectsMissing(t *testing.T) { - path := writeFixture(t, "provider.go", avpProviderMissingValidatePlanSrc) - var stdout, stderr bytes.Buffer - code := runAddValidatePlan([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if !strings.Contains(out, "FooProvider") { - t.Errorf("report should name FooProvider; got:\n%s", out) - } - if !strings.Contains(out, "missing-validate-plan") { - t.Errorf("report should classify as missing-validate-plan; got:\n%s", out) - } - got, _ := os.ReadFile(path) - if string(got) != avpProviderMissingValidatePlanSrc { - t.Errorf("dry-run modified the file; expected no mutation") - } -} - -func TestAddValidatePlan_DryRun_AlreadyImplemented(t *testing.T) { - path := writeFixture(t, "provider.go", avpProviderWithValidatePlanSrc) - var stdout, stderr bytes.Buffer - code := runAddValidatePlan([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if !strings.Contains(out, "already-implemented") { - t.Errorf("report should classify provider as already-implemented; got:\n%s", out) - } -} - -func TestAddValidatePlan_DryRun_HonorsSkipMarker(t *testing.T) { - path := writeFixture(t, "provider.go", avpProviderSkippedValidatePlanSrc) - var stdout, stderr bytes.Buffer - code := runAddValidatePlan([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if !strings.Contains(out, "Skipped") { - t.Errorf("report should have a Skipped section; got:\n%s", out) - } - if !strings.Contains(out, "FooProvider") { - t.Errorf("Skipped section should list FooProvider; got:\n%s", out) - } -} - -func TestAddValidatePlan_DryRun_IgnoresNonProviders(t *testing.T) { - path := writeFixture(t, "settings.go", avpNonProviderSrc) - var stdout, stderr bytes.Buffer - code := runAddValidatePlan([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if strings.Contains(out, "Settings") { - t.Errorf("non-provider type Settings should NOT be reported; got:\n%s", out) - } -} - -// ============================================================ -// Mutation (-fix) -// ============================================================ - -func TestAddValidatePlan_Fix_InsertsStub(t *testing.T) { - path := writeFixture(t, "provider.go", avpProviderMissingValidatePlanSrc) - var stdout, stderr bytes.Buffer - code := runAddValidatePlan([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - gotStr := string(got) - if !strings.Contains(gotStr, "ValidatePlan(plan *IaCPlan) []PlanDiagnostic") { - t.Errorf("inserted stub must be `ValidatePlan(plan *IaCPlan) []PlanDiagnostic`; got:\n%s", gotStr) - } - // Stub returns nil (no-op). - if !strings.Contains(gotStr, "return nil") { - t.Errorf("inserted stub must return nil; got:\n%s", gotStr) - } -} - -func TestAddValidatePlan_Fix_IdempotentOnImplemented(t *testing.T) { - path := writeFixture(t, "provider.go", avpProviderWithValidatePlanSrc) - var stdout, stderr bytes.Buffer - if code := runAddValidatePlan([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr); code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != avpProviderWithValidatePlanSrc { - t.Errorf("provider with ValidatePlan must be byte-identical after fix (idempotent); got:\n%s", string(got)) - } -} - -func TestAddValidatePlan_Fix_HonorsSkipMarker(t *testing.T) { - path := writeFixture(t, "provider.go", avpProviderSkippedValidatePlanSrc) - var stdout, stderr bytes.Buffer - code := runAddValidatePlan([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != avpProviderSkippedValidatePlanSrc { - t.Errorf("skip-marker'd provider must NOT receive ValidatePlan stub; file changed:\n%s", string(got)) - } -} - -func TestAddValidatePlan_Fix_DoesNotTouchNonProvider(t *testing.T) { - path := writeFixture(t, "settings.go", avpNonProviderSrc) - var stdout, stderr bytes.Buffer - if code := runAddValidatePlan([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr); code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != avpNonProviderSrc { - t.Errorf("non-provider file must NOT be modified") - } -} - -// ============================================================ -// Review round-1 regression tests -// ============================================================ - -// avpProviderInterfacesQualifierSrc — review round-1 finding #7. A -// provider whose package imports interfaces and references the -// canonical types as `*interfaces.IaCPlan` etc. must receive a stub -// whose signature uses the same qualifier. rev0 always emitted -// unqualified types and broke compilation. -const avpProviderInterfacesQualifierSrc = `package p - -import ( - "context" - - "github.com/GoCodeAlone/workflow/interfaces" -) - -type FooProvider struct{} - -func (p *FooProvider) Plan(ctx context.Context, desired []interfaces.ResourceSpec, current []interfaces.ResourceState) (*interfaces.IaCPlan, error) { - return &interfaces.IaCPlan{}, nil -} - -func (p *FooProvider) Apply(ctx context.Context, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { - return &interfaces.ApplyResult{}, nil -} -` - -func TestAddValidatePlan_Fix_QualifiedSignature(t *testing.T) { - path := writeFixture(t, "provider.go", avpProviderInterfacesQualifierSrc) - var stdout, stderr bytes.Buffer - if code := runAddValidatePlan([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr); code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - gotStr := string(got) - if !strings.Contains(gotStr, "ValidatePlan(plan *interfaces.IaCPlan) []interfaces.PlanDiagnostic") { - t.Errorf("stub must use the same qualifier as the file's existing imports (interfaces.IaCPlan); got:\n%s", gotStr) - } -} - -// avpProviderWrongSignatureSrc — review round-1 finding #8. A provider -// with a `ValidatePlan` method whose signature is wrong (takes a string -// instead of *IaCPlan) must NOT be classified as already-implemented; -// the codemod would then leave the type failing to satisfy -// interfaces.ProviderValidator. -const avpProviderWrongSignatureSrc = `package p - -import "context" - -type ResourceSpec struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} -type PlanDiagnostic struct{} - -type FooProvider struct{} - -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return &IaCPlan{}, nil -} - -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return &ApplyResult{}, nil -} - -// Wrong signature: parameter is a string, not *IaCPlan. -func (p *FooProvider) ValidatePlan(name string) []PlanDiagnostic { return nil } -` - -func TestAddValidatePlan_DryRun_FlagsWrongSignature(t *testing.T) { - path := writeFixture(t, "provider.go", avpProviderWrongSignatureSrc) - var stdout, stderr bytes.Buffer - code := runAddValidatePlan([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if strings.Contains(out, "already-implemented") { - t.Errorf("ValidatePlan with wrong signature must NOT be classified as already-implemented; got:\n%s", out) - } - if !strings.Contains(out, "missing-validate-plan") { - t.Errorf("ValidatePlan with wrong signature should be classified as missing (signature mismatch); got:\n%s", out) - } -} - -// ============================================================ -// Review round-2 regression tests -// ============================================================ - -// avpProviderValueReceiverSrc — review round-2 finding #5. A provider -// whose existing Plan/Apply use VALUE receivers (`(p FooProvider)`) -// must get a ValidatePlan stub with a value receiver too. rev1 always -// emitted `(p *T)`, mismatching method-sets and breaking the -// ProviderValidator type assertion. -const avpProviderValueReceiverSrc = `package p - -import "context" - -type ResourceSpec struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} -type PlanDiagnostic struct{} - -type ValueProvider struct{} - -func (p ValueProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return &IaCPlan{}, nil -} - -func (p ValueProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return &ApplyResult{}, nil -} -` - -func TestAddValidatePlan_Fix_ValueReceiverConvention(t *testing.T) { - path := writeFixture(t, "provider.go", avpProviderValueReceiverSrc) - var stdout, stderr bytes.Buffer - if code := runAddValidatePlan([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr); code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - gotStr := string(got) - // Stub MUST use value receiver to match Plan/Apply. - if !strings.Contains(gotStr, "func (p ValueProvider) ValidatePlan(") { - t.Errorf("stub must use value receiver to match Plan/Apply convention; got:\n%s", gotStr) - } - // And NOT pointer receiver. - if strings.Contains(gotStr, "func (p *ValueProvider) ValidatePlan(") { - t.Errorf("stub must NOT use pointer receiver when Plan/Apply use value; got:\n%s", gotStr) - } -} - -// ============================================================ -// Mutation-gate negative test -// ============================================================ - -func TestAddValidatePlan_DryRunFalseWithoutFix_DoesNotMutate(t *testing.T) { - path := writeFixture(t, "provider.go", avpProviderMissingValidatePlanSrc) - stat0, _ := os.Stat(path) - mtime0 := stat0.ModTime() - time.Sleep(10 * time.Millisecond) - - var stdout, stderr bytes.Buffer - code := run([]string{"add-validate-plan", "-dry-run=false", path}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != avpProviderMissingValidatePlanSrc { - t.Errorf("file must NOT be mutated when -dry-run=false alone; content changed") - } - stat1, _ := os.Stat(path) - if !stat1.ModTime().Equal(mtime0) { - t.Errorf("file mtime should be unchanged; before=%v after=%v", mtime0, stat1.ModTime()) - } -} diff --git a/cmd/iac-codemod/lint.go b/cmd/iac-codemod/lint.go deleted file mode 100644 index dc66d54b..00000000 --- a/cmd/iac-codemod/lint.go +++ /dev/null @@ -1,1274 +0,0 @@ -// Copyright (c) 2026 Jon Langevin -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "go/types" - "io" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" - "unicode" - "unicode/utf8" - - "golang.org/x/tools/go/analysis" -) - -// osStat / osReadFile are direct stdlib bindings that the var indirections -// `stat` and `readFile` point at by default. The indirection is in place -// so future tests could substitute in-memory filesystems without -// touching disk. -func osStat(p string) (os.FileInfo, error) { return os.Stat(p) } -func osReadFile(p string) ([]byte, error) { return os.ReadFile(p) } - -func init() { - modes["lint"] = runLint -} - -// AssertPlanDelegatesToHelper flags any provider type's Plan method -// whose body does NOT call platform.ComputePlan (the canonical Plan -// helper at platform/differ.go:72). Legacy `wfctlhelpers.Plan(...)` calls -// are also accepted as delegated, for forward-compatibility with rev0 -// of this analyzer when the rewrite target was still misnamed. See -// refactor_plan.go's planHelperImportPath docstring for the rev1 -// review-correction history (Copilot review finding #1). -// -// The check is syntactic — it matches the SelectorExpr regardless of -// whether the call resolves at type-check time, so it works on plugins -// that have not yet vendored the helper. -var AssertPlanDelegatesToHelper = &analysis.Analyzer{ - Name: "AssertPlanDelegatesToHelper", - Doc: "Provider Plan() must delegate to platform.ComputePlan.", - Run: runAssertPlanDelegatesToHelper, -} - -// AssertApplyDelegatesToHelper flags any provider type's Apply method whose -// body does NOT call wfctlhelpers.ApplyPlan. The canonical migration target -// is `return wfctlhelpers.ApplyPlan(ctx, p, plan)`. Same syntactic-match -// approach as AssertPlanDelegatesToHelper. -var AssertApplyDelegatesToHelper = &analysis.Analyzer{ - Name: "AssertApplyDelegatesToHelper", - Doc: "Provider Apply() must delegate to wfctlhelpers.ApplyPlan.", - Run: runAssertApplyDelegatesToHelper, -} - -// AssertDiffSetsNeedsReplaceForForceNew flags any driver Diff method that -// references a ForceNew field (typically FieldChange.ForceNew) but never -// assigns NeedsReplace = true (typically DiffResult.NeedsReplace). This is -// the W-3 contract: when a force-new field changes, the diff must signal -// replacement so platform.ComputePlan classifies the action correctly. -var AssertDiffSetsNeedsReplaceForForceNew = &analysis.Analyzer{ - Name: "AssertDiffSetsNeedsReplaceForForceNew", - Doc: "Driver Diff() that observes ForceNew fields must set DiffResult.NeedsReplace=true.", - Run: runAssertDiffSetsNeedsReplaceForForceNew, -} - -// AssertProviderImplementsValidatePlan flags any provider-shaped type -// (a type with Plan + Apply methods matching the IaCProvider signature) -// that does NOT also have a ValidatePlan method satisfying the -// ProviderValidator interface (`ValidatePlan(plan *IaCPlan) []PlanDiagnostic`). -// The check uses pass.TypesInfo to verify method-set membership rather -// than raw AST string-match per team-lead's W-8 brief. -var AssertProviderImplementsValidatePlan = &analysis.Analyzer{ - Name: "AssertProviderImplementsValidatePlan", - Doc: "Provider type must implement ProviderValidator (ValidatePlan method).", - Run: runAssertProviderImplementsValidatePlan, -} - -// lintAnalyzers is the canonical ordered list of T8.2 analyzers. Order -// is preserved in the report so output is deterministic across runs. -var lintAnalyzers = []*analysis.Analyzer{ - AssertPlanDelegatesToHelper, - AssertApplyDelegatesToHelper, - AssertDiffSetsNeedsReplaceForForceNew, - AssertProviderImplementsValidatePlan, -} - -// lintFinding captures one analyzer diagnostic for the report. -type lintFinding struct { - Path string - Line int - Analyzer string - Message string -} - -// skippedSite captures one declaration suppressed by SkipMarker. -type skippedSite struct { - Path string - Line int - Analyzer string - Decl string // function or type name -} - -// lintReport aggregates findings, skipped sites, and per-file errors -// across an entire lint run. -type lintReport struct { - findings []lintFinding - skipped []skippedSite - errors []string -} - -// runLint is the entry point for the lint subcommand. It is read-only -// by definition: the -fix flag is meaningless and a warning is surfaced -// so the user knows the flag did nothing. Mutation regardless of flag -// combination is pinned by TestRunLint_DoesNotMutateFilesEvenWithFixFlag. -func runLint(args []string, opts *Options, stdout, stderr io.Writer) int { - if len(args) == 0 { - fmt.Fprintln(stderr, "iac-codemod lint: at least one path is required") - usage(stderr) - return 2 - } - if opts != nil && opts.Fix { - // Lint never mutates. Surface a warning so the user knows -fix - // did not change behavior; preserves predictable advisory-only - // semantics from plan §W-8 line 397. - fmt.Fprintln(stderr, "iac-codemod lint: warning: -fix has no effect (lint is read-only)") - } - - report := &lintReport{} - for _, path := range args { - if err := lintPath(path, report); err != nil { - fmt.Fprintf(stderr, "iac-codemod lint: %s: %v\n", path, err) - return 1 - } - } - report.print(stdout) - // Exit code semantics: - // 0 = clean (no findings, no errors) - // 1 = advisory findings present (no per-file errors) - // 2 = per-file parse/type-check errors (findings count - // irrelevant; the analyzer never got a chance to run on - // at least one file) - // - // Round-1 #10 conflated findings and errors at exit 1, which let - // `make migrate-providers || true` swallow real failures. Round-5 - // #7 splits the codes so callers can `|| [ $? -eq 1 ]` to accept - // findings as advisory while still failing on unparseable input. - if len(report.errors) > 0 { - return 2 - } - if len(report.findings) > 0 { - return 1 - } - return 0 -} - -// lintPath walks path for *.go files (excluding _test.go, vendor, -// testdata, hidden dirs) and invokes lintFile on each. Per-file errors -// are recorded in the report rather than aborting the whole run so a -// single broken file in a multi-package plugin does not lose findings -// from the rest. -// -// Round-10 #5: rev2 of this walker called lintFile per file, and -// lintFile re-parsed every sibling per-call → O(n²) on packages with -// many files. Now lintFile takes an optional pre-parsed sibling -// cache (lintDirCache) so per-directory parses are reused across the -// directory's files. -func lintPath(path string, report *lintReport) error { - info, err := stat(path) - if err != nil { - return err - } - if !info.IsDir() { - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return fmt.Errorf("not a Go source file (or is a _test.go): %s", path) - } - if err := lintFile(path, nil, report); err != nil { - report.errors = append(report.errors, fmt.Sprintf("%s: %v", path, err)) - } - return nil - } - // Group files by directory so we can build a per-directory sibling - // parse cache once and reuse it across the directory's files. - dirFiles := make(map[string][]string) - if err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - base := d.Name() - if shouldSkipDir(base) { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { - return nil - } - dir := filepath.Dir(p) - dirFiles[dir] = append(dirFiles[dir], p) - return nil - }); err != nil { - return err - } - // Process each directory with a fresh sibling cache. Errors per - // file are recorded in the report; we never abort the walk. - for dir, paths := range dirFiles { - cache := newLintDirCache(dir) - for _, p := range paths { - if err := lintFile(p, cache, report); err != nil { - report.errors = append(report.errors, fmt.Sprintf("%s: %v", p, err)) - } - } - } - return nil -} - -// lintDirCache caches parsed sibling files for a single directory so -// lintFile doesn't re-parse them per-target. Round-10 #5: closes the -// O(n²) perf gap. -type lintDirCache struct { - dir string - files map[string]*ast.File // path → parsed file (re-used across siblings) - fset *token.FileSet -} - -// newLintDirCache constructs a cache and pre-parses every non-test -// .go file in dir. Errors during pre-parse are silently dropped (the -// per-file pass will surface them via its own parse). -func newLintDirCache(dir string) *lintDirCache { - c := &lintDirCache{ - dir: dir, - files: make(map[string]*ast.File), - fset: token.NewFileSet(), - } - entries, err := os.ReadDir(dir) - if err != nil { - return c - } - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { - continue - } - fpath := filepath.Join(dir, name) - src, err := readFile(fpath) - if err != nil { - continue - } - f, err := parser.ParseFile(c.fset, fpath, src, parser.ParseComments) - if err != nil { - continue - } - c.files[fpath] = f - } - return c -} - -// lintFile parses path, loads its sibling .go files (same directory, -// non-test) so cross-file method sets are visible to the analyzers, -// type-checks tolerantly, and runs every analyzer in lintAnalyzers. -// -// Review round-2 finding #9: rev0/rev1 of this function passed only -// the target file in pass.Files, so providerLikeReceivers / -// driverLikeReceivers / AssertProviderImplementsValidatePlan saw -// only methods declared in that file. Providers with Plan/Apply (or -// drivers with Diff + companion methods) split across sibling files -// were silently skipped — same blind spot the refactor-* modes had -// in round 1. Now lintFile loads every non-test .go file in the same -// directory and feeds the full slice to each analyzer. -// -// Diagnostics for files OTHER than `path` are silently dropped: each -// invocation of lintFile only owns the report for `path`, and the -// outer walker visits each file in turn. This avoids duplicate -// findings without requiring a higher-level dedup. Sibling files -// serve only as method-set context. -func lintFile(path string, cache *lintDirCache, report *lintReport) error { - // Round-10 #5: prefer the per-directory cache (built once per dir - // in lintPath) so sibling parses are reused across the directory's - // files. Falls back to per-call parsing when no cache is supplied - // (single-file invocation). - var primary *ast.File - var fset *token.FileSet - files := []*ast.File{} - if cache != nil && cache.files[path] != nil { - primary = cache.files[path] - fset = cache.fset - } else { - src, err := readFile(path) - if err != nil { - return err - } - fset = token.NewFileSet() - primary, err = parser.ParseFile(fset, path, src, parser.ParseComments) - if err != nil { - return err - } - } - files = append(files, primary) - // Sibling files from the cache (or per-call fallback walk). - if cache != nil { - for sibPath, sib := range cache.files { - if sibPath == path || sib == nil { - continue - } - if sib.Name.Name != primary.Name.Name { - continue - } - files = append(files, sib) - } - } else if entries, err := os.ReadDir(filepath.Dir(path)); err == nil { - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { - continue - } - sibPath := filepath.Join(filepath.Dir(path), name) - if sibPath == path { - continue - } - sibSrc, err := readFile(sibPath) - if err != nil { - continue - } - sib, err := parser.ParseFile(fset, sibPath, sibSrc, parser.ParseComments) - if err != nil { - continue - } - if sib.Name.Name != primary.Name.Name { - continue - } - files = append(files, sib) - } - } - - conf := &types.Config{ - Importer: stubImporterRuntime{}, - Error: func(error) {}, // tolerate type errors; lint is best-effort - } - info := &types.Info{ - Types: make(map[ast.Expr]types.TypeAndValue), - Defs: make(map[*ast.Ident]types.Object), - Uses: make(map[*ast.Ident]types.Object), - Implicits: make(map[ast.Node]types.Object), - Selections: make(map[*ast.SelectorExpr]*types.Selection), - } - pkg, _ := conf.Check(primary.Name.Name, fset, files, info) - - for _, analyzer := range lintAnalyzers { - pass := &analysis.Pass{ - Analyzer: analyzer, - Fset: fset, - Files: files, - Pkg: pkg, - TypesInfo: info, - Report: func(d analysis.Diagnostic) { - // Drop diagnostics targeting other files: the outer - // walker will visit them in their own turn. - diagPath := fset.Position(d.Pos).Filename - if diagPath != "" && diagPath != path { - return - } - report.findings = append(report.findings, lintFinding{ - Path: path, - Line: fset.Position(d.Pos).Line, - Analyzer: analyzer.Name, - Message: d.Message, - }) - }, - } - if _, err := analyzer.Run(pass); err != nil { - return fmt.Errorf("%s: %w", analyzer.Name, err) - } - } - return nil -} - -// stubImporterRuntime is the importer used by the runtime lintFile path. -// It mirrors stubImporter in lint_test.go so test and runtime behavior -// stay aligned. -type stubImporterRuntime struct{} - -func (stubImporterRuntime) Import(path string) (*types.Package, error) { - return types.NewPackage(path, filepath.Base(path)), nil -} - -// stat / readFile are split out so tests could override them in future -// if needed. Today they are thin wrappers over os.Stat / os.ReadFile. -var ( - stat = osStat - readFile = osReadFile -) - -// ============================================================ -// Skip-marker helpers -// ============================================================ - -// hasSkipMarkerOn reports whether the given doc CommentGroup contains -// the canonical SkipMarker from main.go. Used by every analyzer that -// flags a function or type declaration. -// -// Accepted shapes: -// -// // wfctl:skip-iac-codemod -// // wfctl:skip-iac-codemod legacy upsert recovery, see ADR-042 -// // wfctl:skip-iac-codemod\tlegacy upsert recovery, see ADR-042 -// -// The marker followed by ANY whitespace separator + arbitrary -// justification text is honored (review round-2 follow-up A). Go -// maintainers may use spaces or tabs to align justifications; silently -// ignoring tab-delimited reasons would replicate the silent-no-op -// surface plan rev2 line 2400 unifies the marker to prevent. -// -// Rejected shapes (a non-whitespace suffix means a different marker): -// -// // wfctl:skip-iac-codemod-extension -// // wfctl:skip-iac-codemodSOMETHING -// // wfctl:skip-codemod (legacy, design rev1) -// -// The whitespace-separator discipline keeps the match tight enough -// that no substring shadow can bypass marker discipline. -func hasSkipMarkerOn(doc *ast.CommentGroup) bool { - if doc == nil { - return false - } - for _, c := range doc.List { - // Comment text includes the leading `//` per ast.Comment convention. - text := strings.TrimSpace(c.Text) - if text == SkipMarker { - return true - } - if strings.HasPrefix(text, SkipMarker) && len(text) > len(SkipMarker) { - next, _ := utf8.DecodeRuneInString(text[len(SkipMarker):]) - if unicode.IsSpace(next) { - return true - } - } - } - return false -} - -// Skipped sites are surfaced through the same pass.Report channel as -// real findings, distinguished by a message prefix. The driver -// (lintReport.unpackSkippedFromFindings) splits them out before -// rendering so skip records do NOT contribute to the finding count or -// the non-zero exit code. The indirection keeps each analyzer's API -// surface to a single Reportf-style channel rather than threading a -// second sink through every Run signature, and lets unit tests use a -// vanilla analysis.Pass without any custom rigging. -// -// IMPORTANT: lintFile invocation is currently sequential per path. If a -// future maintainer parallelises it, the skip-prefix encoding stays -// safe (each pass owns its own diagnostic slice via its Report closure) -// — but introducing concurrent map access via the package-level -// `modes` var or shared *lintReport pointer would not. See main_test.go -// header for the t.Parallel prohibition that applies here too. - -const skipDiagnosticPrefix = "[skipped] " - -// reportSkip emits a synthetic diagnostic that the driver decodes as a -// skipped-site record rather than a finding. This keeps the analyzer -// API surface minimal (one channel, not two). -func reportSkip(pass *analysis.Pass, pos token.Pos, declName string) { - pass.Report(analysis.Diagnostic{ - Pos: pos, - Message: skipDiagnosticPrefix + declName, - }) -} - -// ============================================================ -// Analyzer #1: AssertPlanDelegatesToHelper -// ============================================================ - -func runAssertPlanDelegatesToHelper(pass *analysis.Pass) (any, error) { - provs := providerLikeReceivers(pass) - typeDocsByFile := receiverTypeDocsForPass(pass) - for _, file := range pass.Files { - typeDocs := typeDocsByFile[file] - for _, decl := range file.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok { - continue - } - if !isProviderMethod(fn, "Plan", 3, 2) { - continue - } - recv := receiverTypeName(fn) - if !provs[recv] { - // Method named Plan on a non-provider type (e.g., a - // deploy target). Skip to keep precision high. - continue - } - // Honor SkipMarker on fn.Doc OR receiver-type docs (review - // round-2 finding #6). - if hasSkipMarkerOn(fn.Doc) || typeDocs[recv].carriesMarker() { - routeSkip(pass, fn) - continue - } - // Round-10 #7: rev3 accepted ANY platform.ComputePlan or - // wfctlhelpers.Plan call anywhere in the body, so a Plan - // method that called the helper as an intermediate step - // (then added bespoke logic, returned a wrapped value, - // etc.) was reported clean despite NOT actually delegating. - // Now we require the canonical SHAPE: either the - // 2-statement delegation form (matching - // isAlreadyDelegatedPlanBody) OR a single-statement legacy - // `return .Plan(...)`. Anything else flags the - // diagnostic so the maintainer reviews the bespoke wrapper. - if !planBodyDelegatesCanonically(fn.Body, file) { - pass.Reportf(fn.Pos(), "%s.%s does not delegate to platform.ComputePlan; non-canonical Plan() body", receiverTypeName(fn), fn.Name.Name) - } - } - } - return nil, nil -} - -// planBodyDelegatesCanonically returns true if body matches the -// canonical Plan-delegation shape (round-10 #7). Accepts EITHER: -// -// - 2-statement rev2 form: `plan, err := .ComputePlan(...); -// return &plan, err` (matches isAlreadyDelegatedPlanBody) -// - single-statement legacy form: `return .Plan(...)` -// OR `return .ComputePlan(...)` (planned-but-not-shipped -// and broken-rev1 fixtures, accepted as advisory-clean here even -// though the rewriter would repair them) -// -// Anything else (including bodies that CALL the helper anywhere but -// don't return its value verbatim) is rejected as non-canonical. -func planBodyDelegatesCanonically(body *ast.BlockStmt, file *ast.File) bool { - if body == nil { - return false - } - // Shape 1: 2-statement form (matches the rewriter's idempotency). - if isAlreadyDelegatedPlanBody(body, file) { - return true - } - // Shape 2: single-statement legacy `return .Plan(...)`. - // The planned-but-not-shipped wfctlhelpers.Plan target was speculative; - // any code using it is fictional and the type-check will fail anyway, - // but we accept it as advisory-clean so a maintainer who hand-applied - // rev0 of this codemod isn't re-flagged. - // - // Round-11 #1: the BROKEN `return platform.ComputePlan(...)` - // single-statement form (rev1 ill-formed rewrite — uncompilable - // due to value/pointer mismatch) is REJECTED here. Lint should - // surface this as still-needs-fixup so `migrate-providers` - // catches partially-migrated providers. - if len(body.List) == 1 { - if ret, ok := body.List[0].(*ast.ReturnStmt); ok && len(ret.Results) == 1 { - if call, ok := ret.Results[0].(*ast.CallExpr); ok { - if sel, ok := call.Fun.(*ast.SelectorExpr); ok { - if x, ok := sel.X.(*ast.Ident); ok { - wfhAlias := pkgAliasFor(file, helperImportPath, "wfctlhelpers") - if (x.Name == wfhAlias || x.Name == "wfctlhelpers") && sel.Sel.Name == "Plan" { - return true - } - } - } - } - } - } - return false -} - -// receiverTypeDocsForPass builds a SINGLE merged receiverDoc map -// across every file in pass.Files. The same map is returned per-file -// (callers do `typeDocs := typeDocsByFile[file]`) — they get the -// directory-wide view so a skip-marker on a sibling file's type -// declaration is honored even when the function being analyzed lives -// in a different file. Round-6 finding #1: rev2 returned per-file -// maps, so `typeDocs[recv]` missed sibling-file TypeSpec docs and -// providers split across files were rewritten despite type-doc skip -// markers. -// -// First-occurrence wins: if multiple files declare the same receiver -// type name (an unusual layout but possible), the first iteration -// order wins. The lint analyzers prefer the in-file declaration over -// shadows since they iterate pass.Files in stable order. -func receiverTypeDocsForPass(pass *analysis.Pass) map[*ast.File]map[string]receiverDoc { - merged := make(map[string]receiverDoc) - for _, file := range pass.Files { - for recv, doc := range receiverTypeDocs(file) { - if _, ok := merged[recv]; ok { - continue - } - merged[recv] = doc - } - } - out := make(map[*ast.File]map[string]receiverDoc, len(pass.Files)) - for _, file := range pass.Files { - out[file] = merged - } - return out -} - -// ============================================================ -// Analyzer #2: AssertApplyDelegatesToHelper -// ============================================================ - -func runAssertApplyDelegatesToHelper(pass *analysis.Pass) (any, error) { - provs := providerLikeReceivers(pass) - typeDocsByFile := receiverTypeDocsForPass(pass) - for _, file := range pass.Files { - typeDocs := typeDocsByFile[file] - for _, decl := range file.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok { - continue - } - if !isProviderMethod(fn, "Apply", 2, 2) { - continue - } - recv := receiverTypeName(fn) - if !provs[recv] { - // Method named Apply on a non-provider type. Skip. - continue - } - // Honor SkipMarker on fn.Doc OR receiver-type docs (review - // round-2 finding #7). - if hasSkipMarkerOn(fn.Doc) || typeDocs[recv].carriesMarker() { - routeSkip(pass, fn) - continue - } - // Round-10 #8: rev3 accepted ANY wfctlhelpers.ApplyPlan - // call anywhere in the body, so an Apply that referenced - // the helper incidentally (with extra work before/after) - // was reported clean despite NOT actually delegating. Now - // we require the canonical single-statement - // `return .ApplyPlan(...)` form (the same shape the - // rewriter checks for idempotency). - if !isAlreadyDelegatedApplyBody(fn.Body, file) { - pass.Reportf(fn.Pos(), "%s.%s does not delegate to wfctlhelpers.ApplyPlan; non-canonical Apply() body", receiverTypeName(fn), fn.Name.Name) - } - } - } - return nil, nil -} - -// ============================================================ -// Analyzer #3: AssertDiffSetsNeedsReplaceForForceNew -// ============================================================ - -func runAssertDiffSetsNeedsReplaceForForceNew(pass *analysis.Pass) (any, error) { - drivers := driverLikeReceivers(pass) - typeDocsByFile := receiverTypeDocsForPass(pass) - for _, file := range pass.Files { - typeDocs := typeDocsByFile[file] - for _, decl := range file.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok { - continue - } - if !isProviderMethod(fn, "Diff", 3, 2) { - continue - } - recv := receiverTypeName(fn) - if !drivers[recv] { - // Method named Diff on a non-driver type (e.g., a - // settings struct or config differ). Skip to keep - // precision high — review finding #3. - continue - } - // Honor SkipMarker on fn.Doc OR receiver-type docs (review - // round-2 finding #8). - if hasSkipMarkerOn(fn.Doc) || typeDocs[recv].carriesMarker() { - routeSkip(pass, fn) - continue - } - refsForceNew := bodyReferencesField(fn.Body, "ForceNew") - assignsNeedsReplace := bodyAssignsField(fn.Body, "NeedsReplace") - if refsForceNew && !assignsNeedsReplace { - pass.Reportf(fn.Pos(), "%s.%s references ForceNew but never assigns NeedsReplace; W-3 force-new contract violated", receiverTypeName(fn), fn.Name.Name) - } - } - } - return nil, nil -} - -// ============================================================ -// Analyzer #4: AssertProviderImplementsValidatePlan -// ============================================================ - -func runAssertProviderImplementsValidatePlan(pass *analysis.Pass) (any, error) { - if pass.Pkg == nil { - return nil, nil - } - scope := pass.Pkg.Scope() - if scope == nil { - return nil, nil - } - // Group method sets by receiver type name, walking AST so we can - // surface the original ast.FuncDecl for skip-marker handling. - // typeDocsByName captures both TypeSpec.Doc and the wrapping - // GenDecl.Doc so the skip-marker check can consult both — review - // round-3 finding #7: rev2 only checked ts.Doc, missing markers - // placed before the `type` keyword (the wrapping GenDecl). - methodsByRecv := make(map[string][]*ast.FuncDecl) - typeDecls := make(map[string]*ast.TypeSpec) - typeDocsByName := make(map[string]receiverDoc) - for _, file := range pass.Files { - for _, decl := range file.Decls { - switch d := decl.(type) { - case *ast.FuncDecl: - if d.Recv == nil || len(d.Recv.List) == 0 { - continue - } - recv := receiverTypeName(d) - if recv == "" { - continue - } - methodsByRecv[recv] = append(methodsByRecv[recv], d) - case *ast.GenDecl: - if d.Tok != token.TYPE { - continue - } - for _, spec := range d.Specs { - ts, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } - if _, isStruct := ts.Type.(*ast.StructType); !isStruct { - continue - } - typeDecls[ts.Name.Name] = ts - typeDocsByName[ts.Name.Name] = receiverDoc{ - TypeSpecDoc: ts.Doc, - GenDeclDoc: d.Doc, - } - } - } - } - } - for recv, methods := range methodsByRecv { - if !looksLikeProvider(methods) { - continue - } - // Skip if the type's TypeSpec.Doc OR wrapping GenDecl.Doc - // carries the marker, or any of the provider's signature - // methods (Plan/Apply) carry it. ValidatePlan being absent is - // the whole point of this analyzer, so checking only - // Plan/Apply is sufficient. - if typeDocsByName[recv].carriesMarker() { - ts := typeDecls[recv] - pos := token.NoPos - if ts != nil { - pos = ts.Pos() - } else if len(methods) > 0 { - pos = methods[0].Pos() - } - routeSkipName(pass, pos, recv) - continue - } - // Round-8 #3: rev2 checked the marker on EVERY method, so a - // marker on Destroy/Status/etc. accidentally suppressed the - // whole provider's analysis. Restrict to Plan and Apply (the - // provider-defining methods that actually opt the type out). - anyMarker := false - for _, m := range methods { - if m.Name.Name != "Plan" && m.Name.Name != "Apply" { - continue - } - if hasSkipMarkerOn(m.Doc) { - anyMarker = true - break - } - } - if anyMarker { - routeSkipName(pass, methods[0].Pos(), recv) - continue - } - // Signature + receiver-kind match. Round-1 #11 added the - // signature check; round-5 #4 added the receiver-kind check - // (a value-receiver provider with a pointer-receiver - // ValidatePlan still fails the ProviderValidator type - // assertion because the method set on `T` does not include - // `*T` methods). hasValidatePlanMethod centralises the logic. - if hasValidatePlanMethod(methods) { - continue - } - // Round-11 #4 reverts round-10 #3's broad-suppress on - // embedded fields: many embeddings (sync.Mutex, loggers, - // config mixins) don't promote ValidatePlan, so real targets - // were silently missed. Maintainers whose providers actually - // promote ValidatePlan can suppress with the explicit - // `// wfctl:skip-iac-codemod` marker (the universal opt-out). - // Report at the type decl if available, else at the first method. - var pos token.Pos - if ts, ok := typeDecls[recv]; ok { - pos = ts.Pos() - } else { - pos = methods[0].Pos() - } - pass.Reportf(pos, "provider type %s does not implement ValidatePlan; ProviderValidator (R-A10) cannot run on plans involving this provider", recv) - } - return nil, nil -} - -// driverLikeReceivers returns the set of receiver type names whose -// method set in pass.Files contains a Diff method AND at least one -// canonical companion driver method (Read, Create, Update, Delete). -// Used by AssertDiffSetsNeedsReplaceForForceNew to keep precision high -// — review finding #3: a type with only Diff (e.g. a config differ) -// is not a resource driver and should not be analysed for force-new -// contract compliance. -func driverLikeReceivers(pass *analysis.Pass) map[string]bool { - methodsByRecv := make(map[string][]*ast.FuncDecl) - for _, file := range pass.Files { - for _, decl := range file.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok { - continue - } - recv := receiverTypeName(fn) - if recv == "" { - continue - } - methodsByRecv[recv] = append(methodsByRecv[recv], fn) - } - } - out := make(map[string]bool) - for recv, methods := range methodsByRecv { - hasDiff, hasCompanion := false, false - for _, m := range methods { - switch m.Name.Name { - case "Diff": - if m.Type.Params != nil && len(m.Type.Params.List) >= 2 && m.Type.Results != nil && len(m.Type.Results.List) == 2 { - hasDiff = true - } - case "Read", "Create", "Update", "Delete": - hasCompanion = true - } - } - if hasDiff && hasCompanion { - out[recv] = true - } - } - return out -} - -// providerLikeReceivers returns the set of receiver type names whose -// method set in pass.Files contains both Plan and Apply with shapes -// matching IaCProvider. Used by every analyzer that should fire only -// on IaC providers (not on deploy targets or other Apply-shaped types). -func providerLikeReceivers(pass *analysis.Pass) map[string]bool { - methodsByRecv := make(map[string][]*ast.FuncDecl) - for _, file := range pass.Files { - for _, decl := range file.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok { - continue - } - recv := receiverTypeName(fn) - if recv == "" { - continue - } - methodsByRecv[recv] = append(methodsByRecv[recv], fn) - } - } - out := make(map[string]bool) - for recv, methods := range methodsByRecv { - if looksLikeProvider(methods) { - out[recv] = true - } - } - return out -} - -// looksLikeProvider returns true if the method list contains both Plan -// and Apply with shapes matching IaCProvider: -// -// Plan(context.Context, []ResourceSpec, []ResourceState) (*IaCPlan, error) -// Apply(context.Context, *IaCPlan) (*ApplyResult, error) -// -// Round-12 #8: rev1 only checked method NAMES + rough arity, so any -// unrelated type with `Plan(...)` and `Apply(...)` (e.g., a deploy -// strategy or a UI handler) was treated as a provider. Tightened to -// match the signature shape via type-name suffix checks (qualified or -// unqualified): IaCPlan / ResourceSpec / ResourceState / ApplyResult / -// context.Context. -func looksLikeProvider(methods []*ast.FuncDecl) bool { - hasPlan, hasApply := false, false - for _, m := range methods { - switch m.Name.Name { - case "Plan": - if planSignatureMatches(m.Type) { - hasPlan = true - } - case "Apply": - if applySignatureMatches(m.Type) { - hasApply = true - } - } - } - return hasPlan && hasApply -} - -// planSignatureMatches verifies the function type matches -// `Plan(ctx, []ResourceSpec, []ResourceState) (*IaCPlan, error)`. -// Returns true if the parameter and result types match by name suffix -// (qualified or unqualified). Used by looksLikeProvider to filter -// false positives on unrelated `Plan` methods (round-12 #8). -func planSignatureMatches(ft *ast.FuncType) bool { - if ft == nil || ft.Params == nil || ft.Results == nil { - return false - } - paramTypes := flattenFieldTypes(ft.Params.List) - if len(paramTypes) != 3 { - return false - } - resultTypes := flattenFieldTypes(ft.Results.List) - if len(resultTypes) != 2 { - return false - } - // Param 1: context.Context (selector .Context) - if !typeNameTailMatches(paramTypes[0], "Context") { - return false - } - // Param 2: []ResourceSpec - arr, ok := paramTypes[1].(*ast.ArrayType) - if !ok || arr.Len != nil || !typeNameTailMatches(arr.Elt, "ResourceSpec") { - return false - } - // Param 3: []ResourceState - arr2, ok := paramTypes[2].(*ast.ArrayType) - if !ok || arr2.Len != nil || !typeNameTailMatches(arr2.Elt, "ResourceState") { - return false - } - // Result 1: *IaCPlan - star, ok := resultTypes[0].(*ast.StarExpr) - if !ok || !typeNameTailMatches(star.X, "IaCPlan") { - return false - } - // Result 2: error - return typeNameTailMatches(resultTypes[1], "error") -} - -// applySignatureMatches verifies the function type matches -// `Apply(ctx, *IaCPlan) (*ApplyResult, error)`. -func applySignatureMatches(ft *ast.FuncType) bool { - if ft == nil || ft.Params == nil || ft.Results == nil { - return false - } - paramTypes := flattenFieldTypes(ft.Params.List) - if len(paramTypes) != 2 { - return false - } - resultTypes := flattenFieldTypes(ft.Results.List) - if len(resultTypes) != 2 { - return false - } - if !typeNameTailMatches(paramTypes[0], "Context") { - return false - } - star, ok := paramTypes[1].(*ast.StarExpr) - if !ok || !typeNameTailMatches(star.X, "IaCPlan") { - return false - } - starR, ok := resultTypes[0].(*ast.StarExpr) - if !ok || !typeNameTailMatches(starR.X, "ApplyResult") { - return false - } - return typeNameTailMatches(resultTypes[1], "error") -} - -// flattenFieldTypes expands a Go FieldList (where `a, b T` is one -// field with two names) into a flat slice of types — one per -// parameter or return value. -func flattenFieldTypes(list []*ast.Field) []ast.Expr { - var out []ast.Expr - for _, f := range list { - count := 1 - if len(f.Names) > 1 { - count = len(f.Names) - } - for i := 0; i < count; i++ { - out = append(out, f.Type) - } - } - return out -} - -// ============================================================ -// Shared AST helpers -// ============================================================ - -// isProviderMethod returns true if fn is a method (has receiver) named -// methodName, with at least minParams parameter fields and exactly -// expectedResults result fields. Parameter and result counts are -// approximate (Go FieldList groups multi-param fields like `a, b T` -// into one field), so the actual call-site arity may differ — but the -// shape filter is sufficient for distinguishing IaCProvider/Driver -// methods from unrelated lookalikes. -func isProviderMethod(fn *ast.FuncDecl, methodName string, minParams, expectedResults int) bool { - if fn.Recv == nil || len(fn.Recv.List) == 0 { - return false - } - if fn.Name.Name != methodName { - return false - } - if fn.Type.Params == nil || len(fn.Type.Params.List) < minParams { - return false - } - if fn.Type.Results == nil || len(fn.Type.Results.List) != expectedResults { - return false - } - if fn.Body == nil { - return false - } - return true -} - -// receiverTypeName extracts the receiver type identifier from a method -// declaration, stripping any pointer indirection. Returns "" for -// unrecognised receiver shapes. -func receiverTypeName(fn *ast.FuncDecl) string { - if fn.Recv == nil || len(fn.Recv.List) == 0 { - return "" - } - expr := fn.Recv.List[0].Type - if star, ok := expr.(*ast.StarExpr); ok { - expr = star.X - } - id, ok := expr.(*ast.Ident) - if !ok { - return "" - } - return id.Name -} - -// bodyCallsSelector reports whether the function body contains a -// CallExpr whose callee is a SelectorExpr with the given X.Name and -// Sel.Name, e.g. `wfctlhelpers.Plan(...)`. -// -//nolint:unused -func bodyCallsSelector(body *ast.BlockStmt, pkgIdent, selName string) bool { - if body == nil { - return false - } - found := false - ast.Inspect(body, func(n ast.Node) bool { - if found { - return false - } - call, ok := n.(*ast.CallExpr) - if !ok { - return true - } - sel, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return true - } - x, ok := sel.X.(*ast.Ident) - if !ok { - return true - } - if x.Name == pkgIdent && sel.Sel.Name == selName { - found = true - return false - } - return true - }) - return found -} - -// bodyReferencesField reports whether the function body references any -// SelectorExpr with the given Sel.Name, e.g. any `.ForceNew`. -func bodyReferencesField(body *ast.BlockStmt, fieldName string) bool { - if body == nil { - return false - } - found := false - ast.Inspect(body, func(n ast.Node) bool { - if found { - return false - } - sel, ok := n.(*ast.SelectorExpr) - if !ok { - return true - } - if sel.Sel.Name == fieldName { - found = true - return false - } - return true - }) - return found -} - -// bodyAssignsField reports whether the function body contains any -// assignment `.fieldName = ` regardless of RHS shape, EXCEPT -// for an explicit literal `false` (which is treated as no-assignment). -// Both `r.NeedsReplace = true` (inside an `if c.ForceNew` guard) and -// the terser `r.NeedsReplace = c.ForceNew` are valid expressions of -// the W-3 force-new contract — review round-1 finding #4 widened the -// matcher so the second pattern doesn't trigger a false positive. -// Review round-2 follow-up B then narrowed the widening so the -// copy-paste bug `r.NeedsReplace = false` (assigning the wrong -// constant inside a ForceNew branch) is still flagged. Maintainers -// who genuinely don't propagate ForceNew leave NeedsReplace untouched -// entirely, which the analyzer also catches. -// -// workflow#539 widened the matcher again to recognize struct-literal -// assignment via *ast.KeyValueExpr (e.g. `&DiffResult{NeedsReplace: -// needsReplace}`), the local-accumulator pattern. The same -// literal-`false` no-assignment carve-out applies symmetrically so a -// struct literal `{NeedsReplace: false}` inside a ForceNew-observing -// function is still flagged. -func bodyAssignsField(body *ast.BlockStmt, fieldName string) bool { - if body == nil { - return false - } - found := false - ast.Inspect(body, func(n ast.Node) bool { - if found { - return false - } - switch v := n.(type) { - case *ast.AssignStmt: - for i, lhs := range v.Lhs { - sel, ok := lhs.(*ast.SelectorExpr) - if !ok { - continue - } - if sel.Sel.Name != fieldName { - continue - } - if i < len(v.Rhs) && isLiteralFalse(v.Rhs[i]) { - // Literal-false assignment is treated as - // no-assignment so a copy-paste typo inside the - // ForceNew branch is still flagged. - continue - } - found = true - return false - } - case *ast.KeyValueExpr: - // Struct-literal assignment, e.g. `&DiffResult{NeedsReplace: - // needsReplace}` (workflow#539 accumulator pattern). - ident, ok := v.Key.(*ast.Ident) - if !ok || ident.Name != fieldName { - return true - } - if isLiteralFalse(v.Value) { - // Symmetric literal-false carve-out: a struct literal - // `{NeedsReplace: false}` inside a ForceNew-observing - // function is still a copy-paste bug. - return true - } - found = true - return false - } - return true - }) - return found -} - -// isLiteralFalse reports whether expr is the literal Go identifier -// `false`. Used as a no-assignment carve-out for both AssignStmt and -// struct-literal KeyValueExpr forms. -func isLiteralFalse(expr ast.Expr) bool { - id, ok := expr.(*ast.Ident) - return ok && id.Name == "false" -} - -// routeSkip records a skipped FuncDecl through the pass.Report channel -// using the skipDiagnosticPrefix encoding. -func routeSkip(pass *analysis.Pass, fn *ast.FuncDecl) { - declName := fmt.Sprintf("%s.%s", receiverTypeName(fn), fn.Name.Name) - reportSkip(pass, fn.Pos(), declName) -} - -// routeSkipName records a skipped declaration by its name (used for -// type-level skips). -func routeSkipName(pass *analysis.Pass, pos token.Pos, name string) { - reportSkip(pass, pos, name) -} - -// ============================================================ -// Report rendering -// ============================================================ - -// print renders the report to w in Markdown-ish format. Findings come -// first (sorted by file, line, analyzer); then skipped sites; then -// per-file errors. Skipped diagnostics encoded with skipDiagnosticPrefix -// are extracted from findings into the skipped section first so the -// finding count reflects only real issues. -func (r *lintReport) print(w io.Writer) { - r.unpackSkippedFromFindings() - - sort.Slice(r.findings, func(i, j int) bool { - if r.findings[i].Path != r.findings[j].Path { - return r.findings[i].Path < r.findings[j].Path - } - if r.findings[i].Line != r.findings[j].Line { - return r.findings[i].Line < r.findings[j].Line - } - return r.findings[i].Analyzer < r.findings[j].Analyzer - }) - sort.Slice(r.skipped, func(i, j int) bool { - if r.skipped[i].Path != r.skipped[j].Path { - return r.skipped[i].Path < r.skipped[j].Path - } - return r.skipped[i].Line < r.skipped[j].Line - }) - - fmt.Fprintln(w, "# iac-codemod lint report") - fmt.Fprintln(w) - fmt.Fprintf(w, "Findings: %d\n", len(r.findings)) - fmt.Fprintf(w, "Skipped: %d\n", len(r.skipped)) - fmt.Fprintf(w, "Errors: %d\n", len(r.errors)) - fmt.Fprintln(w) - - if len(r.findings) > 0 { - fmt.Fprintln(w, "## Findings") - fmt.Fprintln(w) - for _, f := range r.findings { - fmt.Fprintf(w, "- %s:%d [%s] %s\n", f.Path, f.Line, f.Analyzer, f.Message) - } - fmt.Fprintln(w) - } - - if len(r.skipped) > 0 { - fmt.Fprintln(w, "## Skipped (// wfctl:skip-iac-codemod)") - fmt.Fprintln(w) - for _, s := range r.skipped { - fmt.Fprintf(w, "- %s:%d [%s] %s\n", s.Path, s.Line, s.Analyzer, s.Decl) - } - fmt.Fprintln(w) - } - - if len(r.errors) > 0 { - fmt.Fprintln(w, "## Errors") - fmt.Fprintln(w) - for _, e := range r.errors { - fmt.Fprintf(w, "- %s\n", e) - } - fmt.Fprintln(w) - } -} - -// unpackSkippedFromFindings moves skip-prefixed diagnostics from the -// findings list into the skipped list, restoring the canonical exit-code -// semantics (skipped sites do not count as findings). -func (r *lintReport) unpackSkippedFromFindings() { - if len(r.findings) == 0 { - return - } - kept := r.findings[:0] - for _, f := range r.findings { - if decl, ok := strings.CutPrefix(f.Message, skipDiagnosticPrefix); ok { - r.skipped = append(r.skipped, skippedSite{ - Path: f.Path, - Line: f.Line, - Analyzer: f.Analyzer, - Decl: decl, - }) - continue - } - kept = append(kept, f) - } - r.findings = kept -} diff --git a/cmd/iac-codemod/lint_test.go b/cmd/iac-codemod/lint_test.go deleted file mode 100644 index b6bfe0fc..00000000 --- a/cmd/iac-codemod/lint_test.go +++ /dev/null @@ -1,843 +0,0 @@ -// Copyright (c) 2026 Jon Langevin -// SPDX-License-Identifier: Apache-2.0 - -// See main_test.go for the t.Parallel() prohibition (this file follows -// the same constraint — modes map is mutated transitively via the lint -// init() call and cross-test analyzer state). - -package main - -import ( - "bytes" - "fmt" - "go/ast" - "go/parser" - "go/token" - "go/types" - "os" - "path/filepath" - "strings" - "testing" - - "golang.org/x/tools/go/analysis" -) - -// runAnalyzerOnSource parses a single Go source string, type-checks it -// tolerantly, runs the supplied analyzer, and returns the REAL diagnostics -// (skip-encoded synthetic diagnostics from skip-marker handling are -// filtered out here, matching the driver's post-processing). Use -// runAnalyzerOnSourceRaw if you need to inspect skip records directly. -func runAnalyzerOnSource(t *testing.T, src string, analyzer *analysis.Analyzer) []analysis.Diagnostic { - t.Helper() - all := runAnalyzerOnSourceRaw(t, src, analyzer) - out := all[:0] - for _, d := range all { - if strings.HasPrefix(d.Message, skipDiagnosticPrefix) { - continue - } - out = append(out, d) - } - return out -} - -// runAnalyzerOnSourceRaw is like runAnalyzerOnSource but returns ALL -// diagnostics (including skip-encoded ones). Used by skip-marker tests -// that need to verify the synthetic record was emitted at all. -func runAnalyzerOnSourceRaw(t *testing.T, src string, analyzer *analysis.Analyzer) []analysis.Diagnostic { - t.Helper() - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, "src.go", src, parser.ParseComments) - if err != nil { - t.Fatalf("parse: %v\nsrc:\n%s", err, src) - } - conf := &types.Config{ - Importer: stubImporter{}, - Error: func(err error) {}, // tolerate unresolved-import / undeclared-name errors - } - info := &types.Info{ - Types: make(map[ast.Expr]types.TypeAndValue), - Defs: make(map[*ast.Ident]types.Object), - Uses: make(map[*ast.Ident]types.Object), - Implicits: make(map[ast.Node]types.Object), - Selections: make(map[*ast.SelectorExpr]*types.Selection), - } - pkg, _ := conf.Check(file.Name.Name, fset, []*ast.File{file}, info) - var diags []analysis.Diagnostic - pass := &analysis.Pass{ - Analyzer: analyzer, - Fset: fset, - Files: []*ast.File{file}, - Pkg: pkg, - TypesInfo: info, - Report: func(d analysis.Diagnostic) { diags = append(diags, d) }, - } - if _, err := analyzer.Run(pass); err != nil { - t.Fatalf("analyzer %s: %v", analyzer.Name, err) - } - return diags -} - -// stubImporter is a tolerant importer that returns an empty package for -// any import path. It lets type-check proceed past unresolved imports -// like "wfctlhelpers" or "interfaces" without bailing. -type stubImporter struct{} - -func (stubImporter) Import(path string) (*types.Package, error) { - return types.NewPackage(path, filepath.Base(path)), nil -} - -// ============================================================ -// AssertPlanDelegatesToHelper -// ============================================================ - -// providerScaffold is the boilerplate every Plan/Apply test source -// includes so its receiver type satisfies the precision filter -// (providerLikeReceivers — must have BOTH Plan and Apply matching -// IaCProvider shape) and the integration-test "no findings" cases pass -// every analyzer. Apply is canonical and ValidatePlan is present so -// only the method under test (Plan) drives the analyzer behaviour. -const providerScaffold = `package p -import "context" - -type ResourceSpec struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} -type PlanDiagnostic struct{} - -type FooProvider struct{} - -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return wfctlhelpers.ApplyPlan(ctx, p, plan) -} -func (p *FooProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -// planCanonicalSrc uses the canonical 2-statement form per round-2 -// finding #1 (the value/pointer bridge for platform.ComputePlan). -const planCanonicalSrc = providerScaffold + ` -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - plan, err := platform.ComputePlan(ctx, p, desired, current) - return &plan, err -} -` - -// planLegacyDelegatedSrc preserves the rev0 codemod's planned-but-not-shipped -// `wfctlhelpers.Plan` target as also-accepted. Pinned regression: a maintainer -// who hand-applied an early version of the codemod must NOT be re-flagged. -const planLegacyDelegatedSrc = providerScaffold + ` -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return wfctlhelpers.Plan(ctx, p, desired, current) -} -` - -const planNonCanonicalSrc = providerScaffold + ` -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - // Custom planning logic, not delegating to platform.ComputePlan. - return &IaCPlan{}, nil -} -` - -const planSkippedSrc = providerScaffold + ` -// wfctl:skip-iac-codemod -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return &IaCPlan{}, nil -} -` - -func TestAssertPlanDelegatesToHelper_Canonical_NoDiagnostic(t *testing.T) { - diags := runAnalyzerOnSource(t, planCanonicalSrc, AssertPlanDelegatesToHelper) - if len(diags) != 0 { - t.Errorf("canonical Plan should produce no diagnostic; got %d:\n%s", len(diags), diagSummary(diags)) - } -} - -func TestAssertPlanDelegatesToHelper_NonCanonical_Diagnoses(t *testing.T) { - diags := runAnalyzerOnSource(t, planNonCanonicalSrc, AssertPlanDelegatesToHelper) - if len(diags) != 1 { - t.Fatalf("non-canonical Plan should produce 1 diagnostic; got %d:\n%s", len(diags), diagSummary(diags)) - } - if !strings.Contains(diags[0].Message, "platform.ComputePlan") { - t.Errorf("diagnostic should reference platform.ComputePlan (canonical target); got %q", diags[0].Message) - } -} - -// TestAssertPlanDelegatesToHelper_LegacyTargetAccepted pins review round-1 -// finding #1: the analyzer accepts the legacy `wfctlhelpers.Plan` target as -// already-delegated so a maintainer who hand-applied the rev0 codemod isn't -// re-flagged on the next run. -func TestAssertPlanDelegatesToHelper_LegacyTargetAccepted(t *testing.T) { - diags := runAnalyzerOnSource(t, planLegacyDelegatedSrc, AssertPlanDelegatesToHelper) - if len(diags) != 0 { - t.Errorf("legacy wfctlhelpers.Plan target must be accepted as delegated; got %d:\n%s", len(diags), diagSummary(diags)) - } -} - -func TestAssertPlanDelegatesToHelper_SkipMarker_Honored(t *testing.T) { - // Real findings should be empty (the marker suppresses the - // non-canonical-Plan diagnostic). - diags := runAnalyzerOnSource(t, planSkippedSrc, AssertPlanDelegatesToHelper) - if len(diags) != 0 { - t.Errorf("skip-marker should suppress real diagnostic; got %d:\n%s", len(diags), diagSummary(diags)) - } - // And a skip-encoded synthetic diagnostic should be present so the - // driver can surface the skipped site in its report (plan rev2 line - // 2400: "Each mode also surfaces a list of skipped sites in its - // report"). - all := runAnalyzerOnSourceRaw(t, planSkippedSrc, AssertPlanDelegatesToHelper) - gotSkip := false - for _, d := range all { - if strings.HasPrefix(d.Message, skipDiagnosticPrefix) { - gotSkip = true - break - } - } - if !gotSkip { - t.Errorf("skip-marker should produce a skip record for the driver to surface; got:\n%s", diagSummary(all)) - } -} - -// TestSkipMarker_AcceptsTrailingJustification pins review-round-2 finding -// #2: a trailing space + justification context after SkipMarker is a -// natural Go idiom (`// wfctl:skip-iac-codemod legacy upsert recovery, -// see ADR-042`) and must NOT silently turn the marker into a no-op. -// Plan rev2 line 2400 unifies the marker specifically to prevent -// silent-no-op surfaces; permissive trailing-context is in that family. -const planSkippedWithJustificationSrc = providerScaffold + ` -// wfctl:skip-iac-codemod legacy upsert recovery, see ADR-042 -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return &IaCPlan{}, nil -} -` - -func TestSkipMarker_AcceptsTrailingJustification(t *testing.T) { - diags := runAnalyzerOnSource(t, planSkippedWithJustificationSrc, AssertPlanDelegatesToHelper) - if len(diags) != 0 { - t.Errorf("trailing justification text after marker must NOT silently break suppression; got %d diagnostics:\n%s", len(diags), diagSummary(diags)) - } -} - -// TestSkipMarker_RejectsCloseButWrongMarker confirms we only accept the -// canonical marker — a different prefix (e.g. legacy -// `// wfctl:skip-codemod` from the design rev1 era) should still -// flag the diagnostic. Guards against accidentally-too-loose matching. -const planSkippedWithWrongMarkerSrc = providerScaffold + ` -// wfctl:skip-codemod -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return &IaCPlan{}, nil -} -` - -func TestSkipMarker_RejectsCloseButWrongMarker(t *testing.T) { - diags := runAnalyzerOnSource(t, planSkippedWithWrongMarkerSrc, AssertPlanDelegatesToHelper) - if len(diags) != 1 { - t.Errorf("non-canonical marker `// wfctl:skip-codemod` must NOT suppress the diagnostic (plan rev2 unifies on // wfctl:skip-iac-codemod ONLY); got %d:\n%s", len(diags), diagSummary(diags)) - } -} - -// TestSkipMarker_AcceptsTabDelimitedJustification — review round-2 -// follow-up A. Maintainers who tab-align justifications must NOT see a -// silent no-op; the marker logic widens to accept any whitespace -// separator. -const planSkippedTabJustifiedSrc = providerScaffold + "\n// wfctl:skip-iac-codemod\tlegacy upsert recovery, see ADR-042\nfunc (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) {\n\treturn &IaCPlan{}, nil\n}\n" - -func TestSkipMarker_AcceptsTabDelimitedJustification(t *testing.T) { - diags := runAnalyzerOnSource(t, planSkippedTabJustifiedSrc, AssertPlanDelegatesToHelper) - if len(diags) != 0 { - t.Errorf("tab-delimited justification must NOT silently break the marker; got %d:\n%s", len(diags), diagSummary(diags)) - } -} - -// TestSkipMarker_RejectsAdjacentNonWhitespace — review round-2 follow-up -// C. Pin that strings sharing the marker prefix but extending without a -// whitespace separator (dash/letter/digit suffix) are NOT accepted as -// the marker, so future loosening of hasSkipMarkerOn fails this test. -func TestSkipMarker_RejectsAdjacentNonWhitespace(t *testing.T) { - cases := []struct { - name, comment string - }{ - {"dash-suffix", "// wfctl:skip-iac-codemod-extension"}, - {"letters-suffix", "// wfctl:skip-iac-codemodSOMETHING"}, - {"digit-suffix", "// wfctl:skip-iac-codemod1"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - src := providerScaffold + "\n" + tc.comment + "\nfunc (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) {\n\treturn &IaCPlan{}, nil\n}\n" - diags := runAnalyzerOnSource(t, src, AssertPlanDelegatesToHelper) - if len(diags) != 1 { - t.Errorf("comment %q without whitespace separator must NOT match the marker; got %d:\n%s", tc.comment, len(diags), diagSummary(diags)) - } - }) - } -} - -// ============================================================ -// AssertApplyDelegatesToHelper -// ============================================================ - -// applyTestScaffold mirrors providerScaffold but with a canonical Plan -// (so the receiver passes the provider-like filter without the Apply -// analyzer under test being affected). ValidatePlan is included so -// integration-test "no findings" cases stay clean across all analyzers. -const applyTestScaffold = `package p -import "context" - -type ResourceSpec struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} -type PlanDiagnostic struct{} - -type FooProvider struct{} - -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - plan, err := platform.ComputePlan(ctx, p, desired, current) - return &plan, err -} -func (p *FooProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -const applyCanonicalSrc = applyTestScaffold + ` -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return wfctlhelpers.ApplyPlan(ctx, p, plan) -} -` - -const applyNonCanonicalSrc = applyTestScaffold + ` -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return &ApplyResult{}, nil -} -` - -func TestAssertApplyDelegatesToHelper_Canonical_NoDiagnostic(t *testing.T) { - diags := runAnalyzerOnSource(t, applyCanonicalSrc, AssertApplyDelegatesToHelper) - if len(diags) != 0 { - t.Errorf("canonical Apply should produce no diagnostic; got %d:\n%s", len(diags), diagSummary(diags)) - } -} - -func TestAssertApplyDelegatesToHelper_NonCanonical_Diagnoses(t *testing.T) { - diags := runAnalyzerOnSource(t, applyNonCanonicalSrc, AssertApplyDelegatesToHelper) - if len(diags) != 1 { - t.Fatalf("non-canonical Apply should produce 1 diagnostic; got %d:\n%s", len(diags), diagSummary(diags)) - } - if !strings.Contains(diags[0].Message, "wfctlhelpers.ApplyPlan") { - t.Errorf("diagnostic should reference wfctlhelpers.ApplyPlan; got %q", diags[0].Message) - } -} - -// ============================================================ -// AssertDiffSetsNeedsReplaceForForceNew -// ============================================================ - -// driverScaffold provides the Read companion method that -// driverLikeReceivers requires before AssertDiffSetsNeedsReplaceForForceNew -// will fire. Drivers conventionally have Read in addition to Diff. -const driverScaffold = `package p -import "context" - -type ResourceSpec struct{} -type ResourceOutput struct{} -type ResourceState struct{} -type FieldChange struct { - ForceNew bool -} -type DiffResult struct { - NeedsReplace bool - Changes []FieldChange -} - -type FooDriver struct{} - -func (d *FooDriver) Read(ctx context.Context, s ResourceState) (*ResourceOutput, error) { - return nil, nil -} -` - -const diffCanonicalSrc = driverScaffold + ` -func (d *FooDriver) Diff(ctx context.Context, desired ResourceSpec, current *ResourceOutput) (*DiffResult, error) { - r := &DiffResult{} - for _, c := range r.Changes { - if c.ForceNew { - r.NeedsReplace = true - } - } - return r, nil -} -` - -const diffMissingNeedsReplaceSrc = driverScaffold + ` -func (d *FooDriver) Diff(ctx context.Context, desired ResourceSpec, current *ResourceOutput) (*DiffResult, error) { - r := &DiffResult{} - for _, c := range r.Changes { - if c.ForceNew { - // Forgot to set NeedsReplace=true — this is the bug the analyzer flags. - _ = c - } - } - return r, nil -} -` - -func TestAssertDiffSetsNeedsReplaceForForceNew_Canonical_NoDiagnostic(t *testing.T) { - diags := runAnalyzerOnSource(t, diffCanonicalSrc, AssertDiffSetsNeedsReplaceForForceNew) - if len(diags) != 0 { - t.Errorf("canonical Diff should produce no diagnostic; got %d:\n%s", len(diags), diagSummary(diags)) - } -} - -func TestAssertDiffSetsNeedsReplaceForForceNew_MissingAssign_Diagnoses(t *testing.T) { - diags := runAnalyzerOnSource(t, diffMissingNeedsReplaceSrc, AssertDiffSetsNeedsReplaceForForceNew) - if len(diags) != 1 { - t.Fatalf("Diff that references ForceNew but never assigns NeedsReplace should produce 1 diagnostic; got %d:\n%s", len(diags), diagSummary(diags)) - } - if !strings.Contains(diags[0].Message, "NeedsReplace") { - t.Errorf("diagnostic should reference NeedsReplace; got %q", diags[0].Message) - } -} - -// TestAssertDiffSetsNeedsReplaceForForceNew_AcceptsDirectAssign pins -// review finding #4: the alternate canonical pattern `r.NeedsReplace = -// c.ForceNew` (instead of `if c.ForceNew { r.NeedsReplace = true }`) -// also satisfies the W-3 force-new contract and must NOT trigger a -// false-positive diagnostic. -const diffDirectAssignSrc = `package p -import "context" - -type ResourceSpec struct{} -type ResourceOutput struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} -type FieldChange struct { - ForceNew bool -} -type DiffResult struct { - NeedsReplace bool - Changes []FieldChange -} - -type FooDriver struct{} - -func (d *FooDriver) Read(ctx context.Context, s ResourceState) (*ResourceOutput, error) { - return nil, nil -} - -func (d *FooDriver) Diff(ctx context.Context, desired ResourceSpec, current *ResourceOutput) (*DiffResult, error) { - r := &DiffResult{} - for _, c := range r.Changes { - r.NeedsReplace = c.ForceNew - } - return r, nil -} -` - -func TestAssertDiffSetsNeedsReplaceForForceNew_AcceptsDirectAssign(t *testing.T) { - diags := runAnalyzerOnSource(t, diffDirectAssignSrc, AssertDiffSetsNeedsReplaceForForceNew) - if len(diags) != 0 { - t.Errorf("`r.NeedsReplace = c.ForceNew` is a valid alternate canonical; should NOT flag; got %d:\n%s", len(diags), diagSummary(diags)) - } -} - -// TestAssertDiffSetsNeedsReplaceForForceNew_RejectsLiteralFalseAssign -// — review round-2 follow-up B. The widened bodyAssignsField (any RHS) -// would silently accept `r.NeedsReplace = false` inside a ForceNew -// branch — a real copy-paste bug pattern. The matcher must specifically -// treat literal-`false` RHS as no-assignment so this typo is still -// flagged. -const diffLiteralFalseSrc = driverScaffold + ` -func (d *FooDriver) Diff(ctx context.Context, desired ResourceSpec, current *ResourceOutput) (*DiffResult, error) { - r := &DiffResult{} - for _, c := range r.Changes { - if c.ForceNew { - r.NeedsReplace = false - } - } - return r, nil -} -` - -func TestAssertDiffSetsNeedsReplaceForForceNew_RejectsLiteralFalseAssign(t *testing.T) { - diags := runAnalyzerOnSource(t, diffLiteralFalseSrc, AssertDiffSetsNeedsReplaceForForceNew) - if len(diags) != 1 { - t.Errorf("`r.NeedsReplace = false` is a copy-paste bug — analyzer must flag; got %d:\n%s", len(diags), diagSummary(diags)) - } -} - -// TestAssertDiffSetsNeedsReplaceForForceNew_AccumulatorPatternIsClean pins -// workflow#539: the local-accumulator pattern is a valid expression of -// the W-3 force-new contract. The driver declares `var needsReplace -// bool`, sets `needsReplace = true` inside ForceNew-driven branches, -// then returns `&DiffResult{NeedsReplace: needsReplace, ...}`. The -// analyzer must recognize the struct-literal `KeyValueExpr` form as an -// assignment site, not just `*ast.AssignStmt` — otherwise the -// accumulator pattern is reported as a contract violation. -const diffAccumulatorSrc = driverScaffold + ` -func (d *FooDriver) Diff(ctx context.Context, desired ResourceSpec, current *ResourceOutput) (*DiffResult, error) { - if current == nil { - return &DiffResult{}, nil - } - var changes []FieldChange - var needsReplace bool - for _, c := range changes { - if c.ForceNew { - needsReplace = true - } - } - return &DiffResult{ - NeedsReplace: needsReplace, - Changes: changes, - }, nil -} -` - -func TestAssertDiffSetsNeedsReplaceForForceNew_AccumulatorPatternIsClean(t *testing.T) { - diags := runAnalyzerOnSource(t, diffAccumulatorSrc, AssertDiffSetsNeedsReplaceForForceNew) - if len(diags) != 0 { - t.Errorf("accumulator pattern `&DiffResult{NeedsReplace: needsReplace}` is a valid alternate canonical (workflow#539); should NOT flag; got %d:\n%s", len(diags), diagSummary(diags)) - } -} - -// TestAssertDiffSetsNeedsReplaceForForceNew_StructLiteralFalseStillFlags -// is the symmetry test for the accumulator widening: a struct literal -// `&DiffResult{NeedsReplace: false}` inside a function that observes -// ForceNew is still a copy-paste bug and must be flagged. Without this -// guard, the widened matcher would silently accept the bug-shape. -const diffStructLiteralFalseSrc = driverScaffold + ` -func (d *FooDriver) Diff(ctx context.Context, desired ResourceSpec, current *ResourceOutput) (*DiffResult, error) { - var changes []FieldChange - for _, c := range changes { - if c.ForceNew { - _ = c - } - } - return &DiffResult{ - NeedsReplace: false, - Changes: changes, - }, nil -} -` - -func TestAssertDiffSetsNeedsReplaceForForceNew_StructLiteralFalseStillFlags(t *testing.T) { - diags := runAnalyzerOnSource(t, diffStructLiteralFalseSrc, AssertDiffSetsNeedsReplaceForForceNew) - if len(diags) != 1 { - t.Errorf("`&DiffResult{NeedsReplace: false}` is a copy-paste bug; analyzer must flag; got %d:\n%s", len(diags), diagSummary(diags)) - } -} - -// TestAssertDiffSetsNeedsReplaceForForceNew_DirectAccumulatorAssignIsClean pins -// the direct-assignment accumulator form described in the issue: the driver -// initializes a local `needsReplace` bool, sets it to true inside a ForceNew -// branch, then assigns `r.NeedsReplace = needsReplace` (an *ast.AssignStmt -// with a selector LHS and a local-variable RHS). This is distinct from the -// struct-literal KeyValueExpr form (workflow#539) but must equally NOT produce -// a false-positive diagnostic. -const diffDirectAccumulatorSrc = driverScaffold + ` -func (d *FooDriver) Diff(ctx context.Context, desired ResourceSpec, current *ResourceOutput) (*DiffResult, error) { - if current == nil { - return &DiffResult{}, nil - } - r := &DiffResult{} - needsReplace := false - for _, c := range r.Changes { - if c.ForceNew { - needsReplace = true - } - } - r.NeedsReplace = needsReplace - return r, nil -} -` - -func TestAssertDiffSetsNeedsReplaceForForceNew_DirectAccumulatorAssignIsClean(t *testing.T) { - diags := runAnalyzerOnSource(t, diffDirectAccumulatorSrc, AssertDiffSetsNeedsReplaceForForceNew) - if len(diags) != 0 { - t.Errorf("direct-assignment accumulator `r.NeedsReplace = needsReplace` is a valid W-3 expression; should NOT flag; got %d:\n%s", len(diags), diagSummary(diags)) - } -} - -// TestAssertDiffSetsNeedsReplaceForForceNew_NonDriverNotFlagged pins -// review finding #3: the analyzer must NOT fire on types that have a -// method named Diff but are not resource drivers (no Read / Create / -// Update / Delete companion methods). Adversarially: a `func (s -// *Settings) Diff(...)` that happens to match the arity should be -// invisible to this analyzer. -const diffNonDriverSrc = `package p -import "context" - -type ResourceSpec struct{} -type ResourceOutput struct{} -type FieldChange struct { - ForceNew bool -} -type DiffResult struct { - NeedsReplace bool - Changes []FieldChange -} - -// Not a driver — no Read/Create/Update/Delete. Just a settings struct -// that exposes a "Diff" method for unrelated reasons (e.g. config diff). -type SettingsDiff struct{} - -func (s *SettingsDiff) Diff(ctx context.Context, desired ResourceSpec, current *ResourceOutput) (*DiffResult, error) { - r := &DiffResult{} - for _, c := range r.Changes { - if c.ForceNew { - // No NeedsReplace assign — but this isn't a driver, so we - // should not flag. - _ = c - } - } - return r, nil -} -` - -func TestAssertDiffSetsNeedsReplaceForForceNew_NonDriverNotFlagged(t *testing.T) { - diags := runAnalyzerOnSource(t, diffNonDriverSrc, AssertDiffSetsNeedsReplaceForForceNew) - if len(diags) != 0 { - t.Errorf("type with Diff() but no driver-companion method (Read/Create/Update/Delete) should NOT be flagged; got %d:\n%s", len(diags), diagSummary(diags)) - } -} - -// Refresh diffCanonicalSrc to include a Read companion method so it -// passes the new precision filter (provider/driver heuristic). -// (The constant is replaced via Edit after the analyzer is updated; -// this comment is an intent marker only — see lint_test.go body.) - -// ============================================================ -// AssertProviderImplementsValidatePlan -// ============================================================ - -const providerWithValidatePlanSrc = `package p -import "context" - -type ResourceSpec struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} -type PlanDiagnostic struct{} - -type FooProvider struct{} - -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return nil, nil -} -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return nil, nil -} -func (p *FooProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { - return nil -} -` - -const providerWithoutValidatePlanSrc = `package p -import "context" - -type ResourceSpec struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} - -type FooProvider struct{} - -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return nil, nil -} -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return nil, nil -} -` - -func TestAssertProviderImplementsValidatePlan_HasValidatePlan_NoDiagnostic(t *testing.T) { - diags := runAnalyzerOnSource(t, providerWithValidatePlanSrc, AssertProviderImplementsValidatePlan) - if len(diags) != 0 { - t.Errorf("provider with ValidatePlan should produce no diagnostic; got %d:\n%s", len(diags), diagSummary(diags)) - } -} - -func TestAssertProviderImplementsValidatePlan_Missing_Diagnoses(t *testing.T) { - diags := runAnalyzerOnSource(t, providerWithoutValidatePlanSrc, AssertProviderImplementsValidatePlan) - if len(diags) != 1 { - t.Fatalf("provider without ValidatePlan should produce 1 diagnostic; got %d:\n%s", len(diags), diagSummary(diags)) - } - if !strings.Contains(diags[0].Message, "ValidatePlan") { - t.Errorf("diagnostic should reference ValidatePlan; got %q", diags[0].Message) - } -} - -// ============================================================ -// runLint dispatcher (integration) -// ============================================================ - -// writeTempPackage writes a single-package set of files to a tempdir -// and returns the dir. -func writeTempPackage(t *testing.T, files map[string]string) string { - t.Helper() - dir := t.TempDir() - for name, content := range files { - full := filepath.Join(dir, name) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", filepath.Dir(full), err) - } - if err := os.WriteFile(full, []byte(content), 0o644); err != nil { - t.Fatalf("write %s: %v", name, err) - } - } - return dir -} - -// runLintInDir invokes runLint against dir with the given Options and -// returns stdout, stderr, exit code. -func runLintInDir(t *testing.T, dir string, opts Options) (string, string, int) { - t.Helper() - var stdout, stderr bytes.Buffer - code := runLint([]string{dir}, &opts, &stdout, &stderr) - return stdout.String(), stderr.String(), code -} - -func TestRunLint_NoArgs_Exits2(t *testing.T) { - var stdout, stderr bytes.Buffer - code := runLint(nil, &Options{DryRun: true}, &stdout, &stderr) - if code != 2 { - t.Errorf("exit = %d, want 2", code) - } -} - -func TestRunLint_CanonicalSource_NoFindings(t *testing.T) { - dir := writeTempPackage(t, map[string]string{ - "provider.go": planCanonicalSrc, - }) - stdout, _, code := runLintInDir(t, dir, Options{DryRun: true}) - if code != 0 { - t.Errorf("exit = %d, want 0; stdout=%s", code, stdout) - } - if strings.Contains(stdout, "AssertPlanDelegatesToHelper") { - t.Errorf("canonical source should not be flagged; stdout:\n%s", stdout) - } -} - -func TestRunLint_NonCanonical_FindingsPresent(t *testing.T) { - dir := writeTempPackage(t, map[string]string{ - "provider.go": planNonCanonicalSrc, - }) - stdout, _, code := runLintInDir(t, dir, Options{DryRun: true}) - if code != 1 { - t.Errorf("exit = %d, want 1 (findings present); stdout=%s", code, stdout) - } - if !strings.Contains(stdout, "AssertPlanDelegatesToHelper") { - t.Errorf("expected analyzer name in report; stdout:\n%s", stdout) - } - if !strings.Contains(stdout, "provider.go") { - t.Errorf("expected file path in report; stdout:\n%s", stdout) - } -} - -func TestRunLint_SkipMarker_SurfacedInReport(t *testing.T) { - dir := writeTempPackage(t, map[string]string{ - "provider.go": planSkippedSrc, - }) - stdout, _, code := runLintInDir(t, dir, Options{DryRun: true}) - if code != 0 { - t.Errorf("exit = %d, want 0 (skipped, no findings); stdout=%s", code, stdout) - } - if !strings.Contains(stdout, "Skipped") { - t.Errorf("report must surface skipped sites; stdout:\n%s", stdout) - } - if !strings.Contains(stdout, "provider.go") { - t.Errorf("skipped section must include file path; stdout:\n%s", stdout) - } -} - -// TestRunLint_DoesNotMutateFilesEvenWithFixFlag pins the contract from -// carry-forward #2: lint is read-only by definition. Even with -fix and -// -dry-run=false, file mtimes and contents must be unchanged across the -// run. (Fix=true cannot reach this code path through the dispatcher -// because run() in main.go normalizes the gate, but the in-mode contract -// is also pinned for defense-in-depth.) -func TestRunLint_DoesNotMutateFilesEvenWithFixFlag(t *testing.T) { - dir := writeTempPackage(t, map[string]string{ - "provider.go": planNonCanonicalSrc, - }) - target := filepath.Join(dir, "provider.go") - - beforeStat, err := os.Stat(target) - if err != nil { - t.Fatalf("stat before: %v", err) - } - beforeContent, err := os.ReadFile(target) - if err != nil { - t.Fatalf("read before: %v", err) - } - - // Hostile flags: simulate a caller bypassing the dispatcher's gate. - _, _, _ = runLintInDir(t, dir, Options{Fix: true, DryRun: false}) - - afterStat, err := os.Stat(target) - if err != nil { - t.Fatalf("stat after: %v", err) - } - afterContent, err := os.ReadFile(target) - if err != nil { - t.Fatalf("read after: %v", err) - } - - if !beforeStat.ModTime().Equal(afterStat.ModTime()) { - t.Errorf("mtime changed: before=%v, after=%v — lint must never mutate", beforeStat.ModTime(), afterStat.ModTime()) - } - if !bytes.Equal(beforeContent, afterContent) { - t.Errorf("content changed — lint must never mutate") - } -} - -func TestRunLint_FixFlag_WarnsItHasNoEffect(t *testing.T) { - dir := writeTempPackage(t, map[string]string{ - "provider.go": planCanonicalSrc, - }) - _, stderr, _ := runLintInDir(t, dir, Options{Fix: true, DryRun: false}) - if !strings.Contains(stderr, "no effect") { - t.Errorf("stderr should warn that -fix has no effect on lint; got:\n%s", stderr) - } -} - -func TestRunLint_AnalyzerCount_FourRegistered(t *testing.T) { - if len(lintAnalyzers) != 4 { - t.Errorf("plan §T8.2 mandates 4 analyzers; got %d", len(lintAnalyzers)) - } - want := []string{ - "AssertPlanDelegatesToHelper", - "AssertApplyDelegatesToHelper", - "AssertDiffSetsNeedsReplaceForForceNew", - "AssertProviderImplementsValidatePlan", - } - got := make(map[string]bool) - for _, a := range lintAnalyzers { - got[a.Name] = true - } - for _, name := range want { - if !got[name] { - t.Errorf("plan-literal analyzer %q is missing from lintAnalyzers", name) - } - } -} - -func TestRunLint_RegistersIntoModesMap(t *testing.T) { - fn, ok := modes["lint"] - if !ok { - t.Fatalf("lint init() must register runLint into modes map") - } - if fn == nil { - t.Fatalf("modes[\"lint\"] is nil") - } -} - -// diagSummary formats a slice of diagnostics for test failure messages. -func diagSummary(diags []analysis.Diagnostic) string { - if len(diags) == 0 { - return " (none)" - } - var sb strings.Builder - for i, d := range diags { - fmt.Fprintf(&sb, " [%d] pos=%d: %s\n", i, d.Pos, d.Message) - } - return sb.String() -} diff --git a/cmd/iac-codemod/main.go b/cmd/iac-codemod/main.go deleted file mode 100644 index b7fc9f7f..00000000 --- a/cmd/iac-codemod/main.go +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright (c) 2026 Jon Langevin -// SPDX-License-Identifier: Apache-2.0 - -// Command iac-codemod is an AST-based migration tool for IaC plugin providers. -// -// Modes: -// -// refactor-plan — rewrite Plan() bodies to delegate to platform.ComputePlan -// refactor-apply — rewrite Apply() bodies to delegate to wfctlhelpers.ApplyPlan -// (with informative reports for non-canonical idioms) -// add-validate-plan — inject a no-op ValidatePlan stub on providers missing it -// lint — static checks (no rewrite); advisory-only mode -// -// All modes default to dry-run. Pass -fix to opt into mutation. -// -// All modes honor the `// wfctl:skip-iac-codemod` marker on functions and -// types; skipped sites are surfaced in each mode's report. -package main - -import ( - "fmt" - "io" - "os" - "strings" -) - -// SkipMarker is the single canonical comment that opts a function or type -// declaration out of every iac-codemod mode (refactor-plan, refactor-apply, -// add-validate-plan, lint). Plan rev2 (line 2400) unifies the four modes -// on this marker specifically to prevent mismatched-marker silent-no-op -// surfaces (e.g. // wfctl:skip-codemod or // wfctl:skip-plan-codemod). All -// downstream parsers (T8.3-T8.5) MUST reference this constant rather than -// the literal string, and each mode surfaces a list of skipped sites in -// its report. -const SkipMarker = "// wfctl:skip-iac-codemod" - -// Options carries flags shared by every codemod mode. -// -// Mode implementations MUST treat Fix as the sole authority for mutation. -// DryRun is mirrored as `!Fix` purely for ergonomic reading of report -// preambles and is normalized by run() at the dispatcher boundary so a -// user-supplied -dry-run=false cannot bypass the explicit -fix gate -// (plan §W-8 line 2347: "-dry-run flag default true; -fix opts into -// mutation"). Predicates like `if !opts.DryRun { mutate() }` are safe -// because the dispatcher guarantees DryRun==true whenever Fix==false. -type Options struct { - // DryRun reports findings without mutating files. Forced true when - // Fix is false; forced false when Fix is true. The user's - // -dry-run= value is informational once dispatcher normalization - // runs. - DryRun bool - // Fix opts into mutation. Sole authority for mutation gating. - Fix bool -} - -// modeFunc is the entry point for one of the codemod's subcommand modes. -// args is the residual positional argument list (target paths, etc.) after -// shared flags have been parsed off. Returns a process exit code. -type modeFunc func(args []string, opts *Options, stdout, stderr io.Writer) int - -// modes registers every supported subcommand. Tests swap entries in this -// map to capture the parsed Options without spawning a subprocess. -var modes = map[string]modeFunc{ - "refactor-plan": stubMode("refactor-plan"), - "refactor-apply": stubMode("refactor-apply"), - "add-validate-plan": stubMode("add-validate-plan"), - "lint": stubMode("lint"), -} - -// stubMode returns a placeholder modeFunc used by the T8.1 skeleton. -// Subsequent tasks (T8.2 lint, T8.3 refactor-plan, T8.4 refactor-apply, -// T8.5 add-validate-plan) replace these entries with real implementations -// in the package's init() inside their own files. -func stubMode(name string) modeFunc { - return func(_ []string, _ *Options, stdout, _ io.Writer) int { - fmt.Fprintf(stdout, "iac-codemod %s: not yet implemented (skeleton stub)\n", name) - return 0 - } -} - -func main() { - os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) -} - -// run is the testable entry point. Returns the desired process exit code. -func run(args []string, stdout, stderr io.Writer) int { - if len(args) == 0 { - usage(stderr) - return 2 - } - switch args[0] { - case "-h", "--help", "help": - usage(stdout) - return 0 - } - - mode := args[0] - rest := args[1:] - fn, ok := modes[mode] - if !ok { - fmt.Fprintf(stderr, "iac-codemod: unknown mode: %s\n\n", mode) - usage(stderr) - return 2 - } - - // Round-12 #1: rev1 used a single FlagSet with only `-dry-run` - // and `-fix` registered, so any mode-specific flag (e.g. - // `-report-file` for refactor-apply) failed with - // "flag provided but not defined" BEFORE the mode could parse - // it. Now we manually extract the two shared flags from the - // argument list, leaving the rest (including unknown-to-dispatcher - // flags) intact for the mode's own FlagSet. Manual extraction is - // preferred over flag.NewFlagSet's `flag.ContinueOnError` because - // stdlib's parser stops at the first unknown flag and consumes - // nothing further — manual lets us preserve EVERYTHING the mode - // needs. - opts := &Options{} - residual := []string{} - for i := 0; i < len(rest); i++ { - arg := rest[i] - switch arg { - case "-h", "--help": - usage(stdout) - return 0 - case "-dry-run", "--dry-run": - opts.DryRun = true - case "-dry-run=true", "--dry-run=true": - opts.DryRun = true - case "-dry-run=false", "--dry-run=false": - opts.DryRun = false - case "-fix", "--fix": - opts.Fix = true - case "-fix=true", "--fix=true": - opts.Fix = true - case "-fix=false", "--fix=false": - opts.Fix = false - default: - residual = append(residual, arg) - } - } - // Normalize the mutation gate at the dispatcher boundary: Fix is the - // sole authority for "may I mutate?". A user-supplied -dry-run=false - // without -fix must NOT bypass the gate (plan §W-8 line 2347), and - // -fix must override an explicit -dry-run=true. - if opts.Fix { - opts.DryRun = false - } else { - opts.DryRun = true - } - return fn(residual, opts, stdout, stderr) -} - -// shouldSkipDir is the canonical directory-walk filter shared by every -// mode's filepath.WalkDir callback. It excludes: -// -// - "vendor" — the standard Go vendor tree; mirrors `go build`'s -// behavior of treating vendor/ as a private dependency island. -// - "testdata" — by convention not real source. -// - hidden directories (prefix ".", except the literal "."): .git, -// .idea, .vscode, etc. -// - underscore-prefix directories (prefix "_", except the literal -// "_"): Go tooling itself ignores these (cmd/go skips package paths -// starting with underscore). The DigitalOcean plugin uses -// `_worktrees/` for parallel feature branches; without this filter -// a single lint run reports the same site dozens of times across -// stale checkouts. -func shouldSkipDir(base string) bool { - switch base { - case "vendor", "testdata": - return true - } - if len(base) > 1 && (strings.HasPrefix(base, ".") || strings.HasPrefix(base, "_")) { - return true - } - return false -} - -func usage(w io.Writer) { - fmt.Fprintf(w, `usage: iac-codemod [flags] [paths...] - -Modes: - refactor-plan Rewrite Plan() bodies to delegate to platform.ComputePlan. - refactor-apply Rewrite Apply() bodies to delegate to wfctlhelpers.ApplyPlan - (with informative reports for non-canonical idioms). - add-validate-plan Insert a no-op ValidatePlan stub on providers missing it. - lint Run static checks; no rewrite. Advisory-only. - -Flags (all modes): - -dry-run Report findings without mutating files (default true). - -fix Opt into mutation; overrides -dry-run. - -Mode-specific flags: - refactor-apply: - -report-file Also write the Markdown report to . Default - is stdout-only. - - Flags may appear anywhere on the command line (round-12 #1: the - dispatcher uses a manual flag scan instead of stdlib flag, so - positional-then-flag ordering is supported). Mode-specific flags - (e.g. -report-file) are passed through to the mode's own parser. - -Marker: - Functions and type declarations annotated with the comment - %s - are skipped by every mode and surfaced in each mode's report. -`, SkipMarker) -} diff --git a/cmd/iac-codemod/main_test.go b/cmd/iac-codemod/main_test.go deleted file mode 100644 index 7b42db16..00000000 --- a/cmd/iac-codemod/main_test.go +++ /dev/null @@ -1,331 +0,0 @@ -// Copyright (c) 2026 Jon Langevin -// SPDX-License-Identifier: Apache-2.0 - -// Tests in this file MUST NOT call t.Parallel(). The package-global -// `modes` map is mutated and restored under defer (see captureMode, -// TestRun_FlagAfterPath_SilentlyTreatedAsPositional, -// TestRun_PositionalArgsForwardedToMode); concurrent test goroutines -// would race on the same map and -race would catch it only at high -// concurrency. If a future T8.x test needs parallelism, refactor `run` -// to take the mode map as a parameter (dependency injection) so each -// test can build a local map per-test. - -package main - -import ( - "bytes" - "io" - "os" - "strings" - "testing" -) - -// captureMode swaps modes[name] for a recorder that captures the Options -// it was invoked with. Returns a teardown func and a pointer to the -// captured Options (nil until the mode actually runs). -func captureMode(t *testing.T, name string) (*Options, func()) { - t.Helper() - orig, ok := modes[name] - if !ok { - t.Fatalf("captureMode: unknown mode %q", name) - } - captured := &Options{} - called := false - modes[name] = func(args []string, opts *Options, stdout, stderr io.Writer) int { - *captured = *opts - called = true - _ = args - _ = stdout - _ = stderr - return 0 - } - return captured, func() { - modes[name] = orig - if !called { - t.Errorf("captureMode(%q): mode never invoked", name) - } - } -} - -func TestRun_NoArgs_ExitsWithUsage(t *testing.T) { - var stdout, stderr bytes.Buffer - code := run(nil, &stdout, &stderr) - if code != 2 { - t.Errorf("exit code = %d, want 2", code) - } - combined := stdout.String() + stderr.String() - if !strings.Contains(combined, "usage:") { - t.Errorf("expected usage in output; got stdout=%q stderr=%q", stdout.String(), stderr.String()) - } - for _, mode := range []string{"refactor-plan", "refactor-apply", "add-validate-plan", "lint"} { - if !strings.Contains(combined, mode) { - t.Errorf("usage should list mode %q; got %q", mode, combined) - } - } -} - -func TestRun_HelpFlag_ExitsZero(t *testing.T) { - for _, flag := range []string{"-h", "--help", "help"} { - t.Run(flag, func(t *testing.T) { - var stdout, stderr bytes.Buffer - code := run([]string{flag}, &stdout, &stderr) - if code != 0 { - t.Errorf("exit code = %d, want 0", code) - } - if !strings.Contains(stdout.String()+stderr.String(), "usage:") { - t.Errorf("expected usage in output; got stdout=%q stderr=%q", stdout.String(), stderr.String()) - } - }) - } -} - -func TestRun_UnknownMode_Exits2(t *testing.T) { - var stdout, stderr bytes.Buffer - code := run([]string{"frobnicate"}, &stdout, &stderr) - if code != 2 { - t.Errorf("exit code = %d, want 2", code) - } - if !strings.Contains(stderr.String(), "unknown mode") { - t.Errorf("expected 'unknown mode' in stderr; got %q", stderr.String()) - } - if !strings.Contains(stderr.String(), "frobnicate") { - t.Errorf("expected unknown mode name in stderr; got %q", stderr.String()) - } -} - -func TestRun_KnownModes_DispatchToHandlers(t *testing.T) { - for _, mode := range []string{"refactor-plan", "refactor-apply", "add-validate-plan", "lint"} { - t.Run(mode, func(t *testing.T) { - opts, teardown := captureMode(t, mode) - defer teardown() - var stdout, stderr bytes.Buffer - code := run([]string{mode}, &stdout, &stderr) - if code != 0 { - t.Errorf("exit code = %d, want 0; stderr=%q", code, stderr.String()) - } - if !opts.DryRun { - t.Errorf("DryRun should default to true") - } - if opts.Fix { - t.Errorf("Fix should default to false") - } - }) - } -} - -func TestRun_DryRunDefaultsTrue(t *testing.T) { - opts, teardown := captureMode(t, "lint") - defer teardown() - var stdout, stderr bytes.Buffer - if code := run([]string{"lint"}, &stdout, &stderr); code != 0 { - t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) - } - if !opts.DryRun { - t.Errorf("DryRun should default to true; got false") - } -} - -func TestRun_FixOptsIntoMutation(t *testing.T) { - opts, teardown := captureMode(t, "refactor-plan") - defer teardown() - var stdout, stderr bytes.Buffer - if code := run([]string{"refactor-plan", "-fix"}, &stdout, &stderr); code != 0 { - t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) - } - if !opts.Fix { - t.Errorf("Fix should be true when -fix passed") - } - if opts.DryRun { - t.Errorf("DryRun should be false when -fix passed (mutation opt-in)") - } -} - -// TestRun_HelpAfterMode_PrintsGlobalUsageToStdout pins T8.2 carry-forward -// #1 (and review round-2 finding #1): `iac-codemod -h` must -// produce the same structured output as `iac-codemod -h` — including -// the destination stream. Per kubectl / git / gh convention, -h on -// success goes to stdout; the test asserts stream specifically so a -// regression to stderr cannot pass via a string-union check. -func TestRun_HelpAfterMode_PrintsGlobalUsageToStdout(t *testing.T) { - for _, mode := range []string{"refactor-plan", "refactor-apply", "add-validate-plan", "lint"} { - t.Run(mode, func(t *testing.T) { - var stdout, stderr bytes.Buffer - code := run([]string{mode, "-h"}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - // Global usage on -h must land on STDOUT, not stderr. - for _, modeName := range []string{"refactor-plan", "refactor-apply", "add-validate-plan", "lint"} { - if !strings.Contains(stdout.String(), modeName) { - t.Errorf("global usage on -h must go to stdout (matching `iac-codemod -h`); mode %q missing from stdout=%q (stderr=%q)", modeName, stdout.String(), stderr.String()) - } - } - }) - } -} - -// TestRun_DryRunFalseWithoutFix_StillForcesDryRun pins the mutation-gate -// contract from plan §W-8 line 2347: "-dry-run flag default true; -fix opts -// into mutation". Fix must be the SINGLE source of truth for "may I -// mutate?" — if a user passes -dry-run=false without -fix, the dispatcher -// must reassert DryRun=true so T8.2-T8.5 modes that naturally check -// !opts.DryRun cannot be tricked into a silent rewrite. -func TestRun_DryRunFalseWithoutFix_StillForcesDryRun(t *testing.T) { - opts, teardown := captureMode(t, "lint") - defer teardown() - var stdout, stderr bytes.Buffer - if code := run([]string{"lint", "-dry-run=false"}, &stdout, &stderr); code != 0 { - t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) - } - if !opts.DryRun { - t.Errorf("DryRun must remain true without -fix; -dry-run=false alone must NOT bypass the mutation gate (plan §W-8 line 2347)") - } - if opts.Fix { - t.Errorf("Fix should remain false; got true") - } -} - -// TestRun_FixWithDryRunFalse_MutationStillAuthorized covers the redundant -// but legal case: -fix wins regardless of -dry-run's user-supplied value. -func TestRun_FixWithDryRunFalse_MutationStillAuthorized(t *testing.T) { - opts, teardown := captureMode(t, "refactor-plan") - defer teardown() - var stdout, stderr bytes.Buffer - if code := run([]string{"refactor-plan", "-fix", "-dry-run=false"}, &stdout, &stderr); code != 0 { - t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) - } - if !opts.Fix { - t.Errorf("Fix should be true") - } - if opts.DryRun { - t.Errorf("DryRun should be false when -fix is set") - } -} - -// TestRun_FixWithExplicitDryRunTrue_FixWins covers the inverse: -fix wins -// over a user-supplied -dry-run=true. -fix is the single mutation gate; -// -dry-run is informational once -fix is set. -func TestRun_FixWithExplicitDryRunTrue_FixWins(t *testing.T) { - opts, teardown := captureMode(t, "refactor-apply") - defer teardown() - var stdout, stderr bytes.Buffer - if code := run([]string{"refactor-apply", "-dry-run=true", "-fix"}, &stdout, &stderr); code != 0 { - t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) - } - if !opts.Fix { - t.Errorf("Fix should be true") - } - if opts.DryRun { - t.Errorf("DryRun should be false; -fix overrides explicit -dry-run=true") - } -} - -// TestPackageDoc_MentionsSkipMarker is documentation-only insurance that -// the package doc comment in main.go does not silently desync from the -// SkipMarker const. godoc is human-read, not parser-read, so this is -// belt-and-suspenders against a future rename. -func TestPackageDoc_MentionsSkipMarker(t *testing.T) { - src, err := os.ReadFile("main.go") - if err != nil { - t.Fatalf("read main.go: %v", err) - } - if !strings.Contains(string(src), SkipMarker) { - t.Errorf("main.go must reference SkipMarker literal %q somewhere; package doc may have desynced", SkipMarker) - } -} - -// TestSkipMarker_LiteralPinned guards against drift in the canonical marker -// string. Plan rev2 (line 2400) unifies all four modes on a single marker -// specifically to prevent mismatched-marker silent-no-op surfaces. T8.3-T8.5 -// will import SkipMarker for actual parsing; pinning the literal here means -// any rename or typo trips this test before it reaches a mode parser. -func TestSkipMarker_LiteralPinned(t *testing.T) { - const want = "// wfctl:skip-iac-codemod" - if SkipMarker != want { - t.Errorf("canonical marker drift: SkipMarker = %q, want %q", SkipMarker, want) - } -} - -func TestUsage_MentionsSkipMarker(t *testing.T) { - var buf bytes.Buffer - usage(&buf) - if !strings.Contains(buf.String(), SkipMarker) { - t.Errorf("usage must mention canonical marker %q; got:\n%s", SkipMarker, buf.String()) - } -} - -// TestUsage_DocumentsFlagOrderingFlexibility — round-12 #1: usage now -// documents the position-independent flag handling (was: documented a -// stdlib limitation that the manual-scan dispatcher no longer has). -func TestUsage_DocumentsFlagOrderingFlexibility(t *testing.T) { - var buf bytes.Buffer - usage(&buf) - if !strings.Contains(buf.String(), "Flags may appear anywhere") { - t.Errorf("usage must document flag-position flexibility; got:\n%s", buf.String()) - } -} - -// TestRun_FlagAfterPath_RecognizedByDispatcher pins the round-12 #1 -// fix: the manual scan in run() now recognises -dry-run/-fix anywhere -// in the argument list, including after positional args. Previous -// behavior (stdlib flag-pkg stopping at the first non-flag) was -// surprising and made the documented `-report-file` mode flag -// unusable from the CLI entrypoint. The post-round-12 behavior is -// position-independent for the dispatcher's two flags. -func TestRun_FlagAfterPath_RecognizedByDispatcher(t *testing.T) { - var capturedOpts Options - var capturedArgs []string - orig := modes["refactor-plan"] - modes["refactor-plan"] = func(args []string, opts *Options, stdout, stderr io.Writer) int { - capturedOpts = *opts - capturedArgs = append([]string{}, args...) - return 0 - } - defer func() { modes["refactor-plan"] = orig }() - - var stdout, stderr bytes.Buffer - if code := run([]string{"refactor-plan", "/path", "-fix"}, &stdout, &stderr); code != 0 { - t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) - } - if !capturedOpts.Fix { - t.Errorf("Fix should be true when -fix appears AFTER positional (manual-scan dispatcher recognises flags anywhere); got Fix=false") - } - if capturedOpts.DryRun { - t.Errorf("DryRun should be false when -fix is set; got true") - } - // Mode receives only the positional path; -fix was consumed by - // the dispatcher. - wantArgs := []string{"/path"} - if len(capturedArgs) != len(wantArgs) { - t.Fatalf("got args %v, want %v", capturedArgs, wantArgs) - } - for i := range wantArgs { - if capturedArgs[i] != wantArgs[i] { - t.Errorf("arg[%d] = %q, want %q", i, capturedArgs[i], wantArgs[i]) - } - } -} - -func TestRun_PositionalArgsForwardedToMode(t *testing.T) { - var gotArgs []string - orig := modes["lint"] - modes["lint"] = func(args []string, opts *Options, stdout, stderr io.Writer) int { - gotArgs = append([]string{}, args...) - return 0 - } - defer func() { modes["lint"] = orig }() - - var stdout, stderr bytes.Buffer - if code := run([]string{"lint", "-dry-run", "/path/to/plugin", "/another/path"}, &stdout, &stderr); code != 0 { - t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) - } - wantArgs := []string{"/path/to/plugin", "/another/path"} - if len(gotArgs) != len(wantArgs) { - t.Fatalf("got args %v, want %v", gotArgs, wantArgs) - } - for i := range wantArgs { - if gotArgs[i] != wantArgs[i] { - t.Errorf("arg[%d] = %q, want %q", i, gotArgs[i], wantArgs[i]) - } - } -} diff --git a/cmd/iac-codemod/refactor_apply.go b/cmd/iac-codemod/refactor_apply.go deleted file mode 100644 index 21fe5b8a..00000000 --- a/cmd/iac-codemod/refactor_apply.go +++ /dev/null @@ -1,1354 +0,0 @@ -// Copyright (c) 2026 Jon Langevin -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "bytes" - "flag" - "fmt" - "go/ast" - "go/parser" - "go/token" - "io" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" -) - -func init() { - modes["refactor-apply"] = runRefactorApply -} - -// applyCanonicalCallExpr is the canonical replacement-body expression -// emitted by refactor-apply. -// -//nolint:unused -const applyCanonicalCallExpr = "wfctlhelpers.ApplyPlan(ctx, p, plan)" - -// applyClassification labels the disposition of a single Apply() -// method site. The non-canonical idioms are surfaced as distinct -// classes so the report can suggest the right hand-port handling. -type applyClassification int - -const ( - applyCanonical applyClassification = iota - applyAlreadyDelegated - applySkipped - // Non-canonical idioms (each with its own suggested handling): - applyUpsertRecovery // DO upsert-on-create-conflict — emit upsertSupporter hook patch - applyUpdateReplaceCollapse // AWS `case "update", "replace":` — emit "manual port required" - applyCustomErrorWrapping // custom fmt.Errorf wrapping — emit extension-point hook + sample - applyNonCanonicalOther // some other shape we don't recognise - applyMissingSwitch // no switch-on-action; cannot mechanically rewrite -) - -func (c applyClassification) String() string { - switch c { - case applyCanonical: - return "canonical" - case applyAlreadyDelegated: - return "already-delegated" - case applySkipped: - return "skipped" - case applyUpsertRecovery: - return "upsert-recovery" - case applyUpdateReplaceCollapse: - return "update+replace-collapse" - case applyCustomErrorWrapping: - return "custom-error-wrapping" - case applyNonCanonicalOther: - return "non-canonical" - case applyMissingSwitch: - return "missing-action-switch" - default: - return "unknown" - } -} - -// applySite captures one Apply-method site in the report. -type applySite struct { - Path string - Line int - Receiver string - Class applyClassification - OffenderPos string // path:line of the offending construct (for collapse/wrap idioms) - Suggestion string // hand-port suggestion text - Rewrote bool -} - -// applyReport aggregates per-file results across an entire refactor-apply -// run. -type applyReport struct { - sites []applySite - errors []string -} - -// runRefactorApply is the entry point for the refactor-apply subcommand. -// Mode-local flags (currently `-report-file`) are parsed off `args` -// before path walking begins. -func runRefactorApply(args []string, opts *Options, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("iac-codemod refactor-apply", flag.ContinueOnError) - fs.SetOutput(stderr) - // Steer per-mode -h to stdout for symmetry with the top-level - // `iac-codemod -h` (T8.2 carry-forward #1). The dispatcher in main.go - // intercepts `-h` before it reaches this FlagSet, so this closure - // only fires on parse errors. Mode-specific flags (-report-file) - // are documented in main.go's global usage() text — that's the - // fix surface for review round-1 finding #11. - fs.Usage = func() { usage(stdout) } - reportFile := fs.String("report-file", "", "if set, also write the report (Markdown) to this path; default is stdout-only") - if err := fs.Parse(args); err != nil { - // flag.ContinueOnError already wrote a parse-error message via - // SetOutput(stderr); a -h returns ErrHelp which we surface as 0. - if err == flag.ErrHelp { - return 0 - } - return 2 - } - rest := fs.Args() - if len(rest) == 0 { - fmt.Fprintln(stderr, "iac-codemod refactor-apply: at least one path is required") - usage(stderr) - return 2 - } - report := &applyReport{} - for _, path := range rest { - if err := refactorApplyPath(path, opts, report); err != nil { - fmt.Fprintf(stderr, "iac-codemod refactor-apply: %s: %v\n", path, err) - return 1 - } - } - report.print(stdout, opts) - if *reportFile != "" { - var buf bytes.Buffer - report.print(&buf, opts) - if err := os.WriteFile(*reportFile, buf.Bytes(), 0o600); err != nil { - fmt.Fprintf(stderr, "iac-codemod refactor-apply: write report-file %s: %v\n", *reportFile, err) - return 1 - } - } - if len(report.errors) > 0 { - return 1 - } - return 0 -} - -// refactorApplyPath walks `path` for *.go files and processes each. -func refactorApplyPath(path string, opts *Options, report *applyReport) error { - info, err := stat(path) - if err != nil { - return err - } - if !info.IsDir() { - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return fmt.Errorf("not a Go source file (or is a _test.go): %s", path) - } - if err := refactorApplyFile(path, opts, report); err != nil { - report.errors = append(report.errors, fmt.Sprintf("%s: %v", path, err)) - } - return nil - } - return filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - base := d.Name() - if shouldSkipDir(base) { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { - return nil - } - if err := refactorApplyFile(p, opts, report); err != nil { - report.errors = append(report.errors, fmt.Sprintf("%s: %v", p, err)) - } - return nil - }) -} - -// refactorApplyFile parses `path`, classifies every Apply method, and -// (in -fix mode) mutates canonical bodies in place. -func refactorApplyFile(path string, opts *Options, report *applyReport) error { - src, err := readFile(path) - if err != nil { - return err - } - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, path, src, parser.ParseComments) - if err != nil { - return err - } - - // Round-12 #3: skip files in a non-dominant package (same - // rationale as refactor-plan #2). - dominant := dominantPackageForDir(filepath.Dir(path)) - if dominant != "" && file.Name.Name != dominant { - return nil - } - // Directory-wide method set (review round-1 finding #9). - provs := planLikeReceiversInDir(filepath.Dir(path)) - if len(provs) == 0 { - provs = planLikeReceivers(file) - } - // Directory-wide type-doc lookup (review round-6 finding #1) so - // skip-marker on a sibling file's type declaration is honored. - typeDocs := receiverTypeDocsInDir(filepath.Dir(path), file) - - mutated := false - for _, decl := range file.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok { - continue - } - if !isProviderMethod(fn, "Apply", 2, 2) { - continue - } - recv := receiverTypeName(fn) - if !provs[recv] { - continue - } - // Honor SkipMarker on fn.Doc OR receiver-type docs (review - // round-1 finding #4). - if hasSkipMarkerOn(fn.Doc) || typeDocs[recv].carriesMarker() { - report.sites = append(report.sites, applySite{ - Path: path, - Line: fset.Position(fn.Pos()).Line, - Receiver: recv, - Class: applySkipped, - }) - continue - } - class, offenderPos, suggestion := classifyApplyBody(fn, file, fset, path) - site := applySite{ - Path: path, - Line: fset.Position(fn.Pos()).Line, - Receiver: recv, - Class: class, - OffenderPos: offenderPos, - Suggestion: suggestion, - } - if class == applyCanonical && opts != nil && opts.Fix { - rewriteApplyBody(fn, file) - mutated = true - site.Rewrote = true - } - report.sites = append(report.sites, site) - } - - if mutated && opts != nil && opts.Fix { - ensureWfctlhelpersImport(file) // refactor-apply emits wfctlhelpers.ApplyPlan - if err := writeFileAtomic(path, fset, file); err != nil { - return fmt.Errorf("write %s: %w", path, err) - } - } - return nil -} - -// classifyApplyBody returns the disposition of fn's Apply body. If the -// body has any of the recognised non-canonical idioms, the offender's -// path:line and a hand-port suggestion are returned alongside the class -// label. The order of detection is intentional: the most-disruptive -// idiom (collapse) is reported first since it cannot be mechanically -// migrated, then upsert (which has a clean wfctlhelpers hook), then -// custom-error-wrapping. Multiple idioms in one body produce a single -// label; the report points at the first detected. -func classifyApplyBody(fn *ast.FuncDecl, file *ast.File, fset *token.FileSet, path string) (applyClassification, string, string) { - if fn.Body == nil { - return applyNonCanonicalOther, "", "" - } - if isAlreadyDelegatedApplyBody(fn.Body, file) { - return applyAlreadyDelegated, "", "" - } - sw := findActionSwitch(fn.Body) - if sw == nil { - return applyMissingSwitch, "", "Apply body has no `switch action.Action` dispatch — wfctlhelpers.ApplyPlan expects this loop+switch shape; hand-port required." - } - // AWS update+replace collapse: any case clause with both "update" - // and "replace" string literals. - if pos := findUpdateReplaceCollapseCase(sw); pos.IsValid() { - offender := fset.Position(pos) - return applyUpdateReplaceCollapse, fmtPosShort(path, offender.Line), "manual port required: split `case \"update\", \"replace\":` into separate `update` and `replace` clauses (or rely on wfctlhelpers.ApplyPlan's doReplace semantic). The collapsed shape silently treats Replace as Update which loses the delete+create semantic for force-new fields." - } - // DO upsert recovery: errors.Is(err, ErrResourceAlreadyExists). - if pos := findUpsertRecovery(sw); pos.IsValid() { - offender := fset.Position(pos) - return applyUpsertRecovery, fmtPosShort(path, offender.Line), "preserve via wfctlhelpers.ApplyPlan's upsertSupporter hook: drivers that support name-based discovery should implement `SupportsUpsert() bool` returning true; the helper handles ErrResourceAlreadyExists → Read+Update internally. Sample patch: keep the existing `upsertSupporter` interface declaration on the driver type, then delete the manual upsert branch from Apply." - } - // Custom error wrapping: a `case` body where err is reassigned via - // fmt.Errorf with %w wrapping after a driver call. - if pos := findCustomErrorWrap(sw); pos.IsValid() { - offender := fset.Position(pos) - return applyCustomErrorWrapping, fmtPosShort(path, offender.Line), "manual port required: wfctlhelpers.ApplyPlan does NOT expose a per-action error-wrap hook today (review round-1 finding #6: rev0 of this report named a fictional ApplyResultErrorHook / WrapActionError API). Two honest options: (a) preserve the domain-context wrap by adding `// wfctl:skip-iac-codemod` to the Apply method and keeping the manual switch; (b) move the wrap into the driver itself (Create/Update/Delete return the already-wrapped error) so wfctlhelpers' generic dispatcher records it verbatim. Option (b) is preferred because it survives any future migration." - } - // Heuristic: if the switch has the canonical create/update[/delete] - // triple (plus optional separate replace), no non-canonical idiom - // inside the switch, AND the surrounding Apply body matches the - // canonical scaffold (result-init + range-loop + return), treat as - // canonical. Round-5 finding #2: rev3 only verified the switch - // shape — setup/teardown/custom result aggregation OUTSIDE the - // switch was silently dropped on -fix. - // Extract receiver + plan parameter identifier names so the - // outer-shape and loop-body validators don't hardcode `p` / - // `result` / `plan` (round-8 #5 + #9: providers using `res` / - // `pl` / etc. were misclassified as non-canonical even though - // rewriteApplyBody preserves custom names). - recvName := "" - if fn.Recv != nil && len(fn.Recv.List) > 0 && len(fn.Recv.List[0].Names) > 0 { - recvName = fn.Recv.List[0].Names[0].Name - } - planName := "" - if fn.Type.Params != nil && len(fn.Type.Params.List) >= 2 && len(fn.Type.Params.List[1].Names) >= 1 { - planName = fn.Type.Params.List[1].Names[0].Name - } - if hasCanonicalCases(sw, recvName) && isCanonicalApplyOuterShape(fn.Body, recvName, planName) { - return applyCanonical, "", "" - } - return applyNonCanonicalOther, "", "Apply outer shape (result-init + range-loop + return) or switch has unrecognised statements; review manually." -} - -// isCanonicalApplyOuterShape returns true if fn.Body matches the -// canonical 3-statement scaffold around the action switch: -// -// 1. ` := &ApplyResult{...}` -// 2. `for _, action := range .Actions { ... }` -// 3. `return , nil` -// -// `recvName` is the receiver identifier (used by isCanonicalApplyLoopBody -// to validate the driver-lookup receiver is the provider). -// `planName` is the actual `plan` parameter name from the signature -// (round-8 #9: providers using `pl` etc. were misclassified). -// -// The accumulator-variable name is recovered from statement 1 and -// then required to match in statement 3, so any local convention -// (`result`, `res`, `out`) survives as long as it's consistent. -// -// Reject any deviation (extra setup, teardown, custom aggregation, -// trailing helper calls) so bespoke logic outside the switch is -// preserved as non-canonical (review round-5 #2). -func isCanonicalApplyOuterShape(body *ast.BlockStmt, recvName, planName string) bool { - if body == nil || len(body.List) != 3 { - return false - } - if planName == "" { - planName = "plan" - } - // 1. := &ApplyResult{...} — recover the local - // accumulator name so the canonical detector doesn't hardcode - // "result" (round-8 #9). - a, ok := body.List[0].(*ast.AssignStmt) - if !ok || a.Tok != token.DEFINE || len(a.Lhs) != 1 || len(a.Rhs) != 1 { - return false - } - resultIdent, ok := a.Lhs[0].(*ast.Ident) - if !ok { - return false - } - resultName := resultIdent.Name - if resultName == "" || resultName == "_" { - return false - } - un, ok := a.Rhs[0].(*ast.UnaryExpr) - if !ok || un.Op != token.AND { - return false - } - cl, ok := un.X.(*ast.CompositeLit) - if !ok { - return false - } - if !typeNameTailMatches(cl.Type, "ApplyResult") { - return false - } - // 2. for _, action := range .Actions { ... } - rng, ok := body.List[1].(*ast.RangeStmt) - if !ok { - return false - } - xSel, ok := rng.X.(*ast.SelectorExpr) - if !ok || xSel.Sel.Name != "Actions" { - return false - } - if planId, ok := xSel.X.(*ast.Ident); !ok || planId.Name != planName { - return false - } - // Round-7 #3 + #9: validate the loop body is one of the recognised - // canonical scaffolds. Pass the provider receiver name so the - // driver-lookup check (round-8 #5) verifies .ResourceDriver - // rather than accepting any selector. - if !isCanonicalApplyLoopBody(rng.Body, recvName, resultName) { - return false - } - // 3. return , nil - ret, ok := body.List[2].(*ast.ReturnStmt) - if !ok || len(ret.Results) != 2 { - return false - } - if id, ok := ret.Results[0].(*ast.Ident); !ok || id.Name != resultName { - return false - } - if id, ok := ret.Results[1].(*ast.Ident); !ok || id.Name != "nil" { - return false - } - return true -} - -// isCanonicalApplyLoopBody returns true if the for-loop body matches -// one of the canonical scaffolds. Round-7 #3 + #9: rev5 of -// isCanonicalApplyOuterShape only verified the outer 3 statements; -// any per-action logging/metrics/accumulators inside the for loop -// was silently dropped on -fix. -// -// Whitelist (every loop-body statement must match one of these): -// -// - SwitchStmt with tag `.Action` (the action dispatch). Exactly 1 -// such switch is required across the loop body. -// - DeclStmt: `var out *ResourceOutput` (or qualified equivalent). -// - AssignStmt: `, err := .ResourceDriver(...)` (driver lookup). -// - IfStmt: `if err != nil { result.Errors = append(...); continue }` -// OR `if out != nil { result.Resources = append(*out) }` -// -// Anything else (bare logging calls, metric increments, helper-call -// statements, alternate-driver lookup) rejects the canonical -// classification. -func isCanonicalApplyLoopBody(body *ast.BlockStmt, recvName, resultName string) bool { - if body == nil { - return false - } - switchCount := 0 - for _, stmt := range body.List { - switch s := stmt.(type) { - case *ast.SwitchStmt: - switchCount++ - // (the switch body itself is validated by hasCanonicalCases - // in classifyApplyBody before this function fires). - case *ast.DeclStmt: - if !isLocalOutPointerDecl(s) { - return false - } - case *ast.AssignStmt: - if !isCanonicalApplyLoopAssign(s, recvName) { - return false - } - case *ast.IfStmt: - if !isCanonicalApplyLoopIf(s, resultName) { - return false - } - default: - return false - } - } - return switchCount == 1 -} - -// isCanonicalApplyLoopAssign returns true for the canonical loop-body -// AssignStmt shapes: `, err := .ResourceDriver(...)`. The -// receiver MUST be the provider's own receiver identifier (round-8 #5: -// rev3 accepted any `.ResourceDriver(...)`, so `helper.ResourceDriver(...)` -// or `plan.ResourceDriver(...)` falsely classified as canonical). -func isCanonicalApplyLoopAssign(a *ast.AssignStmt, recvName string) bool { - if len(a.Lhs) != 2 || len(a.Rhs) != 1 { - return false - } - if id, ok := a.Lhs[1].(*ast.Ident); !ok || id.Name != "err" { - return false - } - call, ok := a.Rhs[0].(*ast.CallExpr) - if !ok { - return false - } - sel, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return false - } - // Round-9 #2: only ResourceDriver is canonical. wfctlhelpers.ApplyPlan - // dispatches through IaCProvider.ResourceDriver specifically — a - // provider that wraps lookup in `Driver(...)` or `DriverFor(...)` - // would have its wrapper bypassed on rewrite, which can change the - // driver returned (caching, instrumentation, etc.). - if sel.Sel.Name != "ResourceDriver" { - return false - } - // Receiver must be the provider's own identifier. - x, ok := sel.X.(*ast.Ident) - if !ok { - return false - } - if (recvName == "" || x.Name != recvName) && (recvName != "" || x.Name != "p") { - return false - } - // Round-12 #7: also verify the lookup KEY is `action.Resource.Type`. - // wfctlhelpers.ApplyPlan always dispatches with `action.Resource.Type`, - // so a provider that picks drivers by some other key (e.g. - // `action.Tag` or a computed value) would see different driver - // behavior on rewrite. Require the canonical key shape. - if len(call.Args) != 1 { - return false - } - keySel, ok := call.Args[0].(*ast.SelectorExpr) - if !ok || keySel.Sel.Name != "Type" { - return false - } - innerSel, ok := keySel.X.(*ast.SelectorExpr) - if !ok || innerSel.Sel.Name != "Resource" { - return false - } - innerId, ok := innerSel.X.(*ast.Ident) - if !ok || innerId.Name != "action" { - return false - } - return true -} - -// isCanonicalApplyLoopIf returns true for the canonical loop-body -// IfStmt shapes: -// -// - `if err != nil { .Errors = append(...); continue }` -// - `if out != nil { .Resources = append(...) }` -// -// `resultName` is the local accumulator identifier (recovered from the -// outer scaffold). -// -// Round-8 #8: rev6 of isCanonicalApplyLoopIfBodyStmt accepted a bare -// `continue`/`break` statement, but wfctlhelpers ALWAYS records an -// ActionError before continuing past a failure. So a guard like -// `if err != nil { continue }` (no append) would silently change -// behavior on rewrite. Now we require: when the guard body contains a -// continue/break, it MUST also contain an append-to-result statement. -func isCanonicalApplyLoopIf(ifs *ast.IfStmt, resultName string) bool { - if ifs == nil { - return false - } - be, ok := ifs.Cond.(*ast.BinaryExpr) - if !ok || be.Op != token.NEQ { - return false - } - id, ok := be.X.(*ast.Ident) - if !ok || (id.Name != "err" && id.Name != "out") { - return false - } - if rhs, ok := be.Y.(*ast.Ident); !ok || rhs.Name != "nil" { - return false - } - if ifs.Else != nil { - return false - } - hasAppend := false - hasBranch := false - for _, s := range ifs.Body.List { - switch ss := s.(type) { - case *ast.AssignStmt: - if !isCanonicalAppendToResult(ss, resultName) { - return false - } - hasAppend = true - case *ast.BranchStmt: - // Round-9 #1: only `continue` is canonical; `break` - // silently aborts the loop on first error, but - // wfctlhelpers.ApplyPlan records the error and KEEPS - // processing later actions, so accepting `break` would - // silently change behavior on rewrite. - if ss.Tok != token.CONTINUE { - return false - } - hasBranch = true - default: - return false - } - } - // A bare continue/break (no append) is rejected — wfctlhelpers - // always records the ActionError before continuing. - if hasBranch && !hasAppend { - return false - } - return true -} - -// isCanonicalAppendToResult returns true if stmt is -// `. = append(...)`. Used inside loop-body if-guards -// (round-8 #8: tightened to require this shape, not just "any -// append"). -func isCanonicalAppendToResult(s *ast.AssignStmt, resultName string) bool { - if len(s.Lhs) != 1 || len(s.Rhs) != 1 || s.Tok != token.ASSIGN { - return false - } - sel, ok := s.Lhs[0].(*ast.SelectorExpr) - if !ok { - return false - } - if id, ok := sel.X.(*ast.Ident); !ok || id.Name != resultName { - return false - } - call, ok := s.Rhs[0].(*ast.CallExpr) - if !ok { - return false - } - idFn, ok := call.Fun.(*ast.Ident) - if !ok || idFn.Name != "append" { - return false - } - return true -} - -// fmtPosShort renders a path:line short form for offender positions. -// Path is left as-supplied (caller provides the path the user gave). -func fmtPosShort(path string, line int) string { - return fmt.Sprintf("%s:%d", path, line) -} - -// isAlreadyDelegatedApplyBody returns true if fn.Body is a single -// `return .ApplyPlan(...)`. Review round-4 finding -// #4: rev3 hardcoded the package identifier as `wfctlhelpers`. A -// provider that already delegates through an aliased import (e.g. -// `wf "github.com/.../wfctlhelpers"; return wf.ApplyPlan(...)`) was -// misreported as non-canonical. Resolves the import alias via -// pkgAliasFor so any aliased delegation is recognised. -func isAlreadyDelegatedApplyBody(body *ast.BlockStmt, file *ast.File) bool { - if len(body.List) != 1 { - return false - } - ret, ok := body.List[0].(*ast.ReturnStmt) - if !ok || len(ret.Results) != 1 { - return false - } - call, ok := ret.Results[0].(*ast.CallExpr) - if !ok { - return false - } - sel, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return false - } - x, ok := sel.X.(*ast.Ident) - if !ok { - return false - } - if sel.Sel.Name != "ApplyPlan" { - return false - } - // Accept the literal default name OR the file's local alias for - // the wfctlhelpers import path. Falls back to the literal name - // when file is nil (test paths that don't pass it). - wantAlias := pkgAliasFor(file, helperImportPath, "wfctlhelpers") - return x.Name == wantAlias || x.Name == "wfctlhelpers" -} - -// findActionSwitch returns the first switch statement whose tag is a -// SelectorExpr `.Action` (canonical: `action.Action`). Only the -// outermost RangeStmt's body is searched: nested switches inside if -// branches are still matched by ast.Inspect, which is fine — the -// dispatch must be on `something.Action`. -func findActionSwitch(body *ast.BlockStmt) *ast.SwitchStmt { - var found *ast.SwitchStmt - ast.Inspect(body, func(n ast.Node) bool { - if found != nil { - return false - } - sw, ok := n.(*ast.SwitchStmt) - if !ok { - return true - } - sel, ok := sw.Tag.(*ast.SelectorExpr) - if !ok { - return true - } - if sel.Sel.Name == "Action" { - found = sw - return false - } - return true - }) - return found -} - -// findUpdateReplaceCollapseCase returns the position of the first case -// clause whose case-list literals include both "update" and "replace". -// Returns token.NoPos if no such collapse exists. -func findUpdateReplaceCollapseCase(sw *ast.SwitchStmt) token.Pos { - for _, stmt := range sw.Body.List { - cc, ok := stmt.(*ast.CaseClause) - if !ok { - continue - } - hasUpdate, hasReplace := false, false - for _, expr := range cc.List { - s, ok := stringLiteral(expr) - if !ok { - continue - } - switch s { - case "update": - hasUpdate = true - case "replace": - hasReplace = true - } - } - if hasUpdate && hasReplace { - return cc.Pos() - } - } - return token.NoPos -} - -// findUpsertRecovery returns the position of an `errors.Is(err, X)` -// call inside a case clause where X has the suffix `AlreadyExists`. -// Match is conservative: the receiver is `errors`, the selector is -// `Is`, and the second arg's name (or its selector tail) ends in -// "AlreadyExists". This catches both `ErrResourceAlreadyExists` and -// `interfaces.ErrResourceAlreadyExists`. -func findUpsertRecovery(sw *ast.SwitchStmt) token.Pos { - var found token.Pos - ast.Inspect(sw, func(n ast.Node) bool { - if found.IsValid() { - return false - } - call, ok := n.(*ast.CallExpr) - if !ok { - return true - } - sel, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return true - } - x, ok := sel.X.(*ast.Ident) - if !ok || x.Name != "errors" || sel.Sel.Name != "Is" { - return true - } - if len(call.Args) < 2 { - return true - } - if name := tailIdent(call.Args[1]); strings.HasSuffix(name, "AlreadyExists") { - found = call.Pos() - return false - } - return true - }) - return found -} - -// tailIdent returns the trailing identifier name of a SelectorExpr -// chain (or the bare ident name), or "" for unrecognised shapes. -func tailIdent(expr ast.Expr) string { - switch e := expr.(type) { - case *ast.Ident: - return e.Name - case *ast.SelectorExpr: - return e.Sel.Name - } - return "" -} - -// findCustomErrorWrap returns the position of an `err = fmt.Errorf(..., -// %w, err)` reassignment that wraps an existing error — i.e., the RHS -// fmt.Errorf call references the local `err` variable as one of its -// arguments. This is the bespoke domain-context wrapping pattern. -// -// The narrower-than-just-`err = fmt.Errorf(...)` shape is intentional: -// a `default:` case in the action switch often has `err = fmt.Errorf("unknown action %q", ...)`, -// which is a FRESH error for an unknown action, not a wrap of a driver -// error. wfctlhelpers' generic dispatcher already errors on unknown -// actions, so the codemod must NOT flag that benign case. -// -// Match shape: assignment whose LHS is `err` and whose RHS is a -// fmt.Errorf call where at least one arg is the identifier `err`. -func findCustomErrorWrap(sw *ast.SwitchStmt) token.Pos { - var found token.Pos - ast.Inspect(sw, func(n ast.Node) bool { - if found.IsValid() { - return false - } - assign, ok := n.(*ast.AssignStmt) - if !ok { - return true - } - if len(assign.Lhs) != 1 || len(assign.Rhs) != 1 { - return true - } - id, ok := assign.Lhs[0].(*ast.Ident) - if !ok || id.Name != "err" { - return true - } - call, ok := assign.Rhs[0].(*ast.CallExpr) - if !ok { - return true - } - sel, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return true - } - x, ok := sel.X.(*ast.Ident) - if !ok { - return true - } - if x.Name != "fmt" || sel.Sel.Name != "Errorf" { - return true - } - // Must reference `err` somewhere in the args (the wrap target). - // A fmt.Errorf for a fresh error doesn't pass `err`, so this - // keeps the unknown-action default case clean. - for _, a := range call.Args { - if argId, ok := a.(*ast.Ident); ok && argId.Name == "err" { - found = assign.Pos() - return false - } - } - return true - }) - return found -} - -// hasCanonicalCases returns true if the switch has at least the -// "create" + "update" cases (delete is conventional but optional in -// providers that don't support delete via Apply) AND every case body -// has only the canonical-shape statements (driver method calls, -// ResourceRef construction, simple if-guards on action.Current). The -// body-shape validation closes review round-1 finding #5: rev0 of this -// function only checked case labels, so extra bookkeeping or metrics -// inside a case body would still classify as canonical and get silently -// dropped during rewrite. -// -// Recognised case-body statement kinds (each maps to a known shape -// in wfctlhelpers' generic dispatcher): -// -// - AssignStmt: `out, err = drv.Create(ctx, action.Resource)` / -// `err = drv.Delete(ctx, ref)` / `ref := ResourceRef{...}` / -// `ref.ProviderID = action.Current.ProviderID` -// - IfStmt: only the `if action.Current != nil` ProviderID-set -// guard pattern (cond is BinaryExpr NEQ on action.Current and nil) -// - DeclStmt: `var out *ResourceOutput` (rare but legal; wfctlhelpers -// handles its own out variable) -// -// Round-8 #4: rev3 of this function accepted `default:` clauses -// without inspecting their body. Logging/metrics/etc. in default -// silently dropped. Now default bodies are validated against the -// same shape: only AssignStmt of `err = fmt.Errorf(...)` (the -// canonical unknown-action error pattern) is allowed. Everything -// else (including bare logging) rejects. -// -// `recvName` is the provider receiver identifier — passed through to -// caseBodyIsCanonical → isCanonicalCaseAssign → isDriverMethodCall to -// validate driver-receiver names per the round-4 #3 fix. -func hasCanonicalCases(sw *ast.SwitchStmt, recvName string) bool { - hasCreate, hasUpdate := false, false - for _, stmt := range sw.Body.List { - cc, ok := stmt.(*ast.CaseClause) - if !ok { - continue - } - labels := caseLabels(cc) - isCanonicalLabel := false - for _, l := range labels { - switch l { - case "create": - hasCreate = true - isCanonicalLabel = true - case "update": - hasUpdate = true - isCanonicalLabel = true - case "delete", "replace": - isCanonicalLabel = true - } - } - // `default:` (no labels) — round-8 #4: validate body matches - // the canonical unknown-action error shape. Anything else - // (logging, metrics, alternate side-effect) rejects. - if len(labels) == 0 { - if !isCanonicalDefaultBody(cc.Body) { - return false - } - continue - } - if !isCanonicalLabel { - return false - } - if !caseBodyIsCanonical(cc.Body) { - return false - } - // Round-12 #6: verify the case body's driver call matches the - // case label. A `case "create"` body that actually calls - // `.Update(...)` or `.Delete(...)` would be silently rewritten - // away because wfctlhelpers.ApplyPlan dispatches "create" to - // Driver.Create. Mismatch means the rewrite changes semantics. - if !caseBodyMatchesLabel(cc.Body, labels) { - return false - } - } - return hasCreate && hasUpdate -} - -// caseBodyMatchesLabel returns true if the driver-method calls inside -// body match the case labels. The mapping is: -// -// "create" → .Create -// "update" → .Update -// "replace" → either .Update OR .Delete+.Create (helpers may use either) -// "delete" → .Delete -// -// A case body with no driver call still passes (helpers like ref-init -// don't have a method call). A case body whose ONLY driver call has -// the wrong method-name for ANY of the labels rejects. -// -// Round-12 #6: rev1 of hasCanonicalCases didn't link labels to body -// operations; mismatched implementations were silently rewritten. -func caseBodyMatchesLabel(body []ast.Stmt, labels []string) bool { - calledMethods := make(map[string]bool) - for _, stmt := range body { - ast.Inspect(stmt, func(n ast.Node) bool { - call, ok := n.(*ast.CallExpr) - if !ok { - return true - } - sel, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return true - } - switch sel.Sel.Name { - case "Create", "Read", "Update", "Delete": - calledMethods[sel.Sel.Name] = true - } - return true - }) - } - if len(calledMethods) == 0 { - // No driver call (e.g., body just inits a ref var). Passes — - // the canonical detector elsewhere handles ref-init shapes. - return true - } - for _, label := range labels { - expected := map[string]string{ - "create": "Create", - "update": "Update", - "delete": "Delete", - "replace": "Update", // wfctlhelpers' doReplace internally uses Delete+Create - }[label] - if expected == "" { - continue - } - if !calledMethods[expected] { - return false - } - } - return true -} - -// caseLabels returns the unquoted string-literal values of the case -// clause's case-list. A `default:` clause returns an empty slice. -// isCanonicalDefaultBody returns true if body matches the canonical -// `default:` clause shape: a single `err = fmt.Errorf("unknown action -// %q", ...)` assignment. Anything else (logging, metrics, alternate -// side-effects) rejects (round-8 #4). -func isCanonicalDefaultBody(body []ast.Stmt) bool { - if len(body) != 1 { - return false - } - a, ok := body[0].(*ast.AssignStmt) - if !ok || a.Tok != token.ASSIGN || len(a.Lhs) != 1 || len(a.Rhs) != 1 { - return false - } - if id, ok := a.Lhs[0].(*ast.Ident); !ok || id.Name != "err" { - return false - } - call, ok := a.Rhs[0].(*ast.CallExpr) - if !ok { - return false - } - sel, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return false - } - x, ok := sel.X.(*ast.Ident) - if !ok { - return false - } - return x.Name == "fmt" && sel.Sel.Name == "Errorf" -} - -func caseLabels(cc *ast.CaseClause) []string { - var out []string - for _, expr := range cc.List { - if s, ok := stringLiteral(expr); ok { - out = append(out, s) - } - } - return out -} - -// caseBodyIsCanonical returns true if every statement in body is in -// the recognised whitelist (driver call, ResourceRef construction, -// ProviderID guard). The whitelist is intentionally narrow so that -// bespoke statements (logging, metrics, alternate construction) cause -// rejection — the codemod errs on the side of NOT rewriting in -// ambiguous shapes. -func caseBodyIsCanonical(body []ast.Stmt) bool { - for _, stmt := range body { - if !canonicalCaseStmt(stmt) { - return false - } - } - return true -} - -// canonicalCaseStmt returns true if stmt fits one of the canonical -// shapes inside an action-switch case body. The whitelist is -// intentionally narrow: any statement outside the recognised set -// (bookkeeping counters, map updates, accumulators, alternate calls) -// causes rejection. Review round-3 finding #5: rev2 of this function -// accepted ANY AssignStmt, so `createsTotal++` / `metrics[action.Action]++` -// / `result.Stats.Updates++` all passed and the bespoke logic was -// silently dropped during -fix. -// -// Recognised AssignStmt shapes: -// -// - Multi-target call: `out, err = .(...)` with X a Driver -// identifier and METHOD in {Create, Read, Update, Delete} -// - Single-target call: `err = .(...)` (delete-style) -// - Composite literal: `ref := {...}` where T is ResourceRef-shaped -// - Selector assignment: `. = .` where the LHS is a known -// ProviderID-style field (ProviderID, Name, Type) -// -// Recognised non-Assign shapes: -// -// - if-guard: `if action.Current != nil { ... }` containing only -// canonical shapes (recursion via isProviderIDGuard) -// - var-decl: `var out *ResourceOutput` -func canonicalCaseStmt(stmt ast.Stmt) bool { - switch s := stmt.(type) { - case *ast.AssignStmt: - return isCanonicalCaseAssign(s) - case *ast.IfStmt: - return isProviderIDGuard(s) - case *ast.DeclStmt: - // Only `var out *ResourceOutput` (or qualified equivalent). - // Review round-4 finding #6: rev3 accepted ALL DeclStmts, so - // `var x SomeBookkeepingType` declarations passed as canonical - // and the bespoke local variable was silently dropped. - return isLocalOutPointerDecl(s) - } - return false -} - -// isLocalOutPointerDecl returns true if stmt is a single -// `var *` declaration. The name is not -// constrained (the standard convention is `out` but `o` / `result` -// are valid) but the type tail must be ResourceOutput. -func isLocalOutPointerDecl(s *ast.DeclStmt) bool { - gd, ok := s.Decl.(*ast.GenDecl) - if !ok || gd.Tok != token.VAR || len(gd.Specs) != 1 { - return false - } - vs, ok := gd.Specs[0].(*ast.ValueSpec) - if !ok || vs.Type == nil || len(vs.Names) != 1 { - return false - } - star, ok := vs.Type.(*ast.StarExpr) - if !ok { - return false - } - return typeNameTailMatches(star.X, "ResourceOutput") -} - -// isCanonicalCaseAssign tightens the AssignStmt acceptance whitelist -// to known canonical shapes (round-3 #5). -func isCanonicalCaseAssign(a *ast.AssignStmt) bool { - // Multi-target driver call: `out, err = .(...)`. - // Two LHS, one RHS that is a CallExpr on a SelectorExpr. - if len(a.Lhs) == 2 && len(a.Rhs) == 1 { - if isDriverMethodCall(a.Rhs[0]) { - return true - } - } - // Single-target driver call: `err = .(...)`. - if len(a.Lhs) == 1 && len(a.Rhs) == 1 { - if isDriverMethodCall(a.Rhs[0]) { - // LHS must be `err` — a different LHS would mean - // custom variable bookkeeping. - if id, ok := a.Lhs[0].(*ast.Ident); ok && id.Name == "err" { - return true - } - return false - } - // Composite-literal `ref := ResourceRef{...}` ONLY. Review - // round-4 finding #2: rev3 of this branch accepted any - // composite literal, so a bookkeeping struct construction - // (`payload := AuditPayload{...}`) was misclassified as - // canonical and silently dropped. Now the literal type's - // name (qualified or unqualified) must be ResourceRef. - if a.Tok == token.DEFINE { - if cl, ok := a.Rhs[0].(*ast.CompositeLit); ok && typeNameTailMatches(cl.Type, "ResourceRef") { - return true - } - } - // Selector assignment `ref. = ` to a ResourceRef-style - // field (ProviderID, Name, Type). Round-10 #4: rev3 accepted - // any LHS selector with this field name, so unrelated bookkeeping - // like `audit.Type = ...` or `result.ProviderID = ...` was - // misclassified as canonical and dropped on rewrite. The LHS - // receiver must be `ref` (the canonical ResourceRef - // construction site name). - if sel, ok := a.Lhs[0].(*ast.SelectorExpr); ok && a.Tok == token.ASSIGN { - if id, ok := sel.X.(*ast.Ident); !ok || id.Name != "ref" { - return false - } - switch sel.Sel.Name { - case "ProviderID", "Name", "Type": - return true - } - return false - } - } - return false -} - -// isDriverMethodCall reports whether expr is a call to a Driver method -// (Create/Read/Update/Delete) where the receiver is a known -// driver-bound identifier. Review round-4 finding #3: rev3 of this -// function only checked the selector NAME, so any call like -// `helper.Update(...)` or `metrics.Delete(...)` was misclassified as -// canonical driver dispatch and the case body was rewritten away. -// -// The receiver allowlist is intentionally narrow: `d`, `drv`, -// `driver` are the canonical names produced by the standard -// `d, err := p.ResourceDriver(action.Resource.Type)` pattern (DO, -// AWS, GCP, Azure). Anything else falls outside the rewrite-safe -// shape and the case body is reported as non-canonical. -func isDriverMethodCall(expr ast.Expr) bool { - call, ok := expr.(*ast.CallExpr) - if !ok { - return false - } - sel, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return false - } - switch sel.Sel.Name { - case "Create", "Read", "Update", "Delete": - // fall through to receiver check - default: - return false - } - x, ok := sel.X.(*ast.Ident) - if !ok { - return false - } - // Conservative driver-receiver allowlist. Round-5 finding #9: rev3 - // allowlist {d, drv, driver} missed `dr`, `rd`, `rdrv`, etc. Widen - // to a slightly larger set of common single-/short-identifier names - // while still rejecting bookkeeping-style receivers like `metrics`, - // `audit`, `helper` (per round-4 #3 — that's the whole point of - // the receiver check). - switch x.Name { - case "d", "dr", "drv", "rd", "rdrv", "driver", "resourceDriver": - return true - } - return false -} - -// isProviderIDGuard checks for the canonical -// `if action.Current != nil { ... }` guard. Permissive on the body -// since the inner statement is itself a canonical AssignStmt -// (`ref.ProviderID = action.Current.ProviderID`). -func isProviderIDGuard(ifs *ast.IfStmt) bool { - be, ok := ifs.Cond.(*ast.BinaryExpr) - if !ok || be.Op != token.NEQ { - return false - } - xIsCurrent := false - if sel, ok := be.X.(*ast.SelectorExpr); ok && sel.Sel.Name == "Current" { - xIsCurrent = true - } - yIsNil := false - if id, ok := be.Y.(*ast.Ident); ok && id.Name == "nil" { - yIsNil = true - } - if !xIsCurrent || !yIsNil { - // Allow the reverse order too (`nil != action.Current`), - // though it's not idiomatic Go. - yIsCurrent := false - if sel, ok := be.Y.(*ast.SelectorExpr); ok && sel.Sel.Name == "Current" { - yIsCurrent = true - } - xIsNil := false - if id, ok := be.X.(*ast.Ident); ok && id.Name == "nil" { - xIsNil = true - } - if !yIsCurrent || !xIsNil { - return false - } - } - if ifs.Else != nil { - return false - } - for _, s := range ifs.Body.List { - if !canonicalCaseStmt(s) { - return false - } - } - return true -} - -// stringLiteral returns the unquoted value of a BasicLit STRING -// expression, or ("", false) for any other shape. -func stringLiteral(expr ast.Expr) (string, bool) { - bl, ok := expr.(*ast.BasicLit) - if !ok || bl.Kind != token.STRING { - return "", false - } - if len(bl.Value) < 2 { - return "", false - } - // Strip surrounding quotes (single-line strings only). - return bl.Value[1 : len(bl.Value)-1], true -} - -// rewriteApplyBody replaces fn.Body with -// `return wfctlhelpers.ApplyPlan(, , )`. -// -// Identifier recovery + injection (review round-1 #2, round-2 #4): -// -// - Receiver: ensureReceiverName injects "p" if the receiver is -// unnamed (`func (*Provider) Apply(...)`). rev1 fell back to a -// hardcoded "p" without updating the receiver decl, so the -// rewritten call referenced an undefined identifier. -// - ctx: ensureCtxParamName renames `_` → `ctx`; preserves any other -// non-blank name. -// - plan: same shape as ctx, applied to the second parameter slot. -func rewriteApplyBody(fn *ast.FuncDecl, file *ast.File) { - recvName := ensureReceiverName(fn, "p") - ctxName := ensureCtxParamName(fn) - planName := ensureNthParamName(fn, 1, "plan") - // Resolve the wfctlhelpers package alias (review round-3 finding #6: - // rev2 hardcoded "wfctlhelpers" but a file using - // `wf "github.com/.../wfctlhelpers"` wouldn't compile). - pkgAlias := pkgAliasFor(file, helperImportPath, "wfctlhelpers") - - call := &ast.CallExpr{ - Fun: &ast.SelectorExpr{ - X: ast.NewIdent(pkgAlias), - Sel: ast.NewIdent("ApplyPlan"), - }, - Args: []ast.Expr{ - ast.NewIdent(ctxName), - ast.NewIdent(recvName), - ast.NewIdent(planName), - }, - } - fn.Body = &ast.BlockStmt{ - List: []ast.Stmt{ - &ast.ReturnStmt{Results: []ast.Expr{call}}, - }, - } -} - -// ensureNthParamName returns the name of fn's `idx`-th parameter, -// injecting `defaultName` (and renaming `_`) the same way -// ensureCtxParamName does for the first parameter. Used by -// rewriteApplyBody for the `plan` argument slot. -func ensureNthParamName(fn *ast.FuncDecl, idx int, defaultName string) string { - if fn.Type.Params == nil || len(fn.Type.Params.List) <= idx { - return defaultName - } - field := fn.Type.Params.List[idx] - if len(field.Names) == 0 { - field.Names = []*ast.Ident{ast.NewIdent(defaultName)} - return defaultName - } - if len(field.Names) == 1 { - n := field.Names[0].Name - if n == "_" || n == "" { - field.Names[0] = ast.NewIdent(defaultName) - return defaultName - } - return n - } - if field.Names[0].Name != "" && field.Names[0].Name != "_" { - return field.Names[0].Name - } - field.Names[0] = ast.NewIdent(defaultName) - return defaultName -} - -// (writeFileAtomic + ensureImport live in refactor_plan.go; -// refactor-apply reuses them via ensureWfctlhelpersImport.) - -// ============================================================ -// Report rendering -// ============================================================ - -func (r *applyReport) print(w io.Writer, opts *Options) { - sort.Slice(r.sites, func(i, j int) bool { - if r.sites[i].Path != r.sites[j].Path { - return r.sites[i].Path < r.sites[j].Path - } - return r.sites[i].Line < r.sites[j].Line - }) - fmt.Fprintln(w, "# iac-codemod refactor-apply report") - fmt.Fprintln(w) - mode := "dry-run" - if opts != nil && opts.Fix { - mode = "fix" - } - fmt.Fprintf(w, "Mode: %s\n", mode) - fmt.Fprintf(w, "Sites: %d\n", len(r.sites)) - fmt.Fprintf(w, "Errors: %d\n", len(r.errors)) - fmt.Fprintln(w) - - groups := map[applyClassification][]applySite{} - order := []applyClassification{ - applyCanonical, - applyUpsertRecovery, - applyUpdateReplaceCollapse, - applyCustomErrorWrapping, - applyNonCanonicalOther, - applyMissingSwitch, - applyAlreadyDelegated, - applySkipped, - } - for _, s := range r.sites { - groups[s.Class] = append(groups[s.Class], s) - } - headers := map[applyClassification]string{ - applyCanonical: "Canonical (rewrite candidate)", - applyUpsertRecovery: "Upsert recovery — DO-style ErrResourceAlreadyExists path", - applyUpdateReplaceCollapse: "Update+replace collapse — manual port required", - applyCustomErrorWrapping: "Custom error wrapping — extension-point hook required", - applyNonCanonicalOther: "Non-canonical (manual review required)", - applyMissingSwitch: "Missing action-switch — hand-port required", - applyAlreadyDelegated: "Already-delegated (no-op)", - applySkipped: "Skipped (// wfctl:skip-iac-codemod)", - } - for _, c := range order { - sites := groups[c] - if len(sites) == 0 { - continue - } - fmt.Fprintf(w, "## %s\n\n", headers[c]) - for _, s := range sites { - suffix := "" - if c == applyCanonical && s.Rewrote { - suffix = " (rewritten)" - } - line := fmt.Sprintf("- %s:%d %s.Apply %s%s", s.Path, s.Line, s.Receiver, s.Class, suffix) - if s.OffenderPos != "" { - line += fmt.Sprintf(" (offender at %s)", s.OffenderPos) - } - fmt.Fprintln(w, line) - if s.Suggestion != "" { - fmt.Fprintf(w, " - suggestion: %s\n", s.Suggestion) - } - } - fmt.Fprintln(w) - } - - if len(r.errors) > 0 { - fmt.Fprintln(w, "## Errors") - fmt.Fprintln(w) - for _, e := range r.errors { - fmt.Fprintf(w, "- %s\n", e) - } - fmt.Fprintln(w) - } -} diff --git a/cmd/iac-codemod/refactor_apply_test.go b/cmd/iac-codemod/refactor_apply_test.go deleted file mode 100644 index 61364a9e..00000000 --- a/cmd/iac-codemod/refactor_apply_test.go +++ /dev/null @@ -1,717 +0,0 @@ -// Copyright (c) 2026 Jon Langevin -// SPDX-License-Identifier: Apache-2.0 - -// Tests in this file MUST NOT call t.Parallel(). Same global-state -// constraint as main_test.go / lint_test.go / refactor_plan_test.go. - -package main - -import ( - "bytes" - "os" - "strings" - "testing" - "time" -) - -// ============================================================ -// Source fixtures -// ============================================================ - -// applyWithExtraBookkeepingSrc — review round-1 finding #5. An Apply -// body with bespoke bookkeeping inside a case body (here, a metrics -// counter / println) must NOT be classified as canonical: silently -// rewriting would drop the bookkeeping. Test fixture defined below the -// canonicalApplySrc anchor; lookup test follows it. -const applyWithExtraBookkeepingSrc = `package p - -import ( - "context" - "fmt" -) - -type ResourceSpec struct{ Name, Type string } -type ResourceState struct{ Name string; ProviderID string } -type IaCPlan struct{ ID string; Actions []PlanAction } -type PlanAction struct{ Action string; Resource ResourceSpec; Current *ResourceState } -type ApplyResult struct{ PlanID string; Errors []ActionError; Resources []ResourceOutput } -type ActionError struct{ Resource, Action, Error string } -type ResourceRef struct{ Name, Type, ProviderID string } -type ResourceOutput struct{ ProviderID string } -type PlanDiagnostic struct{} - -type Driver interface { - Create(ctx context.Context, r ResourceSpec) (*ResourceOutput, error) - Update(ctx context.Context, ref ResourceRef, r ResourceSpec) (*ResourceOutput, error) - Delete(ctx context.Context, ref ResourceRef) error -} - -type BookkeepingProvider struct{} - -func (p *BookkeepingProvider) ResourceDriver(string) (Driver, error) { return nil, nil } - -func (p *BookkeepingProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return platform.ComputePlan(ctx, p, desired, current) -} - -func (p *BookkeepingProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - result := &ApplyResult{PlanID: plan.ID} - for _, action := range plan.Actions { - d, err := p.ResourceDriver(action.Resource.Type) - if err != nil { - result.Errors = append(result.Errors, ActionError{Resource: action.Resource.Name, Action: action.Action, Error: err.Error()}) - continue - } - var out *ResourceOutput - switch action.Action { - case "create": - fmt.Println("creating") - out, err = d.Create(ctx, action.Resource) - case "update": - ref := ResourceRef{Name: action.Resource.Name, Type: action.Resource.Type} - out, err = d.Update(ctx, ref, action.Resource) - } - if err != nil { - result.Errors = append(result.Errors, ActionError{Resource: action.Resource.Name, Action: action.Action, Error: err.Error()}) - continue - } - _ = out - } - return result, nil -} - -func (p *BookkeepingProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -func TestRefactorApply_ExtraBookkeepingNotCanonical(t *testing.T) { - path := writeFixture(t, "provider.go", applyWithExtraBookkeepingSrc) - var stdout, stderr bytes.Buffer - code := runRefactorApply([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if strings.Contains(out, "BookkeepingProvider.Apply canonical") && !strings.Contains(out, "non-canonical") { - t.Errorf("Apply with extra bookkeeping inside a case body must NOT be canonical; got:\n%s", out) - } - if !strings.Contains(out, "non-canonical") { - t.Errorf("Apply with extra bookkeeping should be reported non-canonical; got:\n%s", out) - } -} - -// Round-2 finding #4 also applied to refactor-apply but the -// unnamed-receiver path can't have a canonical-shape body in real Go: -// without a receiver identifier in scope, the body can't call -// `.ResourceDriver(...)`, which the round-7+round-8-tightened -// canonical detector now requires. The receiver-injection helper -// ensureReceiverName is shared between refactor-plan and refactor-apply; -// coverage is in TestRefactorPlan_Fix_UnnamedReceiverGetsName. - -// canonicalApplySrc is a minimal Apply body the codemod will rewrite. -// Loop+switch on action.Action with create/update/delete branches that -// dispatch directly to the driver. Modeled on the simplest pattern -// expected by wfctlhelpers.ApplyPlan. -const canonicalApplySrc = `package p - -import ( - "context" - "fmt" - "time" -) - -type ResourceSpec struct{ Name, Type string } -type ResourceState struct{ Name string; ProviderID string } -type IaCPlan struct{ ID string; CreatedAt time.Time; Actions []PlanAction } -type PlanAction struct{ Action string; Resource ResourceSpec; Current *ResourceState } -type ApplyResult struct{ PlanID string; Errors []ActionError; Resources []ResourceOutput } -type ActionError struct{ Resource, Action, Error string } -type ResourceRef struct{ Name, Type, ProviderID string } -type ResourceOutput struct{ ProviderID string } -type PlanDiagnostic struct{} - -type Driver interface { - Create(ctx context.Context, r ResourceSpec) (*ResourceOutput, error) - Update(ctx context.Context, ref ResourceRef, r ResourceSpec) (*ResourceOutput, error) - Delete(ctx context.Context, ref ResourceRef) error -} - -type FooProvider struct{} - -func (p *FooProvider) ResourceDriver(string) (Driver, error) { return nil, nil } - -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return wfctlhelpers.Plan(ctx, p, desired, current) -} - -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - result := &ApplyResult{PlanID: plan.ID} - for _, action := range plan.Actions { - d, err := p.ResourceDriver(action.Resource.Type) - if err != nil { - result.Errors = append(result.Errors, ActionError{Resource: action.Resource.Name, Action: action.Action, Error: err.Error()}) - continue - } - var out *ResourceOutput - switch action.Action { - case "create": - out, err = d.Create(ctx, action.Resource) - case "update": - ref := ResourceRef{Name: action.Resource.Name, Type: action.Resource.Type} - if action.Current != nil { - ref.ProviderID = action.Current.ProviderID - } - out, err = d.Update(ctx, ref, action.Resource) - case "delete": - ref := ResourceRef{Name: action.Resource.Name, Type: action.Resource.Type} - if action.Current != nil { - ref.ProviderID = action.Current.ProviderID - } - err = d.Delete(ctx, ref) - default: - err = fmt.Errorf("unknown action %q", action.Action) - } - if err != nil { - result.Errors = append(result.Errors, ActionError{Resource: action.Resource.Name, Action: action.Action, Error: err.Error()}) - continue - } - if out != nil { - result.Resources = append(result.Resources, *out) - } - } - return result, nil -} - -func (p *FooProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -// doUpsertApplySrc replicates the DigitalOcean upsert-on-create-conflict -// pattern. The "create" case branches on errors.Is(err, -// ErrResourceAlreadyExists) and routes through Read+Update to recover. -// The codemod must DETECT this and refuse to rewrite, emitting a -// suggested upsertSupporter hook patch. -const doUpsertApplySrc = `package p - -import ( - "context" - "errors" - "fmt" -) - -type ResourceSpec struct{ Name, Type string } -type ResourceState struct{ Name, ProviderID string } -type IaCPlan struct{ Actions []PlanAction } -type PlanAction struct{ Action string; Resource ResourceSpec; Current *ResourceState } -type ApplyResult struct{ Errors []ActionError; Resources []ResourceOutput } -type ActionError struct{ Resource, Action, Error string } -type ResourceRef struct{ Name, Type, ProviderID string } -type ResourceOutput struct{ ProviderID string } -type PlanDiagnostic struct{} - -var ErrResourceAlreadyExists = errors.New("already exists") - -type upsertSupporter interface{ SupportsUpsert() bool } - -type Driver interface { - Create(ctx context.Context, r ResourceSpec) (*ResourceOutput, error) - Update(ctx context.Context, ref ResourceRef, r ResourceSpec) (*ResourceOutput, error) - Read(ctx context.Context, ref ResourceRef) (*ResourceOutput, error) - Delete(ctx context.Context, ref ResourceRef) error -} - -type DOProvider struct{} - -func (p *DOProvider) ResourceDriver(string) (Driver, error) { return nil, nil } - -func (p *DOProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return wfctlhelpers.Plan(ctx, p, desired, current) -} - -func (p *DOProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - result := &ApplyResult{} - for _, action := range plan.Actions { - d, err := p.ResourceDriver(action.Resource.Type) - if err != nil { - result.Errors = append(result.Errors, ActionError{Resource: action.Resource.Name, Action: action.Action, Error: err.Error()}) - continue - } - var out *ResourceOutput - switch action.Action { - case "create": - out, err = d.Create(ctx, action.Resource) - if errors.Is(err, ErrResourceAlreadyExists) { - us, ok := d.(upsertSupporter) - if !ok || !us.SupportsUpsert() { - break - } - createErr := err - ref := ResourceRef{Name: action.Resource.Name, Type: action.Resource.Type} - existing, readErr := d.Read(ctx, ref) - if readErr != nil { - err = fmt.Errorf("upsert: read after conflict: %w", errors.Join(createErr, readErr)) - break - } - ref.ProviderID = existing.ProviderID - out, err = d.Update(ctx, ref, action.Resource) - } - case "update": - ref := ResourceRef{Name: action.Resource.Name, Type: action.Resource.Type, ProviderID: action.Current.ProviderID} - out, err = d.Update(ctx, ref, action.Resource) - default: - err = fmt.Errorf("unknown action %q", action.Action) - } - if err != nil { - result.Errors = append(result.Errors, ActionError{Resource: action.Resource.Name, Action: action.Action, Error: err.Error()}) - continue - } - if out != nil { - result.Resources = append(result.Resources, *out) - } - } - return result, nil -} - -func (p *DOProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -// awsUpdateReplaceCollapseSrc replicates AWSProvider.Apply: the -// "update" and "replace" actions share a single case clause. The -// codemod must DETECT this and emit "manual port required" with line -// numbers because wfctlhelpers' doReplace path is meaningfully -// different from doUpdate (delete+create vs in-place modify) and -// silent collapse would lose semantic distinction. -const awsUpdateReplaceCollapseSrc = `package p - -import ( - "context" - "fmt" -) - -type ResourceSpec struct{ Name, Type string } -type ResourceState struct{ Name, ProviderID string } -type IaCPlan struct{ ID string; Actions []PlanAction } -type PlanAction struct{ Action string; Resource ResourceSpec; Current *ResourceState } -type ApplyResult struct{ PlanID string; Errors []ActionError; Resources []ResourceOutput } -type ActionError struct{ Resource, Action, Error string } -type ResourceRef struct{ Name, Type, ProviderID string } -type ResourceOutput struct{ ProviderID string } -type PlanDiagnostic struct{} - -type Driver interface { - Create(ctx context.Context, r ResourceSpec) (*ResourceOutput, error) - Update(ctx context.Context, ref ResourceRef, r ResourceSpec) (*ResourceOutput, error) - Delete(ctx context.Context, ref ResourceRef) error -} - -type AWSProvider struct{} - -func (p *AWSProvider) resourceDriver(string) (Driver, error) { return nil, nil } - -func (p *AWSProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return wfctlhelpers.Plan(ctx, p, desired, current) -} - -func (p *AWSProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - result := &ApplyResult{PlanID: plan.ID} - for _, action := range plan.Actions { - drv, err := p.resourceDriver(action.Resource.Type) - if err != nil { - result.Errors = append(result.Errors, ActionError{Resource: action.Resource.Name, Action: action.Action, Error: err.Error()}) - continue - } - var out *ResourceOutput - switch action.Action { - case "create": - out, err = drv.Create(ctx, action.Resource) - case "update", "replace": - ref := ResourceRef{Name: action.Resource.Name, Type: action.Resource.Type} - if action.Current != nil { - ref.ProviderID = action.Current.ProviderID - } - out, err = drv.Update(ctx, ref, action.Resource) - case "delete": - ref := ResourceRef{Name: action.Resource.Name, Type: action.Resource.Type} - if action.Current != nil { - ref.ProviderID = action.Current.ProviderID - } - err = drv.Delete(ctx, ref) - } - if err != nil { - result.Errors = append(result.Errors, ActionError{Resource: action.Resource.Name, Action: action.Action, Error: err.Error()}) - continue - } - if out != nil { - result.Resources = append(result.Resources, *out) - } - } - _ = fmt.Sprintf("anchor") - return result, nil -} - -func (p *AWSProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -// customErrorWrapApplySrc replicates a custom-error-wrapping idiom: -// errors returned from the driver are wrapped with bespoke domain text -// before being recorded. wfctlhelpers' default error path doesn't -// preserve this wrapping, so the codemod must DETECT and emit an -// extension-point hook + sample patch (post-hook on ApplyResult.Errors). -const customErrorWrapApplySrc = `package p - -import ( - "context" - "fmt" -) - -type ResourceSpec struct{ Name, Type string } -type ResourceState struct{ Name, ProviderID string } -type IaCPlan struct{ Actions []PlanAction } -type PlanAction struct{ Action string; Resource ResourceSpec } -type ApplyResult struct{ Errors []ActionError; Resources []ResourceOutput } -type ActionError struct{ Resource, Action, Error string } -type ResourceRef struct{ Name, Type string } -type ResourceOutput struct{} -type PlanDiagnostic struct{} - -type Driver interface { - Create(ctx context.Context, r ResourceSpec) (*ResourceOutput, error) - Update(ctx context.Context, ref ResourceRef, r ResourceSpec) (*ResourceOutput, error) - Delete(ctx context.Context, ref ResourceRef) error -} - -type WrapProvider struct{} - -func (p *WrapProvider) resourceDriver(string) (Driver, error) { return nil, nil } - -func (p *WrapProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return wfctlhelpers.Plan(ctx, p, desired, current) -} - -func (p *WrapProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - result := &ApplyResult{} - for _, action := range plan.Actions { - d, err := p.resourceDriver(action.Resource.Type) - if err != nil { - result.Errors = append(result.Errors, ActionError{Resource: action.Resource.Name, Action: action.Action, Error: err.Error()}) - continue - } - var out *ResourceOutput - switch action.Action { - case "create": - out, err = d.Create(ctx, action.Resource) - if err != nil { - err = fmt.Errorf("wrap: %s create %s failed: %w", "wrap-provider", action.Resource.Name, err) - } - case "update": - out, err = d.Update(ctx, ResourceRef{Name: action.Resource.Name}, action.Resource) - if err != nil { - err = fmt.Errorf("wrap: %s update %s failed: %w", "wrap-provider", action.Resource.Name, err) - } - } - if err != nil { - result.Errors = append(result.Errors, ActionError{Resource: action.Resource.Name, Action: action.Action, Error: err.Error()}) - continue - } - _ = out - } - return result, nil -} - -func (p *WrapProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -// skippedApplySrc carries the canonical marker on the function doc. -// Apply must not be rewritten regardless of body shape; site listed in -// the report. -const skippedApplySrc = `package p - -import "context" - -type ResourceSpec struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} -type FooProvider struct{} -type PlanDiagnostic struct{} - -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { return wfctlhelpers.Plan(ctx, p, desired, current) } - -// wfctl:skip-iac-codemod custom orchestration, see ADR-042 -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return &ApplyResult{}, nil -} - -func (p *FooProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -// alreadyDelegatedApplySrc has Apply already calling wfctlhelpers.ApplyPlan. -// The mode must NOT report it as non-canonical and must NOT mutate it. -const alreadyDelegatedApplySrc = `package p - -import "context" - -type ResourceSpec struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} -type FooProvider struct{} -type PlanDiagnostic struct{} - -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { return wfctlhelpers.Plan(ctx, p, desired, current) } -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return wfctlhelpers.ApplyPlan(ctx, p, plan) -} -func (p *FooProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -// ============================================================ -// Detection (dry-run) -// ============================================================ - -func TestRefactorApply_DryRun_DetectsCanonical(t *testing.T) { - path := writeFixture(t, "provider.go", canonicalApplySrc) - var stdout, stderr bytes.Buffer - code := runRefactorApply([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if !strings.Contains(out, "FooProvider.Apply") { - t.Errorf("report should name FooProvider.Apply; got:\n%s", out) - } - if !strings.Contains(out, "canonical") { - t.Errorf("report should classify as canonical; got:\n%s", out) - } - got, _ := os.ReadFile(path) - if string(got) != canonicalApplySrc { - t.Errorf("dry-run modified the file; expected no mutation") - } -} - -func TestRefactorApply_DryRun_DetectsDOUpsertRecovery(t *testing.T) { - path := writeFixture(t, "provider.go", doUpsertApplySrc) - var stdout, stderr bytes.Buffer - code := runRefactorApply([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if !strings.Contains(out, "DOProvider.Apply") { - t.Errorf("report should name DOProvider.Apply; got:\n%s", out) - } - if !strings.Contains(out, "upsert-recovery") { - t.Errorf("report should classify as upsert-recovery; got:\n%s", out) - } - if !strings.Contains(out, "upsertSupporter") { - t.Errorf("report should suggest upsertSupporter hook patch; got:\n%s", out) - } -} - -func TestRefactorApply_DryRun_DetectsUpdateReplaceCollapse(t *testing.T) { - path := writeFixture(t, "provider.go", awsUpdateReplaceCollapseSrc) - var stdout, stderr bytes.Buffer - code := runRefactorApply([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if !strings.Contains(out, "AWSProvider.Apply") { - t.Errorf("report should name AWSProvider.Apply; got:\n%s", out) - } - if !strings.Contains(out, "update+replace-collapse") { - t.Errorf("report should classify as update+replace-collapse; got:\n%s", out) - } - if !strings.Contains(out, "manual port required") { - t.Errorf("report should advise manual port; got:\n%s", out) - } - // Must include line numbers for the offending case clause. - if !strings.Contains(out, ":") { - t.Errorf("report should include path:line for the offending case; got:\n%s", out) - } -} - -func TestRefactorApply_DryRun_DetectsCustomErrorWrapping(t *testing.T) { - path := writeFixture(t, "provider.go", customErrorWrapApplySrc) - var stdout, stderr bytes.Buffer - code := runRefactorApply([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if !strings.Contains(out, "WrapProvider.Apply") { - t.Errorf("report should name WrapProvider.Apply; got:\n%s", out) - } - if !strings.Contains(out, "custom-error-wrapping") { - t.Errorf("report should classify as custom-error-wrapping; got:\n%s", out) - } - if !strings.Contains(out, "extension-point") { - t.Errorf("report should mention extension-point hook; got:\n%s", out) - } -} - -func TestRefactorApply_DryRun_HonorsSkipMarker(t *testing.T) { - path := writeFixture(t, "provider.go", skippedApplySrc) - var stdout, stderr bytes.Buffer - code := runRefactorApply([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if !strings.Contains(out, "Skipped") { - t.Errorf("report should have a Skipped section; got:\n%s", out) - } - if !strings.Contains(out, "FooProvider.Apply") { - t.Errorf("Skipped section should list FooProvider.Apply; got:\n%s", out) - } -} - -func TestRefactorApply_DryRun_AlreadyDelegated(t *testing.T) { - path := writeFixture(t, "provider.go", alreadyDelegatedApplySrc) - var stdout, stderr bytes.Buffer - code := runRefactorApply([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if !strings.Contains(out, "already-delegated") { - t.Errorf("already-delegated Apply should be classified explicitly; got:\n%s", out) - } -} - -// ============================================================ -// Mutation (-fix) -// ============================================================ - -func TestRefactorApply_Fix_RewritesCanonical(t *testing.T) { - path := writeFixture(t, "provider.go", canonicalApplySrc) - var stdout, stderr bytes.Buffer - code := runRefactorApply([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - gotStr := string(got) - if !strings.Contains(gotStr, "return wfctlhelpers.ApplyPlan(ctx, p, plan)") { - t.Errorf("rewritten Apply must call wfctlhelpers.ApplyPlan; got:\n%s", gotStr) - } - if strings.Contains(gotStr, "switch action.Action {") { - t.Errorf("canonical switch should be removed by rewrite; got:\n%s", gotStr) - } - if !strings.Contains(gotStr, `"github.com/GoCodeAlone/workflow/iac/wfctlhelpers"`) { - t.Errorf("rewrite should add wfctlhelpers import; got:\n%s", gotStr) - } -} - -func TestRefactorApply_Fix_DoesNotRewriteUpsertRecovery(t *testing.T) { - path := writeFixture(t, "provider.go", doUpsertApplySrc) - var stdout, stderr bytes.Buffer - code := runRefactorApply([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != doUpsertApplySrc { - t.Errorf("upsert-recovery must NOT be rewritten; file changed:\n%s", string(got)) - } -} - -func TestRefactorApply_Fix_DoesNotRewriteUpdateReplaceCollapse(t *testing.T) { - path := writeFixture(t, "provider.go", awsUpdateReplaceCollapseSrc) - var stdout, stderr bytes.Buffer - code := runRefactorApply([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != awsUpdateReplaceCollapseSrc { - t.Errorf("update+replace-collapse must NOT be rewritten; file changed:\n%s", string(got)) - } -} - -func TestRefactorApply_Fix_DoesNotRewriteCustomErrorWrapping(t *testing.T) { - path := writeFixture(t, "provider.go", customErrorWrapApplySrc) - var stdout, stderr bytes.Buffer - code := runRefactorApply([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != customErrorWrapApplySrc { - t.Errorf("custom-error-wrapping must NOT be rewritten; file changed:\n%s", string(got)) - } -} - -func TestRefactorApply_Fix_HonorsSkipMarker(t *testing.T) { - path := writeFixture(t, "provider.go", skippedApplySrc) - var stdout, stderr bytes.Buffer - code := runRefactorApply([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != skippedApplySrc { - t.Errorf("skip-marker'd Apply must NOT be rewritten; file changed:\n%s", string(got)) - } -} - -func TestRefactorApply_Fix_IdempotentOnAlreadyDelegated(t *testing.T) { - path := writeFixture(t, "provider.go", alreadyDelegatedApplySrc) - var stdout, stderr bytes.Buffer - if code := runRefactorApply([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr); code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != alreadyDelegatedApplySrc { - t.Errorf("already-delegated source must be byte-identical after fix (idempotent)") - } -} - -// ============================================================ -// codemod-report.md output (per spec line 2388) -// ============================================================ - -func TestRefactorApply_DryRun_WritesReportFile(t *testing.T) { - // Per plan §T8.4 line 2388: "Output `codemod-report.md` with per-file - // findings + suggested handling." When -report-file is supplied the - // mode writes the report there as well as stdout. Default report - // filename matches the spec literally. - dir := t.TempDir() - reportPath := dir + "/codemod-report.md" - path := writeFixture(t, "provider.go", doUpsertApplySrc) - var stdout, stderr bytes.Buffer - code := runRefactorApply([]string{"-report-file", reportPath, path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - body, err := os.ReadFile(reportPath) - if err != nil { - t.Fatalf("report file not written: %v", err) - } - if !strings.Contains(string(body), "upsert-recovery") { - t.Errorf("report file must include classification; got:\n%s", string(body)) - } -} - -// ============================================================ -// Mutation-gate negative tests -// ============================================================ - -func TestRefactorApply_DryRunFalseWithoutFix_DoesNotMutate(t *testing.T) { - path := writeFixture(t, "provider.go", canonicalApplySrc) - stat0, _ := os.Stat(path) - mtime0 := stat0.ModTime() - time.Sleep(10 * time.Millisecond) - - var stdout, stderr bytes.Buffer - code := run([]string{"refactor-apply", "-dry-run=false", path}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != canonicalApplySrc { - t.Errorf("file must NOT be mutated; content changed") - } - stat1, _ := os.Stat(path) - if !stat1.ModTime().Equal(mtime0) { - t.Errorf("file mtime should be unchanged; before=%v after=%v", mtime0, stat1.ModTime()) - } -} diff --git a/cmd/iac-codemod/refactor_plan.go b/cmd/iac-codemod/refactor_plan.go deleted file mode 100644 index c3892c73..00000000 --- a/cmd/iac-codemod/refactor_plan.go +++ /dev/null @@ -1,1404 +0,0 @@ -// Copyright (c) 2026 Jon Langevin -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "bytes" - "fmt" - "go/ast" - "go/format" - "go/parser" - "go/token" - "io" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" -) - -func init() { - modes["refactor-plan"] = runRefactorPlan -} - -// helperImportPath is the canonical Go import path for the wfctlhelpers -// package (used by refactor-apply for ApplyPlan delegation). Any source -// file that gains a `wfctlhelpers.ApplyPlan` call must also import this -// package. -const helperImportPath = "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" - -// planHelperImportPath is the import path for platform.ComputePlan, the -// canonical Plan helper provider Plan() bodies delegate to. This is a -// rev1-review correction: the plan-doc named `wfctlhelpers.Plan` as the -// rewrite target, but no such API exists today in the repo. The actual -// Plan-equivalent helper is `platform.ComputePlan(ctx, p, desired, current)` -// at platform/differ.go:72. Switching the codemod target to the real API -// closes Copilot review finding #1 (lint.go:45 + refactor_plan.go:36): -// "the rewrite target does not exist in the repository today; rewritten -// files would fail to compile". -const planHelperImportPath = "github.com/GoCodeAlone/workflow/platform" - -// planCanonicalCallExpr is the canonical replacement-body expression -// emitted by refactor-plan. Calls platform.ComputePlan (the real helper); -// see planHelperImportPath above for the review-correction rationale. -// -//nolint:unused -const planCanonicalCallExpr = "platform.ComputePlan(ctx, p, desired, current)" - -// planClassification labels the disposition of a single Plan() method -// site. Each report entry carries one classification; the rewriter -// honors only `planCanonical`. -type planClassification int - -const ( - // planCanonical: body matches the configHash-compare template; safe - // to rewrite to wfctlhelpers.Plan. - planCanonical planClassification = iota - // planNonCanonical: body has out-of-template logic; report only, - // never rewrite. - planNonCanonical - // planAlreadyDelegated: body is the canonical 2-statement - // `plan, err := platform.ComputePlan(...); return &plan, err` - // form (or the legacy `return wfctlhelpers.Plan(...)` shape); - // report as no-op (idempotent), do NOT rewrite. Round-11 #6: - // rev1 of this comment still referenced the old `wfctlhelpers.Plan` - // target; the actual recognised shape is platform.ComputePlan - // per planHelperImportPath above. - planAlreadyDelegated - // planSkipped: function carries the SkipMarker; report into the - // Skipped section. (Distinct from the lint-mode skip path because - // refactor-plan tracks skips per-site for the report.) - planSkipped -) - -// String renders the classification for the report. Lower-case so -// "non-canonical" / "canonical" read naturally inline. -func (c planClassification) String() string { - switch c { - case planCanonical: - return "canonical" - case planNonCanonical: - return "non-canonical" - case planAlreadyDelegated: - return "already-delegated" - case planSkipped: - return "skipped" - default: - return "unknown" - } -} - -// planSite captures one Plan-method site in the report. -type planSite struct { - Path string - Line int - Receiver string // type name, e.g. "DOProvider" - Class planClassification // canonical / non-canonical / already-delegated / skipped - Reason string // for non-canonical: why detection rejected the body - Rewrote bool // set true if this site was rewritten on -fix -} - -// planReport aggregates per-file results across an entire refactor-plan run. -type planReport struct { - sites []planSite - errors []string -} - -// runRefactorPlan is the entry point for the refactor-plan subcommand. -// It walks the supplied paths, classifies each Plan method site, and -// (when -fix is set) rewrites canonical bodies in place via atomic -// temp-file + rename. -func runRefactorPlan(args []string, opts *Options, stdout, stderr io.Writer) int { - if len(args) == 0 { - fmt.Fprintln(stderr, "iac-codemod refactor-plan: at least one path is required") - usage(stderr) - return 2 - } - report := &planReport{} - for _, path := range args { - if err := refactorPlanPath(path, opts, report); err != nil { - fmt.Fprintf(stderr, "iac-codemod refactor-plan: %s: %v\n", path, err) - return 1 - } - } - report.print(stdout, opts) - if len(report.errors) > 0 { - return 1 - } - return 0 -} - -// refactorPlanPath walks `path` for *.go files (excluding _test.go, -// vendor, testdata, hidden) and processes each. Per-file errors are -// recorded in the report so a single broken file does not abort the run. -func refactorPlanPath(path string, opts *Options, report *planReport) error { - info, err := stat(path) - if err != nil { - return err - } - if !info.IsDir() { - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return fmt.Errorf("not a Go source file (or is a _test.go): %s", path) - } - if err := refactorPlanFile(path, opts, report); err != nil { - report.errors = append(report.errors, fmt.Sprintf("%s: %v", path, err)) - } - return nil - } - return filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - base := d.Name() - if shouldSkipDir(base) { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { - return nil - } - if err := refactorPlanFile(p, opts, report); err != nil { - report.errors = append(report.errors, fmt.Sprintf("%s: %v", p, err)) - } - return nil - }) -} - -// refactorPlanFile parses `path`, classifies every Plan method, and (in -// -fix mode) mutates the AST and writes the result atomically. -func refactorPlanFile(path string, opts *Options, report *planReport) error { - src, err := readFile(path) - if err != nil { - return err - } - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, path, src, parser.ParseComments) - if err != nil { - return err - } - - // Build the receiver-shape filter using the directory-wide - // method set so providers whose Plan/Apply live in sibling files - // are still recognised (review round-1 finding #9). Per-file - // fallback when the directory walk fails — keeps the rev0 - // behavior on isolated single-file targets. - // Round-12 #2: skip files in a non-dominant package. The - // directory-wide provs/typeDocs are built from the dominant - // package only; processing a non-dominant file against another - // package's method set could rewrite the wrong file when - // receiver names overlap. - dominant := dominantPackageForDir(filepath.Dir(path)) - if dominant != "" && file.Name.Name != dominant { - return nil - } - provs := planLikeReceiversInDir(filepath.Dir(path)) - if len(provs) == 0 { - provs = planLikeReceivers(file) - } - // Directory-wide type-doc lookup so a `// wfctl:skip-iac-codemod` - // marker on a sibling file's type declaration is honored even when - // the Plan/Apply methods we're walking live in a separate file - // (review round-6 finding #1). - typeDocs := receiverTypeDocsInDir(filepath.Dir(path), file) - - mutated := false - for _, decl := range file.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok { - continue - } - if !isProviderMethod(fn, "Plan", 3, 2) { - continue - } - recv := receiverTypeName(fn) - if !provs[recv] { - // Method named Plan on a non-provider type — skip with no - // report entry (lint already reports those if relevant; the - // codemod focuses on rewriting providers). - continue - } - // Honor the marker on the function doc, the receiver type's - // TypeSpec doc, AND the wrapping GenDecl doc. Review round-1 - // finding #4: PR description promises type-doc + GenDecl-doc - // honoring; rev0 only checked fn.Doc. - if hasSkipMarkerOn(fn.Doc) || typeDocs[recv].carriesMarker() { - report.sites = append(report.sites, planSite{ - Path: path, - Line: fset.Position(fn.Pos()).Line, - Receiver: recv, - Class: planSkipped, - }) - continue - } - class, reason := classifyPlanBody(fn, file) - site := planSite{ - Path: path, - Line: fset.Position(fn.Pos()).Line, - Receiver: recv, - Class: class, - Reason: reason, - } - if class == planCanonical && opts != nil && opts.Fix { - rewritePlanBody(fn, file) - mutated = true - site.Rewrote = true - } - report.sites = append(report.sites, site) - } - - if mutated && opts != nil && opts.Fix { - // Ensure the platform import is present (refactor-plan emits - // platform.ComputePlan). The function is idempotent. - ensurePlatformImport(file) - if err := writeFileAtomic(path, fset, file); err != nil { - return fmt.Errorf("write %s: %w", path, err) - } - } - return nil -} - -// planLikeReceivers returns the set of receiver type names whose method -// set in `file` includes both Plan and Apply with shapes matching -// IaCProvider. Used as a fallback path when no package context is -// available; production callers should prefer planLikeReceiversInDir -// (review round-1 finding #9: rev0 of this function only consulted -// the current file, missing providers whose Plan and Apply live in -// sibling files). -func planLikeReceivers(file *ast.File) map[string]bool { - methodsByRecv := make(map[string][]*ast.FuncDecl) - for _, decl := range file.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok { - continue - } - recv := receiverTypeName(fn) - if recv == "" { - continue - } - methodsByRecv[recv] = append(methodsByRecv[recv], fn) - } - out := make(map[string]bool) - for recv, methods := range methodsByRecv { - if looksLikeProvider(methods) { - out[recv] = true - } - } - return out -} - -// planLikeReceiversInDir returns the set of receiver type names whose -// method set across ALL non-test .go files in dir includes both Plan -// and Apply (canonical IaCProvider shape). Closes review round-1 -// finding #9: a provider whose Plan() and Apply() live in sibling -// files (e.g. provider_plan.go + provider_apply.go) is invisible to -// the per-file planLikeReceivers helper. Per-directory aggregation -// matches Go's package-scoped method-set semantics. -// -// Errors are tolerated: any file whose parser.ParseFile call fails is -// silently dropped from the aggregation. The intent is to widen the -// detection net, not to enforce package-correctness (which is the -// linter's job). -func planLikeReceiversInDir(dir string) map[string]bool { - out, _ := planLikeProviderMethodsInDir(dir) - return out -} - -// dominantPackageForDir returns the most-common `package P` clause -// across non-test .go files in dir (lex-first wins on tie). Used by -// refactor-* and add-validate-plan to skip files in non-dominant -// packages — round-12 #2/#3/#4: rev2 walked every file but built -// provs/typeDocs from only the dominant package, so a non-dominant -// file with overlapping receiver names could be rewritten against -// the dominant package's method set and produce a wrong-file -// migration. Returns "" when dir cannot be read. -func dominantPackageForDir(dir string) string { - entries, err := os.ReadDir(dir) - if err != nil { - return "" - } - pkgCounts := make(map[string]int) - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { - continue - } - fpath := filepath.Join(dir, name) - src, err := readFile(fpath) - if err != nil { - continue - } - fs := token.NewFileSet() - f, err := parser.ParseFile(fs, fpath, src, parser.PackageClauseOnly) - if err != nil { - continue - } - pkgCounts[f.Name.Name]++ - } - if len(pkgCounts) == 0 { - return "" - } - pkgNames := make([]string, 0, len(pkgCounts)) - for pkg := range pkgCounts { - pkgNames = append(pkgNames, pkg) - } - sort.Strings(pkgNames) - dominant := "" - dominantCount := 0 - for _, pkg := range pkgNames { - if pkgCounts[pkg] > dominantCount { - dominant = pkg - dominantCount = pkgCounts[pkg] - } - } - return dominant -} - -// planLikeProviderMethodsInDir is like planLikeReceiversInDir but also -// returns the per-receiver method slice (across all files in dir) so -// callers can inspect ValidatePlan presence + receiver-kind for -// providers split across sibling files (round-2 #5 + round-3 #1). -// -// Files are filtered by package name: only files whose `package P` -// clause matches the dominant (most-common) package in dir are -// aggregated. Review round-5 finding #6: rev2 merged methods from -// EVERY non-test .go file regardless of package, so a build-tagged -// or mixed-package directory could fold methods from unrelated -// packages into a synthetic provider and drive incorrect rewrites / -// stub insertion. -// -// The returned slice contains *ast.FuncDecl values from a SEPARATE -// parser.ParseFile call than any caller's primary file parse, so -// caller code that relies on AST-pointer identity must dedupe (see -// add_validate_plan.go's name-based merge). -func planLikeProviderMethodsInDir(dir string) (map[string]bool, map[string][]*ast.FuncDecl) { - entries, err := os.ReadDir(dir) - if err != nil { - return nil, nil - } - // Pass 1: parse every candidate file's package clause to find the - // dominant package. - type parsedFile struct { - pkg string - file *ast.File - } - var files []parsedFile - pkgCounts := make(map[string]int) - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { - continue - } - fpath := filepath.Join(dir, name) - src, err := readFile(fpath) - if err != nil { - continue - } - fs := token.NewFileSet() - file, err := parser.ParseFile(fs, fpath, src, parser.ParseComments) - if err != nil { - continue - } - pkgCounts[file.Name.Name]++ - files = append(files, parsedFile{pkg: file.Name.Name, file: file}) - } - if len(files) == 0 { - return nil, nil - } - // Round-10 #6: rev3 used range-over-map which has random iteration - // order, so on a tie the dominant package selection was - // nondeterministic. Sort the package names so the tie-break is - // stable (lexicographic-first wins). - pkgNames := make([]string, 0, len(pkgCounts)) - for pkg := range pkgCounts { - pkgNames = append(pkgNames, pkg) - } - sort.Strings(pkgNames) - dominant := "" - dominantCount := 0 - for _, pkg := range pkgNames { - if pkgCounts[pkg] > dominantCount { - dominant = pkg - dominantCount = pkgCounts[pkg] - } - } - // Pass 2: aggregate methods only from the dominant package. - methodsByRecv := make(map[string][]*ast.FuncDecl) - for _, p := range files { - if p.pkg != dominant { - continue - } - for _, decl := range p.file.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok { - continue - } - recv := receiverTypeName(fn) - if recv == "" { - continue - } - methodsByRecv[recv] = append(methodsByRecv[recv], fn) - } - } - out := make(map[string]bool) - for recv, methods := range methodsByRecv { - if looksLikeProvider(methods) { - out[recv] = true - } - } - return out, methodsByRecv -} - -// receiverDoc captures the documentation positions where a skip marker -// could be placed for a given receiver type: the inner TypeSpec.Doc -// (between `type` and the type name) and the wrapping GenDecl.Doc -// (before the `type` keyword). hasSkipMarkerOn handles nil so the -// call site can pass either field unconditionally. -type receiverDoc struct { - TypeSpecDoc *ast.CommentGroup - GenDeclDoc *ast.CommentGroup -} - -func (d receiverDoc) carriesMarker() bool { - return hasSkipMarkerOn(d.TypeSpecDoc) || hasSkipMarkerOn(d.GenDeclDoc) -} - -// receiverTypeDocs returns a map from receiver type name to its -// associated documentation positions. Used by refactor-plan and -// refactor-apply to check the SkipMarker at type-doc and GenDecl-doc -// levels in addition to the function-doc level (review round-1 -// finding #4). -// -// Single-file scope only — for cross-file scenarios (provider type -// declared in a sibling file from its Plan/Apply methods), use -// receiverTypeDocsInDir which merges across the directory's dominant -// package (review round-6 finding #1). -func receiverTypeDocs(file *ast.File) map[string]receiverDoc { - out := make(map[string]receiverDoc) - for _, decl := range file.Decls { - gd, ok := decl.(*ast.GenDecl) - if !ok || gd.Tok != token.TYPE { - continue - } - for _, spec := range gd.Specs { - ts, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } - out[ts.Name.Name] = receiverDoc{ - TypeSpecDoc: ts.Doc, - GenDeclDoc: gd.Doc, - } - } - } - return out -} - -// receiverTypeDocsInDir returns the receiver-type doc map merged across -// every non-test .go file in dir whose `package P` clause matches the -// dominant package. Closes review round-6 finding #1: rev3 of refactor-* -// ran receiverTypeDocs on the per-file AST only, so a provider whose -// type declaration lived in a SIBLING file (round-3's directory-wide -// method-set scan made this layout possible) had its `// wfctl:skip-iac-codemod` -// type-doc marker silently ignored, and the methods in the current -// file would still be rewritten. -// -// File parses are reused (not deduped) — each file gets its own -// FileSet/parse — but all yielded receiverDocs share the same -// dominant-package filter as planLikeProviderMethodsInDir to keep the -// build-tagged / mixed-package case safe. -// -// Falls back to the per-file map if the directory walk fails (e.g. -// path is a single file, not a directory). -func receiverTypeDocsInDir(dir string, primary *ast.File) map[string]receiverDoc { - out := receiverTypeDocs(primary) - entries, err := os.ReadDir(dir) - if err != nil { - return out - } - // Determine dominant package from the directory. - pkgCounts := make(map[string]int) - type parsedDoc struct { - pkg string - file *ast.File - } - var files []parsedDoc - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { - continue - } - fpath := filepath.Join(dir, name) - src, err := readFile(fpath) - if err != nil { - continue - } - fs := token.NewFileSet() - f, err := parser.ParseFile(fs, fpath, src, parser.ParseComments) - if err != nil { - continue - } - pkgCounts[f.Name.Name]++ - files = append(files, parsedDoc{pkg: f.Name.Name, file: f}) - } - dominant := primary.Name.Name - if dominantCount, ok := pkgCounts[dominant]; !ok || dominantCount == 0 { - // Primary's package isn't in the directory walk (rare — - // happens when `path` is outside the dominant package). Just - // return the per-file map unchanged. - return out - } - for pkg, c := range pkgCounts { - if c > pkgCounts[dominant] { - dominant = pkg - } - } - // Merge sibling docs into out. The primary file's TypeSpec docs - // take precedence (they're already in `out` from receiverTypeDocs); - // sibling-file docs are added only for receivers not yet in `out`. - for _, p := range files { - if p.pkg != dominant { - continue - } - if p.file == primary { - continue // already merged via receiverTypeDocs(primary) - } - sib := receiverTypeDocs(p.file) - for recv, doc := range sib { - if _, ok := out[recv]; ok { - continue - } - out[recv] = doc - } - } - return out -} - -// classifyPlanBody inspects the body of a Plan method and returns its -// classification + (when non-canonical) a short reason. Detection is -// purely structural and conservative: only bodies that match the -// configHash-compare template are returned as canonical; anything else -// — including bodies that are MOSTLY canonical but have an extra -// statement — is reported as non-canonical. The conservative bias is -// intentional: a false-canonical risks silently dropping bespoke logic -// during rewrite, whereas a false-non-canonical merely surfaces a -// finding the maintainer can review and either skip-mark or hand-port. -func classifyPlanBody(fn *ast.FuncDecl, file *ast.File) (planClassification, string) { - if fn.Body == nil { - return planNonCanonical, "missing body" - } - // Already-delegated: single statement `return wfctlhelpers.Plan(...)`. - if isAlreadyDelegatedPlanBody(fn.Body, file) { - return planAlreadyDelegated, "" - } - // Canonical: body matches the configHash-compare template. - // Extract the actual `desired` and `current` parameter names from - // the signature so the canonical detector can validate the body - // against THIS provider's naming convention (round-8 #6: rev3 - // hardcoded "current"/"desired", missing providers using `state`/ - // `specs`). - desiredParam := nthParamName(fn, 1, "desired") - currentParam := nthParamName(fn, 2, "current") - if isCanonicalPlanBody(fn.Body, desiredParam, currentParam) { - return planCanonical, "" - } - return planNonCanonical, "Plan body does not match configHash-compare template" -} - -// nthParamName returns the name of fn's `idx`-th parameter (0-based) -// or `defaultName` if the parameter is unnamed/blank. Used by the -// canonical detector and rewriter to honor whatever names the original -// author used. -func nthParamName(fn *ast.FuncDecl, idx int, defaultName string) string { - if fn.Type.Params == nil || len(fn.Type.Params.List) <= idx { - return defaultName - } - field := fn.Type.Params.List[idx] - if len(field.Names) == 0 { - return defaultName - } - n := field.Names[0].Name - if n == "" || n == "_" { - return defaultName - } - return n -} - -// isAlreadyDelegatedPlanBody returns true ONLY for the canonical -// 2-statement rev2 form (with package alias resolution per round-4 #4): -// -// plan, err := .ComputePlan(ctx, p, desired, current) -// return &plan, err -// -// Round-5 finding #5: the legacy single-statement forms (broken rev1 -// `return platform.ComputePlan(...)` and rev0 `return wfctlhelpers.Plan(...)`) -// are NOT accepted as already-delegated. They're uncompilable broken -// output. Treating them as no-op meant rerunning the fixed codemod -// would never repair them. They now classify as non-canonical (the -// classifyPlanBody fallthrough) so a fresh -fix produces the correct -// 2-statement form. -// -// (The lint analyzer's "delegated" check still accepts the legacy -// forms as delegated for advisory purposes, since the marker mismatch -// is benign there. Only the rewriter distinguishes "broken output -// needing repair" from "true no-op idempotent".) -func isAlreadyDelegatedPlanBody(body *ast.BlockStmt, file *ast.File) bool { - platformAlias := pkgAliasFor(file, planHelperImportPath, "platform") - if len(body.List) != 2 { - return false - } - if !isPlatformComputePlanAssign(body.List[0], platformAlias) { - return false - } - return isAddrPlanReturn(body.List[1]) -} - -// isPlatformComputePlanAssign returns true if stmt is -// `plan, err := .ComputePlan(...)`. pkgAlias is the local -// name the file uses for the platform import (resolved by caller). -func isPlatformComputePlanAssign(stmt ast.Stmt, pkgAlias string) bool { - a, ok := stmt.(*ast.AssignStmt) - if !ok || a.Tok != token.DEFINE || len(a.Lhs) != 2 || len(a.Rhs) != 1 { - return false - } - call, ok := a.Rhs[0].(*ast.CallExpr) - if !ok { - return false - } - sel, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return false - } - x, ok := sel.X.(*ast.Ident) - if !ok { - return false - } - return (x.Name == pkgAlias || x.Name == "platform") && sel.Sel.Name == "ComputePlan" -} - -// isAddrPlanReturn returns true if stmt is `return &, ` for -// some idents X, Y. Conservative match for the canonical 2-statement -// rewrite output. -func isAddrPlanReturn(stmt ast.Stmt) bool { - ret, ok := stmt.(*ast.ReturnStmt) - if !ok || len(ret.Results) != 2 { - return false - } - un, ok := ret.Results[0].(*ast.UnaryExpr) - if !ok || un.Op != token.AND { - return false - } - if _, ok := un.X.(*ast.Ident); !ok { - return false - } - if _, ok := ret.Results[1].(*ast.Ident); !ok { - return false - } - return true -} - -// isCanonicalPlanBody recognizes the configHash-compare template. The -// shape we accept (fuzzy on whitespace + identifier choice but tight on -// semantic structure): -// -// 1. An assignment building a name->state map: ` := make(map[string], len(current))`. -// 2. A range over `current` populating that map. -// 3. A composite-literal assignment building a `*{...}` plan -// (any IaCPlan-shaped struct). -// 4. A range over `desired` whose body appends `Action: "create"` or -// `Action: "update"` to plan.Actions, with the update branch gated -// on `configHash(...) != configHash(...)`. -// 5. A final `return plan, nil`. -// -// This is intentionally tighter than "first-pass heuristic" — review -// round 0 finding (anticipated): a too-loose canonical detector silently -// rewrites bespoke planners that happen to share keywords. -func isCanonicalPlanBody(body *ast.BlockStmt, desiredParam, currentParam string) bool { - stmts := body.List - - // 1. currentByName := make(map[string]...) - idx := 0 - if idx >= len(stmts) { - return false - } - if !isMapMakeAssign(stmts[idx]) { - return false - } - idx++ - - // 2. range over the `current` parameter (whatever the actual name). - if idx >= len(stmts) { - return false - } - if !isRangeOverIdent(stmts[idx], currentParam) { - return false - } - idx++ - - // 3. plan composite literal assignment - if idx >= len(stmts) { - return false - } - if !isPlanCompositeAssign(stmts[idx]) { - return false - } - idx++ - - // 4. range over the `desired` parameter (whatever the actual name) - // whose body has create + configHash-gated update. - if idx >= len(stmts) { - return false - } - rng, ok := stmts[idx].(*ast.RangeStmt) - if !ok { - return false - } - xIdent, ok := rng.X.(*ast.Ident) - if !ok || xIdent.Name != desiredParam { - return false - } - if !rangeBodyMatchesCanonicalDesired(rng.Body) { - return false - } - idx++ - - // 5. return plan, nil — must be EXACTLY this shape. Review round-3 - // finding #2: rev2 accepted any 2-result return, so a planner with - // the canonical scaffold but a bespoke final return (returning a - // cloned plan, propagating an error value, etc.) would still - // classify as canonical and the bespoke return logic would be - // silently dropped during rewrite. - if idx >= len(stmts) { - return false - } - ret, ok := stmts[idx].(*ast.ReturnStmt) - if !ok || len(ret.Results) != 2 { - return false - } - if id, ok := ret.Results[0].(*ast.Ident); !ok || id.Name != "plan" { - return false - } - if id, ok := ret.Results[1].(*ast.Ident); !ok || id.Name != "nil" { - return false - } - idx++ - - // Trailing junk → reject. - return idx == len(stmts) -} - -// isMapMakeAssign matches ` := make(map[string], ...)`. -func isMapMakeAssign(stmt ast.Stmt) bool { - a, ok := stmt.(*ast.AssignStmt) - if !ok || a.Tok != token.DEFINE || len(a.Rhs) != 1 { - return false - } - call, ok := a.Rhs[0].(*ast.CallExpr) - if !ok { - return false - } - id, ok := call.Fun.(*ast.Ident) - if !ok || id.Name != "make" { - return false - } - if len(call.Args) < 1 { - return false - } - _, ok = call.Args[0].(*ast.MapType) - return ok -} - -// isRangeOverIdent matches `for ..., ... := range { ... }`. -func isRangeOverIdent(stmt ast.Stmt, name string) bool { - rng, ok := stmt.(*ast.RangeStmt) - if !ok { - return false - } - id, ok := rng.X.(*ast.Ident) - if !ok { - return false - } - return id.Name == name -} - -// isPlanCompositeAssign matches `plan := &{...}`. -func isPlanCompositeAssign(stmt ast.Stmt) bool { - a, ok := stmt.(*ast.AssignStmt) - if !ok || a.Tok != token.DEFINE || len(a.Lhs) != 1 || len(a.Rhs) != 1 { - return false - } - if id, ok := a.Lhs[0].(*ast.Ident); !ok || id.Name != "plan" { - return false - } - un, ok := a.Rhs[0].(*ast.UnaryExpr) - if !ok || un.Op != token.AND { - return false - } - cl, ok := un.X.(*ast.CompositeLit) - if !ok { - return false - } - _ = cl - return true -} - -// rangeBodyMatchesCanonicalDesired verifies the body of the -// range-over-desired loop is EXACTLY the configHash-compare template: -// -// 1. lookup statement (`cur, exists := []`) -// 2. `if !exists { plan.Actions = append(plan.Actions, ...); continue }` -// — body MUST be exactly: one append-to-plan.Actions + one continue. -// 3. `if configHash(...) != configHash(...) { plan.Actions = append(plan.Actions, ...) }` -// — body MUST be exactly: one append-to-plan.Actions. -// -// Reject any statement that doesn't fit these three slots — bespoke -// telemetry, metrics, alternate construction, etc. — to keep the -// canonical detector tight. Round-5 finding #1: rev3 only checked the -// guard expressions and statement count; it never inspected what the -// branch bodies did, so extra logic inside `!exists` (or different -// create/update behavior) classified as canonical and was silently -// dropped during -fix. -// -// Both branch bodies are validated by isCanonicalPlanActionsAppendOnly -// (append + optional continue) so a planner with extra side-effects -// inside either branch is rejected. -// -// Top-level statement count must be exactly 3. The lookup statement -// may be assignment-style (`:=`) or simple-assign (`=`) — both are -// valid Go. -func rangeBodyMatchesCanonicalDesired(body *ast.BlockStmt) bool { - stmts := body.List - if len(stmts) != 3 { - return false - } - // 1. lookup `, := []` or single-target equivalent. - a, ok := stmts[0].(*ast.AssignStmt) - if !ok || (a.Tok != token.DEFINE && a.Tok != token.ASSIGN) { - return false - } - if len(a.Lhs) != 2 || len(a.Rhs) != 1 { - return false - } - if _, isIndex := a.Rhs[0].(*ast.IndexExpr); !isIndex { - return false - } - // 2. !exists guard with append+continue body. - notExists, ok := stmts[1].(*ast.IfStmt) - if !ok { - return false - } - u, ok := notExists.Cond.(*ast.UnaryExpr) - if !ok || u.Op != token.NOT { - return false - } - // Accept both `exists` (DO convention) and `ok` (idiomatic Go). - // Round-5 finding #8: rev3 hardcoded "exists", missing the - // semantically-identical `cur, ok := currentByName[...]` form. - id, ok := u.X.(*ast.Ident) - if !ok || (id.Name != "exists" && id.Name != "ok") { - return false - } - if notExists.Else != nil { - return false - } - if !isCanonicalCreateBranchBody(notExists.Body) { - return false - } - // 3. configHash != configHash guard with append-only body. - hashGuard, ok := stmts[2].(*ast.IfStmt) - if !ok { - return false - } - be, ok := hashGuard.Cond.(*ast.BinaryExpr) - if !ok || be.Op != token.NEQ { - return false - } - if !isConfigHashCall(be.X) || !isConfigHashCall(be.Y) { - return false - } - if hashGuard.Else != nil { - return false - } - if !isCanonicalUpdateBranchBody(hashGuard.Body) { - return false - } - return true -} - -// isCanonicalCreateBranchBody returns true if body is exactly: -// -// plan.Actions = append(plan.Actions, PlanAction{Action: "create", ...}) -// continue -// -// Round-12 #5: requires the appended action's `Action: "create"` field -// so a planner that builds different actions (e.g., "queue", "noop") -// from the canonical scaffold is rejected, preventing silent drop of -// custom action types. -func isCanonicalCreateBranchBody(body *ast.BlockStmt) bool { - if body == nil || len(body.List) != 2 { - return false - } - if !isPlanActionsAppendAssign(body.List[0], "create") { - return false - } - br, ok := body.List[1].(*ast.BranchStmt) - if !ok || br.Tok != token.CONTINUE { - return false - } - return true -} - -// isCanonicalUpdateBranchBody returns true if body is exactly: -// -// plan.Actions = append(plan.Actions, PlanAction{Action: "update", ...}) -// -// Round-12 #5: requires the appended action's `Action: "update"` field. -func isCanonicalUpdateBranchBody(body *ast.BlockStmt) bool { - if body == nil || len(body.List) != 1 { - return false - } - return isPlanActionsAppendAssign(body.List[0], "update") -} - -// isPlanActionsAppendAssign returns true if stmt is -// `plan.Actions = append(plan.Actions, )`. Both LHS AND -// the append's first argument must reference plan.Actions; the -// payload (second arg) is unconstrained (composite literal is fine). -// -// Round-7 finding #1: rev5 only verified the LHS, so a bespoke -// `plan.Actions = append(otherSlice, ...)` (e.g., a planner that -// builds actions from an alternate slice) was misclassified as -// canonical and the alternate-slice logic silently dropped during -// rewrite. -func isPlanActionsAppendAssign(stmt ast.Stmt, expectedAction string) bool { - a, ok := stmt.(*ast.AssignStmt) - if !ok || a.Tok != token.ASSIGN || len(a.Lhs) != 1 || len(a.Rhs) != 1 { - return false - } - sel, ok := a.Lhs[0].(*ast.SelectorExpr) - if !ok || sel.Sel.Name != "Actions" { - return false - } - if id, ok := sel.X.(*ast.Ident); !ok || id.Name != "plan" { - return false - } - call, ok := a.Rhs[0].(*ast.CallExpr) - if !ok { - return false - } - idFn, ok := call.Fun.(*ast.Ident) - if !ok || idFn.Name != "append" || len(call.Args) < 2 { - return false - } - // Verify append's first argument is also `plan.Actions`. - firstSel, ok := call.Args[0].(*ast.SelectorExpr) - if !ok || firstSel.Sel.Name != "Actions" { - return false - } - if id, ok := firstSel.X.(*ast.Ident); !ok || id.Name != "plan" { - return false - } - // Round-12 #5: verify the appended payload is a PlanAction - // composite literal whose Action field matches expectedAction. - // This rejects bespoke planners that use the canonical scaffold - // but build different actions (e.g., a planner that emits "noop" - // or "queue" instead of "create"/"update"); silent rewrite would - // drop those custom action types. - if expectedAction != "" { - cl, ok := call.Args[1].(*ast.CompositeLit) - if !ok { - return false - } - actionLit := "" - for _, elt := range cl.Elts { - kv, ok := elt.(*ast.KeyValueExpr) - if !ok { - continue - } - if k, ok := kv.Key.(*ast.Ident); !ok || k.Name != "Action" { - continue - } - if bl, ok := kv.Value.(*ast.BasicLit); ok && bl.Kind == token.STRING { - actionLit = strings.Trim(bl.Value, `"`) - } - break - } - if actionLit != expectedAction { - return false - } - } - return true -} - -// isConfigHashCall reports whether expr is a call to the unexported -// `configHash` function: `configHash()`. Used to recognise the -// configHash-compare guard inside the canonical Plan template. -func isConfigHashCall(expr ast.Expr) bool { - call, ok := expr.(*ast.CallExpr) - if !ok { - return false - } - id, ok := call.Fun.(*ast.Ident) - if !ok { - return false - } - return id.Name == "configHash" -} - -// rewritePlanBody replaces fn.Body with the canonical 2-statement -// delegation to platform.ComputePlan: -// -// plan, err := platform.ComputePlan(, , desired, current) -// return &plan, err -// -// platform.ComputePlan returns `(interfaces.IaCPlan, error)` BY VALUE, -// but provider Plan methods return `(*interfaces.IaCPlan, error)`. -// Review round-2 finding #1: a single-statement -// `return platform.ComputePlan(...)` rewrite produces uncompilable code -// because the value/pointer mismatch can't be implicitly bridged. The -// 2-statement form takes the address of the local return value before -// returning it, matching the provider interface. -// -// Receiver and ctx identifiers are recovered from the signature; rules -// (review round-1 #2, round-2 #3): -// -// - If the receiver is unnamed (`func (*Provider) Plan(...)`), give -// it a name (`p`) so the substituted call has a referent. rev1 -// fell back to a hardcoded "p" but didn't update the receiver -// decl, so the rewritten call referenced an undefined identifier. -// - Blank `_` ctx parameters are renamed to `ctx` (standard idiom); -// non-blank ctx names are preserved. -func rewritePlanBody(fn *ast.FuncDecl, file *ast.File) { - recvName := ensureReceiverName(fn, "p") - ctxName := ensureCtxParamName(fn) - // Review round-3 finding #3: rev2 hardcoded "desired" and "current" - // as the 2nd/3rd argument names. A canonical Plan declared as - // `Plan(ctx, specs, state)` rewrites to references to undefined - // identifiers `desired` / `current`. Extract the actual parameter - // names from the signature so the substituted call always - // references real identifiers. - desiredName := ensureNthParamName(fn, 1, "desired") - currentName := ensureNthParamName(fn, 2, "current") - - // Resolve the package alias for github.com/GoCodeAlone/workflow/platform - // so the call uses whatever name the file already imports under - // (review round-3 finding #4: rev2 hardcoded "platform" but a file - // using `pf "github.com/.../platform"` wouldn't compile because - // `platform` is undefined). - pkgAlias := pkgAliasFor(file, planHelperImportPath, "platform") - - call := &ast.CallExpr{ - Fun: &ast.SelectorExpr{ - X: ast.NewIdent(pkgAlias), - Sel: ast.NewIdent("ComputePlan"), - }, - Args: []ast.Expr{ - ast.NewIdent(ctxName), - ast.NewIdent(recvName), - ast.NewIdent(desiredName), - ast.NewIdent(currentName), - }, - } - // emits an assignment: plan, err := .ComputePlan(ctx, p, desired, current) - planAssign := &ast.AssignStmt{ - Lhs: []ast.Expr{ast.NewIdent("plan"), ast.NewIdent("err")}, - Tok: token.DEFINE, - Rhs: []ast.Expr{call}, - } - // emits a return statement: return &plan, err - returnStmt := &ast.ReturnStmt{ - Results: []ast.Expr{ - &ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("plan")}, - ast.NewIdent("err"), - }, - } - fn.Body = &ast.BlockStmt{List: []ast.Stmt{planAssign, returnStmt}} -} - -// ensureReceiverName returns the receiver identifier of fn, mutating -// the AST to add `defaultName` if the receiver is unnamed (e.g. -// `func (*Provider) Plan(...)`). Used by rewritePlanBody and -// rewriteApplyBody to make the substituted call site valid even on -// previously-anonymous receivers (review round-2 #3 + #4). -func ensureReceiverName(fn *ast.FuncDecl, defaultName string) string { - if fn.Recv == nil || len(fn.Recv.List) == 0 { - return defaultName - } - first := fn.Recv.List[0] - if len(first.Names) > 0 && first.Names[0].Name != "" && first.Names[0].Name != "_" { - return first.Names[0].Name - } - // Receiver is unnamed (or `_`). Inject `defaultName` so the - // rewritten call has a referent. - first.Names = []*ast.Ident{ast.NewIdent(defaultName)} - return defaultName -} - -// ensureCtxParamName returns the name of the first parameter, renaming -// blank `_` to `ctx` so the substituted call has a referent. If the -// parameter already has a non-blank name, that name is preserved and -// returned (review round-1 #2). -func ensureCtxParamName(fn *ast.FuncDecl) string { - if fn.Type.Params == nil || len(fn.Type.Params.List) < 1 { - return "ctx" - } - first := fn.Type.Params.List[0] - if len(first.Names) == 0 { - first.Names = []*ast.Ident{ast.NewIdent("ctx")} - return "ctx" - } - if len(first.Names) == 1 { - n := first.Names[0].Name - if n == "_" || n == "" { - first.Names[0] = ast.NewIdent("ctx") - return "ctx" - } - return n - } - if first.Names[0].Name != "" && first.Names[0].Name != "_" { - return first.Names[0].Name - } - first.Names[0] = ast.NewIdent("ctx") - return "ctx" -} - -// ensureImport adds an unaliased ImportSpec for `path` if one is not -// already present. Returns true if an import was added. -func ensureImport(file *ast.File, path string) bool { - return ensureImportAs(file, path, "") -} - -// ensureImportAs adds an ImportSpec for `path` with optional `alias` -// if one is not already present. If `alias` is non-empty, the spec is -// emitted as `alias "path"` so call sites referencing `alias.X` -// resolve. Round-9 #4: round-4's ensureImport injected the unaliased -// import even when the stub used a sibling-derived alias (e.g. -// `iface.IaCPlan`), leaving the rewritten file referring to undefined -// `iface`. ensureImportAs propagates the alias through. -func ensureImportAs(file *ast.File, path, alias string) bool { - for _, imp := range file.Imports { - if imp.Path == nil { - continue - } - v := strings.Trim(imp.Path.Value, `"`) - if v == path { - return false - } - } - newImport := &ast.ImportSpec{ - Path: &ast.BasicLit{Kind: token.STRING, Value: `"` + path + `"`}, - } - if alias != "" { - newImport.Name = ast.NewIdent(alias) - } - for _, decl := range file.Decls { - gd, ok := decl.(*ast.GenDecl) - if !ok || gd.Tok != token.IMPORT { - continue - } - gd.Specs = append(gd.Specs, newImport) - if !gd.Lparen.IsValid() { - gd.Lparen = gd.Pos() - gd.Rparen = gd.End() - } - return true - } - gd := &ast.GenDecl{ - Tok: token.IMPORT, - Lparen: token.NoPos, - Specs: []ast.Spec{newImport}, - } - file.Decls = append([]ast.Decl{gd}, file.Decls...) - return true -} - -// ensurePlatformImport adds a `platform` import to file if absent; -// idempotent. Used by refactor-plan rewrites which substitute -// platform.ComputePlan calls. -func ensurePlatformImport(file *ast.File) bool { - return ensureImport(file, planHelperImportPath) -} - -// ensureWfctlhelpersImport adds a `wfctlhelpers` import to file if -// absent; idempotent. Used by refactor-apply rewrites which substitute -// wfctlhelpers.ApplyPlan calls. -func ensureWfctlhelpersImport(file *ast.File) bool { - return ensureImport(file, helperImportPath) -} - -// pkgAliasFor returns the local package name used by `file` for -// `importPath`. If the file imports the path under an explicit alias -// (`pf "github.com/.../platform"`), the alias is returned; otherwise -// the package's default name is `defaultName`. If the file does not -// import the path at all, returns `defaultName` (the caller is -// expected to call ensureImport before relying on this name). -// -// Review round-3 findings #4 + #6: rev2 of refactor-plan / refactor-apply -// hardcoded "platform" / "wfctlhelpers" as the call-site selector even -// when the file already used an aliased import. ensureImport saw the -// aliased import as satisfying the path check and skipped adding a -// fresh one, leaving the rewritten code referring to an undefined -// identifier. pkgAliasFor closes that gap by selecting the right name -// at rewrite time. -func pkgAliasFor(file *ast.File, importPath, defaultName string) string { - if file == nil { - return defaultName - } - for _, imp := range file.Imports { - if imp.Path == nil { - continue - } - if strings.Trim(imp.Path.Value, `"`) != importPath { - continue - } - if imp.Name != nil { - n := imp.Name.Name - if n == "" || n == "_" || n == "." { - return defaultName - } - return n - } - return defaultName - } - return defaultName -} - -// writeFileAtomic prints `file` to a temp sibling and renames it over -// `path`. The two-step write protects against partial writes on crash: -// either the destination contains the full new contents or it remains -// unchanged. -// -// Round-11 #2: rev1 left the temp file at os.CreateTemp's default -// 0600 mode, so the rename clobbered the source's original permissions -// (e.g., 0644 → 0600). Now captures the original mode via os.Stat -// and chmods the temp file to match before the rename. -func writeFileAtomic(path string, fset *token.FileSet, file *ast.File) error { - var buf bytes.Buffer - // format.Node produces gofmt-canonical output (the same algorithm - // `go fmt` uses), which keeps the rewrite indistinguishable from a - // hand-formatted file. Plain printer.Fprint produces tab-aligned - // columns that drift from gofmt output and would look like - // codemod-touched files in code review. - if err := format.Node(&buf, fset, file); err != nil { - return err - } - return writeFileAtomicBytesPreserveMode(path, buf.Bytes()) -} - -// writeFileAtomicBytesPreserveMode is the underlying atomic-write -// helper that captures the source file's mode and applies it to the -// temp file before rename. Used by both writeFileAtomic and -// writeFileAtomicBytes so both AST-printing and raw-bytes writers -// preserve permissions (round-11 #2 + #5). -func writeFileAtomicBytesPreserveMode(path string, data []byte) error { - // Capture the source's original mode so the rename doesn't - // clobber it with CreateTemp's default 0600. - var origMode os.FileMode = 0o644 - if info, err := os.Stat(path); err == nil { - origMode = info.Mode().Perm() - } - dir := filepath.Dir(path) - tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".codemod-") - if err != nil { - return err - } - tmpPath := tmp.Name() - defer func() { - // Best-effort cleanup if rename fails. - _ = os.Remove(tmpPath) - }() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Chmod(tmpPath, origMode); err != nil { - return err - } - return os.Rename(tmpPath, path) -} - -// print renders the report. Findings/sites are sorted by file, line so -// output is deterministic across runs. -func (r *planReport) print(w io.Writer, opts *Options) { - sort.Slice(r.sites, func(i, j int) bool { - if r.sites[i].Path != r.sites[j].Path { - return r.sites[i].Path < r.sites[j].Path - } - return r.sites[i].Line < r.sites[j].Line - }) - - fmt.Fprintln(w, "# iac-codemod refactor-plan report") - fmt.Fprintln(w) - mode := "dry-run" - if opts != nil && opts.Fix { - mode = "fix" - } - fmt.Fprintf(w, "Mode: %s\n", mode) - fmt.Fprintf(w, "Sites: %d\n", len(r.sites)) - fmt.Fprintf(w, "Errors: %d\n", len(r.errors)) - fmt.Fprintln(w) - - if len(r.sites) > 0 { - // Group by classification for readability. - var canonical, nonCanonical, alreadyDelegated, skipped []planSite - for _, s := range r.sites { - switch s.Class { - case planCanonical: - canonical = append(canonical, s) - case planNonCanonical: - nonCanonical = append(nonCanonical, s) - case planAlreadyDelegated: - alreadyDelegated = append(alreadyDelegated, s) - case planSkipped: - skipped = append(skipped, s) - } - } - printSitesSection(w, "Canonical (rewrite candidate)", canonical, true) - printSitesSection(w, "Non-canonical (manual review required)", nonCanonical, false) - printSitesSection(w, "Already-delegated (no-op)", alreadyDelegated, false) - printSitesSection(w, "Skipped (// wfctl:skip-iac-codemod)", skipped, false) - } - - if len(r.errors) > 0 { - fmt.Fprintln(w, "## Errors") - fmt.Fprintln(w) - for _, e := range r.errors { - fmt.Fprintf(w, "- %s\n", e) - } - fmt.Fprintln(w) - } -} - -// printSitesSection renders one classification group. -func printSitesSection(w io.Writer, header string, sites []planSite, showRewrite bool) { - if len(sites) == 0 { - return - } - fmt.Fprintf(w, "## %s\n\n", header) - for _, s := range sites { - suffix := "" - if showRewrite && s.Rewrote { - suffix = " (rewritten)" - } - if s.Reason != "" { - fmt.Fprintf(w, "- %s:%d %s.Plan %s — %s%s\n", s.Path, s.Line, s.Receiver, s.Class, s.Reason, suffix) - } else { - fmt.Fprintf(w, "- %s:%d %s.Plan %s%s\n", s.Path, s.Line, s.Receiver, s.Class, suffix) - } - } - fmt.Fprintln(w) -} diff --git a/cmd/iac-codemod/refactor_plan_test.go b/cmd/iac-codemod/refactor_plan_test.go deleted file mode 100644 index b7923b6d..00000000 --- a/cmd/iac-codemod/refactor_plan_test.go +++ /dev/null @@ -1,575 +0,0 @@ -// Copyright (c) 2026 Jon Langevin -// SPDX-License-Identifier: Apache-2.0 - -// Tests in this file MUST NOT call t.Parallel(). Same global-state -// constraint as main_test.go and lint_test.go (the package-level `modes` -// map is mutated transitively through init()). - -package main - -import ( - "bytes" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// ============================================================ -// Golden-file source fixtures -// ============================================================ - -// canonicalPlanSrc is the configHash-compare canonical pattern T8.3 -// targets for rewrite. Modeled on the DigitalOcean DOProvider.Plan body -// at workflow-plugin-digitalocean/internal/provider.go:141 (rev1 of the -// codemod). Mutation must replace the entire body with a single -// `return wfctlhelpers.Plan(ctx, p, desired, current)` and add an import -// for the helper package if it is not already present. -const canonicalPlanSrc = `package p - -import ( - "context" - "fmt" - "time" -) - -type ResourceSpec struct{ Name string; Config map[string]any } -type ResourceState struct{ Name string; AppliedConfig map[string]any } -type IaCPlan struct{ ID string; CreatedAt time.Time; Actions []PlanAction } -type PlanAction struct{ Action string; Resource ResourceSpec; Current *ResourceState } -type ApplyResult struct{} -type PlanDiagnostic struct{} - -type DOProvider struct{} - -func configHash(m map[string]any) string { return "" } - -// Plan computes the set of actions needed to reach the desired state. -func (p *DOProvider) Plan(_ context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - currentByName := make(map[string]ResourceState, len(current)) - for _, r := range current { - currentByName[r.Name] = r - } - - plan := &IaCPlan{ - ID: fmt.Sprintf("plan-%d", time.Now().UnixNano()), - CreatedAt: time.Now(), - } - - for _, spec := range desired { - cur, exists := currentByName[spec.Name] - if !exists { - plan.Actions = append(plan.Actions, PlanAction{ - Action: "create", - Resource: spec, - }) - continue - } - if configHash(cur.AppliedConfig) != configHash(spec.Config) { - plan.Actions = append(plan.Actions, PlanAction{ - Action: "update", - Resource: spec, - Current: &cur, - }) - } - } - return plan, nil -} - -func (p *DOProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return wfctlhelpers.ApplyPlan(ctx, p, plan) -} -func (p *DOProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -// nonCanonicalPlanSrc has out-of-template logic (an extra log call and a -// custom return shape) that T8.3 must REFUSE to rewrite. The mode emits -// a finding instead. -const nonCanonicalPlanSrc = `package p - -import ( - "context" - "fmt" -) - -type ResourceSpec struct{ Name string; Config map[string]any } -type ResourceState struct{ Name string; AppliedConfig map[string]any } -type IaCPlan struct{ Actions []PlanAction } -type PlanAction struct{ Action string; Resource ResourceSpec } -type ApplyResult struct{} - -type FooProvider struct{} - -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - // Out-of-template: telemetry call + bespoke ordering logic. - fmt.Println("planning custom flow") - plan := &IaCPlan{} - for _, spec := range desired { - _ = spec - plan.Actions = append(plan.Actions, PlanAction{Action: "noop"}) - } - return plan, nil -} - -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { return nil, nil } -` - -// skippedPlanSrc carries the canonical marker on the function doc and -// must NOT be rewritten regardless of body shape. The skipped site is -// listed in the report. -const skippedPlanSrc = `package p - -import "context" - -type ResourceSpec struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} -type FooProvider struct{} - -// wfctl:skip-iac-codemod legacy custom planning, see ADR-042 -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - return &IaCPlan{}, nil -} - -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { return nil, nil } -` - -// canonicalPlanUnnamedReceiverSrc — review round-2 finding #3. A -// canonical Plan method whose receiver is unnamed -// (`func (*DOProvider) Plan(...)`) must produce a rewrite that compiles: -// the rewriter must inject a receiver name (`p`) AND update the receiver -// decl so the substituted call has a real referent. -const canonicalPlanUnnamedReceiverSrc = `package p - -import ( - "context" - "fmt" - "time" -) - -type ResourceSpec struct{ Name string; Config map[string]any } -type ResourceState struct{ Name string; AppliedConfig map[string]any } -type IaCPlan struct{ ID string; CreatedAt time.Time; Actions []PlanAction } -type PlanAction struct{ Action string; Resource ResourceSpec; Current *ResourceState } -type ApplyResult struct{} -type PlanDiagnostic struct{} - -type DOProvider struct{} - -func configHash(m map[string]any) string { return "" } - -// Unnamed receiver: ` + "`func (*DOProvider) Plan(...)`" + ` style. -func (*DOProvider) Plan(_ context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - currentByName := make(map[string]ResourceState, len(current)) - for _, r := range current { - currentByName[r.Name] = r - } - plan := &IaCPlan{ID: fmt.Sprintf("plan-%d", time.Now().UnixNano()), CreatedAt: time.Now()} - for _, spec := range desired { - cur, exists := currentByName[spec.Name] - if !exists { - plan.Actions = append(plan.Actions, PlanAction{Action: "create", Resource: spec}) - continue - } - if configHash(cur.AppliedConfig) != configHash(spec.Config) { - plan.Actions = append(plan.Actions, PlanAction{Action: "update", Resource: spec, Current: &cur}) - } - } - return plan, nil -} - -func (p *DOProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return wfctlhelpers.ApplyPlan(ctx, p, plan) -} -func (p *DOProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -func TestRefactorPlan_Fix_UnnamedReceiverGetsName(t *testing.T) { - path := writeFixture(t, "provider.go", canonicalPlanUnnamedReceiverSrc) - var stdout, stderr bytes.Buffer - if code := runRefactorPlan([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr); code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - gotStr := string(got) - // The receiver must have been given a name (`p`) AND the call must - // reference it. Bare ast manipulation of an unnamed receiver into - // the body would refer to a non-existent `p` — test pins the fix. - if !strings.Contains(gotStr, "func (p *DOProvider) Plan(") { - t.Errorf("rewrite must inject a receiver name on previously-anonymous receivers; got:\n%s", gotStr) - } - if !strings.Contains(gotStr, "platform.ComputePlan(ctx, p,") { - t.Errorf("rewrite must reference the injected receiver name; got:\n%s", gotStr) - } -} - -// canonicalPlanCustomCtxNameSrc — review round-1 finding #2 regression -// test (non-blank ctx-param name preserved). The rewriter must NOT -// rename `c` to `ctx`; the substituted call must reference `c`. -const canonicalPlanCustomCtxNameSrc = `package p - -import ( - "context" - "fmt" - "time" -) - -type ResourceSpec struct{ Name string; Config map[string]any } -type ResourceState struct{ Name string; AppliedConfig map[string]any } -type IaCPlan struct{ ID string; CreatedAt time.Time; Actions []PlanAction } -type PlanAction struct{ Action string; Resource ResourceSpec; Current *ResourceState } -type ApplyResult struct{} -type PlanDiagnostic struct{} - -type DOProvider struct{} - -func configHash(m map[string]any) string { return "" } - -func (p *DOProvider) Plan(c context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - currentByName := make(map[string]ResourceState, len(current)) - for _, r := range current { - currentByName[r.Name] = r - } - plan := &IaCPlan{ID: fmt.Sprintf("plan-%d", time.Now().UnixNano()), CreatedAt: time.Now()} - for _, spec := range desired { - cur, exists := currentByName[spec.Name] - if !exists { - plan.Actions = append(plan.Actions, PlanAction{Action: "create", Resource: spec}) - continue - } - if configHash(cur.AppliedConfig) != configHash(spec.Config) { - plan.Actions = append(plan.Actions, PlanAction{Action: "update", Resource: spec, Current: &cur}) - } - } - return plan, nil -} - -func (p *DOProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return wfctlhelpers.ApplyPlan(ctx, p, plan) -} -func (p *DOProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -func TestRefactorPlan_Fix_PreservesCustomCtxName(t *testing.T) { - path := writeFixture(t, "provider.go", canonicalPlanCustomCtxNameSrc) - var stdout, stderr bytes.Buffer - if code := runRefactorPlan([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr); code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - gotStr := string(got) - if !strings.Contains(gotStr, "Plan(c context.Context") { - t.Errorf("rewrite should preserve the custom ctx-param name `c`; got:\n%s", gotStr) - } - if !strings.Contains(gotStr, "platform.ComputePlan(c, p,") { - t.Errorf("substituted call must reference the original ctx name `c`, not `ctx`; got:\n%s", gotStr) - } -} - -// canonicalPlanWithExtraLoggingSrc — review round-1 finding #3. A Plan -// body whose desired-loop has an additional logging call (a real-world -// bespoke planner) must NOT be classified as canonical: silently -// rewriting it would drop the log line. -const canonicalPlanWithExtraLoggingSrc = `package p - -import ( - "context" - "fmt" - "time" -) - -type ResourceSpec struct{ Name string; Config map[string]any } -type ResourceState struct{ Name string; AppliedConfig map[string]any } -type IaCPlan struct{ ID string; CreatedAt time.Time; Actions []PlanAction } -type PlanAction struct{ Action string; Resource ResourceSpec; Current *ResourceState } -type ApplyResult struct{} -type PlanDiagnostic struct{} - -type DOProvider struct{} - -func configHash(m map[string]any) string { return "" } - -func (p *DOProvider) Plan(_ context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - currentByName := make(map[string]ResourceState, len(current)) - for _, r := range current { - currentByName[r.Name] = r - } - - plan := &IaCPlan{ - ID: fmt.Sprintf("plan-%d", time.Now().UnixNano()), - CreatedAt: time.Now(), - } - - for _, spec := range desired { - fmt.Println("planning:", spec.Name) // BESPOKE TELEMETRY — must not be silently dropped - cur, exists := currentByName[spec.Name] - if !exists { - plan.Actions = append(plan.Actions, PlanAction{Action: "create", Resource: spec}) - continue - } - if configHash(cur.AppliedConfig) != configHash(spec.Config) { - plan.Actions = append(plan.Actions, PlanAction{Action: "update", Resource: spec, Current: &cur}) - } - } - return plan, nil -} - -func (p *DOProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { - return wfctlhelpers.ApplyPlan(ctx, p, plan) -} -func (p *DOProvider) ValidatePlan(plan *IaCPlan) []PlanDiagnostic { return nil } -` - -func TestRefactorPlan_ExtraLoggingNotCanonical(t *testing.T) { - path := writeFixture(t, "provider.go", canonicalPlanWithExtraLoggingSrc) - var stdout, stderr bytes.Buffer - code := runRefactorPlan([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if strings.Contains(out, "DOProvider.Plan canonical") { - t.Errorf("Plan body with extra side-effect (telemetry) must NOT be classified canonical; got:\n%s", out) - } - if !strings.Contains(out, "non-canonical") { - t.Errorf("Plan body with extra side-effect should be reported non-canonical; got:\n%s", out) - } -} - -// alreadyDelegatedPlanSrc has a Plan body that is the canonical -// 2-statement delegation to platform.ComputePlan (the rev2 form which -// bridges the value/pointer mismatch from review round-2 finding #1). -// The mode must NOT report it as non-canonical and must NOT mutate it -// (idempotent). -const alreadyDelegatedPlanSrc = `package p - -import "context" - -type ResourceSpec struct{} -type ResourceState struct{} -type IaCPlan struct{} -type ApplyResult struct{} -type FooProvider struct{} - -func (p *FooProvider) Plan(ctx context.Context, desired []ResourceSpec, current []ResourceState) (*IaCPlan, error) { - plan, err := platform.ComputePlan(ctx, p, desired, current) - return &plan, err -} - -func (p *FooProvider) Apply(ctx context.Context, plan *IaCPlan) (*ApplyResult, error) { return nil, nil } -` - -// ============================================================ -// Helpers -// ============================================================ - -// writeFixture writes src to a fresh tempdir, returning the path. -func writeFixture(t *testing.T, name, src string) string { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, name) - if err := os.WriteFile(path, []byte(src), 0o644); err != nil { - t.Fatalf("write fixture %s: %v", path, err) - } - return path -} - -// ============================================================ -// Detection / reporting (dry-run) -// ============================================================ - -func TestRefactorPlan_DryRun_DetectsCanonical(t *testing.T) { - path := writeFixture(t, "provider.go", canonicalPlanSrc) - var stdout, stderr bytes.Buffer - code := runRefactorPlan([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if !strings.Contains(out, "DOProvider.Plan") { - t.Errorf("report should name DOProvider.Plan; got:\n%s", out) - } - if !strings.Contains(out, "canonical") { - t.Errorf("report should mark site as canonical (rewrite candidate); got:\n%s", out) - } - // Dry-run must not mutate. - got, _ := os.ReadFile(path) - if string(got) != canonicalPlanSrc { - t.Errorf("dry-run modified the file; expected no mutation") - } -} - -func TestRefactorPlan_DryRun_ReportsNonCanonical(t *testing.T) { - path := writeFixture(t, "provider.go", nonCanonicalPlanSrc) - var stdout, stderr bytes.Buffer - code := runRefactorPlan([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if !strings.Contains(out, "FooProvider.Plan") { - t.Errorf("report should name FooProvider.Plan; got:\n%s", out) - } - if !strings.Contains(out, "non-canonical") { - t.Errorf("report should mark site as non-canonical; got:\n%s", out) - } -} - -func TestRefactorPlan_DryRun_HonorsSkipMarker(t *testing.T) { - path := writeFixture(t, "provider.go", skippedPlanSrc) - var stdout, stderr bytes.Buffer - code := runRefactorPlan([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if !strings.Contains(out, "Skipped") { - t.Errorf("report should have a Skipped section; got:\n%s", out) - } - if !strings.Contains(out, "FooProvider.Plan") { - t.Errorf("Skipped section should list FooProvider.Plan; got:\n%s", out) - } -} - -func TestRefactorPlan_DryRun_AlreadyDelegatedReportedAsNoop(t *testing.T) { - path := writeFixture(t, "provider.go", alreadyDelegatedPlanSrc) - var stdout, stderr bytes.Buffer - code := runRefactorPlan([]string{path}, &Options{DryRun: true, Fix: false}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - out := stdout.String() - if strings.Contains(out, "non-canonical") { - t.Errorf("already-delegated Plan should NOT be reported non-canonical; got:\n%s", out) - } - if !strings.Contains(out, "already-delegated") { - t.Errorf("already-delegated should be classified explicitly; got:\n%s", out) - } -} - -// ============================================================ -// Mutation (-fix) -// ============================================================ - -func TestRefactorPlan_Fix_RewritesCanonical(t *testing.T) { - path := writeFixture(t, "provider.go", canonicalPlanSrc) - var stdout, stderr bytes.Buffer - code := runRefactorPlan([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read after fix: %v", err) - } - gotStr := string(got) - // Round-2 finding #1: platform.ComputePlan returns IaCPlan by value; - // provider Plan returns *IaCPlan. The rewrite uses the 2-statement - // form (`plan, err := platform.ComputePlan(...); return &plan, err`) - // to bridge the value/pointer mismatch. - if !strings.Contains(gotStr, "plan, err := platform.ComputePlan(ctx, p, desired, current)") { - t.Errorf("rewritten body should call platform.ComputePlan via 2-statement form; got:\n%s", gotStr) - } - if !strings.Contains(gotStr, "return &plan, err") { - t.Errorf("rewritten body should return &plan, err to bridge value→pointer; got:\n%s", gotStr) - } - if strings.Contains(gotStr, "currentByName := make(") { - t.Errorf("canonical body should be removed by rewrite; got:\n%s", gotStr) - } - // Helper import must be present after rewrite. - if !strings.Contains(gotStr, `"github.com/GoCodeAlone/workflow/platform"`) { - t.Errorf("rewrite should add platform import; got:\n%s", gotStr) - } -} - -func TestRefactorPlan_Fix_RenamesBlankReceiverParamSoCtxResolves(t *testing.T) { - // The DO provider declares Plan(_ context.Context, ...) and after - // rewrite the body must reference the ctx parameter. The codemod - // renames the blank `_` parameter to `ctx` so the substituted call - // compiles. Pinned regression: if the renamer is dropped, the - // rewritten file fails to type-check. - path := writeFixture(t, "provider.go", canonicalPlanSrc) - var stdout, stderr bytes.Buffer - if code := runRefactorPlan([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr); code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if !strings.Contains(string(got), "Plan(ctx context.Context") { - t.Errorf("blank ctx param should be renamed to ctx so the rewritten body compiles; got:\n%s", string(got)) - } -} - -func TestRefactorPlan_Fix_DoesNotRewriteNonCanonical(t *testing.T) { - path := writeFixture(t, "provider.go", nonCanonicalPlanSrc) - var stdout, stderr bytes.Buffer - code := runRefactorPlan([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != nonCanonicalPlanSrc { - t.Errorf("non-canonical body must NOT be rewritten; file changed:\n%s", string(got)) - } -} - -func TestRefactorPlan_Fix_HonorsSkipMarker(t *testing.T) { - path := writeFixture(t, "provider.go", skippedPlanSrc) - var stdout, stderr bytes.Buffer - code := runRefactorPlan([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != skippedPlanSrc { - t.Errorf("skip-marker'd body must NOT be rewritten; file changed:\n%s", string(got)) - } -} - -func TestRefactorPlan_Fix_IdempotentOnAlreadyDelegated(t *testing.T) { - path := writeFixture(t, "provider.go", alreadyDelegatedPlanSrc) - var stdout, stderr bytes.Buffer - if code := runRefactorPlan([]string{path}, &Options{DryRun: false, Fix: true}, &stdout, &stderr); code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != alreadyDelegatedPlanSrc { - t.Errorf("already-delegated source must be byte-identical after fix (idempotent); diff:\nbefore:\n%s\nafter:\n%s", alreadyDelegatedPlanSrc, string(got)) - } -} - -// ============================================================ -// Mutation-gate negative tests (T8.1 review pattern) -// ============================================================ - -// TestRefactorPlan_DryRunFalseWithoutFix_DoesNotMutate pins the dispatcher -// gate from main_test.go: a user-supplied -dry-run=false without -fix must -// NOT bypass mutation. The mode is invoked via run() so dispatcher -// normalization runs; we then verify file mtime and content unchanged. -func TestRefactorPlan_DryRunFalseWithoutFix_DoesNotMutate(t *testing.T) { - path := writeFixture(t, "provider.go", canonicalPlanSrc) - stat0, _ := os.Stat(path) - mtime0 := stat0.ModTime() - - // Sleep 1 nanosecond worth of mtime resolution? We use file mtime AND - // content equality; either being unchanged is sufficient. For - // portability across filesystems, we don't require sub-second mtime - // granularity — we assert content unchanged AND the dispatcher - // normalized DryRun=true. - time.Sleep(10 * time.Millisecond) - - var stdout, stderr bytes.Buffer - code := run([]string{"refactor-plan", "-dry-run=false", path}, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) - } - got, _ := os.ReadFile(path) - if string(got) != canonicalPlanSrc { - t.Errorf("file must NOT be mutated when -dry-run=false is passed without -fix; content changed:\n%s", string(got)) - } - stat1, _ := os.Stat(path) - if !stat1.ModTime().Equal(mtime0) { - t.Errorf("file mtime should be unchanged; before=%v after=%v", mtime0, stat1.ModTime()) - } -} diff --git a/docs/migrations/2026-05-16-v2-lifecycle-phase1-inventory.md b/docs/migrations/2026-05-16-v2-lifecycle-phase1-inventory.md index 0db2ef9e..2cb982ee 100644 --- a/docs/migrations/2026-05-16-v2-lifecycle-phase1-inventory.md +++ b/docs/migrations/2026-05-16-v2-lifecycle-phase1-inventory.md @@ -27,13 +27,20 @@ PR #639 landed the v2 action lifecycle hook path (`wfctlhelpers.ApplyPlanWithHoo | `iac/conformance/scenario_replace_cascade_preserves_dependents.go:92` | MIGRATED in this PR | Same | | `cmd/wfctl/infra_apply_in_process_test.go:77` | MIGRATED in this PR (was missing from initial inventory; surfaced by staticcheck SA1019) | Same | -### iac-codemod tool references (NOT runtime callers) +### ~~iac-codemod tool references (NOT runtime callers)~~ — superseded by workflow#699 + +> **Update 2026-05-17 (workflow#699):** `cmd/iac-codemod/` was deleted in PR 1 +> of the IaCProvider.Apply hard-removal cascade. The codemod's reason-to-exist +> (migrate v1 `Apply` impls to v2 `wfctlhelpers.ApplyPlan` delegation) +> evaporated when `IaCProvider.Apply` itself was removed from the interface + +> proto. The original references below are kept for historical context but +> are no longer actionable. | File:Line | Notes | |-----------|-------| -| `cmd/iac-codemod/refactor_apply.go:29` (`applyCanonicalCallExpr` constant, `//nolint:unused`) | **Documentation-only constant; NOT consumed by AST rewriter.** Phase 3 lockstep update bumps this constant TOGETHER with `rewriteApplyBody` (line 1231 hardcoded `ast.NewIdent("ApplyPlan")`) + `isAlreadyDelegatedApplyBody` (line 630 hardcoded `sel.Sel.Name != "ApplyPlan"`) + `runAssertApplyDelegatesToHelper` + `refactor_apply_test.go:593`. Phase 1 does NOT touch this — bumping just the constant would create internal inconsistency. | -| `cmd/iac-codemod/refactor_apply.go:1208` (doc comment) | Same Phase 3 lockstep update | -| `cmd/iac-codemod/lint.go:54` (comment) + `lint.go:641` (matcher consumer) | Same | +| `cmd/iac-codemod/refactor_apply.go:29` (`applyCanonicalCallExpr` constant, `//nolint:unused`) | ~~Phase 3 lockstep update bumps this constant.~~ DELETED per workflow#699. | +| `cmd/iac-codemod/refactor_apply.go:1208` (doc comment) | ~~Same Phase 3 lockstep update.~~ DELETED per workflow#699. | +| `cmd/iac-codemod/lint.go:54` (comment) + `lint.go:641` (matcher consumer) | ~~Same.~~ DELETED per workflow#699. | ### v1 `provider.Apply(ctx, &plan)` direct callers (workflow side, NOT through wfctlhelpers) From 867b7f2705fa9fa2fdeae64a431e86aefc49a930 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 17:42:26 -0400 Subject: [PATCH 21/22] chore: go mod tidy (workflow#699 PR 1) golang.org/x/tools demoted to indirect after cmd/iac-codemod deletion removed the direct dependency. --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e477a720..b6804c30 100644 --- a/go.mod +++ b/go.mod @@ -64,7 +64,6 @@ require ( golang.org/x/term v0.42.0 golang.org/x/text v0.36.0 golang.org/x/time v0.15.0 - golang.org/x/tools v0.44.0 google.golang.org/grpc v1.80.0 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 google.golang.org/protobuf v1.36.11 @@ -266,6 +265,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sys v0.43.0 // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect From a241efa7c7f2e719ef32cba7f73dfbe5b6cb9ea0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 17 May 2026 17:45:45 -0400 Subject: [PATCH 22/22] =?UTF-8?q?fix(pr-702):=20Copilot=20round-1=20?= =?UTF-8?q?=E2=80=94=20Makefile=20POSIX=20ERE=20+=20stale=20godoc=20+=20lo?= =?UTF-8?q?g=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 material findings: - Makefile lint guard: \s is non-portable in POSIX ERE (BSD/busybox grep treats it as literal s); switch to [[:space:]] so the workflow#699 re-introduction guard fires correctly on every CI host. - deploy_providers.go:230,232 deprecation logs: clarify that runtime enforcement reads typed CapabilitiesResponse, not the manifest field (out-of-date manifest with v2 in Capabilities will still load). - infra_apply_v2_test.go:58-67: rewrite stale godoc that still described the deleted v1-vs-v2 manifest-driven dispatch decision; describe the current v2-only path + load-time enforcement. - infra_apply_v2_only_test.go: clarify the test's role as structural tripwire vs the runtime apply-path coverage in TestApplyWithProviderAndStore_V2RoutesThroughWfctlhelpers. --- Makefile | 2 +- cmd/wfctl/deploy_providers.go | 4 ++-- cmd/wfctl/infra_apply_v2_only_test.go | 26 ++++++++++++++++---------- cmd/wfctl/infra_apply_v2_test.go | 17 +++++++++-------- 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index bade626d..fc0d1ca9 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ bench-compare: # CI so a future PR can't silently restore the deleted dispatch path). lint: golangci-lint run --timeout=5m - @if grep -qE '^\s*rpc Apply\s*\(' plugin/external/proto/iac.proto; then \ + @if grep -qE '^[[:space:]]*rpc Apply[[:space:]]*\(' plugin/external/proto/iac.proto; then \ echo "workflow#699: rpc Apply re-introduced in iac.proto; see decisions/0024-iac-typed-force-cutover.md"; \ exit 1; \ else \ diff --git a/cmd/wfctl/deploy_providers.go b/cmd/wfctl/deploy_providers.go index a6c955f8..b68fb25e 100644 --- a/cmd/wfctl/deploy_providers.go +++ b/cmd/wfctl/deploy_providers.go @@ -227,9 +227,9 @@ func discoverAndLoadIaCProvider(ctx context.Context, providerName string, cfg ma // may be called multiple times per invocation). switch manifestCPV { case "": - log.Printf("plugin %q: deprecation — manifest iacProvider.computePlanVersion is empty; declare \"v2\" explicitly (workflow#699)", pName) + log.Printf("plugin %q: deprecation — manifest iacProvider.computePlanVersion is empty; declare \"v2\" explicitly (workflow#699). Note: runtime enforcement reads the typed CapabilitiesResponse.compute_plan_version, not this manifest field; an out-of-date manifest with v2 in Capabilities will still load.", pName) case "v1": - log.Printf("plugin %q: deprecation — manifest iacProvider.computePlanVersion=\"v1\"; load-time gate will reject this (workflow#699)", pName) + log.Printf("plugin %q: deprecation — manifest iacProvider.computePlanVersion=\"v1\"; update to \"v2\" (workflow#699). Note: runtime enforcement reads the typed CapabilitiesResponse.compute_plan_version, not this manifest field; an out-of-date manifest with v2 in Capabilities will still load.", pName) } mgr := external.NewExternalPluginManager(pluginDir, nil) diff --git a/cmd/wfctl/infra_apply_v2_only_test.go b/cmd/wfctl/infra_apply_v2_only_test.go index 2e819ca3..58112764 100644 --- a/cmd/wfctl/infra_apply_v2_only_test.go +++ b/cmd/wfctl/infra_apply_v2_only_test.go @@ -7,21 +7,27 @@ import ( "github.com/GoCodeAlone/workflow/interfaces" ) -// TestInfraApply_V2OnlyDispatch_NoV1Branch asserts runInfraApply collapses -// to a single v2-only dispatch after workflow#699 removes provider.Apply. -// The presence of any conditional branch on a v1-vs-v2 selector is a -// regression: per ADR 0024, v2 is the only supported dispatch. +// TestInfraApply_V2OnlyDispatch_NoV1Branch is a compile-time tripwire + +// runtime tripwire: post-workflow#699 the IaCProvider interface no longer +// declares Apply. If any implementation accidentally re-adds an Apply +// method that satisfies the legacy v1 dispatch signature, this test +// fires. +// +// This is a structural assertion, not a full apply-path exercise — the +// apply-path coverage lives in TestApplyWithProviderAndStore_V2RoutesThroughWfctlhelpers +// (which spies on applyV2ApplyPlanWithHooksFn to prove the v2 helper +// is invoked). Both tests together cover: +// - structural: provider type cannot satisfy v1 Apply signature (this test) +// - runtime: applyWithProviderAndStore routes through wfctlhelpers (sibling) +// +// Per ADR 0024 + workflow#699: v2 is the only supported dispatch. func TestInfraApply_V2OnlyDispatch_NoV1Branch(t *testing.T) { - t.Run("collapses dispatch when typedIaCAdapter declares no ComputePlanVersion method", func(t *testing.T) { - // stub provider satisfies the trimmed interfaces.IaCProvider - // (no Apply method) and has no ComputePlanVersion declarer. - // runInfraApply MUST route through wfctlhelpers.ApplyPlanWithHooks - // and MUST NOT type-assert against a v1 dispatch. + t.Run("trimmed IaCProvider interface does not satisfy legacy Apply signature", func(t *testing.T) { var p interfaces.IaCProvider = &stubV2OnlyProvider{} if _, ok := p.(interface { Apply(context.Context, *interfaces.IaCPlan) (*interfaces.ApplyResult, error) }); ok { - t.Fatalf("provider unexpectedly satisfies legacy Apply interface") + t.Fatalf("provider unexpectedly satisfies legacy Apply interface — workflow#699 regression") } }) } diff --git a/cmd/wfctl/infra_apply_v2_test.go b/cmd/wfctl/infra_apply_v2_test.go index 0a8f1227..255100a9 100644 --- a/cmd/wfctl/infra_apply_v2_test.go +++ b/cmd/wfctl/infra_apply_v2_test.go @@ -56,15 +56,16 @@ func TestApplyWithProviderAndStore_PassesLiveProviderToComputePlan(t *testing.T) } // TestApplyWithProviderAndStore_V2RoutesThroughWfctlhelpers verifies -// T3.7's manifest-driven dispatch: a provider whose -// ComputePlanVersion() returns "v2" routes through -// wfctlhelpers.ApplyPlan instead of provider.Apply. The seam is -// applyV2ApplyPlanWithHooksFn (var-indirected wfctlhelpers.ApplyPlan). +// the v2 dispatch path: applyWithProviderAndStore routes through +// wfctlhelpers.ApplyPlan (the only supported path post-workflow#699). +// The seam is applyV2ApplyPlanWithHooksFn (var-indirected +// wfctlhelpers.ApplyPlan). // -// rev2/rev3-locked: there is NO env-var. The branch is purely -// plugin-author-controlled via plugin.json's -// iacProvider.computePlanVersion (read at provider load time and -// surfaced via the optional ComputePlanVersionDeclarer interface). +// Post-workflow#699: there is no v1 fallback; provider.Apply, +// ComputePlanVersion(), ComputePlanVersionDeclarer, and the manifest's +// iacProvider.computePlanVersion v1/v2 dispatch decision are all gone. +// Load-time enforcement at discoverAndLoadIaCProvider rejects providers +// whose typed CapabilitiesResponse.compute_plan_version != "v2". func TestApplyWithProviderAndStore_V2RoutesThroughWfctlhelpers(t *testing.T) { v2Provider := &iactest.NoopProvider{ ProviderName: "v2-stub",