From 542d54ba90d913cc16babd4c9a0a18cb53278143 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 12 May 2026 11:06:53 -0400 Subject: [PATCH 01/26] fix(plugin/external): handle empty ConfigMessage for input-only STRICT_PROTO step contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps that declare STRICT_PROTO mode + InputMessage + OutputMessage but no ConfigMessage (e.g., step.eventbus.ack, step.eventbus.publish) failed engine initialization with: STRICT_PROTO contract for config message "" cannot use legacy Struct fallback: missing protobuf message name The step has no per-instance config schema — data flows through the input message. Engine now treats empty ConfigMessage as "no typed config", encodes cfg as legacy *structpb.Struct, returns nil typed payload. Plugin's typed factory reads from InputMessage as designed. Caught by BMW PR #278 image-launch smoke against v0.51.3 + eventbus v0.3.0 (steps.eventbus.{ack,publish,consume} have empty ConfigMessage). Test: TestCreateTypedConfigRequestEmptyConfigMessageStrictProto. --- plugin/external/adapter.go | 12 ++++++++++++ plugin/external/adapter_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/plugin/external/adapter.go b/plugin/external/adapter.go index 3c304cc9..28efac30 100644 --- a/plugin/external/adapter.go +++ b/plugin/external/adapter.go @@ -303,6 +303,18 @@ func createTypedConfigRequest(descriptor *pb.ContractDescriptor, cfg map[string] } return s, nil, nil } + // Steps with STRICT_PROTO mode but no ConfigMessage are input-only + // (eventbus.ack, eventbus.publish, etc.) — they declare InputMessage + + // OutputMessage but no per-instance config schema. Encode cfg as legacy + // struct only; typed payload is nil. The plugin's typed factory reads + // data from the input message, not from the config. + if descriptor.ConfigMessage == "" { + s, err := mapToStruct(cfg) + if err != nil { + return nil, nil, fmt.Errorf("encode config as Struct (input-only typed contract): %w", err) + } + return s, nil, nil + } // Strip engine-internal "_"-prefix keys before proto decode. STRICT_PROTO // and PROTO_WITH_LEGACY_STRUCT modules use protojson with DiscardUnknown // = false (convert.go:62), which rejects engine internals like diff --git a/plugin/external/adapter_test.go b/plugin/external/adapter_test.go index 8f1f54e4..fda138a8 100644 --- a/plugin/external/adapter_test.go +++ b/plugin/external/adapter_test.go @@ -1022,6 +1022,33 @@ func TestCreateTypedConfigRequestStripsInternalKeysForStrictProtoStep(t *testing } } +// TestCreateTypedConfigRequestEmptyConfigMessageStrictProto covers step +// contracts that declare STRICT_PROTO with InputMessage + OutputMessage but +// no ConfigMessage (input-only steps like step.eventbus.ack / +// step.eventbus.publish). The engine must encode cfg as a legacy +// *structpb.Struct, NOT attempt to encode an unnamed typed proto. +func TestCreateTypedConfigRequestEmptyConfigMessageStrictProto(t *testing.T) { + descriptor := &pb.ContractDescriptor{ + Kind: pb.ContractKind_CONTRACT_KIND_STEP, + StepType: "step.eventbus.ack", + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + InputMessage: "workflow.plugin.eventbus.v1.AckRequest", + OutputMessage: "workflow.plugin.eventbus.v1.AckResponse", + // ConfigMessage intentionally empty — step has no per-instance + // config schema; data flows via the input message. + } + legacy, typed, err := createTypedConfigRequest(descriptor, nil, nil) + if err != nil { + t.Fatalf("createTypedConfigRequest with empty ConfigMessage: %v", err) + } + if typed != nil { + t.Fatalf("expected nil typed *anypb.Any for input-only step contract; got %v", typed) + } + if legacy != nil && len(legacy.Fields) != 0 { + t.Fatalf("expected empty legacy struct for nil cfg; got %v", legacy.Fields) + } +} + // TestCreateTypedConfigRequestRetainsInternalKeysInLegacyStruct asserts the // legacy-struct path keeps "_"-prefix keys on its *structpb.Struct payload. // Legacy modules consume "_config_dir" at the plugin side to resolve filesystem- From d2b75290bd4fe6e9e25497a0f9f7c58712835275 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 12 May 2026 11:30:31 -0400 Subject: [PATCH 02/26] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20comment=20scope=20+=20test=20asserts=20both=20nil?= =?UTF-8?q?=20+=20non-nil=20cfg=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugin/external/adapter.go | 15 +++++++++------ plugin/external/adapter_test.go | 27 +++++++++++++++++++++------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/plugin/external/adapter.go b/plugin/external/adapter.go index 28efac30..82a0a4e0 100644 --- a/plugin/external/adapter.go +++ b/plugin/external/adapter.go @@ -303,15 +303,18 @@ func createTypedConfigRequest(descriptor *pb.ContractDescriptor, cfg map[string] } return s, nil, nil } - // Steps with STRICT_PROTO mode but no ConfigMessage are input-only - // (eventbus.ack, eventbus.publish, etc.) — they declare InputMessage + - // OutputMessage but no per-instance config schema. Encode cfg as legacy - // struct only; typed payload is nil. The plugin's typed factory reads - // data from the input message, not from the config. + // Contracts that declare a typed Mode (STRICT_PROTO or + // PROTO_WITH_LEGACY_STRUCT) but leave ConfigMessage empty have no + // per-instance config schema — primarily input-only steps like + // step.eventbus.ack/publish/consume where data flows through the + // InputMessage proto, but also applies to any contract Kind that + // legitimately omits a config schema. Encode cfg as legacy struct + // only; typed payload is nil. The plugin's typed factory reads data + // from the input message (or other typed payload), not from config. if descriptor.ConfigMessage == "" { s, err := mapToStruct(cfg) if err != nil { - return nil, nil, fmt.Errorf("encode config as Struct (input-only typed contract): %w", err) + return nil, nil, fmt.Errorf("encode config as Struct (no typed config schema): %w", err) } return s, nil, nil } diff --git a/plugin/external/adapter_test.go b/plugin/external/adapter_test.go index fda138a8..2d723f81 100644 --- a/plugin/external/adapter_test.go +++ b/plugin/external/adapter_test.go @@ -1022,11 +1022,13 @@ func TestCreateTypedConfigRequestStripsInternalKeysForStrictProtoStep(t *testing } } -// TestCreateTypedConfigRequestEmptyConfigMessageStrictProto covers step +// TestCreateTypedConfigRequestEmptyConfigMessageStrictProto covers // contracts that declare STRICT_PROTO with InputMessage + OutputMessage but // no ConfigMessage (input-only steps like step.eventbus.ack / -// step.eventbus.publish). The engine must encode cfg as a legacy -// *structpb.Struct, NOT attempt to encode an unnamed typed proto. +// step.eventbus.publish). The engine must NOT attempt to encode an +// unnamed typed proto; typed payload is nil, legacy struct mirrors cfg +// (nil cfg → nil legacy via mapToStruct(nil); non-nil cfg → populated +// struct). func TestCreateTypedConfigRequestEmptyConfigMessageStrictProto(t *testing.T) { descriptor := &pb.ContractDescriptor{ Kind: pb.ContractKind_CONTRACT_KIND_STEP, @@ -1037,15 +1039,28 @@ func TestCreateTypedConfigRequestEmptyConfigMessageStrictProto(t *testing.T) { // ConfigMessage intentionally empty — step has no per-instance // config schema; data flows via the input message. } + // nil cfg — mapToStruct(nil) returns nil; legacy is permitted to be nil. legacy, typed, err := createTypedConfigRequest(descriptor, nil, nil) if err != nil { - t.Fatalf("createTypedConfigRequest with empty ConfigMessage: %v", err) + t.Fatalf("createTypedConfigRequest with nil cfg + empty ConfigMessage: %v", err) } if typed != nil { t.Fatalf("expected nil typed *anypb.Any for input-only step contract; got %v", typed) } - if legacy != nil && len(legacy.Fields) != 0 { - t.Fatalf("expected empty legacy struct for nil cfg; got %v", legacy.Fields) + if legacy != nil { + t.Fatalf("expected nil legacy struct for nil cfg; got %v", legacy.Fields) + } + // Non-nil cfg — fields populated into legacy struct; typed still nil. + cfg := map[string]any{"timeout_ms": float64(5000)} + legacy2, typed2, err := createTypedConfigRequest(descriptor, cfg, nil) + if err != nil { + t.Fatalf("createTypedConfigRequest with cfg + empty ConfigMessage: %v", err) + } + if typed2 != nil { + t.Fatalf("expected nil typed *anypb.Any for input-only step contract; got %v", typed2) + } + if legacy2 == nil || legacy2.Fields["timeout_ms"] == nil { + t.Fatalf("expected legacy struct with timeout_ms populated; got %v", legacy2) } } From fa6a9fc0125ac75021ae9441e614f438c5fbe833 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 02:09:21 -0400 Subject: [PATCH 03/26] docs(#617): design for godo removal from workflow core Force-cutover single-PR plan: delete 11 legacy DO modules+steps (~3042 LOC), strip 8 registration sites, remove godo from go.mod, add load-time migration error pointing to workflow-plugin-digitalocean + infra.* IaC types. AWS SDK audit deferred to follow-up issue (will auto-progress after merge). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-13-issue-617-godo-removal-design.md | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/plans/2026-05-13-issue-617-godo-removal-design.md diff --git a/docs/plans/2026-05-13-issue-617-godo-removal-design.md b/docs/plans/2026-05-13-issue-617-godo-removal-design.md new file mode 100644 index 00000000..0541711b --- /dev/null +++ b/docs/plans/2026-05-13-issue-617-godo-removal-design.md @@ -0,0 +1,196 @@ +# Issue #617 — Remove godo (DigitalOcean SDK) from Workflow Core + +**Status:** Draft for adversarial review +**Owner:** autonomous pipeline (intel352) +**Issue:** [GoCodeAlone/workflow#617](https://github.com/GoCodeAlone/workflow/issues/617) +**Date:** 2026-05-13 + +## Summary + +Workflow core directly imports `github.com/digitalocean/godo` to back six legacy IaC modules (`platform.do_app`, `platform.doks`, `platform.do_dns`, `platform.do_database`, `platform.do_networking`, `cloud.account` DO resolver) and five legacy pipeline steps (`step.do_deploy/status/logs/scale/destroy`). The same surface is already implemented in `workflow-plugin-digitalocean` v0.12.0 as a proper IaC provider plugin (`iac.provider` module type, gRPC, computePlanVersion v2). Dependabot bumps to godo therefore drift core, the wrong owner. + +This design proposes a **single-PR force-cutover** that deletes the legacy DO surface from workflow core, removes `godo` from `go.mod`, and emits an actionable migration error when a user config still references the legacy module types. This mirrors the precedent established by the strict-contracts force-cutover (memory: `feedback_force_strict_contracts_no_compat.md`). + +AWS SDK usage is **explicitly out of scope** for this issue — `iam/`, `plugin/rbac/aws.go`, `artifact/s3.go`, and IaC drivers under `platform/providers/aws/` are audited as a separate follow-up issue created at the end of this work. + +## Goals (acceptance criteria from #617) + +1. Workflow core no longer imports `github.com/digitalocean/godo` for IaC/App Platform behavior. +2. Existing DO App Platform behavior remains available through `workflow-plugin-digitalocean`. +3. `wfctl` errors remain actionable when a provider plugin is missing or a legacy DO module type is referenced. +4. Dependabot provider SDK bumps target the provider repo, not workflow core. + +## Non-goals + +- Removing AWS SDK from core (separate issue created at end of work). +- Replacing the DO plugin's existing functionality. +- Backwards-compatible shim modules — force-cutover, no compat layer. +- Touching `module/iac_state_spaces.go` (S3-compat backend uses `aws-sdk-go-v2`, not godo). +- Migration tooling beyond an actionable load-time error pointing at the migration guide. + +## Current state — surface to remove + +### Module files (godo importers) + +| File | Lines | Purpose | +|------|------|---------| +| `module/platform_do_app.go` | 430 | App Platform module | +| `module/platform_do_app_test.go` | 399 | tests | +| `module/platform_do_database.go` | 263 | Managed Database module | +| `module/platform_do_database_test.go` | 66 | tests | +| `module/platform_do_dns.go` | 357 | DNS module | +| `module/platform_do_dns_test.go` | 270 | tests | +| `module/platform_do_networking.go` | 370 | VPC + firewall module | +| `module/platform_do_networking_test.go` | 264 | tests | +| `module/platform_doks.go` | 329 | DOKS Kubernetes module | +| `module/cloud_account_do.go` | 74 | DO credential resolvers + `doClient()` | +| `module/pipeline_step_do.go` | 220 | 5 DO App Platform pipeline steps | +| **Total** | **~3042** | | + +### Registration / schema sites + +| File | Edit | +|------|------| +| `plugins/platform/plugin.go` | Drop `platform.do_*` + `platform.doks` from `ModuleTypes`; drop 5 module factories; drop `step.do_*` from `StepTypes`; drop 5 step factories. | +| `plugins/platform/plugin_test.go` | Drop the 5 `step.do_*` + 5 `platform.do_*` assertions. | +| `schema/schema.go` | Drop 5 module-type entries + 5 step-type entries. | +| `schema/module_schema.go` | Drop 5 module schemas + 5 step descriptions. | +| `schema/step_schema_builtins.go` | Drop 5 step schema `Register` calls. | +| `cmd/wfctl/type_registry.go` | Drop 5 module + 5 step type-registry entries. | +| `cmd/wfctl/infra.go:577` | `return t == "infra.container_service"` (drop `|| t == "platform.do_app"`). | +| `cmd/wfctl/deploy_providers.go:419-424` | Drop `"platform.do_app"` from `deployTargetTypes`. | +| `cmd/wfctl/ci_run_dryrun.go:178-183` | Drop `"platform.do_app"` from `deployTargetTypes`. | +| `module/multi_region.go:123` | Rewrite error message to point at `workflow-plugin-digitalocean` + `infra.*` types. | +| `DOCUMENTATION.md` | Replace the 5 module rows + 5 step rows with a paragraph pointing at the DO plugin. | +| `go.mod` / `go.sum` | `go mod tidy` after deletion drops `github.com/digitalocean/godo` + transitive deps. | + +### Migration error + +Add a thin guard in the engine's module-loader that, when it encounters a config module of type `platform.doks` / `platform.do_app` / `platform.do_dns` / `platform.do_database` / `platform.do_networking` and the type is not registered by any loaded plugin, emits: + +``` +unsupported legacy module type %q: this type was removed from workflow core in v. +Use workflow-plugin-digitalocean (https://github.com/GoCodeAlone/workflow-plugin-digitalocean) +and migrate to the equivalent infra.* IaC type: + platform.do_app → infra.container_service (provider: digitalocean) + platform.do_database → infra.database (provider: digitalocean) + platform.do_dns → infra.dns (provider: digitalocean) + platform.do_networking → infra.vpc + infra.firewall (provider: digitalocean) + platform.doks → infra.k8s_cluster (provider: digitalocean) +``` + +Implementation: add `legacyDOTypes` set + lookup in `engine.go BuildFromConfig` (or wherever unknown-module-type currently errors) producing the message above. The lookup is in the **unknown-type fallback path** — when the plugin IS loaded and registers the same names, that path is unreachable. (Plugin v0.12.0 does NOT register legacy names — it registers `iac.provider`. Therefore the error fires whenever a user with the new core + old config tries to load.) + +This satisfies acceptance criterion #3. + +## Considered approaches + +### Option A — Single-PR force-cutover (RECOMMENDED) + +Delete in one PR: 11 module files, all registration sites, godo from go.mod, plus migration error + migration doc. Tag a new minor; CHANGELOG calls out breaking change. + +**Pros:** Mirrors strict-contracts precedent; no duplication window; clean git history; Dependabot stops touching core immediately. +**Cons:** Any consumer YAML using `platform.do_*` breaks on engine upgrade. Mitigated by actionable error message + migration guide. + +### Option B — Phased deprecation (REJECTED) + +Mark legacy modules deprecated, gate behind a `LEGACY_DO_MODULES=1` env var, remove godo in a later release. + +**Pros:** Soft landing. +**Cons:** Fights force-cutover precedent; perpetuates duplication; Dependabot still nags core during the window; doubles the work (two PRs, deprecation warnings, retest matrix); a "later release" reliably becomes "never." + +### Option C — Move-then-delete (REJECTED for DO; matches the AWS audit follow-up) + +Audit DO plugin parity, file gap issues against `workflow-plugin-digitalocean`, fix gaps, then delete from core. + +**Pros:** Surfaces gaps before consumer surprise. +**Cons:** Premature here — plugin v0.12.0 has been the de facto IaC provider in BMW deploys since v0.51.2 (memory: `project_strict_contracts_cutover_complete.md`); the legacy modules predate the IaC abstraction and produce a different (non-conformant) state shape. There is no parity to verify — the new path supersedes the old path with a different config schema. Migration is a config rewrite, not a code port. + +The "move-then-delete" model fits the AWS audit better because parts of AWS legitimately stay (RBAC, secrets, artifact). For DO, every godo importer is replaced. + +## Recommendation + +**Option A**, one PR `feat: remove godo from core (issue #617)`. + +## Assumptions (load-bearing) + +1. **Plugin parity assumption:** `workflow-plugin-digitalocean` v0.12.0 covers every resource served by the deleted core modules. *Test:* the parity matrix below maps each legacy module to its plugin replacement; the matrix MUST be re-validated before merge. + +2. **No internal consumers downstream:** No downstream repo's YAML still relies on `platform.do_*` / `step.do_*` types post-IaC migration. *Test:* the implementer greps `buymywishlist`, `core-dump`, `workflow-cloud`, `workflow-scenarios`, `ratchet`, `ratchet-cli` config trees for the legacy names before opening the PR. Any hit becomes either a migration PR in that repo (Option A still ships) or a blocker (revisit). + +3. **Schema allow-lists are advisory, not authoritative:** Removing entries from `schema/schema.go` does not silently re-allow them elsewhere — the registry is the only enforcement point and we're removing them there too. *Test:* `go test ./schema/...` after deletion. + +4. **`go mod tidy` is sufficient to drop godo:** No other core file imports godo besides the listed eleven. *Test:* `grep -rn "digitalocean/godo" --include="*.go"` returns no results post-deletion (excluding worktree dirs). + +5. **Engine v0.NEXT bump is acceptable:** This is a breaking change; CHANGELOG + a minor-version bump suffices. The user has authorized the autonomous pipeline to ship breaking changes (memory: `feedback_force_strict_contracts_no_compat.md`). + +6. **DO plugin minEngineVersion `0.51.2` remains valid:** The plugin does not depend on any core symbol we're removing — it imports godo itself and only consumes the `iac.provider` interface from core. *Test:* the implementer runs `go build ./...` against the DO plugin with the post-cutover workflow module pinned via `replace`. + +## Parity matrix — legacy core module → plugin replacement + +| Legacy core type | Plugin replacement (`workflow-plugin-digitalocean` v0.12.0) | Notes | +|------------------|-------------------------------------------------------------|-------| +| `platform.do_app` | `infra.container_service` + provider `digitalocean` → driver `internal/drivers/app_platform.go` | App Platform spec maps, region routing, build spec, migration repair, image presence — all present in plugin. | +| `platform.do_database` | `infra.database` + provider `digitalocean` → driver `internal/drivers/database.go` | Managed PG / MySQL / Redis. | +| `platform.do_dns` | `infra.dns` + provider `digitalocean` → `internal/drivers/dns_*.go` (declared in plugin docs) | DNS zone + records. | +| `platform.do_networking` | `infra.vpc` + `infra.firewall` + provider `digitalocean` → `internal/drivers/vpc.go`, `internal/drivers/firewall.go` (per plugin manifest) | VPC + firewall split per IaC model. | +| `platform.doks` | `infra.k8s_cluster` + provider `digitalocean` (plugin manifest) | DOKS cluster + node pool. | +| `cloud.account` (DO resolver) | DO plugin manages its own DO API token via `iac.provider` credential broker | Plugin doesn't need the legacy resolver chain. | +| `step.do_deploy/status/logs/scale/destroy` | `step.iac_plan/apply/status/destroy` (generic IaC steps) + plugin drivers | Generic steps drive any IaC provider; DO is no longer special-cased. | + +If any cell of this matrix is wrong, the implementer files an issue against `workflow-plugin-digitalocean` BEFORE submitting the cutover PR, and the cutover PR blocks on that issue's fix. + +## Self-challenge round + +1. **Lazier solution?** A single `replace github.com/digitalocean/godo => ../shim` is lazier but doesn't satisfy goal #1 (godo still in go.mod). A `// nolint:godox` is laziest but ignores the problem. No lazier path satisfies all four goals. + +2. **Most fragile assumption?** Assumption #2 — no downstream consumer still uses `platform.do_*`. Mitigation: the implementer's pre-PR grep step covers it; the load-time migration error covers the field; the CHANGELOG covers expectations. Cost of a missed hit = one follow-up migration PR in the affected repo. + +3. **YAGNI sweep — what does this design solve that wasn't asked?** Two items examined: + - Migration error message (kept — directly satisfies goal #3 "wfctl errors remain actionable"). + - Generic "legacy provider type" framework (dropped — only DO needs this today; AWS legacy types stay; adding a framework is premature abstraction). + +4. **Partial failure surface:** `go mod tidy` fails on a transitive godo dep we missed → CI catches before merge. Plugin doesn't satisfy a parity cell → caught by the matrix re-validation step pre-merge; if discovered post-merge, fixed in plugin and core is unaffected because the symbol is gone from core. Config loads with a removed type → migration error fires; no silent skip. + +5. **Fights existing pattern?** No. Force-cutover precedent: `feedback_force_strict_contracts_no_compat.md`. The IaC migration design (`docs/plans/2026-04-17-deploy-pipeline-multi-env-design.md` lines 126-130) explicitly maps the legacy DO types to `infra.*` — this design completes that migration. + +**Top 3 doubts surfaced for adversarial review:** + +1. Are there cached external YAML configs (BMW prod, core-dump prod) still using `platform.do_*` that will fail on next deploy? Migration error catches it but operators may not be reading the changelog. +2. Does the DO plugin's `iac.provider` IaC state shape converge with the legacy modules' state shape, or is there an unmigrated state-file flag day? (State-shape mismatch already known to exist — memory `project_strict_contracts_cutover_complete.md`.) +3. Does removing `cloud_account_do.go` break the credential resolver registry for any test or external module that registers a DO-flavored resolver? `RegisterCredentialResolver` is a global registry; removing one register call should be safe but the order-dependence is worth checking. + +## Implementation plan (preview — full plan written by writing-plans skill) + +Single PR, ~5 tasks: + +1. **T1 — Delete legacy module + step files (11 files).** Pure deletion; tests assert removal. +2. **T2 — Strip registration sites (8 files).** Edits to `plugins/platform/plugin.go`, `schema/*.go`, `cmd/wfctl/type_registry.go`, `cmd/wfctl/infra.go`, `cmd/wfctl/deploy_providers.go`, `cmd/wfctl/ci_run_dryrun.go`, `plugins/platform/plugin_test.go`, `module/multi_region.go`. +3. **T3 — Add load-time migration error + tests.** Engine fails-closed on legacy DO types with actionable message; new test fixtures cover all 5 legacy types. +4. **T4 — `go mod tidy` + grep gate.** Confirm zero `digitalocean/godo` imports remain (excluding worktrees); update go.sum; CI gate added in CI workflow that re-greps on every PR to prevent regression. +5. **T5 — Docs + CHANGELOG + migration guide stub.** Update `DOCUMENTATION.md`, prepend a CHANGELOG breaking-change entry, add `docs/migrations/v-godo-removal.md` with the 5 legacy → `infra.*` mappings. + +Post-merge: file follow-up issue **"#NEXT — Audit AWS SDK usage in workflow core (RBAC/secrets/artifact stay; IaC drivers reviewed for plugin move)"** so the AWS half of the issue's audit-points note is tracked. + +## Rollback + +This change affects build, package version, and runtime config loading. Rollback path: + +- **Pre-merge:** revert the branch; no consumer impact. +- **Post-merge, pre-tag:** revert the PR; force a new minor without the change. No consumer impact. +- **Post-tag:** consumers pin the previous tag (`go get github.com/GoCodeAlone/workflow@v`). Migration error reverts. Provided we have not advanced any consumer's pin in the same window, this is a clean fallback. CHANGELOG must call out the pinned-pre-cutover version explicitly. +- **State files written by the new path:** unaffected (state lives in `iac.state` backends, not in deleted code). + +## Open questions (none blocking — autonomous pipeline proceeds) + +- Should the migration error be a hard error or a warning + skip? **Decision (autonomous):** hard error. A silently-skipped module is worse than a failed load; goal #3 mandates actionable errors. Re-open if adversarial review pushes back. +- Should `cloud_account_do.go` deletion include removing the registered resolver names (`digitalocean/static`, `digitalocean/env`, `digitalocean/api_token`) from any global registry to prevent dead config keys? **Decision (autonomous):** yes — the registry is purely additive via init(); deleting the file removes the init(). Add a test that the credential registry has zero `digitalocean/*` entries post-deletion. + +## References + +- Issue #617 +- PR #421 (godo dependabot bump — the trigger signal) +- Memory: `feedback_force_strict_contracts_no_compat.md` — force-cutover precedent +- Memory: `project_strict_contracts_cutover_complete.md` — typed-gRPC cutover; DO plugin v1.0.1 strict-contracts release +- Memory: `project_do_plugin_typed_iac_gap.md` — DO plugin IaC service registration history +- `docs/plans/2026-04-17-deploy-pipeline-multi-env-design.md` lines 126-130 — legacy → `infra.*` mapping (decided long before this issue) From 13a0e8c085ecdb5e56d2a2319b3905288e9ac423 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 02:18:15 -0400 Subject: [PATCH 04/26] docs(#617): revise design per adversarial review cycle 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-1 fix: add step-type migration guard (5 step.do_* types) alongside the module-type guard; error message branches on plugin-loaded detection. I-1 fix: parity matrix split into per-step rows; step.do_logs and step.do_scale flagged as GAPs with pre-merge follow-up issues in workflow-plugin-digitalocean. I-2 fix: migration error has two branches — 'install plugin' vs 'config-only issue, plugin already loaded'. Minors: exact grep invocation in T4; dns.go typo; infra_apply_test.go:1990 added to T2 review list. Companion: wfctl modernize rules in scope of T5 (auto-rewrite YAML). Considered approaches: added Option B' (build tag fence — rejected). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-13-issue-617-godo-removal-design.md | 97 ++++++++++++++++--- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/docs/plans/2026-05-13-issue-617-godo-removal-design.md b/docs/plans/2026-05-13-issue-617-godo-removal-design.md index 0541711b..bf76a02e 100644 --- a/docs/plans/2026-05-13-issue-617-godo-removal-design.md +++ b/docs/plans/2026-05-13-issue-617-godo-removal-design.md @@ -64,24 +64,53 @@ AWS SDK usage is **explicitly out of scope** for this issue — `iam/`, `plugin/ | `DOCUMENTATION.md` | Replace the 5 module rows + 5 step rows with a paragraph pointing at the DO plugin. | | `go.mod` / `go.sum` | `go mod tidy` after deletion drops `github.com/digitalocean/godo` + transitive deps. | -### Migration error +### Migration error — modules + steps (both paths covered) -Add a thin guard in the engine's module-loader that, when it encounters a config module of type `platform.doks` / `platform.do_app` / `platform.do_dns` / `platform.do_database` / `platform.do_networking` and the type is not registered by any loaded plugin, emits: +Two guards, one per registry. Both fire in the unknown-type fallback path so they are unreachable when the type is registered by a loaded plugin. + +**Module guard** — in `engine.go BuildFromConfig` (unknown-module-type branch): ``` unsupported legacy module type %q: this type was removed from workflow core in v. -Use workflow-plugin-digitalocean (https://github.com/GoCodeAlone/workflow-plugin-digitalocean) -and migrate to the equivalent infra.* IaC type: + +DigitalOcean IaC moved to workflow-plugin-digitalocean. +%s + +Migrate this module to the equivalent infra.* IaC type: platform.do_app → infra.container_service (provider: digitalocean) platform.do_database → infra.database (provider: digitalocean) platform.do_dns → infra.dns (provider: digitalocean) platform.do_networking → infra.vpc + infra.firewall (provider: digitalocean) platform.doks → infra.k8s_cluster (provider: digitalocean) + +See docs/migrations/v-godo-removal.md. +``` + +The middle `%s` line branches on plugin-loaded detection (closes adversarial finding I-2): + +- If `iac.provider` factory is registered in the application's factory map AND a `provider: digitalocean` infra.* binding exists somewhere in the loaded plugins → emit `"workflow-plugin-digitalocean is already loaded; your config still references the legacy module name."` +- Otherwise → emit `"Install workflow-plugin-digitalocean: https://github.com/GoCodeAlone/workflow-plugin-digitalocean"` + +**Step guard** — in `module/pipeline_step_registry.go` (or `engine.go buildPipelineSteps`'s unknown-step-type branch), for the five legacy step types. Per-step messages because the mapping is NOT one-to-one (closes finding I-1): + +``` +step.do_deploy → step.iac_apply (against an infra.container_service module) +step.do_destroy → step.iac_destroy (against an infra.container_service module) +step.do_status → step.iac_status (against an infra.container_service module) +step.do_logs → no direct equivalent. The DO plugin attaches deploy logs + internally via its Troubleshoot hook on step.iac_apply + failure. For ad-hoc log fetch, use `wfctl infra logs`. + A pipeline-step replacement is tracked in + workflow-plugin-digitalocean issue . +step.do_scale → no direct equivalent. Update instance_count in the + infra.container_service module config and re-run + step.iac_apply. A first-class step.iac_scale is tracked + in workflow-plugin-digitalocean issue . ``` -Implementation: add `legacyDOTypes` set + lookup in `engine.go BuildFromConfig` (or wherever unknown-module-type currently errors) producing the message above. The lookup is in the **unknown-type fallback path** — when the plugin IS loaded and registers the same names, that path is unreachable. (Plugin v0.12.0 does NOT register legacy names — it registers `iac.provider`. Therefore the error fires whenever a user with the new core + old config tries to load.) +The same plugin-loaded detection branches the step-guard prefix (`Install ... / already loaded ...`). -This satisfies acceptance criterion #3. +This satisfies acceptance criterion #3 for both modules and pipeline steps. ## Considered approaches @@ -99,6 +128,13 @@ Mark legacy modules deprecated, gate behind a `LEGACY_DO_MODULES=1` env var, rem **Pros:** Soft landing. **Cons:** Fights force-cutover precedent; perpetuates duplication; Dependabot still nags core during the window; doubles the work (two PRs, deprecation warnings, retest matrix); a "later release" reliably becomes "never." +### Option B′ — Go build tag fence (REJECTED) + +Add `//go:build !workflow_strict` (or similar) to the six godo-importing files so the production binary excludes them while tests stay. + +**Pros:** No deletion; tests keep running; "reversible." +**Cons:** Fails goal #1 — `godo` remains in `go.mod` because the build-tagged code still parses. Fails goal #4 — Dependabot still nags. Perpetuates ambiguity about "supported." Adds a build matrix for zero net benefit over Option A. + ### Option C — Move-then-delete (REJECTED for DO; matches the AWS audit follow-up) Audit DO plugin parity, file gap issues against `workflow-plugin-digitalocean`, fix gaps, then delete from core. @@ -112,6 +148,20 @@ The "move-then-delete" model fits the AWS audit better because parts of AWS legi **Option A**, one PR `feat: remove godo from core (issue #617)`. +### Companion: `wfctl modernize` rule (in scope of T5) + +The engine already has a `modernize` command (`mcp__workflow__modernize` tool + wfctl subcommand) that auto-rewrites legacy YAML anti-patterns. Add five rewrite rules so user YAML migrates with one `wfctl modernize --write` invocation: + +- `module/type: platform.do_app` → `module/type: infra.container_service` + inject `config.provider: digitalocean` +- `module/type: platform.do_database` → `module/type: infra.database` + provider +- `module/type: platform.do_dns` → `module/type: infra.dns` + provider +- `module/type: platform.do_networking` → split into `infra.vpc` + `infra.firewall` modules (lossy — emit a comment-prefixed warning when source has both `vpc` and `firewalls` keys with non-overlapping shapes) +- `module/type: platform.doks` → `module/type: infra.k8s_cluster` + provider +- `step/type: step.do_deploy/status/destroy` → `step.iac_apply/status/destroy` with `module` field re-bound to the migrated module name +- `step/type: step.do_logs/scale` → emit a `wfctl: cannot rewrite — see migration guide` annotation; do not delete the step (operator must address manually) + +This reduces migration friction from manual-rewrite to one command + manual review of the two annotated step types. Folds into T5. + ## Assumptions (load-bearing) 1. **Plugin parity assumption:** `workflow-plugin-digitalocean` v0.12.0 covers every resource served by the deleted core modules. *Test:* the parity matrix below maps each legacy module to its plugin replacement; the matrix MUST be re-validated before merge. @@ -132,11 +182,15 @@ The "move-then-delete" model fits the AWS audit better because parts of AWS legi |------------------|-------------------------------------------------------------|-------| | `platform.do_app` | `infra.container_service` + provider `digitalocean` → driver `internal/drivers/app_platform.go` | App Platform spec maps, region routing, build spec, migration repair, image presence — all present in plugin. | | `platform.do_database` | `infra.database` + provider `digitalocean` → driver `internal/drivers/database.go` | Managed PG / MySQL / Redis. | -| `platform.do_dns` | `infra.dns` + provider `digitalocean` → `internal/drivers/dns_*.go` (declared in plugin docs) | DNS zone + records. | +| `platform.do_dns` | `infra.dns` + provider `digitalocean` → `internal/drivers/dns.go` | DNS zone + records. | | `platform.do_networking` | `infra.vpc` + `infra.firewall` + provider `digitalocean` → `internal/drivers/vpc.go`, `internal/drivers/firewall.go` (per plugin manifest) | VPC + firewall split per IaC model. | | `platform.doks` | `infra.k8s_cluster` + provider `digitalocean` (plugin manifest) | DOKS cluster + node pool. | | `cloud.account` (DO resolver) | DO plugin manages its own DO API token via `iac.provider` credential broker | Plugin doesn't need the legacy resolver chain. | -| `step.do_deploy/status/logs/scale/destroy` | `step.iac_plan/apply/status/destroy` (generic IaC steps) + plugin drivers | Generic steps drive any IaC provider; DO is no longer special-cased. | +| `step.do_deploy` | `step.iac_apply` against `infra.container_service` | 1:1 mapping; provider drives apply. | +| `step.do_status` | `step.iac_status` against `infra.container_service` | 1:1 mapping. | +| `step.do_destroy` | `step.iac_destroy` against `infra.container_service` | 1:1 mapping. | +| `step.do_logs` | **GAP** — no pipeline-step equivalent | DO plugin attaches logs via `Troubleshoot` hook internally on apply failure; ad-hoc fetch via `wfctl infra logs`. Tracked in plugin issue (filed pre-merge). Documented in migration guide. | +| `step.do_scale` | **GAP** — config-driven re-apply only | Update `instance_count` in `infra.container_service` config + re-run `step.iac_apply`. First-class `step.iac_scale` tracked in plugin issue (filed pre-merge). Documented in migration guide. | If any cell of this matrix is wrong, the implementer files an issue against `workflow-plugin-digitalocean` BEFORE submitting the cutover PR, and the cutover PR blocks on that issue's fix. @@ -165,10 +219,18 @@ If any cell of this matrix is wrong, the implementer files an issue against `wor Single PR, ~5 tasks: 1. **T1 — Delete legacy module + step files (11 files).** Pure deletion; tests assert removal. -2. **T2 — Strip registration sites (8 files).** Edits to `plugins/platform/plugin.go`, `schema/*.go`, `cmd/wfctl/type_registry.go`, `cmd/wfctl/infra.go`, `cmd/wfctl/deploy_providers.go`, `cmd/wfctl/ci_run_dryrun.go`, `plugins/platform/plugin_test.go`, `module/multi_region.go`. +2. **T2 — Strip registration sites (9 files).** Edits to `plugins/platform/plugin.go`, `schema/schema.go`, `schema/module_schema.go`, `schema/step_schema_builtins.go`, `cmd/wfctl/type_registry.go`, `cmd/wfctl/infra.go`, `cmd/wfctl/deploy_providers.go`, `cmd/wfctl/ci_run_dryrun.go`, `plugins/platform/plugin_test.go`, `module/multi_region.go`. Implementer also reviews `cmd/wfctl/infra_apply_test.go` line 1990 (negative-test fixture using `type: platform.do_app`) — replace with a synthetic non-existent type or remove if the negative case is redundant. 3. **T3 — Add load-time migration error + tests.** Engine fails-closed on legacy DO types with actionable message; new test fixtures cover all 5 legacy types. -4. **T4 — `go mod tidy` + grep gate.** Confirm zero `digitalocean/godo` imports remain (excluding worktrees); update go.sum; CI gate added in CI workflow that re-greps on every PR to prevent regression. -5. **T5 — Docs + CHANGELOG + migration guide stub.** Update `DOCUMENTATION.md`, prepend a CHANGELOG breaking-change entry, add `docs/migrations/v-godo-removal.md` with the 5 legacy → `infra.*` mappings. +4. **T4 — `go mod tidy` + grep gate.** Confirm zero `digitalocean/godo` imports remain; update go.sum; add CI gate. Exact grep invocation: + ```sh + grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + "digitalocean/godo" . + ``` + Gate lives in `.github/workflows/ci.yml` (or wherever `golangci-lint` already runs) as a fail-on-match step. Same grep also runs as a pre-commit step locally documented in CONTRIBUTING (no install required, repo-relative). +5. **T5 — Docs + CHANGELOG + migration guide + modernize rules.** Update `DOCUMENTATION.md`, prepend CHANGELOG breaking-change entry, add `docs/migrations/v-godo-removal.md` (5 module + 5 step mappings, plus explicit GAP callout for `step.do_logs` / `step.do_scale` with workaround YAML examples), implement the seven `wfctl modernize` rewrite rules above + test fixtures, file the two follow-up issues in `workflow-plugin-digitalocean` (`step.iac_logs`, `step.iac_scale`) and wire their issue numbers back into the migration error messages. Post-merge: file follow-up issue **"#NEXT — Audit AWS SDK usage in workflow core (RBAC/secrets/artifact stay; IaC drivers reviewed for plugin move)"** so the AWS half of the issue's audit-points note is tracked. @@ -186,6 +248,19 @@ This change affects build, package version, and runtime config loading. Rollback - Should the migration error be a hard error or a warning + skip? **Decision (autonomous):** hard error. A silently-skipped module is worse than a failed load; goal #3 mandates actionable errors. Re-open if adversarial review pushes back. - Should `cloud_account_do.go` deletion include removing the registered resolver names (`digitalocean/static`, `digitalocean/env`, `digitalocean/api_token`) from any global registry to prevent dead config keys? **Decision (autonomous):** yes — the registry is purely additive via init(); deleting the file removes the init(). Add a test that the credential registry has zero `digitalocean/*` entries post-deletion. +## Adversarial review history + +### Cycle 1 (FAIL) — 2026-05-13 + +- **C-1** Migration error covered modules but not `step.do_*` steps → **fixed**: added per-step guard + per-step migration message (modules/steps both branched on plugin-loaded detection). +- **I-1** Parity matrix collapsed 5 step types into 4 generic ones, hiding `step.do_logs` + `step.do_scale` capability gap → **fixed**: parity matrix now lists each step row separately, GAPs called out, two follow-up issues to be filed pre-merge in `workflow-plugin-digitalocean`. +- **I-2** Migration error misleads users who already have plugin loaded → **fixed**: migration error branches on plugin-loaded detection (different prefix for "install" vs "already loaded — config issue"). +- **m-1** Grep gate worktree exclusion underspecified → **fixed**: exact `grep -rn ... --exclude-dir=...` invocation in T4. +- **m-2** `dns_*.go` → `dns.go` typo → **fixed**. +- **m-3** `cmd/wfctl/infra_apply_test.go:1990` fixture missing from T2 → **fixed**: explicitly called out for review. +- **Option B′ (build tag fence)** added to Considered approaches per reviewer suggestion (rejected with stated reason). +- **`wfctl modernize` companion** added per reviewer suggestion (in scope of T5). + ## References - Issue #617 From 021285ebe841bdde1f668509745223539092d44b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 02:23:52 -0400 Subject: [PATCH 05/26] docs(#617): revise design per adversarial review cycle 2 I-1: platform_doks_test.go (164 LOC) added to deletion inventory. Total now 12 files / ~3206 LOC; T1 scope updated. m-1: wfctl modernize flag corrected (--apply, not --write). m-2: example/ sub-module go.mod also pins godo as indirect; T4 now runs go mod tidy in both root and example/, plus a second grep over go.mod files to catch residual indirect dependencies. Cycle-1 fixes verified to hold. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-13-issue-617-godo-removal-design.md | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-05-13-issue-617-godo-removal-design.md b/docs/plans/2026-05-13-issue-617-godo-removal-design.md index bf76a02e..4241788d 100644 --- a/docs/plans/2026-05-13-issue-617-godo-removal-design.md +++ b/docs/plans/2026-05-13-issue-617-godo-removal-design.md @@ -43,9 +43,10 @@ AWS SDK usage is **explicitly out of scope** for this issue — `iam/`, `plugin/ | `module/platform_do_networking.go` | 370 | VPC + firewall module | | `module/platform_do_networking_test.go` | 264 | tests | | `module/platform_doks.go` | 329 | DOKS Kubernetes module | +| `module/platform_doks_test.go` | 164 | tests | | `module/cloud_account_do.go` | 74 | DO credential resolvers + `doClient()` | | `module/pipeline_step_do.go` | 220 | 5 DO App Platform pipeline steps | -| **Total** | **~3042** | | +| **Total (12 files)** | **~3206** | | ### Registration / schema sites @@ -160,7 +161,7 @@ The engine already has a `modernize` command (`mcp__workflow__modernize` tool + - `step/type: step.do_deploy/status/destroy` → `step.iac_apply/status/destroy` with `module` field re-bound to the migrated module name - `step/type: step.do_logs/scale` → emit a `wfctl: cannot rewrite — see migration guide` annotation; do not delete the step (operator must address manually) -This reduces migration friction from manual-rewrite to one command + manual review of the two annotated step types. Folds into T5. +This reduces migration friction from manual-rewrite to one `wfctl modernize --apply ` invocation + manual review of the two annotated step types. (Flag is `--apply`, verified against `cmd/wfctl/modernize.go`.) Folds into T5. ## Assumptions (load-bearing) @@ -218,10 +219,18 @@ If any cell of this matrix is wrong, the implementer files an issue against `wor Single PR, ~5 tasks: -1. **T1 — Delete legacy module + step files (11 files).** Pure deletion; tests assert removal. +1. **T1 — Delete legacy module + step files (12 files).** Pure deletion (all 12 rows in the "Module files" table above, including `module/platform_doks_test.go`); a new test asserts removal of registry entries. 2. **T2 — Strip registration sites (9 files).** Edits to `plugins/platform/plugin.go`, `schema/schema.go`, `schema/module_schema.go`, `schema/step_schema_builtins.go`, `cmd/wfctl/type_registry.go`, `cmd/wfctl/infra.go`, `cmd/wfctl/deploy_providers.go`, `cmd/wfctl/ci_run_dryrun.go`, `plugins/platform/plugin_test.go`, `module/multi_region.go`. Implementer also reviews `cmd/wfctl/infra_apply_test.go` line 1990 (negative-test fixture using `type: platform.do_app`) — replace with a synthetic non-existent type or remove if the negative case is redundant. 3. **T3 — Add load-time migration error + tests.** Engine fails-closed on legacy DO types with actionable message; new test fixtures cover all 5 legacy types. -4. **T4 — `go mod tidy` + grep gate.** Confirm zero `digitalocean/godo` imports remain; update go.sum; add CI gate. Exact grep invocation: +4. **T4 — `go mod tidy` + grep gate.** Confirm zero `digitalocean/godo` imports remain in code AND in module files; update go.sum; add CI gate. + + Tidy steps: + ```sh + go mod tidy # root module + (cd example && go mod tidy) # standalone example/ sub-module also pins godo as indirect + ``` + + Grep gate (exact invocation): ```sh grep -rn --include="*.go" \ --exclude-dir=_worktrees \ @@ -229,6 +238,10 @@ Single PR, ~5 tasks: --exclude-dir=.claude \ "digitalocean/godo" . ``` + Plus a second grep over `go.mod` files (root + `example/go.mod`) to ensure neither still lists godo: + ```sh + grep -nH "digitalocean/godo" go.mod example/go.mod || true # fails build if any hit + ``` Gate lives in `.github/workflows/ci.yml` (or wherever `golangci-lint` already runs) as a fail-on-match step. Same grep also runs as a pre-commit step locally documented in CONTRIBUTING (no install required, repo-relative). 5. **T5 — Docs + CHANGELOG + migration guide + modernize rules.** Update `DOCUMENTATION.md`, prepend CHANGELOG breaking-change entry, add `docs/migrations/v-godo-removal.md` (5 module + 5 step mappings, plus explicit GAP callout for `step.do_logs` / `step.do_scale` with workaround YAML examples), implement the seven `wfctl modernize` rewrite rules above + test fixtures, file the two follow-up issues in `workflow-plugin-digitalocean` (`step.iac_logs`, `step.iac_scale`) and wire their issue numbers back into the migration error messages. @@ -250,6 +263,13 @@ This change affects build, package version, and runtime config loading. Rollback ## Adversarial review history +### Cycle 2 (FAIL) — 2026-05-13 + +- **I-1** (new) `module/platform_doks_test.go` (164 LOC) missing from deletion inventory → **fixed**: row added; total bumped to 12 files / ~3206 LOC; T1 scope updated. +- **m-1** (new) wfctl flag was `--write`, actual flag is `--apply` → **fixed**. +- **m-2** (new) `example/go.mod` carries `godo` as indirect dependency; T4 grep only covered `*.go` → **fixed**: `(cd example && go mod tidy)` added; second grep over `go.mod` files added. +- **Cycle-1 fixes verified to hold** — no regressions introduced by cycle-1 changes. + ### Cycle 1 (FAIL) — 2026-05-13 - **C-1** Migration error covered modules but not `step.do_*` steps → **fixed**: added per-step guard + per-step migration message (modules/steps both branched on plugin-loaded detection). From 801fae0db3be64baab0c2acaf4f651e447869eb8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 02:32:35 -0400 Subject: [PATCH 06/26] docs(#617): incorporate adversarial cycle 3 minor amendments (PASS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m-1: grep gates now !-prefixed to fail CI on match (|| true was silent no-op). m-2: plugin-loaded detection simplified to single factory-map lookup. m-3: workflow-scenarios migration sequencing constraint added. t-1: T2 file count 9→10. Cycle 3 verdict PASS (0 Critical / 0 Important / 3 Minor incorporated). Pipeline advances to writing-plans. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-13-issue-617-godo-removal-design.md | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/docs/plans/2026-05-13-issue-617-godo-removal-design.md b/docs/plans/2026-05-13-issue-617-godo-removal-design.md index 4241788d..a60fc7fc 100644 --- a/docs/plans/2026-05-13-issue-617-godo-removal-design.md +++ b/docs/plans/2026-05-13-issue-617-godo-removal-design.md @@ -89,9 +89,11 @@ See docs/migrations/v-godo-removal.md. The middle `%s` line branches on plugin-loaded detection (closes adversarial finding I-2): -- If `iac.provider` factory is registered in the application's factory map AND a `provider: digitalocean` infra.* binding exists somewhere in the loaded plugins → emit `"workflow-plugin-digitalocean is already loaded; your config still references the legacy module name."` +- If the `iac.provider` factory is registered in the engine's module factory map (`_, iacLoaded := e.moduleFactories["iac.provider"]`) → emit `"workflow-plugin-digitalocean is already loaded; your config still references the legacy module name."` - Otherwise → emit `"Install workflow-plugin-digitalocean: https://github.com/GoCodeAlone/workflow-plugin-digitalocean"` +(The single-factory-map check is sufficient because the DO plugin is the only known publisher of `iac.provider` in the GoCodeAlone ecosystem. An ANDed check on `provider: digitalocean` infra.* bindings would have no clean implementation path at the engine layer and would not add discriminating signal — see cycle-3 review m-2.) + **Step guard** — in `module/pipeline_step_registry.go` (or `engine.go buildPipelineSteps`'s unknown-step-type branch), for the five legacy step types. Per-step messages because the mapping is NOT one-to-one (closes finding I-1): ``` @@ -167,7 +169,7 @@ This reduces migration friction from manual-rewrite to one `wfctl modernize --ap 1. **Plugin parity assumption:** `workflow-plugin-digitalocean` v0.12.0 covers every resource served by the deleted core modules. *Test:* the parity matrix below maps each legacy module to its plugin replacement; the matrix MUST be re-validated before merge. -2. **No internal consumers downstream:** No downstream repo's YAML still relies on `platform.do_*` / `step.do_*` types post-IaC migration. *Test:* the implementer greps `buymywishlist`, `core-dump`, `workflow-cloud`, `workflow-scenarios`, `ratchet`, `ratchet-cli` config trees for the legacy names before opening the PR. Any hit becomes either a migration PR in that repo (Option A still ships) or a blocker (revisit). +2. **No internal consumers downstream:** No downstream repo's YAML still relies on `platform.do_*` / `step.do_*` types post-IaC migration. *Test:* the implementer greps `buymywishlist`, `core-dump`, `workflow-cloud`, `workflow-scenarios`, `ratchet`, `ratchet-cli` config trees for the legacy names before opening the PR. Any hit becomes either a migration PR in that repo (Option A still ships) or a blocker (revisit). **Sequencing constraint:** any `workflow-scenarios` migration PRs must merge before — or in the same batch as — the engine cutover tag is consumed by scenario-CI, otherwise scenario CI will fail with the (correctly) actionable migration errors. 3. **Schema allow-lists are advisory, not authoritative:** Removing entries from `schema/schema.go` does not silently re-allow them elsewhere — the registry is the only enforcement point and we're removing them there too. *Test:* `go test ./schema/...` after deletion. @@ -220,7 +222,7 @@ If any cell of this matrix is wrong, the implementer files an issue against `wor Single PR, ~5 tasks: 1. **T1 — Delete legacy module + step files (12 files).** Pure deletion (all 12 rows in the "Module files" table above, including `module/platform_doks_test.go`); a new test asserts removal of registry entries. -2. **T2 — Strip registration sites (9 files).** Edits to `plugins/platform/plugin.go`, `schema/schema.go`, `schema/module_schema.go`, `schema/step_schema_builtins.go`, `cmd/wfctl/type_registry.go`, `cmd/wfctl/infra.go`, `cmd/wfctl/deploy_providers.go`, `cmd/wfctl/ci_run_dryrun.go`, `plugins/platform/plugin_test.go`, `module/multi_region.go`. Implementer also reviews `cmd/wfctl/infra_apply_test.go` line 1990 (negative-test fixture using `type: platform.do_app`) — replace with a synthetic non-existent type or remove if the negative case is redundant. +2. **T2 — Strip registration sites (10 files).** Edits to `plugins/platform/plugin.go`, `schema/schema.go`, `schema/module_schema.go`, `schema/step_schema_builtins.go`, `cmd/wfctl/type_registry.go`, `cmd/wfctl/infra.go`, `cmd/wfctl/deploy_providers.go`, `cmd/wfctl/ci_run_dryrun.go`, `plugins/platform/plugin_test.go`, `module/multi_region.go`. Implementer also reviews `cmd/wfctl/infra_apply_test.go` line 1990 (negative-test fixture using `type: platform.do_app`) — replace with a synthetic non-existent type or remove if the negative case is redundant. 3. **T3 — Add load-time migration error + tests.** Engine fails-closed on legacy DO types with actionable message; new test fixtures cover all 5 legacy types. 4. **T4 — `go mod tidy` + grep gate.** Confirm zero `digitalocean/godo` imports remain in code AND in module files; update go.sum; add CI gate. @@ -230,17 +232,17 @@ Single PR, ~5 tasks: (cd example && go mod tidy) # standalone example/ sub-module also pins godo as indirect ``` - Grep gate (exact invocation): - ```sh - grep -rn --include="*.go" \ - --exclude-dir=_worktrees \ - --exclude-dir=.worktrees \ - --exclude-dir=.claude \ - "digitalocean/godo" . - ``` - Plus a second grep over `go.mod` files (root + `example/go.mod`) to ensure neither still lists godo: + Grep gate (fail-on-match, exact invocation — both gates use `!` to invert grep's exit code so a match becomes a failing CI step): ```sh - grep -nH "digitalocean/godo" go.mod example/go.mod || true # fails build if any hit + # *.go gate: + ! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + "digitalocean/godo" . + + # go.mod gate (root + example/): + ! grep -qH "digitalocean/godo" go.mod example/go.mod ``` Gate lives in `.github/workflows/ci.yml` (or wherever `golangci-lint` already runs) as a fail-on-match step. Same grep also runs as a pre-commit step locally documented in CONTRIBUTING (no install required, repo-relative). 5. **T5 — Docs + CHANGELOG + migration guide + modernize rules.** Update `DOCUMENTATION.md`, prepend CHANGELOG breaking-change entry, add `docs/migrations/v-godo-removal.md` (5 module + 5 step mappings, plus explicit GAP callout for `step.do_logs` / `step.do_scale` with workaround YAML examples), implement the seven `wfctl modernize` rewrite rules above + test fixtures, file the two follow-up issues in `workflow-plugin-digitalocean` (`step.iac_logs`, `step.iac_scale`) and wire their issue numbers back into the migration error messages. @@ -263,6 +265,16 @@ This change affects build, package version, and runtime config loading. Rollback ## Adversarial review history +### Cycle 3 (PASS — 0 Critical / 0 Important; 3 Minor incorporated) — 2026-05-13 + +- **m-1** Grep gates lacked `!`-prefix; `|| true` silently suppressed exit code → **fixed**: both gates now use `!` prefix to fail CI on match. +- **m-2** Plugin-loaded detection over-specified (AND of factory map + provider binding) → **fixed**: simplified to `_, iacLoaded := e.moduleFactories["iac.provider"]` with rationale. +- **m-3** Missing sequencing constraint for `workflow-scenarios` migration → **fixed**: explicit constraint added to Assumption #2. +- **t-1** T2 file count was 9; actual list contained 10 → **fixed**. +- **Cycle-1 and Cycle-2 fixes verified to hold.** + +Verdict: PASS. Pipeline advances to writing-plans. + ### Cycle 2 (FAIL) — 2026-05-13 - **I-1** (new) `module/platform_doks_test.go` (164 LOC) missing from deletion inventory → **fixed**: row added; total bumped to 12 files / ~3206 LOC; T1 scope updated. From 899e8f07f69b07df32e2b7d446b15ca4f966f054 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 02:35:59 -0400 Subject: [PATCH 07/26] docs(#617): implementation plan (5 tasks, 1 PR) Single-PR force-cutover, 5 tasks: T1: delete 12 legacy DO files (~3206 LOC) T2: strip 10 registration sites + remap wfctl detection hooks T3: add legacy-type migration error guards (module + step paths) T4: go mod tidy + CI grep gate T5: docs + CHANGELOG + migration guide + wfctl modernize rules + file follow-up issues in workflow-plugin-digitalocean (logs/scale GAPs) and workflow (AWS audit) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-13-issue-617-godo-removal.md | 1215 +++++++++++++++++ 1 file changed, 1215 insertions(+) create mode 100644 docs/plans/2026-05-13-issue-617-godo-removal.md diff --git a/docs/plans/2026-05-13-issue-617-godo-removal.md b/docs/plans/2026-05-13-issue-617-godo-removal.md new file mode 100644 index 00000000..18d7321e --- /dev/null +++ b/docs/plans/2026-05-13-issue-617-godo-removal.md @@ -0,0 +1,1215 @@ +# Remove godo from workflow core (issue #617) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Force-cutover delete the six godo-importing legacy DigitalOcean IaC modules + five legacy pipeline steps from workflow core, remove `github.com/digitalocean/godo` from `go.mod` (root + `example/`), and add actionable load-time migration errors so user configs that still reference the legacy types get a clear pointer to `workflow-plugin-digitalocean` v0.12.0+ and the `infra.*` IaC type system. + +**Architecture:** Single PR. Pure deletion of 12 files + edits to 10 registration sites + new migration-error guards in `engine.go` (module path) and `module/pipeline_step_registry.go` (step path). Plugin-loaded detection via a single `_, ok := e.moduleFactories["iac.provider"]` lookup. CI gate: `!`-prefixed grep over `*.go` and `go.mod` files (root + `example/`) to prevent regression. `wfctl modernize` rules auto-rewrite legacy YAML. + +**Tech Stack:** Go 1.26, `github.com/GoCodeAlone/workflow` engine, `cmd/wfctl`, `modernize/` package, GitHub Actions CI. + +**Base branch:** `main` + +--- + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 5 +**Estimated Lines of Change:** ~3206 deleted + ~400 added + ~80 edited = net ~−2700 (informational; not enforced) + +**Out of scope:** +- AWS SDK audit (separate follow-up issue filed at end of T5, addresses `iam/`, `plugin/rbac/aws.go`, `artifact/s3.go`, `platform/providers/aws/` IaC drivers). +- New plugin-side step types (`step.iac_logs`, `step.iac_scale`) — tracked as follow-up issues in `workflow-plugin-digitalocean` (filed in T5; out of scope for this plan). +- Changes to `module/iac_state_spaces.go` — it uses `aws-sdk-go-v2` (not godo) for S3-compat blob access. +- Downstream consumer migration PRs (`buymywishlist-phase3`, `workflow-scenarios` scenarios 42/51) — tracked as follow-ups; the engine cutover PR ships independently with migration errors as the user-facing path. +- Compatibility shim, build tag fence, deprecation period — explicitly rejected in design. + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | feat: remove godo from core (issue #617) | Task 1, Task 2, Task 3, Task 4, Task 5 | `feat/issue-617-godo-removal` | + +**Status:** Draft + +--- + +## Task 1: Delete legacy DO module + step files + +**Files:** +- Delete: `module/platform_do_app.go` +- Delete: `module/platform_do_app_test.go` +- Delete: `module/platform_do_database.go` +- Delete: `module/platform_do_database_test.go` +- Delete: `module/platform_do_dns.go` +- Delete: `module/platform_do_dns_test.go` +- Delete: `module/platform_do_networking.go` +- Delete: `module/platform_do_networking_test.go` +- Delete: `module/platform_doks.go` +- Delete: `module/platform_doks_test.go` +- Delete: `module/cloud_account_do.go` +- Delete: `module/pipeline_step_do.go` +- Test: `module/godo_absent_test.go` (new — asserts the godo import is gone from the package) + +**Step 1: Write the failing test** + +Create `module/godo_absent_test.go`: + +```go +package module_test + +import ( + "go/parser" + "go/token" + "path/filepath" + "strings" + "testing" +) + +// TestGodoNotImported_InModulePackage asserts no file under module/ imports +// github.com/digitalocean/godo. This is the regression gate for issue #617. +func TestGodoNotImported_InModulePackage(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatalf("glob: %v", err) + } + fset := token.NewFileSet() + for _, f := range files { + af, err := parser.ParseFile(fset, f, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", f, err) + } + for _, imp := range af.Imports { + if strings.Trim(imp.Path.Value, `"`) == "github.com/digitalocean/godo" { + t.Errorf("%s imports github.com/digitalocean/godo (issue #617 — moved to workflow-plugin-digitalocean)", f) + } + } + } +} +``` + +**Step 2: Run test to verify it fails (godo files still present)** + +Run: `go test ./module -run TestGodoNotImported_InModulePackage -v` +Expected: FAIL with 6 lines naming each godo-importing file. + +**Step 3: Delete the 12 legacy files** + +```bash +rm module/platform_do_app.go module/platform_do_app_test.go \ + module/platform_do_database.go module/platform_do_database_test.go \ + module/platform_do_dns.go module/platform_do_dns_test.go \ + module/platform_do_networking.go module/platform_do_networking_test.go \ + module/platform_doks.go module/platform_doks_test.go \ + module/cloud_account_do.go module/pipeline_step_do.go +``` + +**Step 4: Verify package still parses (will fail at link time due to registrations — that is T2's problem)** + +Run: `go vet ./module/...` +Expected: clean (or fails only on undefined symbols *outside* the `module` package, e.g., in `plugins/platform/`). If anything inside `module/` fails to compile, the deletion missed a sibling file — investigate. + +**Step 5: Run T1 regression test** + +Run: `go test ./module -run TestGodoNotImported_InModulePackage -v` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add module/godo_absent_test.go module/ +git commit -m "$(cat <<'EOF' +feat(#617): delete legacy DO modules (godo importers) + +Removes 12 files / ~3206 LOC. Registration sites cleaned in T2. + +* platform_do_app.go + test +* platform_do_database.go + test +* platform_do_dns.go + test +* platform_do_networking.go + test +* platform_doks.go + test +* cloud_account_do.go (DO credential resolvers + doClient()) +* pipeline_step_do.go (5 DO App Platform step types) + +Adds godo_absent_test.go as a regression gate inside module/. +EOF +)" +``` + +**Rollback:** `git revert ` restores the 12 files; combined with T2/T3 revert restores all registrations and migration errors. + +--- + +## Task 2: Strip registration sites and remap detection hooks + +**Files:** +- Modify: `plugins/platform/plugin.go` (drop 5 module factories + 5 step factories + 10 strings from `ModuleTypes` / `StepTypes` slices) +- Modify: `plugins/platform/plugin_test.go` (drop 10 string-presence assertions) +- Modify: `schema/schema.go` (drop 5 module-type entries + 5 step-type entries from the registry slices) +- Modify: `schema/module_schema.go` (drop 5 module schemas + 5 step descriptions) +- Modify: `schema/step_schema_builtins.go` (drop 5 `Register(&StepSchema{Type: "step.do_*"})` calls) +- Modify: `cmd/wfctl/type_registry.go` (drop 5 module entries + 5 step entries from the type-registry map) +- Modify: `cmd/wfctl/infra.go:577` — change `return t == "infra.container_service" || t == "platform.do_app"` to `return t == "infra.container_service"`. +- Modify: `cmd/wfctl/deploy_providers.go:419-424` — drop the `"platform.do_app"` line from the `deployTargetTypes` slice. +- Modify: `cmd/wfctl/ci_run_dryrun.go:178-183` — drop the `"platform.do_app"` line from the `deployTargetTypes` slice. +- Modify: `module/multi_region.go:123` — replace the error message text (see Step 3). +- Modify: `cmd/wfctl/infra_apply_test.go:1990` — the negative-test YAML fixture uses `type: platform.do_app`. Replace with `type: example.legacy_unknown` (a synthetic type that will never be registered) so the test's intent (negative coverage for unknown types) is preserved without referencing a removed type. +- Test: `cmd/wfctl/legacy_do_types_removed_test.go` (new — asserts the type registry no longer contains the legacy keys) + +**Step 1: Write the failing test** + +Create `cmd/wfctl/legacy_do_types_removed_test.go`: + +```go +package main + +import "testing" + +// TestLegacyDOTypesAbsent_FromTypeRegistry locks the post-cutover state of +// cmd/wfctl/type_registry.go for issue #617. If any legacy type leaks back in, +// this test fires and the CI gate fires. +func TestLegacyDOTypesAbsent_FromTypeRegistry(t *testing.T) { + r := buildTypeRegistry() + legacy := []string{ + "platform.do_app", "platform.do_database", "platform.do_dns", + "platform.do_networking", "platform.doks", + "step.do_deploy", "step.do_status", "step.do_logs", + "step.do_scale", "step.do_destroy", + } + for _, tname := range legacy { + if _, ok := r[tname]; ok { + t.Errorf("type registry still contains legacy DO type %q (issue #617)", tname) + } + } +} +``` + +(If the existing `buildTypeRegistry` symbol differs, use the actual constructor. Verify by grep before writing the assertion target.) + +**Step 2: Run test to verify it fails** + +Run: `go test ./cmd/wfctl -run TestLegacyDOTypesAbsent_FromTypeRegistry -v` +Expected: FAIL with 10 lines naming each legacy type still in the registry. + +**Step 3: Apply the registration deletions and detection-hook remappings** + +For each file in the Files list, perform the listed deletion. The `module/multi_region.go:123` rewrite: + +```go +// Before: +return fmt.Errorf("platform.region %q: provider %q is not yet supported; use platform.doks modules per region for DigitalOcean multi-region deployments", m.name, providerType) + +// After: +return fmt.Errorf("platform.region %q: provider %q is not yet supported; for DigitalOcean multi-region, use infra.k8s_cluster modules per region with provider: digitalocean (requires workflow-plugin-digitalocean)", m.name, providerType) +``` + +**Step 4: Run tests** + +Run: `go test ./cmd/wfctl -run TestLegacyDOTypesAbsent_FromTypeRegistry -v` +Expected: PASS. + +Run: `go build ./...` +Expected: clean. + +Run: `go test ./plugins/platform/... ./schema/... ./module/... ./cmd/wfctl/...` +Expected: PASS — any test that asserted the presence of a legacy `platform.do_*` or `step.do_*` was updated in this task to assert its absence. + +**Step 5: Commit** + +```bash +git add plugins/ schema/ cmd/wfctl/ module/multi_region.go cmd/wfctl/legacy_do_types_removed_test.go +git commit -m "$(cat <<'EOF' +feat(#617): strip DO registration sites + remap wfctl detection hooks + +* plugins/platform: drop 5 module + 5 step factories. +* schema/*: drop 10 entries from registries and schema descriptions. +* cmd/wfctl/type_registry.go: drop 10 type entries. +* cmd/wfctl/{infra.go,deploy_providers.go,ci_run_dryrun.go}: remap + isContainerType and deployTargetTypes to infra.container_service only. +* module/multi_region.go: rewrite DOKS multi-region hint to point at + infra.k8s_cluster + workflow-plugin-digitalocean. +* cmd/wfctl/infra_apply_test.go: replace platform.do_app negative-test + fixture with example.legacy_unknown synthetic type. + +Adds legacy_do_types_removed_test.go as a registry-absence regression gate. +EOF +)" +``` + +**Rollback:** `git revert ` restores all 10 registration sites; the package will fail to compile until T1 is also reverted (the factories reference deleted symbols). + +--- + +## Task 3: Add load-time migration error guards (module + step) + +**Files:** +- Modify: `engine.go:508` — replace the single `unknown module type` error with a legacy-DO-aware branch (see Step 3). +- Modify: `module/pipeline_step_registry.go:35` — replace the single `unknown step type` error with the same legacy-DO-aware branch for step types. +- Create: `module/legacy_do_migration.go` — small package-internal helper holding the legacy-type lookup table and the formatted message builders. Shared between the engine module-path and the step-registry step-path. +- Test: `engine_legacy_do_migration_test.go` (new — covers all 5 module types × {plugin loaded, plugin not loaded}) +- Test: `module/pipeline_step_legacy_do_migration_test.go` (new — covers all 5 step types × {plugin loaded, plugin not loaded}) + +**Step 1: Write the failing tests** + +Create `engine_legacy_do_migration_test.go` at repo root: + +```go +package workflow_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow" + "github.com/GoCodeAlone/workflow/config" +) + +func TestLegacyDOModuleError_PluginNotLoaded(t *testing.T) { + cases := []struct{ legacyType, hint string }{ + {"platform.do_app", "infra.container_service"}, + {"platform.do_database", "infra.database"}, + {"platform.do_dns", "infra.dns"}, + {"platform.do_networking", "infra.vpc"}, + {"platform.doks", "infra.k8s_cluster"}, + } + for _, tc := range cases { + t.Run(tc.legacyType, func(t *testing.T) { + e := workflow.NewEngine() + cfg := &config.WorkflowConfig{Modules: []config.ModuleConfig{{Name: "x", Type: tc.legacyType, Config: map[string]any{}}}} + err := e.BuildFromConfig(cfg) + if err == nil { + t.Fatalf("expected error for legacy type %q", tc.legacyType) + } + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-digitalocean", + "Install workflow-plugin-digitalocean", + tc.hint, + } { + if !strings.Contains(msg, want) { + t.Errorf("error for %q missing %q; got: %s", tc.legacyType, want, msg) + } + } + }) + } +} + +func TestLegacyDOModuleError_PluginLoaded(t *testing.T) { + e := workflow.NewEngine() + // Register a stub iac.provider factory to simulate workflow-plugin-digitalocean being loaded. + e.RegisterModuleFactory("iac.provider", func(name string, cfg map[string]any) interface{} { return nil }) + + cfg := &config.WorkflowConfig{Modules: []config.ModuleConfig{{Name: "x", Type: "platform.do_app", Config: map[string]any{}}}} + err := e.BuildFromConfig(cfg) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "already loaded") { + t.Errorf("plugin-loaded branch must say 'already loaded'; got: %s", msg) + } + if strings.Contains(msg, "Install workflow-plugin-digitalocean") { + t.Errorf("plugin-loaded branch must NOT instruct install; got: %s", msg) + } +} +``` + +Create `module/pipeline_step_legacy_do_migration_test.go`: + +```go +package module_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/module" +) + +func TestLegacyDOStepError_PluginNotLoaded(t *testing.T) { + // step.do_logs / step.do_scale have GAP messages; the others map 1:1 to step.iac_*. + cases := []struct{ step, mustContain string }{ + {"step.do_deploy", "step.iac_apply"}, + {"step.do_status", "step.iac_status"}, + {"step.do_destroy", "step.iac_destroy"}, + {"step.do_logs", "wfctl infra logs"}, + {"step.do_scale", "instance_count"}, + } + for _, tc := range cases { + t.Run(tc.step, func(t *testing.T) { + _, err := module.CreateStep(tc.step, "x", map[string]any{}, nil) + if err == nil { + t.Fatalf("expected error for %q", tc.step) + } + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-digitalocean", + "Install workflow-plugin-digitalocean", + tc.mustContain, + } { + if !strings.Contains(msg, want) { + t.Errorf("error for %q missing %q; got: %s", tc.step, want, msg) + } + } + }) + } +} +``` + +(Confirm the `module.CreateStep` symbol — if the actual constructor name differs, use it.) + +**Step 2: Run tests to verify they fail** + +Run: `go test ./... -run 'TestLegacyDO(Module|Step)Error' -v` +Expected: FAIL — the engine currently emits the generic `"unknown module type %q for module %q"` and `"unknown step type: %s"` messages; neither mentions godo / workflow-plugin-digitalocean / infra.*. + +**Step 3: Implement the migration helper and wire it into both error paths** + +Create `module/legacy_do_migration.go`: + +```go +package module + +import ( + "fmt" + "sort" + "strings" +) + +// LegacyDOModuleTypes maps each removed legacy DigitalOcean module type to its +// infra.* IaC successor (issue #617). +var LegacyDOModuleTypes = map[string]string{ + "platform.do_app": "infra.container_service", + "platform.do_database": "infra.database", + "platform.do_dns": "infra.dns", + "platform.do_networking": "infra.vpc + infra.firewall", + "platform.doks": "infra.k8s_cluster", +} + +// LegacyDOStepTypes maps each removed legacy DigitalOcean step type to its +// successor or to a workaround when no 1:1 successor exists. +var LegacyDOStepTypes = map[string]string{ + "step.do_deploy": "step.iac_apply (against an infra.container_service module)", + "step.do_status": "step.iac_status (against an infra.container_service module)", + "step.do_destroy": "step.iac_destroy (against an infra.container_service module)", + "step.do_logs": "no direct pipeline-step equivalent; use `wfctl infra logs` ad-hoc, or rely on the DO plugin's Troubleshoot hook on step.iac_apply failure", + "step.do_scale": "no direct pipeline-step equivalent; update instance_count in the infra.container_service module config and re-run step.iac_apply", +} + +// IsLegacyDOModuleType reports whether t is a removed legacy DO module type. +func IsLegacyDOModuleType(t string) bool { _, ok := LegacyDOModuleTypes[t]; return ok } + +// IsLegacyDOStepType reports whether t is a removed legacy DO step type. +func IsLegacyDOStepType(t string) bool { _, ok := LegacyDOStepTypes[t]; return ok } + +// FormatLegacyDOModuleError builds the actionable migration error for a legacy +// DO module type. iacProviderLoaded indicates whether the iac.provider factory +// is registered in the engine — used to branch between the "install plugin" +// and "config-only issue" messages. +func FormatLegacyDOModuleError(legacyType, moduleName string, iacProviderLoaded bool) error { + successor, ok := LegacyDOModuleTypes[legacyType] + if !ok { + return nil + } + pluginLine := "Install workflow-plugin-digitalocean: https://github.com/GoCodeAlone/workflow-plugin-digitalocean" + if iacProviderLoaded { + pluginLine = "workflow-plugin-digitalocean is already loaded; your config still references the legacy module name." + } + var b strings.Builder + fmt.Fprintf(&b, "unsupported legacy module type %q (module %q): this type was removed from workflow core in v0.52.0 — DigitalOcean IaC moved to workflow-plugin-digitalocean.\n\n", legacyType, moduleName) + b.WriteString(pluginLine) + b.WriteString("\n\nMigrate this module to: ") + b.WriteString(successor) + b.WriteString(" (provider: digitalocean)\n\nFull mapping:\n") + keys := make([]string, 0, len(LegacyDOModuleTypes)) + for k := range LegacyDOModuleTypes { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&b, " %s → %s\n", k, LegacyDOModuleTypes[k]) + } + b.WriteString("\nSee docs/migrations/v0.52.0-godo-removal.md") + return fmt.Errorf("%s", b.String()) +} + +// FormatLegacyDOStepError builds the actionable migration error for a legacy +// DO step type. +func FormatLegacyDOStepError(legacyType string, iacProviderLoaded bool) error { + successor, ok := LegacyDOStepTypes[legacyType] + if !ok { + return nil + } + pluginLine := "Install workflow-plugin-digitalocean: https://github.com/GoCodeAlone/workflow-plugin-digitalocean" + if iacProviderLoaded { + pluginLine = "workflow-plugin-digitalocean is already loaded; your config still references the legacy step name." + } + var b strings.Builder + fmt.Fprintf(&b, "unsupported legacy step type %q: this step was removed from workflow core in v0.52.0 — DigitalOcean IaC moved to workflow-plugin-digitalocean.\n\n", legacyType) + b.WriteString(pluginLine) + b.WriteString("\n\nMigrate this step to: ") + b.WriteString(successor) + b.WriteString("\n\nSee docs/migrations/v0.52.0-godo-removal.md") + return fmt.Errorf("%s", b.String()) +} +``` + +Modify `engine.go:508`: + +```go +// Before: +factory, exists := e.moduleFactories[modCfg.Type] +if !exists { + return fmt.Errorf("unknown module type %q for module %q — ensure the required plugin is loaded", modCfg.Type, modCfg.Name) +} + +// After: +factory, exists := e.moduleFactories[modCfg.Type] +if !exists { + if module.IsLegacyDOModuleType(modCfg.Type) { + _, iacLoaded := e.moduleFactories["iac.provider"] + return module.FormatLegacyDOModuleError(modCfg.Type, modCfg.Name, iacLoaded) + } + return fmt.Errorf("unknown module type %q for module %q — ensure the required plugin is loaded", modCfg.Type, modCfg.Name) +} +``` + +(Add `"github.com/GoCodeAlone/workflow/module"` to engine.go imports if not already present.) + +Modify `module/pipeline_step_registry.go:35`. Since this is in the `module` package itself, the helper is callable directly. Step-registry needs to know whether the iac.provider module factory is loaded — pass a detection callback in or expose a package-level setter. Simplest implementation: a package-level boolean updated by the engine at `BuildFromConfig` time before step construction. New file `module/iac_provider_loaded.go`: + +```go +package module + +// iacProviderLoaded tracks whether the engine's module factory map currently +// contains "iac.provider". Set by the engine before invoking step factories so +// that legacy DO step migration errors can branch on it. +var iacProviderLoaded bool + +// SetIaCProviderLoaded is called by the engine after module factory registration +// is complete but before step factories run. +func SetIaCProviderLoaded(loaded bool) { iacProviderLoaded = loaded } +``` + +Wire it in `engine.go` BuildFromConfig just before step construction: + +```go +_, iacLoaded := e.moduleFactories["iac.provider"] +module.SetIaCProviderLoaded(iacLoaded) +``` + +And use it in `module/pipeline_step_registry.go:35`: + +```go +// Before: +return nil, fmt.Errorf("unknown step type: %s", stepType) + +// After: +if IsLegacyDOStepType(stepType) { + return nil, FormatLegacyDOStepError(stepType, iacProviderLoaded) +} +return nil, fmt.Errorf("unknown step type: %s", stepType) +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test ./... -run 'TestLegacyDO(Module|Step)Error' -v` +Expected: PASS (all 12 sub-cases — 5 modules × 1 not-loaded + 1 module loaded; 5 steps × 1 not-loaded). + +Run: `go test ./...` +Expected: PASS overall (the existing tests untouched by T1/T2/T3 should still pass). + +**Step 5: Commit** + +```bash +git add module/legacy_do_migration.go module/iac_provider_loaded.go \ + engine.go module/pipeline_step_registry.go \ + engine_legacy_do_migration_test.go module/pipeline_step_legacy_do_migration_test.go +git commit -m "$(cat <<'EOF' +feat(#617): actionable migration errors for legacy DO types + +Adds module.LegacyDOModuleTypes + LegacyDOStepTypes lookup tables and two +formatters (FormatLegacyDOModuleError, FormatLegacyDOStepError). Both branch +on whether iac.provider is registered in the engine's factory map: + - not loaded → "Install workflow-plugin-digitalocean: " + - loaded → "already loaded; your config still references the legacy name" + +Wired into engine.go:508 (module path) and pipeline_step_registry.go:35 +(step path). SetIaCProviderLoaded bridges the boolean from engine to module +package. + +Each step type gets a per-step message; step.do_logs and step.do_scale have +GAP messages with workarounds because no 1:1 pipeline-step successor exists +yet (tracked as follow-up issues in T5). +EOF +)" +``` + +**Rollback:** `git revert ` restores generic unknown-type errors. Combined with T1/T2 revert, repository returns to pre-cutover state. + +--- + +## Task 4: `go mod tidy` (root + example) + CI grep gate + +**Files:** +- Modify: `go.mod` (drop `github.com/digitalocean/godo` direct require + transitive bumps via `go mod tidy`) +- Modify: `go.sum` (regenerated) +- Modify: `example/go.mod` (drop indirect godo) +- Modify: `example/go.sum` (regenerated) +- Modify: `.github/workflows/ci.yml` — add a `godo-banned` job that runs the `!`-prefixed greps. +- Test: this task's verification IS the CI gate itself; no new unit test. + +**Step 1: Run the tidies and verify godo is gone** + +```bash +go mod tidy +(cd example && go mod tidy) +``` + +**Step 2: Verify** + +Run: +```bash +! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + "digitalocean/godo" . +! grep -qH "digitalocean/godo" go.mod example/go.mod +``` +Expected: BOTH commands exit 0 (no match → grep exits 1 → `!` inverts to 0 → success). + +If either fails (i.e., grep finds godo), inspect: a transitive dependency may still pull it. Identify with `go mod why github.com/digitalocean/godo` and investigate. + +**Step 3: Add the CI gate** + +Modify `.github/workflows/ci.yml` to add a job (placed near `golangci-lint`): + +```yaml + godo-banned: + name: Verify godo is not imported (issue #617) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Grep gate — *.go files must not import godo + run: | + ! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + "digitalocean/godo" . + - name: Grep gate — go.mod files must not list godo + run: | + ! grep -qH "digitalocean/godo" go.mod example/go.mod +``` + +(If `.github/workflows/ci.yml` does not exist or has a different name, locate the existing Go-build workflow file via `ls .github/workflows/` and add the job there. Adapt the runner/checkout action versions to match the rest of the file.) + +**Step 4: Verify locally one more time, including the gate's exact commands** + +```bash +bash -c '! grep -rn --include="*.go" --exclude-dir=_worktrees --exclude-dir=.worktrees --exclude-dir=.claude "digitalocean/godo" .' +echo "exit: $?" +# Expected: exit: 0 +bash -c '! grep -qH "digitalocean/godo" go.mod example/go.mod' +echo "exit: $?" +# Expected: exit: 0 +``` + +**Step 5: Commit** + +```bash +git add go.mod go.sum example/go.mod example/go.sum .github/workflows/ +git commit -m "$(cat <<'EOF' +feat(#617): drop godo from go.mod + add CI grep gate + +* go mod tidy on root and example/ drops github.com/digitalocean/godo + (direct from root, indirect from example/). +* New CI job 'godo-banned' fails the build on any *.go import of godo OR + any mention of godo in go.mod files. Excludes _worktrees, .worktrees, + and .claude (local agent state, not committed source). + +This satisfies acceptance criterion #4 (dependabot bumps target the +provider repo, not workflow core). +EOF +)" +``` + +**Rollback:** `git revert ` restores godo to go.mod and removes the CI gate. Combined with T1/T2/T3 revert returns to pre-cutover state. + +--- + +## Task 5: Docs, CHANGELOG, migration guide, `wfctl modernize` rules + file follow-up issues + +**Files:** +- Modify: `DOCUMENTATION.md` (replace the 5 module rows + 5 step rows in the platform tables with a single paragraph pointing at the DO plugin) +- Modify: `CHANGELOG.md` (prepend a `## v0.52.0` section with the breaking-change entry) +- Create: `docs/migrations/v0.52.0-godo-removal.md` (full migration guide — 5 module mappings + 5 step mappings + GAP callouts + before/after YAML examples + step-by-step migration recipe + ADR-style "why this was done") +- Create: `modernize/legacy_do_rule.go` (new modernize rules — see Step 3) +- Modify: `modernize/modernize.go` `AllRules()` to append the new rule +- Test: `modernize/legacy_do_rule_test.go` (new — covers Check + Fix for each of the 5 module + 5 step rewrites) + +**Step 1: Write the failing test** + +Create `modernize/legacy_do_rule_test.go`: + +```go +package modernize_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/modernize" + "gopkg.in/yaml.v3" +) + +func TestLegacyDORule_Rewrites(t *testing.T) { + cases := []struct { + name string + yamlIn string + wantNew string // must appear in fixed YAML + wantDrop string // must NOT appear in fixed YAML (the legacy type) + }{ + { + name: "platform.do_app → infra.container_service", + yamlIn: "modules:\n - name: api\n type: platform.do_app\n config:\n region: nyc\n", + wantNew: "infra.container_service", + wantDrop: "platform.do_app", + }, + { + name: "platform.do_database → infra.database", + yamlIn: "modules:\n - name: db\n type: platform.do_database\n config: {}\n", + wantNew: "infra.database", + wantDrop: "platform.do_database", + }, + { + name: "platform.do_dns → infra.dns", + yamlIn: "modules:\n - name: dns\n type: platform.do_dns\n config: {}\n", + wantNew: "infra.dns", + wantDrop: "platform.do_dns", + }, + { + name: "platform.doks → infra.k8s_cluster", + yamlIn: "modules:\n - name: k8s\n type: platform.doks\n config: {}\n", + wantNew: "infra.k8s_cluster", + wantDrop: "platform.doks", + }, + { + name: "step.do_deploy → step.iac_apply", + yamlIn: "pipelines:\n - steps:\n - type: step.do_deploy\n", + wantNew: "step.iac_apply", + wantDrop: "step.do_deploy", + }, + } + rule := modernize.LegacyDORule() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var root yaml.Node + if err := yaml.Unmarshal([]byte(tc.yamlIn), &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } + findings := rule.Check(&root, []byte(tc.yamlIn)) + if len(findings) == 0 { + t.Fatalf("expected a finding, got 0") + } + rule.Fix(&root) + out, err := yaml.Marshal(&root) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(out) + if !strings.Contains(s, tc.wantNew) { + t.Errorf("fixed YAML missing %q; got:\n%s", tc.wantNew, s) + } + if strings.Contains(s, tc.wantDrop) { + t.Errorf("fixed YAML still contains legacy %q; got:\n%s", tc.wantDrop, s) + } + }) + } +} + +func TestLegacyDORule_LogsScaleAnnotatedNotDropped(t *testing.T) { + // step.do_logs and step.do_scale have NO 1:1 successor. Rule must: + // - flag them as findings, + // - NOT modify the YAML (no silent loss). + for _, stepType := range []string{"step.do_logs", "step.do_scale"} { + t.Run(stepType, func(t *testing.T) { + in := "pipelines:\n - steps:\n - type: " + stepType + "\n" + var root yaml.Node + yaml.Unmarshal([]byte(in), &root) + rule := modernize.LegacyDORule() + findings := rule.Check(&root, []byte(in)) + if len(findings) == 0 { + t.Fatalf("expected a finding for %q", stepType) + } + if findings[0].Fixable { + t.Errorf("%q must be marked Fixable: false (no auto-rewrite); got Fixable: true", stepType) + } + rule.Fix(&root) + out, _ := yaml.Marshal(&root) + if !strings.Contains(string(out), stepType) { + t.Errorf("Fix MUST NOT remove legacy %q; got:\n%s", stepType, out) + } + }) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./modernize/... -run TestLegacyDORule -v` +Expected: FAIL with "undefined: modernize.LegacyDORule". + +**Step 3: Implement the rule** + +Create `modernize/legacy_do_rule.go`: + +```go +package modernize + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// LegacyDORule rewrites legacy DigitalOcean module + step types to their +// infra.* IaC successors (issue #617). Auto-fixable for 5 modules and 3 of 5 +// steps; the two GAP steps (step.do_logs, step.do_scale) are flagged but not +// modified, because they have no 1:1 pipeline-step successor. +func LegacyDORule() Rule { + moduleMap := map[string]string{ + "platform.do_app": "infra.container_service", + "platform.do_database": "infra.database", + "platform.do_dns": "infra.dns", + "platform.doks": "infra.k8s_cluster", + // platform.do_networking is intentionally NOT auto-fixed: it splits + // 1→2 (infra.vpc + infra.firewall), which requires structural + // rewrite the operator must review. + } + stepMap := map[string]string{ + "step.do_deploy": "step.iac_apply", + "step.do_status": "step.iac_status", + "step.do_destroy": "step.iac_destroy", + } + gapTypes := map[string]string{ + "platform.do_networking": "splits into infra.vpc + infra.firewall — manual rewrite required", + "step.do_logs": "no pipeline-step successor; use `wfctl infra logs` or rely on DO plugin Troubleshoot", + "step.do_scale": "no pipeline-step successor; edit instance_count and re-run step.iac_apply", + } + + return Rule{ + ID: "legacy-do-types", + Description: "Rewrite legacy DigitalOcean module/step types to infra.* IaC successors (issue #617).", + Severity: "error", + Check: func(root *yaml.Node, raw []byte) []Finding { + var out []Finding + walkTypeNodes(root, func(typeVal *yaml.Node) { + if successor, ok := moduleMap[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in v0.52.0; rewrite to %s (provider: digitalocean) — requires workflow-plugin-digitalocean", typeVal.Value, successor), + Fixable: true, + }) + } + if successor, ok := stepMap[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in v0.52.0; rewrite to %s — requires workflow-plugin-digitalocean", typeVal.Value, successor), + Fixable: true, + }) + } + if reason, ok := gapTypes[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in v0.52.0 — %s", typeVal.Value, reason), + Fixable: false, + }) + } + }) + return out + }, + Fix: func(root *yaml.Node) []Change { + var out []Change + walkTypeNodes(root, func(typeVal *yaml.Node) { + if successor, ok := moduleMap[typeVal.Value]; ok { + old := typeVal.Value + typeVal.Value = successor + out = append(out, Change{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Description: fmt.Sprintf("rewrote %s → %s", old, successor), + }) + } + if successor, ok := stepMap[typeVal.Value]; ok { + old := typeVal.Value + typeVal.Value = successor + out = append(out, Change{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Description: fmt.Sprintf("rewrote %s → %s", old, successor), + }) + } + // gapTypes are intentionally not modified. + }) + return out + }, + } +} + +// walkTypeNodes traverses a YAML AST and invokes visit on every value node +// whose parent mapping key is "type". +func walkTypeNodes(n *yaml.Node, visit func(*yaml.Node)) { + if n == nil { + return + } + if n.Kind == yaml.MappingNode { + for i := 0; i+1 < len(n.Content); i += 2 { + k, v := n.Content[i], n.Content[i+1] + if k.Value == "type" && v.Kind == yaml.ScalarNode { + visit(v) + } + walkTypeNodes(v, visit) + } + return + } + for _, c := range n.Content { + walkTypeNodes(c, visit) + } +} +``` + +Append to `modernize/modernize.go` `AllRules()`: + +```go +return []Rule{ + hyphenStepsRule(), + conditionalFieldRule(), + dbQueryModeRule(), + dbQueryIndexRule(), + absoluteDbPathRule(), + emptyRoutesRule(), + camelCaseConfigRule(), + requestParseConfigRule(), + LegacyDORule(), // <-- ADD +} +``` + +**Step 4: Run rule tests to verify they pass** + +Run: `go test ./modernize/... -run TestLegacyDORule -v` +Expected: PASS. + +Run: `go test ./modernize/...` +Expected: PASS overall. + +**Step 5: Write the docs + migration guide + CHANGELOG** + +Modify `DOCUMENTATION.md`: locate the "Platform Modules" table containing the 5 `platform.do_*` rows and the "Platform Steps" table containing the 5 `step.do_*` rows. Replace each row block with: + +```markdown +**DigitalOcean IaC modules and steps** were removed from workflow core in +v0.52.0 and moved to the +[workflow-plugin-digitalocean](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) +external plugin. After loading the plugin, use the generic `infra.*` module +types with `provider: digitalocean` and the generic `step.iac_*` pipeline +steps. See [v0.52.0 migration guide](docs/migrations/v0.52.0-godo-removal.md). +``` + +Prepend to `CHANGELOG.md`: + +```markdown +## v0.52.0 (2026-05-13) — BREAKING + +### Removed (issue #617) + +- All legacy DigitalOcean IaC modules (`platform.do_app`, `platform.do_database`, `platform.do_dns`, `platform.do_networking`, `platform.doks`) and the DO credential resolver `cloud_account_do.go`. +- All legacy DigitalOcean pipeline steps (`step.do_deploy`, `step.do_status`, `step.do_logs`, `step.do_scale`, `step.do_destroy`). +- The `github.com/digitalocean/godo` dependency from `go.mod` (root and `example/`). + +### Migration + +DigitalOcean IaC moved to [`workflow-plugin-digitalocean`](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) v0.12.0+. After loading the plugin, replace legacy module types with `infra.*` types and `provider: digitalocean`. Run `wfctl modernize --apply ` to auto-rewrite supported types. Two step types (`step.do_logs`, `step.do_scale`) have no 1:1 pipeline successor — workarounds documented in the [v0.52.0 migration guide](docs/migrations/v0.52.0-godo-removal.md). + +Configs that still reference the legacy types now fail to load with an actionable error pointing to the plugin and the relevant `infra.*` successor. +``` + +Create `docs/migrations/v0.52.0-godo-removal.md`: + +```markdown +# v0.52.0 — Removing godo from workflow core (issue #617) + +## What changed + +The five legacy `platform.do_*` modules, the `cloud.account` DO credential +resolver, and the five legacy `step.do_*` pipeline steps were removed from +workflow core. The `github.com/digitalocean/godo` dependency is no longer +pulled by the workflow module. + +DigitalOcean IaC functionality moved entirely to +[`workflow-plugin-digitalocean`](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) +v0.12.0+, which exposes the same resources through the generic `infra.*` IaC +type system with `provider: digitalocean`. + +## Why + +Workflow core should own IaC interfaces and orchestration, not provider SDKs. +Dependabot bumps to godo now target the DO plugin repo, not core. See ADR or +the design doc at `docs/plans/2026-05-13-issue-617-godo-removal-design.md`. + +## Migration recipe + +1. Install the DO plugin (v0.12.0+): + ```yaml + plugins: + - name: digitalocean + source: github.com/GoCodeAlone/workflow-plugin-digitalocean + version: ">=0.12.0" + ``` + +2. Run the modernizer over each affected YAML config: + ```sh + wfctl modernize --apply ./config/*.yaml + ``` + This rewrites the 5 module types and 3 step types automatically. Two step + types (`step.do_logs`, `step.do_scale`) and one module type + (`platform.do_networking`) are flagged but not auto-rewritten — see below. + +3. Manually address the GAP types listed below. + +4. Re-run `wfctl validate` and `wfctl infra plan` to confirm the rewritten + config loads and produces the same plan. + +## Module type mapping + +| Legacy type | Successor | Auto-fix | +|--------------------------|-----------------------------------|----------| +| `platform.do_app` | `infra.container_service` | Yes | +| `platform.do_database` | `infra.database` | Yes | +| `platform.do_dns` | `infra.dns` | Yes | +| `platform.do_networking` | `infra.vpc` + `infra.firewall` | **No** — splits 1→2, manual review required | +| `platform.doks` | `infra.k8s_cluster` | Yes | + +All successors require `config.provider: digitalocean`. + +## Step type mapping + +| Legacy type | Successor | Auto-fix | +|--------------------|--------------------------------------------------------------------|----------| +| `step.do_deploy` | `step.iac_apply` (against an `infra.container_service` module) | Yes | +| `step.do_status` | `step.iac_status` (against an `infra.container_service` module) | Yes | +| `step.do_destroy` | `step.iac_destroy` (against an `infra.container_service` module) | Yes | +| `step.do_logs` | **GAP** — no pipeline step successor; use `wfctl infra logs` ad-hoc, or rely on the DO plugin's Troubleshoot hook on `step.iac_apply` failure. Tracked: workflow-plugin-digitalocean issue | **No** | +| `step.do_scale` | **GAP** — no pipeline step successor; update `instance_count` in the `infra.container_service` module config and re-run `step.iac_apply`. Tracked: workflow-plugin-digitalocean issue | **No** | + +## Before / after examples + +### App Platform + +Before: +```yaml +modules: + - name: api + type: platform.do_app + config: + region: nyc + services: + - name: web + image: registry.digitalocean.com/myorg/api:latest +``` + +After: +```yaml +modules: + - name: api + type: infra.container_service + config: + provider: digitalocean + region: nyc + services: + - name: web + image: registry.digitalocean.com/myorg/api:latest +``` + +### Pipeline step + +Before: +```yaml +pipelines: + - id: deploy + steps: + - type: step.do_deploy + config: { app: api } +``` + +After: +```yaml +pipelines: + - id: deploy + steps: + - type: step.iac_apply + config: { module: api } +``` + +## Errors you may see + +* `unsupported legacy module type "platform.do_app" (module "api"): this type was removed from workflow core in v0.52.0 — DigitalOcean IaC moved to workflow-plugin-digitalocean.` — fix the config per the table above; install the plugin if not already loaded. +* `unsupported legacy step type "step.do_logs": ...` — see GAP entry above; remove the step and use `wfctl infra logs` ad-hoc, or wait for `step.iac_logs` (tracked). + +## Rollback + +If your environment cannot upgrade in this cycle, pin to the previous workflow +core tag (`go get github.com/GoCodeAlone/workflow@v0.51.3`). The legacy modules +remain available there. +``` + +**Step 6: File two follow-up issues in `workflow-plugin-digitalocean` and wire their numbers into the migration error** + +Using `gh`: + +```bash +LOGS_ISSUE_BODY=$(cat <<'EOF' +Legacy step.do_logs in workflow core was removed in workflow v0.52.0 (issue +GoCodeAlone/workflow#617). There is no 1:1 pipeline-step successor in the +generic step.iac_* family yet. Current workaround for users: `wfctl infra logs` +ad-hoc, or rely on the DO plugin's Troubleshoot hook on step.iac_apply +failure. This issue tracks adding a first-class step.iac_logs (in core) or +step.app_logs (in the DO plugin's exposed step set). +EOF +) +SCALE_ISSUE_BODY=$(cat <<'EOF' +Legacy step.do_scale in workflow core was removed in workflow v0.52.0 (issue +GoCodeAlone/workflow#617). Current workaround: update instance_count in the +infra.container_service module config and re-run step.iac_apply. This issue +tracks adding a first-class step.iac_scale (config-less runtime scale). +EOF +) +gh issue create --repo GoCodeAlone/workflow-plugin-digitalocean \ + --title "Add step.iac_logs (or step.app_logs) — closes step.do_logs migration GAP from workflow#617" \ + --body "$LOGS_ISSUE_BODY" +gh issue create --repo GoCodeAlone/workflow-plugin-digitalocean \ + --title "Add step.iac_scale — closes step.do_scale migration GAP from workflow#617" \ + --body "$SCALE_ISSUE_BODY" +``` + +Capture the two issue URLs / numbers and patch the migration guide's two ` / ` placeholders + the migration error help message templates in `module/legacy_do_migration.go` if they contained placeholders. (The actual text in T3's helper does not contain URL placeholders — only the migration guide does — so this step is doc-only.) + +**Step 7: Verify the docs build / render** + +Run: `find docs -name "*.md" -exec grep -l "TODO\|` removes the modernize rule, migration guide, CHANGELOG entry. Combined with T1/T2/T3/T4 revert returns to pre-cutover state. Plugin follow-up issues remain filed (they describe genuine gaps regardless of whether this PR ships). + +--- + +## Verification per change class (summary) + +| Task | Class | Verification | +|------|-------|--------------| +| T1 | Internal-logic refactor (pure deletion + import test) | `go test ./module -run TestGodoNotImported_InModulePackage` PASS | +| T2 | Internal-logic refactor (registry edits) | `go test ./cmd/wfctl -run TestLegacyDOTypesAbsent_FromTypeRegistry` PASS + `go build ./...` clean | +| T3 | Internal-logic refactor (new error path + helper) | `go test -run 'TestLegacyDO(Module|Step)Error'` PASS | +| T4 | **Version pin update** | `go mod tidy` clean + CI `godo-banned` job PASS + `! grep ...` locally exits 0. **Rollback:** revert T4 commit; godo returns to go.mod (no runtime effect because no code uses it after T1-T3 either). | +| T5 | Documentation + new CLI rule | `go test ./modernize -run TestLegacyDORule` PASS + `grep -n "platform.do_app" DOCUMENTATION.md` returns nothing + two plugin issues filed + AWS audit issue filed | + +T4 is the only task with the runtime-launch-validation trigger (version-pin update), and the rollback note is included. + +--- + +## End-of-PR checklist (run before opening PR) + +1. `go test ./...` — all green. +2. `! grep -rn --include="*.go" --exclude-dir=_worktrees --exclude-dir=.worktrees --exclude-dir=.claude "digitalocean/godo" .` exits 0. +3. `! grep -qH "digitalocean/godo" go.mod example/go.mod` exits 0. +4. `wfctl modernize --apply ` rewrites correctly (manual smoke). +5. `go build ./cmd/wfctl && ./wfctl validate ` produces the actionable migration error and exits non-zero. +6. `go build ./cmd/server && ./server -config ` produces the same error and exits non-zero. +7. CHANGELOG.md has the v0.52.0 BREAKING entry at the top. +8. Two follow-up issues filed in `workflow-plugin-digitalocean`; URLs wired into the migration guide. +9. One follow-up issue filed in `workflow` for the AWS audit (no URL wiring needed — independent stream). +10. PR description references issue #617 and lists the breaking-change impact. + +--- + +## References + +- Design doc: `docs/plans/2026-05-13-issue-617-godo-removal-design.md` +- Issue: [GoCodeAlone/workflow#617](https://github.com/GoCodeAlone/workflow/issues/617) +- Trigger PR (Dependabot bump): [PR #421](https://github.com/GoCodeAlone/workflow/pull/421) +- Plugin: [`workflow-plugin-digitalocean`](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) v0.12.0+ +- Precedent: `feedback_force_strict_contracts_no_compat.md` (force-cutover pattern); `project_strict_contracts_cutover_complete.md` (typed-gRPC cutover, DO plugin v1.0.1) From e6b68727d753009b26a17b4fe34e0f8beb5714c8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 02:43:47 -0400 Subject: [PATCH 08/26] docs(#617): revise plan per adversarial review cycle 1 (plan phase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-1 fix: T3 engine test uses NewStdEngine(app, logger) + AddModuleType() per engine.go:146,210; package workflow. C-2 fix: T3 step test uses module.NewStepRegistry().Create() per pipeline_step_registry.go:18,32. I-1 fix: T2 test calls KnownModuleTypes() / KnownStepTypes() directly (invented buildTypeRegistry() was never a thing). I-2 fix: iacProviderLoaded is now sync/atomic.Bool with IsIaCProviderLoaded() accessor — eliminates race with parallel tests under go test -race. I-3 fix: gap-type modernize test covers all 3 gap types (do_logs, do_scale, do_networking) — previously only first two. m-1: acknowledged walkTypeNodes vs walkNodes duplication; documented intent. m-2 fix: module.RemovedInVersion constant; no more v0.52.0 sprinkled in 7+ places. m-3 fix: modernize/testdata/legacy-do-config.{,.expected}.yaml committed; end-of-PR checklist points at it. End-of-PR checklist: added mandatory `go test -race ./...`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-13-issue-617-godo-removal.md | 146 +++++++++++++----- 1 file changed, 107 insertions(+), 39 deletions(-) diff --git a/docs/plans/2026-05-13-issue-617-godo-removal.md b/docs/plans/2026-05-13-issue-617-godo-removal.md index 18d7321e..caec7dc1 100644 --- a/docs/plans/2026-05-13-issue-617-godo-removal.md +++ b/docs/plans/2026-05-13-issue-617-godo-removal.md @@ -170,22 +170,30 @@ import "testing" // cmd/wfctl/type_registry.go for issue #617. If any legacy type leaks back in, // this test fires and the CI gate fires. func TestLegacyDOTypesAbsent_FromTypeRegistry(t *testing.T) { - r := buildTypeRegistry() - legacy := []string{ + modules := KnownModuleTypes() + steps := KnownStepTypes() + legacyModules := []string{ "platform.do_app", "platform.do_database", "platform.do_dns", "platform.do_networking", "platform.doks", + } + legacySteps := []string{ "step.do_deploy", "step.do_status", "step.do_logs", "step.do_scale", "step.do_destroy", } - for _, tname := range legacy { - if _, ok := r[tname]; ok { - t.Errorf("type registry still contains legacy DO type %q (issue #617)", tname) + for _, tname := range legacyModules { + if _, ok := modules[tname]; ok { + t.Errorf("module type registry still contains legacy DO type %q (issue #617)", tname) + } + } + for _, tname := range legacySteps { + if _, ok := steps[tname]; ok { + t.Errorf("step type registry still contains legacy DO type %q (issue #617)", tname) } } } ``` -(If the existing `buildTypeRegistry` symbol differs, use the actual constructor. Verify by grep before writing the assertion target.) +(API confirmed against `cmd/wfctl/type_registry.go:25` `KnownModuleTypes()` and `:727` `KnownStepTypes()`.) **Step 2: Run test to verify it fails** @@ -252,19 +260,29 @@ EOF **Step 1: Write the failing tests** -Create `engine_legacy_do_migration_test.go` at repo root: +Create `engine_legacy_do_migration_test.go` at repo root (in-package — same package convention as `engine_test.go`): ```go -package workflow_test +package workflow import ( "strings" "testing" - "github.com/GoCodeAlone/workflow" + "github.com/GoCodeAlone/modular" "github.com/GoCodeAlone/workflow/config" ) +// newTestEngine builds an isolated StdEngine — same pattern as engine_test.go. +func newTestEngine(t *testing.T) *StdEngine { + t.Helper() + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), nil) + if err := app.Init(); err != nil { + t.Fatalf("app.Init: %v", err) + } + return NewStdEngine(app, app.Logger()) +} + func TestLegacyDOModuleError_PluginNotLoaded(t *testing.T) { cases := []struct{ legacyType, hint string }{ {"platform.do_app", "infra.container_service"}, @@ -275,7 +293,7 @@ func TestLegacyDOModuleError_PluginNotLoaded(t *testing.T) { } for _, tc := range cases { t.Run(tc.legacyType, func(t *testing.T) { - e := workflow.NewEngine() + e := newTestEngine(t) cfg := &config.WorkflowConfig{Modules: []config.ModuleConfig{{Name: "x", Type: tc.legacyType, Config: map[string]any{}}}} err := e.BuildFromConfig(cfg) if err == nil { @@ -297,9 +315,10 @@ func TestLegacyDOModuleError_PluginNotLoaded(t *testing.T) { } func TestLegacyDOModuleError_PluginLoaded(t *testing.T) { - e := workflow.NewEngine() - // Register a stub iac.provider factory to simulate workflow-plugin-digitalocean being loaded. - e.RegisterModuleFactory("iac.provider", func(name string, cfg map[string]any) interface{} { return nil }) + e := newTestEngine(t) + // Register a stub iac.provider factory to simulate workflow-plugin-digitalocean + // being loaded. ModuleFactory signature: func(name string, config map[string]any) modular.Module. + e.AddModuleType("iac.provider", func(name string, cfg map[string]any) modular.Module { return nil }) cfg := &config.WorkflowConfig{Modules: []config.ModuleConfig{{Name: "x", Type: "platform.do_app", Config: map[string]any{}}}} err := e.BuildFromConfig(cfg) @@ -316,6 +335,8 @@ func TestLegacyDOModuleError_PluginLoaded(t *testing.T) { } ``` +(APIs verified: `NewStdEngine(app modular.Application, logger modular.Logger)` at `engine.go:146`; `AddModuleType(moduleType string, factory ModuleFactory)` at `engine.go:210`; `ModuleFactory` is `func(name string, config map[string]any) modular.Module`. Test package convention matches `engine_test.go:1` — `package workflow`.) + Create `module/pipeline_step_legacy_do_migration_test.go`: ```go @@ -339,7 +360,8 @@ func TestLegacyDOStepError_PluginNotLoaded(t *testing.T) { } for _, tc := range cases { t.Run(tc.step, func(t *testing.T) { - _, err := module.CreateStep(tc.step, "x", map[string]any{}, nil) + r := module.NewStepRegistry() + _, err := r.Create(tc.step, "x", map[string]any{}, nil) if err == nil { t.Fatalf("expected error for %q", tc.step) } @@ -359,7 +381,7 @@ func TestLegacyDOStepError_PluginNotLoaded(t *testing.T) { } ``` -(Confirm the `module.CreateStep` symbol — if the actual constructor name differs, use it.) +(API verified: `module.NewStepRegistry()` at `module/pipeline_step_registry.go:18`; `(*StepRegistry).Create(stepType, name string, config map[string]any, app any)` at `:32`. Empty registry exercises the unknown-type fallback path where the legacy guard fires.) **Step 2: Run tests to verify they fail** @@ -379,6 +401,12 @@ import ( "strings" ) +// RemovedInVersion is the workflow tag that ships issue #617's force-cutover. +// Used in every legacy-DO migration error and in the wfctl modernize rule. +// Update both this constant and the docs/migrations/v-godo-removal.md +// filename when the release tag is finalised. +const RemovedInVersion = "v0.52.0" + // LegacyDOModuleTypes maps each removed legacy DigitalOcean module type to its // infra.* IaC successor (issue #617). var LegacyDOModuleTypes = map[string]string{ @@ -419,7 +447,7 @@ func FormatLegacyDOModuleError(legacyType, moduleName string, iacProviderLoaded pluginLine = "workflow-plugin-digitalocean is already loaded; your config still references the legacy module name." } var b strings.Builder - fmt.Fprintf(&b, "unsupported legacy module type %q (module %q): this type was removed from workflow core in v0.52.0 — DigitalOcean IaC moved to workflow-plugin-digitalocean.\n\n", legacyType, moduleName) + fmt.Fprintf(&b, "unsupported legacy module type %q (module %q): this type was removed from workflow core in %s — DigitalOcean IaC moved to workflow-plugin-digitalocean.\n\n", legacyType, moduleName, RemovedInVersion) b.WriteString(pluginLine) b.WriteString("\n\nMigrate this module to: ") b.WriteString(successor) @@ -448,7 +476,7 @@ func FormatLegacyDOStepError(legacyType string, iacProviderLoaded bool) error { pluginLine = "workflow-plugin-digitalocean is already loaded; your config still references the legacy step name." } var b strings.Builder - fmt.Fprintf(&b, "unsupported legacy step type %q: this step was removed from workflow core in v0.52.0 — DigitalOcean IaC moved to workflow-plugin-digitalocean.\n\n", legacyType) + fmt.Fprintf(&b, "unsupported legacy step type %q: this step was removed from workflow core in %s — DigitalOcean IaC moved to workflow-plugin-digitalocean.\n\n", legacyType, RemovedInVersion) b.WriteString(pluginLine) b.WriteString("\n\nMigrate this step to: ") b.WriteString(successor) @@ -479,22 +507,30 @@ if !exists { (Add `"github.com/GoCodeAlone/workflow/module"` to engine.go imports if not already present.) -Modify `module/pipeline_step_registry.go:35`. Since this is in the `module` package itself, the helper is callable directly. Step-registry needs to know whether the iac.provider module factory is loaded — pass a detection callback in or expose a package-level setter. Simplest implementation: a package-level boolean updated by the engine at `BuildFromConfig` time before step construction. New file `module/iac_provider_loaded.go`: +Modify `module/pipeline_step_registry.go:35`. Since this is in the `module` package itself, the helper is callable directly. Step-registry needs to know whether the iac.provider module factory is loaded — the engine sets a goroutine-safe flag before step construction. New file `module/iac_provider_loaded.go`: ```go package module +import "sync/atomic" + // iacProviderLoaded tracks whether the engine's module factory map currently // contains "iac.provider". Set by the engine before invoking step factories so -// that legacy DO step migration errors can branch on it. -var iacProviderLoaded bool +// that legacy DO step migration errors can branch on it. atomic.Bool is used +// because parallel tests in the module package access this concurrently +// (race-checked via `go test -race ./...`). +var iacProviderLoaded atomic.Bool // SetIaCProviderLoaded is called by the engine after module factory registration // is complete but before step factories run. -func SetIaCProviderLoaded(loaded bool) { iacProviderLoaded = loaded } +func SetIaCProviderLoaded(loaded bool) { iacProviderLoaded.Store(loaded) } + +// IsIaCProviderLoaded reports whether the iac.provider factory has been +// observed in the engine's factory map. +func IsIaCProviderLoaded() bool { return iacProviderLoaded.Load() } ``` -Wire it in `engine.go` BuildFromConfig just before step construction: +Wire it in `engine.go` `BuildFromConfig` just before step construction: ```go _, iacLoaded := e.moduleFactories["iac.provider"] @@ -509,7 +545,7 @@ return nil, fmt.Errorf("unknown step type: %s", stepType) // After: if IsLegacyDOStepType(stepType) { - return nil, FormatLegacyDOStepError(stepType, iacProviderLoaded) + return nil, FormatLegacyDOStepError(stepType, IsIaCProviderLoaded()) } return nil, fmt.Errorf("unknown step type: %s", stepType) ``` @@ -650,7 +686,9 @@ EOF - Create: `docs/migrations/v0.52.0-godo-removal.md` (full migration guide — 5 module mappings + 5 step mappings + GAP callouts + before/after YAML examples + step-by-step migration recipe + ADR-style "why this was done") - Create: `modernize/legacy_do_rule.go` (new modernize rules — see Step 3) - Modify: `modernize/modernize.go` `AllRules()` to append the new rule -- Test: `modernize/legacy_do_rule_test.go` (new — covers Check + Fix for each of the 5 module + 5 step rewrites) +- Test: `modernize/legacy_do_rule_test.go` (new — covers Check + Fix for each of the 5 module + 5 step rewrites + 3 gap types) +- Create: `modernize/testdata/legacy-do-config.yaml` (committed smoke-test fixture exercising every legacy type) +- Create: `modernize/testdata/legacy-do-config.expected.yaml` (the post-`modernize --apply` output, used as a golden file for the smoke test in step 9 below) **Step 1: Write the failing test** @@ -732,27 +770,38 @@ func TestLegacyDORule_Rewrites(t *testing.T) { } } -func TestLegacyDORule_LogsScaleAnnotatedNotDropped(t *testing.T) { - // step.do_logs and step.do_scale have NO 1:1 successor. Rule must: +func TestLegacyDORule_GapTypesFlaggedNotRewritten(t *testing.T) { + // step.do_logs, step.do_scale, and platform.do_networking have NO 1:1 + // auto-fixable successor. Rule must: // - flag them as findings, // - NOT modify the YAML (no silent loss). - for _, stepType := range []string{"step.do_logs", "step.do_scale"} { - t.Run(stepType, func(t *testing.T) { - in := "pipelines:\n - steps:\n - type: " + stepType + "\n" + cases := []struct { + name string + legacy string + yamlIn string + }{ + {"step.do_logs", "step.do_logs", "pipelines:\n - steps:\n - type: step.do_logs\n"}, + {"step.do_scale", "step.do_scale", "pipelines:\n - steps:\n - type: step.do_scale\n"}, + {"platform.do_networking", "platform.do_networking", "modules:\n - name: net\n type: platform.do_networking\n config: {}\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { var root yaml.Node - yaml.Unmarshal([]byte(in), &root) + if err := yaml.Unmarshal([]byte(tc.yamlIn), &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } rule := modernize.LegacyDORule() - findings := rule.Check(&root, []byte(in)) + findings := rule.Check(&root, []byte(tc.yamlIn)) if len(findings) == 0 { - t.Fatalf("expected a finding for %q", stepType) + t.Fatalf("expected a finding for %q", tc.legacy) } if findings[0].Fixable { - t.Errorf("%q must be marked Fixable: false (no auto-rewrite); got Fixable: true", stepType) + t.Errorf("%q must be marked Fixable: false (no auto-rewrite); got Fixable: true", tc.legacy) } rule.Fix(&root) out, _ := yaml.Marshal(&root) - if !strings.Contains(string(out), stepType) { - t.Errorf("Fix MUST NOT remove legacy %q; got:\n%s", stepType, out) + if !strings.Contains(string(out), tc.legacy) { + t.Errorf("Fix MUST NOT remove legacy %q; got:\n%s", tc.legacy, out) } }) } @@ -865,7 +914,11 @@ func LegacyDORule() Rule { } // walkTypeNodes traverses a YAML AST and invokes visit on every value node -// whose parent mapping key is "type". +// whose parent mapping key is "type". This differs from the package's existing +// walkNodes helper which visits every node — extracted as a separate helper +// because the type-key constraint produces tighter visitor code at call sites. +// If a future refactor unifies the two, prefer adding a key-filter parameter +// to walkNodes over keeping the duplication. func walkTypeNodes(n *yaml.Node, visit func(*yaml.Node)) { if n == nil { return @@ -1194,11 +1247,12 @@ T4 is the only task with the runtime-launch-validation trigger (version-pin upda ## End-of-PR checklist (run before opening PR) 1. `go test ./...` — all green. +1a. `go test -race ./...` — all green (mandatory because T3 introduces a package-level atomic and the module package has parallel tests). 2. `! grep -rn --include="*.go" --exclude-dir=_worktrees --exclude-dir=.worktrees --exclude-dir=.claude "digitalocean/godo" .` exits 0. 3. `! grep -qH "digitalocean/godo" go.mod example/go.mod` exits 0. -4. `wfctl modernize --apply ` rewrites correctly (manual smoke). -5. `go build ./cmd/wfctl && ./wfctl validate ` produces the actionable migration error and exits non-zero. -6. `go build ./cmd/server && ./server -config ` produces the same error and exits non-zero. +4. `wfctl modernize --apply modernize/testdata/legacy-do-config.yaml` (fixture committed in T5) rewrites legacy types — verify against `modernize/testdata/legacy-do-config.expected.yaml`. +5. `go build ./cmd/wfctl && ./wfctl validate modernize/testdata/legacy-do-config.yaml` produces the actionable migration error and exits non-zero. +6. `go build ./cmd/server && ./server -config modernize/testdata/legacy-do-config.yaml` produces the same error and exits non-zero. 7. CHANGELOG.md has the v0.52.0 BREAKING entry at the top. 8. Two follow-up issues filed in `workflow-plugin-digitalocean`; URLs wired into the migration guide. 9. One follow-up issue filed in `workflow` for the AWS audit (no URL wiring needed — independent stream). @@ -1206,6 +1260,20 @@ T4 is the only task with the runtime-launch-validation trigger (version-pin upda --- +## Adversarial review history (plan phase) + +### Cycle 1 (FAIL) — 2026-05-13 + +- **C-1** T3 engine test invented `workflow.NewEngine()` + `e.RegisterModuleFactory()` → **fixed**: use `NewStdEngine(app, app.Logger())` and `AddModuleType()` per `engine.go:146,210`; `package workflow`. +- **C-2** T3 step test invented `module.CreateStep()` → **fixed**: use `module.NewStepRegistry().Create()` per `module/pipeline_step_registry.go:18,32`. +- **I-1** T2 test invented `buildTypeRegistry()` → **fixed**: call `KnownModuleTypes()` + `KnownStepTypes()` directly. +- **I-2** Global `iacProviderLoaded` raced with parallel tests → **fixed**: `sync/atomic.Bool` + `IsIaCProviderLoaded()` accessor. +- **I-3** Missing test for `platform.do_networking` gap behaviour → **fixed**: gap-type test renamed and broadened to all three gap types. +- **m-1** New `walkTypeNodes` duplicates existing `walkNodes` → **acknowledged**: note added recommending unification in a future refactor; kept separate for now to preserve tight call-site code. +- **m-2** Version `v0.52.0` hardcoded in 7+ places → **fixed**: `module.RemovedInVersion` constant. +- **m-3** Smoke fixture not committed → **fixed**: `modernize/testdata/legacy-do-config.yaml` + `.expected.yaml` added to T5. +- End-of-PR checklist: added `go test -race ./...` and pointed checklist items 4-6 at the committed fixture. + ## References - Design doc: `docs/plans/2026-05-13-issue-617-godo-removal-design.md` From 064c5571dbe4b8f8351f5b37efa80cb9dc59ffb7 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 02:54:00 -0400 Subject: [PATCH 09/26] docs(#617): revise plan per adversarial review cycle 2 (plan phase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-1 fix (scope-limit Option 2): modernize Fix only renames type:, does NOT inject config.provider:digitalocean. Migration guide now has explicit manual provider-add step + example YAML + error string user will see. C-2 fix: cmd/wfctl/deploy.go added to T2 (platform.* prefix collector + "no platform.* modules" error message — both updated to include infra.*). I-1: newTestEngine intentional plugin omission documented. I-2: T5 includes comment-hygiene cleanup for hasPlatformModules / isInfraType. m-1 fix: newTestEngine uses mockLogger{} matching engine_test.go pattern. m-2 fix: legacyDORemovedInVersion duplicated in modernize package (import cycle prevents shared constant) with keep-in-sync comment. m-3 fix: AWS issue body now derives in-scope list from a runtime grep rather than copying speculative names. Cycle 1 plan-phase fixes verified to hold. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-13-issue-617-godo-removal.md | 150 ++++++++++++++---- 1 file changed, 116 insertions(+), 34 deletions(-) diff --git a/docs/plans/2026-05-13-issue-617-godo-removal.md b/docs/plans/2026-05-13-issue-617-godo-removal.md index caec7dc1..7cb05633 100644 --- a/docs/plans/2026-05-13-issue-617-godo-removal.md +++ b/docs/plans/2026-05-13-issue-617-godo-removal.md @@ -153,6 +153,7 @@ EOF - Modify: `cmd/wfctl/infra.go:577` — change `return t == "infra.container_service" || t == "platform.do_app"` to `return t == "infra.container_service"`. - Modify: `cmd/wfctl/deploy_providers.go:419-424` — drop the `"platform.do_app"` line from the `deployTargetTypes` slice. - Modify: `cmd/wfctl/ci_run_dryrun.go:178-183` — drop the `"platform.do_app"` line from the `deployTargetTypes` slice. +- Modify: `cmd/wfctl/deploy.go:839,901` — the `wfctl deploy cloud` subcommand collects modules via `strings.HasPrefix(m.Type, "platform.")` and errors with `"no platform.* modules found"` when none match. Post-cutover the user's modern config uses `infra.*` types; both call sites must include `infra.*` as well. Replace the prefix check with `strings.HasPrefix(m.Type, "platform.") || strings.HasPrefix(m.Type, "infra.")` and update the error message to `"no platform.* or infra.* modules found in config — nothing to deploy"`. Header comment on line 781 updated to reference both prefixes. - Modify: `module/multi_region.go:123` — replace the error message text (see Step 3). - Modify: `cmd/wfctl/infra_apply_test.go:1990` — the negative-test YAML fixture uses `type: platform.do_app`. Replace with `type: example.legacy_unknown` (a synthetic type that will never be registered) so the test's intent (negative coverage for unknown types) is preserved without referencing a removed type. - Test: `cmd/wfctl/legacy_do_types_removed_test.go` (new — asserts the type registry no longer contains the legacy keys) @@ -273,16 +274,32 @@ import ( "github.com/GoCodeAlone/workflow/config" ) -// newTestEngine builds an isolated StdEngine — same pattern as engine_test.go. +// newTestEngine builds an isolated StdEngine with no plugins loaded — required +// so that the iac.provider factory-map lookup is deterministically false in +// the "plugin not loaded" test, and so that the manual AddModuleType stub in +// the "plugin loaded" test is the only factory in the map. This intentionally +// differs from setupEngineTest (engine_test.go), which calls loadAllPlugins. +// Uses mock.Logger to match the existing test infrastructure. func newTestEngine(t *testing.T) *StdEngine { t.Helper() - app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), nil) + logger := &mockLogger{} + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) if err := app.Init(); err != nil { t.Fatalf("app.Init: %v", err) } - return NewStdEngine(app, app.Logger()) + return NewStdEngine(app, logger) } +// mockLogger is a no-op modular.Logger satisfying the interface needed for +// engine construction. Mirrors the test mock used elsewhere in this package +// (search for `mockLogger` in engine_test.go for the canonical shape). +type mockLogger struct{} + +func (mockLogger) Debug(string, ...any) {} +func (mockLogger) Info(string, ...any) {} +func (mockLogger) Warn(string, ...any) {} +func (mockLogger) Error(string, ...any) {} + func TestLegacyDOModuleError_PluginNotLoaded(t *testing.T) { cases := []struct{ legacyType, hint string }{ {"platform.do_app", "infra.container_service"}, @@ -689,6 +706,7 @@ EOF - Test: `modernize/legacy_do_rule_test.go` (new — covers Check + Fix for each of the 5 module + 5 step rewrites + 3 gap types) - Create: `modernize/testdata/legacy-do-config.yaml` (committed smoke-test fixture exercising every legacy type) - Create: `modernize/testdata/legacy-do-config.expected.yaml` (the post-`modernize --apply` output, used as a golden file for the smoke test in step 9 below) +- Modify: `cmd/wfctl/infra_apply.go:130-131` + `cmd/wfctl/infra.go:460` — comment hygiene: drop the "legacy DigitalOcean" phrasing in `hasPlatformModules` / `isInfraType` rationale comments. Both functions remain correct for the surviving `platform.*` types (e.g., `platform.kubernetes`, `platform.ecs`); only the DO-specific framing is stale. **Step 1: Write the failing test** @@ -713,7 +731,7 @@ func TestLegacyDORule_Rewrites(t *testing.T) { wantDrop string // must NOT appear in fixed YAML (the legacy type) }{ { - name: "platform.do_app → infra.container_service", + name: "platform.do_app → infra.container_service (provider NOT auto-injected)", yamlIn: "modules:\n - name: api\n type: platform.do_app\n config:\n region: nyc\n", wantNew: "infra.container_service", wantDrop: "platform.do_app", @@ -826,10 +844,30 @@ import ( "gopkg.in/yaml.v3" ) +// legacyDORemovedInVersion intentionally duplicates module.RemovedInVersion +// because importing the module package from modernize would create an import +// cycle (modernize is a peer to module, and module-side tests can already +// transitively pull modernize via the wfctl command). Keep the two constants +// in sync when bumping the release tag. +const legacyDORemovedInVersion = "v0.52.0" + // LegacyDORule rewrites legacy DigitalOcean module + step types to their -// infra.* IaC successors (issue #617). Auto-fixable for 5 modules and 3 of 5 -// steps; the two GAP steps (step.do_logs, step.do_scale) are flagged but not -// modified, because they have no 1:1 pipeline-step successor. +// infra.* IaC successors (issue #617). +// +// IMPORTANT: The Fix function ONLY renames the `type:` key — it does NOT +// inject the required `config.provider: digitalocean` setting, because that +// requires modifying a sibling mapping that may already contain unrelated +// keys the operator must review. The rule's Check Message and the migration +// guide both instruct the operator to add the provider key manually after +// running modernize. The committed `testdata/legacy-do-config.expected.yaml` +// fixture asserts the post-modernize shape: types renamed, provider NOT +// auto-added. Adding provider injection in a future iteration is tracked as +// a follow-up (see migration guide). +// +// Auto-fixable for 4 of 5 modules (platform.do_app/database/dns/doks) and +// 3 of 5 steps (step.do_deploy/status/destroy). The GAP types (do_networking +// splits 1→2; step.do_logs/scale have no pipeline-step successor) are flagged +// but not modified. func LegacyDORule() Rule { moduleMap := map[string]string{ "platform.do_app": "infra.container_service", @@ -862,7 +900,7 @@ func LegacyDORule() Rule { out = append(out, Finding{ RuleID: "legacy-do-types", Line: typeVal.Line, - Message: fmt.Sprintf("%s removed in v0.52.0; rewrite to %s (provider: digitalocean) — requires workflow-plugin-digitalocean", typeVal.Value, successor), + Message: fmt.Sprintf("%s removed in %s; rewrite to %s (provider: digitalocean) — requires workflow-plugin-digitalocean", typeVal.Value, legacyDORemovedInVersion, successor), Fixable: true, }) } @@ -870,7 +908,7 @@ func LegacyDORule() Rule { out = append(out, Finding{ RuleID: "legacy-do-types", Line: typeVal.Line, - Message: fmt.Sprintf("%s removed in v0.52.0; rewrite to %s — requires workflow-plugin-digitalocean", typeVal.Value, successor), + Message: fmt.Sprintf("%s removed in %s; rewrite to %s — requires workflow-plugin-digitalocean", typeVal.Value, legacyDORemovedInVersion, successor), Fixable: true, }) } @@ -878,7 +916,7 @@ func LegacyDORule() Rule { out = append(out, Finding{ RuleID: "legacy-do-types", Line: typeVal.Line, - Message: fmt.Sprintf("%s removed in v0.52.0 — %s", typeVal.Value, reason), + Message: fmt.Sprintf("%s removed in %s — %s", typeVal.Value, legacyDORemovedInVersion, reason), Fixable: false, }) } @@ -989,7 +1027,7 @@ Prepend to `CHANGELOG.md`: ### Migration -DigitalOcean IaC moved to [`workflow-plugin-digitalocean`](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) v0.12.0+. After loading the plugin, replace legacy module types with `infra.*` types and `provider: digitalocean`. Run `wfctl modernize --apply ` to auto-rewrite supported types. Two step types (`step.do_logs`, `step.do_scale`) have no 1:1 pipeline successor — workarounds documented in the [v0.52.0 migration guide](docs/migrations/v0.52.0-godo-removal.md). +DigitalOcean IaC moved to [`workflow-plugin-digitalocean`](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) v0.12.0+. After loading the plugin, replace legacy module types with `infra.*` types and `provider: digitalocean`. Run `wfctl modernize --apply ` to auto-rewrite supported types — **then manually add `provider: digitalocean` to each rewritten module's `config:` block** (the modernize rule does not inject the provider key; see the [migration guide](docs/migrations/v0.52.0-godo-removal.md) for the exact recipe). Two step types (`step.do_logs`, `step.do_scale`) have no 1:1 pipeline successor — workarounds documented in the migration guide. Configs that still reference the legacy types now fail to load with an actionable error pointing to the plugin and the relevant `infra.*` successor. ``` @@ -1031,13 +1069,39 @@ the design doc at `docs/plans/2026-05-13-issue-617-godo-removal-design.md`. ```sh wfctl modernize --apply ./config/*.yaml ``` - This rewrites the 5 module types and 3 step types automatically. Two step - types (`step.do_logs`, `step.do_scale`) and one module type - (`platform.do_networking`) are flagged but not auto-rewritten — see below. + This **renames the type field** for 4 module types and 3 step types + automatically. Two step types (`step.do_logs`, `step.do_scale`) and one + module type (`platform.do_networking`) are flagged but not auto-rewritten + — see below. + +3. **Add `provider: digitalocean` to each rewritten module's `config:` + block.** The modernize rule does NOT auto-inject this key, because the + `config:` block typically contains operator-authored settings that + shouldn't be silently modified. Example: + + ```yaml + # After modernize (type renamed, provider absent): + modules: + - name: api + type: infra.container_service + config: + region: nyc # <-- modernize left this alone + + # Operator adds provider key manually: + modules: + - name: api + type: infra.container_service + config: + provider: digitalocean # <-- ADD THIS + region: nyc + ``` + + Forgetting this produces a load-time error: + `infra module "api" (infra.container_service): 'provider' config is required`. -3. Manually address the GAP types listed below. +4. Manually address the GAP types listed below. -4. Re-run `wfctl validate` and `wfctl infra plan` to confirm the rewritten +5. Re-run `wfctl validate` and `wfctl infra plan` to confirm the rewritten config loads and produces the same plan. ## Module type mapping @@ -1185,31 +1249,38 @@ EOF **Step 9: File the AWS audit follow-up issue in `GoCodeAlone/workflow`** +**Before writing the issue body, regenerate the in-scope file list from the current tree** rather than copying speculative names from the design: + +```sh +# Discover actual aws-sdk-go-v2 importers in module/: +grep -rln "github.com/aws/aws-sdk-go-v2" --include="*.go" module/ | sort +# Also list drivers: +ls platform/providers/aws/drivers/*.go 2>/dev/null +# RBAC + non-IaC stays: +ls iam/aws*.go plugin/rbac/aws*.go artifact/s3*.go module/iac_state_spaces.go provider/aws/deploy.go 2>/dev/null +``` + +Then write the issue body. Template (replace the `<...>` placeholders with the actual grep output): + ```bash AWS_BODY=$(cat <<'EOF' Continuation of #617. The DO half of the SDK audit shipped in v0.52.0 (godo -gone from core). This issue tracks the AWS half: - -In scope: -- `module/platform_ecs.go`, `module/cloud_account_aws*.go`, - `module/platform_apigateway.go`, `module/codebuild.go`, - `module/nosql_dynamodb.go`, `module/platform_kubernetes_kind.go`, - `module/platform_networking.go`, `module/platform_autoscaling.go`, - `module/platform_dns_backends.go`, `module/aws_api_gateway.go`, - `module/s3_storage.go`, `module/storage_artifact_s3.go`, - `module/pipeline_step_s3_upload.go` and the IaC drivers under - `platform/providers/aws/` — review for move to workflow-plugin-aws - using the same Option A force-cutover pattern. +gone from core). This issue tracks the AWS half. + +In scope (move to workflow-plugin-aws via the same Option A force-cutover +pattern used for #617): + Out of scope (justified non-IaC core surfaces; STAY in core): -- `iam/aws.go` — RBAC integration. -- `plugin/rbac/aws.go` — RBAC plugin glue. -- `artifact/s3.go` — generic artifact storage backend (S3-compat). -- `provider/aws/deploy.go` — IaC adapter (revisit if it's a thin wrapper). -- `module/iac_state_spaces.go` — S3-compat state backend (DO Spaces too). +- `iam/aws.go` — RBAC integration +- `plugin/rbac/aws.go` — RBAC plugin glue +- `artifact/s3.go` — generic S3-compat artifact storage +- `provider/aws/deploy.go` — IaC adapter (revisit if thin wrapper) +- `module/iac_state_spaces.go` — S3-compat state backend (also used by DO Spaces) Goal: same as #617 — Dependabot bumps for AWS SDKs target the provider -plugin repo, not core, except for the non-IaC surfaces above. +plugin repo, not core, except for the surfaces above. EOF ) gh issue create --repo GoCodeAlone/workflow \ @@ -1262,6 +1333,17 @@ T4 is the only task with the runtime-launch-validation trigger (version-pin upda ## Adversarial review history (plan phase) +### Cycle 2 (FAIL) — 2026-05-13 + +- **C-1** `wfctl modernize` Fix renamed `type:` but did not inject `config.provider: digitalocean` → produced YAML that fails to load → **fixed by scope-limit (Option 2)**: rule explicitly does not inject the provider key; the migration guide adds a manual step with example YAML and the error string the user will hit; rule docstring + test names + expected fixture all assert the scope-limited behaviour. +- **C-2** `cmd/wfctl/deploy.go:839,901` had its own `strings.HasPrefix(m.Type, "platform.")` collector + "no platform.* modules found" error — missed in T2 scope → **fixed**: file added to T2's edit list; both call sites updated to include `infra.*` prefix. +- **I-1** `newTestEngine` differs from `setupEngineTest` (no `loadAllPlugins`) — intentional but not documented → **fixed**: comment added explaining the intentional divergence. +- **I-2** `hasPlatformModules` + `isInfraType` rationale comments still cite DigitalOcean → **fixed**: comment-hygiene edit added to T5. +- **m-1** `newTestEngine` passed `nil` logger to `NewStdApplication`; deviated from existing test pattern → **fixed**: use `mockLogger{}` matching the canonical shape in engine_test.go. +- **m-2** `RemovedInVersion` declared in `module/` but hardcoded again in `modernize/` (import cycle prevents reuse) → **fixed**: explicit duplicate `legacyDORemovedInVersion` in modernize with a documented "keep in sync" comment. +- **m-3** AWS audit issue body invented speculative file names → **fixed**: T5 Step 9 now runs the grep BEFORE writing the body and uses the grep output to populate the in-scope list. +- **Cycle 1 fixes verified to hold** — no regressions introduced. + ### Cycle 1 (FAIL) — 2026-05-13 - **C-1** T3 engine test invented `workflow.NewEngine()` + `e.RegisterModuleFactory()` → **fixed**: use `NewStdEngine(app, app.Logger())` and `AddModuleType()` per `engine.go:146,210`; `package workflow`. From 249179e4c65f00d32841bfdffc259d4552bcc629 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 03:01:00 -0400 Subject: [PATCH 10/26] docs(#617): revise plan per adversarial review cycle 3 (plan phase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-1 fix: drop redeclared mockLogger from engine_legacy_do_migration_test.go; reuse the existing in-package type from engine_test.go:482. C-2 fix: drop legacyDORemovedInVersion duplicate; no import cycle exists (verified via go list). modernize now imports module and uses module.RemovedInVersion directly. Single source of truth. I-1 fix: add TestLegacyDOStepError_PluginLoaded (was missing — only not-loaded branch was tested for steps). m-1 fix: actions/checkout@v5 → @v4 (repo standard). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-13-issue-617-godo-removal.md | 63 ++++++++++++------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/docs/plans/2026-05-13-issue-617-godo-removal.md b/docs/plans/2026-05-13-issue-617-godo-removal.md index 7cb05633..52a2ebe2 100644 --- a/docs/plans/2026-05-13-issue-617-godo-removal.md +++ b/docs/plans/2026-05-13-issue-617-godo-removal.md @@ -279,7 +279,9 @@ import ( // the "plugin not loaded" test, and so that the manual AddModuleType stub in // the "plugin loaded" test is the only factory in the map. This intentionally // differs from setupEngineTest (engine_test.go), which calls loadAllPlugins. -// Uses mock.Logger to match the existing test infrastructure. +// Reuses the `mockLogger` type already defined in engine_test.go — both files +// are in package workflow so the type is visible at compile time. DO NOT +// redeclare it here. func newTestEngine(t *testing.T) *StdEngine { t.Helper() logger := &mockLogger{} @@ -290,16 +292,6 @@ func newTestEngine(t *testing.T) *StdEngine { return NewStdEngine(app, logger) } -// mockLogger is a no-op modular.Logger satisfying the interface needed for -// engine construction. Mirrors the test mock used elsewhere in this package -// (search for `mockLogger` in engine_test.go for the canonical shape). -type mockLogger struct{} - -func (mockLogger) Debug(string, ...any) {} -func (mockLogger) Info(string, ...any) {} -func (mockLogger) Warn(string, ...any) {} -func (mockLogger) Error(string, ...any) {} - func TestLegacyDOModuleError_PluginNotLoaded(t *testing.T) { cases := []struct{ legacyType, hint string }{ {"platform.do_app", "infra.container_service"}, @@ -377,6 +369,8 @@ func TestLegacyDOStepError_PluginNotLoaded(t *testing.T) { } for _, tc := range cases { t.Run(tc.step, func(t *testing.T) { + module.SetIaCProviderLoaded(false) + defer module.SetIaCProviderLoaded(false) r := module.NewStepRegistry() _, err := r.Create(tc.step, "x", map[string]any{}, nil) if err == nil { @@ -396,6 +390,25 @@ func TestLegacyDOStepError_PluginNotLoaded(t *testing.T) { }) } } + +func TestLegacyDOStepError_PluginLoaded(t *testing.T) { + // Symmetric to TestLegacyDOModuleError_PluginLoaded — flips the global flag + // and confirms the step guard's "already loaded" branch fires. + module.SetIaCProviderLoaded(true) + defer module.SetIaCProviderLoaded(false) + r := module.NewStepRegistry() + _, err := r.Create("step.do_deploy", "x", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "already loaded") { + t.Errorf("plugin-loaded branch must say 'already loaded'; got: %s", msg) + } + if strings.Contains(msg, "Install workflow-plugin-digitalocean") { + t.Errorf("plugin-loaded branch must NOT instruct install; got: %s", msg) + } +} ``` (API verified: `module.NewStepRegistry()` at `module/pipeline_step_registry.go:18`; `(*StepRegistry).Create(stepType, name string, config map[string]any, app any)` at `:32`. Empty registry exercises the unknown-type fallback path where the legacy guard fires.) @@ -646,7 +659,7 @@ Modify `.github/workflows/ci.yml` to add a job (placed near `golangci-lint`): name: Verify godo is not imported (issue #617) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 - name: Grep gate — *.go files must not import godo run: | ! grep -rn --include="*.go" \ @@ -841,15 +854,13 @@ package modernize import ( "fmt" + "github.com/GoCodeAlone/workflow/module" "gopkg.in/yaml.v3" ) -// legacyDORemovedInVersion intentionally duplicates module.RemovedInVersion -// because importing the module package from modernize would create an import -// cycle (modernize is a peer to module, and module-side tests can already -// transitively pull modernize via the wfctl command). Keep the two constants -// in sync when bumping the release tag. -const legacyDORemovedInVersion = "v0.52.0" +// (Import cycle check: `modernize` imports schema + yaml; `module` does NOT +// transitively pull `modernize`. Confirmed by `go list -f '{{ join .Imports +// "\n" }}'` on both packages.) // LegacyDORule rewrites legacy DigitalOcean module + step types to their // infra.* IaC successors (issue #617). @@ -900,7 +911,7 @@ func LegacyDORule() Rule { out = append(out, Finding{ RuleID: "legacy-do-types", Line: typeVal.Line, - Message: fmt.Sprintf("%s removed in %s; rewrite to %s (provider: digitalocean) — requires workflow-plugin-digitalocean", typeVal.Value, legacyDORemovedInVersion, successor), + Message: fmt.Sprintf("%s removed in %s; rewrite to %s (provider: digitalocean) — requires workflow-plugin-digitalocean", typeVal.Value, module.RemovedInVersion, successor), Fixable: true, }) } @@ -908,7 +919,7 @@ func LegacyDORule() Rule { out = append(out, Finding{ RuleID: "legacy-do-types", Line: typeVal.Line, - Message: fmt.Sprintf("%s removed in %s; rewrite to %s — requires workflow-plugin-digitalocean", typeVal.Value, legacyDORemovedInVersion, successor), + Message: fmt.Sprintf("%s removed in %s; rewrite to %s — requires workflow-plugin-digitalocean", typeVal.Value, module.RemovedInVersion, successor), Fixable: true, }) } @@ -916,7 +927,7 @@ func LegacyDORule() Rule { out = append(out, Finding{ RuleID: "legacy-do-types", Line: typeVal.Line, - Message: fmt.Sprintf("%s removed in %s — %s", typeVal.Value, legacyDORemovedInVersion, reason), + Message: fmt.Sprintf("%s removed in %s — %s", typeVal.Value, module.RemovedInVersion, reason), Fixable: false, }) } @@ -1333,6 +1344,14 @@ T4 is the only task with the runtime-launch-validation trigger (version-pin upda ## Adversarial review history (plan phase) +### Cycle 3 (FAIL) — 2026-05-13 + +- **C-1** Plan declared `type mockLogger struct{}` in `engine_legacy_do_migration_test.go` (same package as `engine_test.go:482` which already declares it) → compile error → **fixed**: redeclaration removed; helper reuses the existing in-package type. +- **C-2** `legacyDORemovedInVersion` duplicate was justified by a falsely claimed import cycle (`go list -f '{{ join .Imports "\n" }}'` confirmed no cycle) → **fixed**: dropped the duplicate; modernize/legacy_do_rule.go now imports `module` and references `module.RemovedInVersion` directly. Single source of truth. +- **I-1** Step "already loaded" branch had no test → **fixed**: added `TestLegacyDOStepError_PluginLoaded` symmetric to the engine equivalent. +- **m-1** CI snippet pinned `actions/checkout@v5` which doesn't exist (repo uses `@v4` everywhere) → **fixed**. +- **Cycle 1 and Cycle 2 plan-phase fixes verified to hold.** + ### Cycle 2 (FAIL) — 2026-05-13 - **C-1** `wfctl modernize` Fix renamed `type:` but did not inject `config.provider: digitalocean` → produced YAML that fails to load → **fixed by scope-limit (Option 2)**: rule explicitly does not inject the provider key; the migration guide adds a manual step with example YAML and the error string the user will hit; rule docstring + test names + expected fixture all assert the scope-limited behaviour. @@ -1340,7 +1359,7 @@ T4 is the only task with the runtime-launch-validation trigger (version-pin upda - **I-1** `newTestEngine` differs from `setupEngineTest` (no `loadAllPlugins`) — intentional but not documented → **fixed**: comment added explaining the intentional divergence. - **I-2** `hasPlatformModules` + `isInfraType` rationale comments still cite DigitalOcean → **fixed**: comment-hygiene edit added to T5. - **m-1** `newTestEngine` passed `nil` logger to `NewStdApplication`; deviated from existing test pattern → **fixed**: use `mockLogger{}` matching the canonical shape in engine_test.go. -- **m-2** `RemovedInVersion` declared in `module/` but hardcoded again in `modernize/` (import cycle prevents reuse) → **fixed**: explicit duplicate `legacyDORemovedInVersion` in modernize with a documented "keep in sync" comment. +- **m-2** `RemovedInVersion` declared in `module/` but hardcoded again in `modernize/` (import cycle prevents reuse) → **fixed**: explicit duplicate `module.RemovedInVersion` in modernize with a documented "keep in sync" comment. - **m-3** AWS audit issue body invented speculative file names → **fixed**: T5 Step 9 now runs the grep BEFORE writing the body and uses the grep output to populate the in-scope list. - **Cycle 1 fixes verified to hold** — no regressions introduced. From 15d16940c8592e5f01d50e0480733adbd331b476 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 03:09:50 -0400 Subject: [PATCH 11/26] docs(#617): revise plan per adversarial review cycle 4 (plan phase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-1 fix: extract shared constants/formatters to internal/legacydo leaf package. Earlier cycle's "no import cycle" claim was wrong: module→plugin→modernize is a real transitive chain (verified via go list -deps). modernize cannot import module. Both packages now import only the leaf legacydo package. I-1 fix: replace package-level atomic.Bool iacProviderLoaded global with StepRegistry instance field. Per-registry state; parallel tests can own fresh NewStepRegistry() instances; no global mutation between tests. Engine sets the field via r.SetIaCProviderLoaded(loaded) just before pipeline construction. I-2 fix: design doc drops the credential-registry-zero-DO-entries test (unimplementable — credentialResolvers is unexported). Rationale: registry is additive via init(); deleting file removes init() — self- evidencing. No API-surface-for-test added. m-1 fix: T2 spec includes rename of platformModules local variable to deployTargetModules in cmd/wfctl/deploy.go. Cycle 1/2/3 plan-phase fixes verified to hold. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-13-issue-617-godo-removal-design.md | 2 +- .../2026-05-13-issue-617-godo-removal.md | 156 ++++++++++-------- 2 files changed, 86 insertions(+), 72 deletions(-) diff --git a/docs/plans/2026-05-13-issue-617-godo-removal-design.md b/docs/plans/2026-05-13-issue-617-godo-removal-design.md index a60fc7fc..d5bd3cf0 100644 --- a/docs/plans/2026-05-13-issue-617-godo-removal-design.md +++ b/docs/plans/2026-05-13-issue-617-godo-removal-design.md @@ -261,7 +261,7 @@ This change affects build, package version, and runtime config loading. Rollback ## Open questions (none blocking — autonomous pipeline proceeds) - Should the migration error be a hard error or a warning + skip? **Decision (autonomous):** hard error. A silently-skipped module is worse than a failed load; goal #3 mandates actionable errors. Re-open if adversarial review pushes back. -- Should `cloud_account_do.go` deletion include removing the registered resolver names (`digitalocean/static`, `digitalocean/env`, `digitalocean/api_token`) from any global registry to prevent dead config keys? **Decision (autonomous):** yes — the registry is purely additive via init(); deleting the file removes the init(). Add a test that the credential registry has zero `digitalocean/*` entries post-deletion. +- Should `cloud_account_do.go` deletion include removing the registered resolver names (`digitalocean/static`, `digitalocean/env`, `digitalocean/api_token`) from any global registry to prevent dead config keys? **Decision (autonomous):** the registry is purely additive via `init()`; deleting the file removes the `init()` call, which is itself the evidence that no DO resolver is registered. No separate test is added — `credentialResolvers` is unexported (`module/cloud_credential_resolver.go:14`) and adding an exported accessor solely for a one-shot self-evidencing assertion is API-surface-for-test-only. Verified instead by the build (no godo importer remains) and by the migration error path (which fires when a `cloud.account` with `provider: digitalocean` is loaded but no DO resolver is registered). ## Adversarial review history diff --git a/docs/plans/2026-05-13-issue-617-godo-removal.md b/docs/plans/2026-05-13-issue-617-godo-removal.md index 52a2ebe2..1e27a7e4 100644 --- a/docs/plans/2026-05-13-issue-617-godo-removal.md +++ b/docs/plans/2026-05-13-issue-617-godo-removal.md @@ -153,7 +153,7 @@ EOF - Modify: `cmd/wfctl/infra.go:577` — change `return t == "infra.container_service" || t == "platform.do_app"` to `return t == "infra.container_service"`. - Modify: `cmd/wfctl/deploy_providers.go:419-424` — drop the `"platform.do_app"` line from the `deployTargetTypes` slice. - Modify: `cmd/wfctl/ci_run_dryrun.go:178-183` — drop the `"platform.do_app"` line from the `deployTargetTypes` slice. -- Modify: `cmd/wfctl/deploy.go:839,901` — the `wfctl deploy cloud` subcommand collects modules via `strings.HasPrefix(m.Type, "platform.")` and errors with `"no platform.* modules found"` when none match. Post-cutover the user's modern config uses `infra.*` types; both call sites must include `infra.*` as well. Replace the prefix check with `strings.HasPrefix(m.Type, "platform.") || strings.HasPrefix(m.Type, "infra.")` and update the error message to `"no platform.* or infra.* modules found in config — nothing to deploy"`. Header comment on line 781 updated to reference both prefixes. +- Modify: `cmd/wfctl/deploy.go:839,901` — the `wfctl deploy cloud` subcommand collects modules via `strings.HasPrefix(m.Type, "platform.")` and errors with `"no platform.* modules found"` when none match. Post-cutover the user's modern config uses `infra.*` types; both call sites must include `infra.*` as well. Replace the prefix check with `strings.HasPrefix(m.Type, "platform.") || strings.HasPrefix(m.Type, "infra.")` and update the error message to `"no platform.* or infra.* modules found in config — nothing to deploy"`. Header comment on line 781 updated to reference both prefixes. **Rename the local slice variable `platformModules` to `deployTargetModules`** in the same edit so the name reflects what it now contains. - Modify: `module/multi_region.go:123` — replace the error message text (see Step 3). - Modify: `cmd/wfctl/infra_apply_test.go:1990` — the negative-test YAML fixture uses `type: platform.do_app`. Replace with `type: example.legacy_unknown` (a synthetic type that will never be registered) so the test's intent (negative coverage for unknown types) is preserved without referencing a removed type. - Test: `cmd/wfctl/legacy_do_types_removed_test.go` (new — asserts the type registry no longer contains the legacy keys) @@ -255,7 +255,7 @@ EOF **Files:** - Modify: `engine.go:508` — replace the single `unknown module type` error with a legacy-DO-aware branch (see Step 3). - Modify: `module/pipeline_step_registry.go:35` — replace the single `unknown step type` error with the same legacy-DO-aware branch for step types. -- Create: `module/legacy_do_migration.go` — small package-internal helper holding the legacy-type lookup table and the formatted message builders. Shared between the engine module-path and the step-registry step-path. +- Create: `internal/legacydo/types.go` — **leaf package** containing the legacy-type lookup tables, the `RemovedInVersion` constant, and the message-formatter helpers. Lives in `internal/` so neither `module/` nor `modernize/` transitively imports it via any indirect path. Both packages import it directly: `module/` for the engine + step guard wiring; `modernize/` for the rewrite rule. **Architectural reason:** `module` transitively imports `modernize` via `plugin` (`go list -deps github.com/GoCodeAlone/workflow/module | grep modernize` returns a match — `plugin/manifest.go` and `plugin/engine_plugin.go` both import `modernize`). Therefore `modernize` cannot import `module` directly; a shared leaf package is the only cycle-free way to share the constants. - Test: `engine_legacy_do_migration_test.go` (new — covers all 5 module types × {plugin loaded, plugin not loaded}) - Test: `module/pipeline_step_legacy_do_migration_test.go` (new — covers all 5 step types × {plugin loaded, plugin not loaded}) @@ -369,9 +369,7 @@ func TestLegacyDOStepError_PluginNotLoaded(t *testing.T) { } for _, tc := range cases { t.Run(tc.step, func(t *testing.T) { - module.SetIaCProviderLoaded(false) - defer module.SetIaCProviderLoaded(false) - r := module.NewStepRegistry() + r := module.NewStepRegistry() // fresh registry — iacProviderLoaded defaults to false _, err := r.Create(tc.step, "x", map[string]any{}, nil) if err == nil { t.Fatalf("expected error for %q", tc.step) @@ -392,11 +390,10 @@ func TestLegacyDOStepError_PluginNotLoaded(t *testing.T) { } func TestLegacyDOStepError_PluginLoaded(t *testing.T) { - // Symmetric to TestLegacyDOModuleError_PluginLoaded — flips the global flag - // and confirms the step guard's "already loaded" branch fires. - module.SetIaCProviderLoaded(true) - defer module.SetIaCProviderLoaded(false) + // Symmetric to TestLegacyDOModuleError_PluginLoaded — flips the per-registry + // flag and confirms the step guard's "already loaded" branch fires. r := module.NewStepRegistry() + r.SetIaCProviderLoaded(true) _, err := r.Create("step.do_deploy", "x", map[string]any{}, nil) if err == nil { t.Fatal("expected error") @@ -420,10 +417,15 @@ Expected: FAIL — the engine currently emits the generic `"unknown module type **Step 3: Implement the migration helper and wire it into both error paths** -Create `module/legacy_do_migration.go`: +Create `internal/legacydo/types.go` (leaf package — imports only stdlib, so both `module/` and `modernize/` can import it without cycles): ```go -package module +// Package legacydo holds the read-only data and message formatters for the +// legacy DigitalOcean module + step types removed in issue #617. Lives in +// internal/ so that both module/ and modernize/ can import it without a +// cycle (module transitively imports modernize via plugin, so modernize +// cannot import module). +package legacydo import ( "fmt" @@ -437,9 +439,9 @@ import ( // filename when the release tag is finalised. const RemovedInVersion = "v0.52.0" -// LegacyDOModuleTypes maps each removed legacy DigitalOcean module type to its +// ModuleTypes maps each removed legacy DigitalOcean module type to its // infra.* IaC successor (issue #617). -var LegacyDOModuleTypes = map[string]string{ +var ModuleTypes = map[string]string{ "platform.do_app": "infra.container_service", "platform.do_database": "infra.database", "platform.do_dns": "infra.dns", @@ -447,9 +449,9 @@ var LegacyDOModuleTypes = map[string]string{ "platform.doks": "infra.k8s_cluster", } -// LegacyDOStepTypes maps each removed legacy DigitalOcean step type to its +// StepTypes maps each removed legacy DigitalOcean step type to its // successor or to a workaround when no 1:1 successor exists. -var LegacyDOStepTypes = map[string]string{ +var StepTypes = map[string]string{ "step.do_deploy": "step.iac_apply (against an infra.container_service module)", "step.do_status": "step.iac_status (against an infra.container_service module)", "step.do_destroy": "step.iac_destroy (against an infra.container_service module)", @@ -457,18 +459,18 @@ var LegacyDOStepTypes = map[string]string{ "step.do_scale": "no direct pipeline-step equivalent; update instance_count in the infra.container_service module config and re-run step.iac_apply", } -// IsLegacyDOModuleType reports whether t is a removed legacy DO module type. -func IsLegacyDOModuleType(t string) bool { _, ok := LegacyDOModuleTypes[t]; return ok } +// IsModuleType reports whether t is a removed legacy DO module type. +func IsModuleType(t string) bool { _, ok := ModuleTypes[t]; return ok } -// IsLegacyDOStepType reports whether t is a removed legacy DO step type. -func IsLegacyDOStepType(t string) bool { _, ok := LegacyDOStepTypes[t]; return ok } +// IsStepType reports whether t is a removed legacy DO step type. +func IsStepType(t string) bool { _, ok := StepTypes[t]; return ok } -// FormatLegacyDOModuleError builds the actionable migration error for a legacy +// FormatModuleError builds the actionable migration error for a legacy // DO module type. iacProviderLoaded indicates whether the iac.provider factory // is registered in the engine — used to branch between the "install plugin" // and "config-only issue" messages. -func FormatLegacyDOModuleError(legacyType, moduleName string, iacProviderLoaded bool) error { - successor, ok := LegacyDOModuleTypes[legacyType] +func FormatModuleError(legacyType, moduleName string, iacProviderLoaded bool) error { + successor, ok := ModuleTypes[legacyType] if !ok { return nil } @@ -482,22 +484,22 @@ func FormatLegacyDOModuleError(legacyType, moduleName string, iacProviderLoaded b.WriteString("\n\nMigrate this module to: ") b.WriteString(successor) b.WriteString(" (provider: digitalocean)\n\nFull mapping:\n") - keys := make([]string, 0, len(LegacyDOModuleTypes)) - for k := range LegacyDOModuleTypes { + keys := make([]string, 0, len(ModuleTypes)) + for k := range ModuleTypes { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { - fmt.Fprintf(&b, " %s → %s\n", k, LegacyDOModuleTypes[k]) + fmt.Fprintf(&b, " %s → %s\n", k, ModuleTypes[k]) } b.WriteString("\nSee docs/migrations/v0.52.0-godo-removal.md") return fmt.Errorf("%s", b.String()) } -// FormatLegacyDOStepError builds the actionable migration error for a legacy +// FormatStepError builds the actionable migration error for a legacy // DO step type. -func FormatLegacyDOStepError(legacyType string, iacProviderLoaded bool) error { - successor, ok := LegacyDOStepTypes[legacyType] +func FormatStepError(legacyType string, iacProviderLoaded bool) error { + successor, ok := StepTypes[legacyType] if !ok { return nil } @@ -527,58 +529,59 @@ if !exists { // After: factory, exists := e.moduleFactories[modCfg.Type] if !exists { - if module.IsLegacyDOModuleType(modCfg.Type) { + if legacydo.IsModuleType(modCfg.Type) { _, iacLoaded := e.moduleFactories["iac.provider"] - return module.FormatLegacyDOModuleError(modCfg.Type, modCfg.Name, iacLoaded) + return legacydo.FormatModuleError(modCfg.Type, modCfg.Name, iacLoaded) } return fmt.Errorf("unknown module type %q for module %q — ensure the required plugin is loaded", modCfg.Type, modCfg.Name) } ``` -(Add `"github.com/GoCodeAlone/workflow/module"` to engine.go imports if not already present.) +(Add `"github.com/GoCodeAlone/workflow/internal/legacydo"` to engine.go imports.) -Modify `module/pipeline_step_registry.go:35`. Since this is in the `module` package itself, the helper is callable directly. Step-registry needs to know whether the iac.provider module factory is loaded — the engine sets a goroutine-safe flag before step construction. New file `module/iac_provider_loaded.go`: +For the step path, **avoid the package-level global** that cycle 4 reviewer flagged as a logic-race risk: instead, attach the `iacProviderLoaded` boolean to the `StepRegistry` as a field set by the engine before pipeline construction. Modify `module/pipeline_step_registry.go`: ```go -package module - -import "sync/atomic" - -// iacProviderLoaded tracks whether the engine's module factory map currently -// contains "iac.provider". Set by the engine before invoking step factories so -// that legacy DO step migration errors can branch on it. atomic.Bool is used -// because parallel tests in the module package access this concurrently -// (race-checked via `go test -race ./...`). -var iacProviderLoaded atomic.Bool +// Add to StepRegistry struct (around line 13): +type StepRegistry struct { + factories map[string]StepFactory + iacProviderLoaded bool // set by SetIaCProviderLoaded; consumed by Create +} +// New method on StepRegistry: // SetIaCProviderLoaded is called by the engine after module factory registration -// is complete but before step factories run. -func SetIaCProviderLoaded(loaded bool) { iacProviderLoaded.Store(loaded) } +// is complete and before pipeline construction. Per-registry state — no global — +// so parallel test runs that build independent StepRegistry instances do not +// share or race the flag. +func (r *StepRegistry) SetIaCProviderLoaded(loaded bool) { + r.iacProviderLoaded = loaded +} -// IsIaCProviderLoaded reports whether the iac.provider factory has been -// observed in the engine's factory map. -func IsIaCProviderLoaded() bool { return iacProviderLoaded.Load() } +// Modify (r *StepRegistry).Create at line 32: +func (r *StepRegistry) Create(stepType, name string, config map[string]any, app any) (PipelineStep, error) { + factory, ok := r.factories[stepType] + if !ok { + if legacydo.IsStepType(stepType) { + return nil, legacydo.FormatStepError(stepType, r.iacProviderLoaded) + } + return nil, fmt.Errorf("unknown step type: %s", stepType) + } + return factory(name, config, app) +} ``` -Wire it in `engine.go` `BuildFromConfig` just before step construction: +Wire it in `engine.go` `BuildFromConfig` just before step construction. The engine already has a `stepRegistry *module.StepRegistry` field (verify; if it constructs a fresh one each call, this hook moves to that constructor). Pattern: ```go _, iacLoaded := e.moduleFactories["iac.provider"] -module.SetIaCProviderLoaded(iacLoaded) +e.stepRegistry.SetIaCProviderLoaded(iacLoaded) ``` -And use it in `module/pipeline_step_registry.go:35`: +If the engine does not already retain a `stepRegistry` field, add one to `StdEngine` and route step creation through it. **No package-level global, no atomic.Bool.** -```go -// Before: -return nil, fmt.Errorf("unknown step type: %s", stepType) +(Add `"github.com/GoCodeAlone/workflow/internal/legacydo"` to pipeline_step_registry.go imports.) -// After: -if IsLegacyDOStepType(stepType) { - return nil, FormatLegacyDOStepError(stepType, IsIaCProviderLoaded()) -} -return nil, fmt.Errorf("unknown step type: %s", stepType) -``` +(The Create-method patch above replaces the previous `return nil, fmt.Errorf("unknown step type: %s", stepType)` at line 35.) **Step 4: Run tests to verify they pass** @@ -591,7 +594,7 @@ Expected: PASS overall (the existing tests untouched by T1/T2/T3 should still pa **Step 5: Commit** ```bash -git add module/legacy_do_migration.go module/iac_provider_loaded.go \ +git add internal/legacydo/ \ engine.go module/pipeline_step_registry.go \ engine_legacy_do_migration_test.go module/pipeline_step_legacy_do_migration_test.go git commit -m "$(cat <<'EOF' @@ -854,13 +857,15 @@ package modernize import ( "fmt" - "github.com/GoCodeAlone/workflow/module" + "github.com/GoCodeAlone/workflow/internal/legacydo" "gopkg.in/yaml.v3" ) -// (Import cycle check: `modernize` imports schema + yaml; `module` does NOT -// transitively pull `modernize`. Confirmed by `go list -f '{{ join .Imports -// "\n" }}'` on both packages.) +// Import note: `modernize` MUST NOT import `module` directly. `module` +// transitively imports `modernize` via `plugin` (plugin/manifest.go + +// plugin/engine_plugin.go), so `modernize → module` creates an import cycle. +// Shared constants live in `internal/legacydo`, a leaf package that imports +// only stdlib and is safe for both `module` and `modernize` to consume. // LegacyDORule rewrites legacy DigitalOcean module + step types to their // infra.* IaC successors (issue #617). @@ -911,7 +916,7 @@ func LegacyDORule() Rule { out = append(out, Finding{ RuleID: "legacy-do-types", Line: typeVal.Line, - Message: fmt.Sprintf("%s removed in %s; rewrite to %s (provider: digitalocean) — requires workflow-plugin-digitalocean", typeVal.Value, module.RemovedInVersion, successor), + Message: fmt.Sprintf("%s removed in %s; rewrite to %s (provider: digitalocean) — requires workflow-plugin-digitalocean", typeVal.Value, legacydo.RemovedInVersion, successor), Fixable: true, }) } @@ -919,7 +924,7 @@ func LegacyDORule() Rule { out = append(out, Finding{ RuleID: "legacy-do-types", Line: typeVal.Line, - Message: fmt.Sprintf("%s removed in %s; rewrite to %s — requires workflow-plugin-digitalocean", typeVal.Value, module.RemovedInVersion, successor), + Message: fmt.Sprintf("%s removed in %s; rewrite to %s — requires workflow-plugin-digitalocean", typeVal.Value, legacydo.RemovedInVersion, successor), Fixable: true, }) } @@ -927,7 +932,7 @@ func LegacyDORule() Rule { out = append(out, Finding{ RuleID: "legacy-do-types", Line: typeVal.Line, - Message: fmt.Sprintf("%s removed in %s — %s", typeVal.Value, module.RemovedInVersion, reason), + Message: fmt.Sprintf("%s removed in %s — %s", typeVal.Value, legacydo.RemovedInVersion, reason), Fixable: false, }) } @@ -1227,7 +1232,7 @@ gh issue create --repo GoCodeAlone/workflow-plugin-digitalocean \ --body "$SCALE_ISSUE_BODY" ``` -Capture the two issue URLs / numbers and patch the migration guide's two ` / ` placeholders + the migration error help message templates in `module/legacy_do_migration.go` if they contained placeholders. (The actual text in T3's helper does not contain URL placeholders — only the migration guide does — so this step is doc-only.) +Capture the two issue URLs / numbers and patch the migration guide's two ` / ` placeholders. (The error text in `internal/legacydo/types.go` does not contain URL placeholders — only the migration guide does — so this step is doc-only.) **Step 7: Verify the docs build / render** @@ -1344,10 +1349,19 @@ T4 is the only task with the runtime-launch-validation trigger (version-pin upda ## Adversarial review history (plan phase) +### Cycle 4 (FAIL) — 2026-05-13 + +- **C-1** Cycle 3's "no import cycle" claim was wrong — `module` transitively imports `modernize` via `plugin` (`go list -deps github.com/GoCodeAlone/workflow/module | grep modernize` returns `modernize` because `plugin/manifest.go` and `plugin/engine_plugin.go` import it). Therefore `modernize → module` IS a cycle → **fixed**: shared constants/formatters moved to a new leaf package `internal/legacydo/types.go` that imports only stdlib. Both `module/` (via the engine guard) and `modernize/` (via the rewrite rule) import `internal/legacydo` cycle-free. +- **I-1** Package-level `atomic.Bool iacProviderLoaded` is a logic-race surface (atomic.Bool blocks data-race detector but not test-order non-determinism when multiple tests mutate the flag) → **fixed**: replaced the global with a `StepRegistry.iacProviderLoaded` instance field; `r.SetIaCProviderLoaded(loaded)` sets it, `r.Create` reads it. Per-registry state; parallel tests can each own a fresh `NewStepRegistry()`. +- **I-2** Design doc proposed an "assert credential registry has zero `digitalocean/*` entries" test, but `credentialResolvers` is unexported and there is no accessor → **fixed (option a)**: design doc rewritten to remove the proposed test; rationale "registry is additive via init(); deleting file removes init()" is the evidence; no API-for-test-only added. +- **m-1** `platformModules` local variable in `deploy.go` would carry `infra.*` items after T2 — misleading name → **fixed**: T2 spec now includes the rename to `deployTargetModules`. +- **m-2** `newTestEngine` couples to `package workflow` for `mockLogger` visibility → **acknowledged as informational** in cycle 3; no plan change. Current package structure makes the coupling correct. +- **Cycle 1/2/3 plan-phase fixes verified to hold.** + ### Cycle 3 (FAIL) — 2026-05-13 - **C-1** Plan declared `type mockLogger struct{}` in `engine_legacy_do_migration_test.go` (same package as `engine_test.go:482` which already declares it) → compile error → **fixed**: redeclaration removed; helper reuses the existing in-package type. -- **C-2** `legacyDORemovedInVersion` duplicate was justified by a falsely claimed import cycle (`go list -f '{{ join .Imports "\n" }}'` confirmed no cycle) → **fixed**: dropped the duplicate; modernize/legacy_do_rule.go now imports `module` and references `module.RemovedInVersion` directly. Single source of truth. +- **C-2** `legacyDORemovedInVersion` duplicate was justified by a falsely claimed import cycle (`go list -f '{{ join .Imports "\n" }}'` confirmed no cycle) → **fixed**: dropped the duplicate; modernize/legacy_do_rule.go now imports `module` and references `legacydo.RemovedInVersion` directly. Single source of truth. - **I-1** Step "already loaded" branch had no test → **fixed**: added `TestLegacyDOStepError_PluginLoaded` symmetric to the engine equivalent. - **m-1** CI snippet pinned `actions/checkout@v5` which doesn't exist (repo uses `@v4` everywhere) → **fixed**. - **Cycle 1 and Cycle 2 plan-phase fixes verified to hold.** @@ -1359,7 +1373,7 @@ T4 is the only task with the runtime-launch-validation trigger (version-pin upda - **I-1** `newTestEngine` differs from `setupEngineTest` (no `loadAllPlugins`) — intentional but not documented → **fixed**: comment added explaining the intentional divergence. - **I-2** `hasPlatformModules` + `isInfraType` rationale comments still cite DigitalOcean → **fixed**: comment-hygiene edit added to T5. - **m-1** `newTestEngine` passed `nil` logger to `NewStdApplication`; deviated from existing test pattern → **fixed**: use `mockLogger{}` matching the canonical shape in engine_test.go. -- **m-2** `RemovedInVersion` declared in `module/` but hardcoded again in `modernize/` (import cycle prevents reuse) → **fixed**: explicit duplicate `module.RemovedInVersion` in modernize with a documented "keep in sync" comment. +- **m-2** `RemovedInVersion` declared in `module/` but hardcoded again in `modernize/` (import cycle prevents reuse) → **fixed**: explicit duplicate `legacydo.RemovedInVersion` in modernize with a documented "keep in sync" comment. - **m-3** AWS audit issue body invented speculative file names → **fixed**: T5 Step 9 now runs the grep BEFORE writing the body and uses the grep output to populate the in-scope list. - **Cycle 1 fixes verified to hold** — no regressions introduced. @@ -1371,7 +1385,7 @@ T4 is the only task with the runtime-launch-validation trigger (version-pin upda - **I-2** Global `iacProviderLoaded` raced with parallel tests → **fixed**: `sync/atomic.Bool` + `IsIaCProviderLoaded()` accessor. - **I-3** Missing test for `platform.do_networking` gap behaviour → **fixed**: gap-type test renamed and broadened to all three gap types. - **m-1** New `walkTypeNodes` duplicates existing `walkNodes` → **acknowledged**: note added recommending unification in a future refactor; kept separate for now to preserve tight call-site code. -- **m-2** Version `v0.52.0` hardcoded in 7+ places → **fixed**: `module.RemovedInVersion` constant. +- **m-2** Version `v0.52.0` hardcoded in 7+ places → **fixed**: `legacydo.RemovedInVersion` constant. - **m-3** Smoke fixture not committed → **fixed**: `modernize/testdata/legacy-do-config.yaml` + `.expected.yaml` added to T5. - End-of-PR checklist: added `go test -race ./...` and pointed checklist items 4-6 at the committed fixture. From a019f13f662a7e8c4b25e738422881307e6fc08d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 03:16:01 -0400 Subject: [PATCH 12/26] docs(#617): revise plan per adversarial review cycle 5 (plan phase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-1 fix: schema.ValidateConfig fires at engine.go:400 BEFORE the factory loop at :506. Removing legacy DO types from schema/schema.go alone would cause the generic schema error to mask the actionable migration message. T3 now appends legacydo.ModuleTypes + StepTypes to schema.WithExtra{Module,Step}Types so schema passes them through to the factory guard — the real rejection point. I-1 fix: e.stepRegistry is interfaces.StepRegistrar; SetIaCProviderLoaded is not on the interface. Plan now uses the type-assertion pattern from engine.go:163,216 (matches precedent; interface NOT widened). I-2 fix: stale "T3 introduces a package-level atomic" comment in the end-of-PR checklist updated to reflect the per-registry instance field. m-1 fix: legacyDORule() unexported (matches peers); test in internal package modernize (matches sibling test files); external modernize import dropped. Cycle 1-4 plan-phase fixes verified to hold. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-13-issue-617-godo-removal.md | 55 +++++++++++++++---- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/docs/plans/2026-05-13-issue-617-godo-removal.md b/docs/plans/2026-05-13-issue-617-godo-removal.md index 1e27a7e4..e17b74a2 100644 --- a/docs/plans/2026-05-13-issue-617-godo-removal.md +++ b/docs/plans/2026-05-13-issue-617-godo-removal.md @@ -537,6 +537,28 @@ if !exists { } ``` +**Schema-validation ordering caveat (critical):** `schema.ValidateConfig(cfg, valOpts...)` at `engine.go:400` runs BEFORE the factory loop at `:506`. After T2 removes the five legacy DO types from `schema/schema.go`'s allow-list, schema validation will reject the config with the generic `"unknown module type"` schema error before the factory guard ever runs — making `legacydo.FormatModuleError` unreachable for module types. To fix, **add the five legacy DO module types to the `WithExtraModuleTypes` call** so schema validation passes them through and the factory-lookup guard becomes the rejection point: + +```go +// Modify engine.go around line 393-398: +if len(e.moduleFactories) > 0 || true { // always add extras for legacy DO types + extra := make([]string, 0, len(e.moduleFactories)+len(legacydo.ModuleTypes)) + for t := range e.moduleFactories { + extra = append(extra, t) + } + // Pass legacy DO module types through schema so the factory-loop guard + // (which emits legacydo.FormatModuleError) is the rejection point — + // schema rejection produces a generic error and would mask the + // actionable migration message (issue #617). + for t := range legacydo.ModuleTypes { + extra = append(extra, t) + } + valOpts = append(valOpts, schema.WithExtraModuleTypes(extra...)) +} +``` + +The same applies to step types — schema validation runs `WithExtraStepTypes` too. Inspect the existing wiring around `engine.go:380-400` and add an equivalent loop appending `legacydo.StepTypes` keys to the step-types extras. The exact placement depends on whether `WithExtraStepTypes` is already called; if not, add the call. + (Add `"github.com/GoCodeAlone/workflow/internal/legacydo"` to engine.go imports.) For the step path, **avoid the package-level global** that cycle 4 reviewer flagged as a logic-race risk: instead, attach the `iacProviderLoaded` boolean to the `StepRegistry` as a field set by the engine before pipeline construction. Modify `module/pipeline_step_registry.go`: @@ -570,14 +592,18 @@ func (r *StepRegistry) Create(stepType, name string, config map[string]any, app } ``` -Wire it in `engine.go` `BuildFromConfig` just before step construction. The engine already has a `stepRegistry *module.StepRegistry` field (verify; if it constructs a fresh one each call, this hook moves to that constructor). Pattern: +Wire it in `engine.go` `BuildFromConfig` just before step construction. The engine field is `stepRegistry interfaces.StepRegistrar` at `engine.go:73`; `SetIaCProviderLoaded` is a method on `*module.StepRegistry`, NOT on the `StepRegistrar` interface. Use the same type-assertion pattern already used elsewhere in `engine.go:163,216`: ```go _, iacLoaded := e.moduleFactories["iac.provider"] -e.stepRegistry.SetIaCProviderLoaded(iacLoaded) +if r, ok := e.stepRegistry.(*module.StepRegistry); ok { + r.SetIaCProviderLoaded(iacLoaded) +} ``` -If the engine does not already retain a `stepRegistry` field, add one to `StdEngine` and route step creation through it. **No package-level global, no atomic.Bool.** +(Do NOT extend the `StepRegistrar` interface — the method is private wiring between engine and the concrete registry; widening the interface adds a method burden to every alternate `StepRegistrar` implementor downstream for no benefit. The type-assertion pattern matches the precedent.) + +**No package-level global, no atomic.Bool.** (Add `"github.com/GoCodeAlone/workflow/internal/legacydo"` to pipeline_step_registry.go imports.) @@ -729,13 +755,12 @@ EOF Create `modernize/legacy_do_rule_test.go`: ```go -package modernize_test +package modernize import ( "strings" "testing" - "github.com/GoCodeAlone/workflow/modernize" "gopkg.in/yaml.v3" ) @@ -777,7 +802,7 @@ func TestLegacyDORule_Rewrites(t *testing.T) { wantDrop: "step.do_deploy", }, } - rule := modernize.LegacyDORule() + rule := legacyDORule() for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var root yaml.Node @@ -824,7 +849,7 @@ func TestLegacyDORule_GapTypesFlaggedNotRewritten(t *testing.T) { if err := yaml.Unmarshal([]byte(tc.yamlIn), &root); err != nil { t.Fatalf("unmarshal: %v", err) } - rule := modernize.LegacyDORule() + rule := legacyDORule() findings := rule.Check(&root, []byte(tc.yamlIn)) if len(findings) == 0 { t.Fatalf("expected a finding for %q", tc.legacy) @@ -845,7 +870,7 @@ func TestLegacyDORule_GapTypesFlaggedNotRewritten(t *testing.T) { **Step 2: Run tests to verify they fail** Run: `go test ./modernize/... -run TestLegacyDORule -v` -Expected: FAIL with "undefined: modernize.LegacyDORule". +Expected: FAIL with "undefined: legacyDORule". **Step 3: Implement the rule** @@ -884,7 +909,7 @@ import ( // 3 of 5 steps (step.do_deploy/status/destroy). The GAP types (do_networking // splits 1→2; step.do_logs/scale have no pipeline-step successor) are flagged // but not modified. -func LegacyDORule() Rule { +func legacyDORule() Rule { moduleMap := map[string]string{ "platform.do_app": "infra.container_service", "platform.do_database": "infra.database", @@ -1005,7 +1030,7 @@ return []Rule{ emptyRoutesRule(), camelCaseConfigRule(), requestParseConfigRule(), - LegacyDORule(), // <-- ADD + legacyDORule(), // <-- ADD } ``` @@ -1334,7 +1359,7 @@ T4 is the only task with the runtime-launch-validation trigger (version-pin upda ## End-of-PR checklist (run before opening PR) 1. `go test ./...` — all green. -1a. `go test -race ./...` — all green (mandatory because T3 introduces a package-level atomic and the module package has parallel tests). +1a. `go test -race ./...` — all green (the `module` package has parallel tests; while T3's per-registry instance field eliminates the global, `-race` is still mandatory to catch any future regression and to verify the engine→stepRegistry hook is goroutine-safe). 2. `! grep -rn --include="*.go" --exclude-dir=_worktrees --exclude-dir=.worktrees --exclude-dir=.claude "digitalocean/godo" .` exits 0. 3. `! grep -qH "digitalocean/godo" go.mod example/go.mod` exits 0. 4. `wfctl modernize --apply modernize/testdata/legacy-do-config.yaml` (fixture committed in T5) rewrites legacy types — verify against `modernize/testdata/legacy-do-config.expected.yaml`. @@ -1349,6 +1374,14 @@ T4 is the only task with the runtime-launch-validation trigger (version-pin upda ## Adversarial review history (plan phase) +### Cycle 5 (FAIL) — 2026-05-13 + +- **C-1** `schema.ValidateConfig` at `engine.go:400` fires BEFORE the factory loop at `:506` — removing the 5 legacy module types from `schema/schema.go`'s allow-list (T2) would cause the generic schema error to be returned ahead of the actionable `legacydo.FormatModuleError`, making the migration message unreachable → **fixed**: T3 wiring now adds the 5 legacy DO module types (and 5 step types) to `schema.WithExtraModuleTypes` / `WithExtraStepTypes` so schema passes them through to the factory guard, which is the real rejection point. +- **I-1** Plan wrote `e.stepRegistry.SetIaCProviderLoaded(iacLoaded)` but `e.stepRegistry` is `interfaces.StepRegistrar` (no such method on the interface) → would not compile → **fixed**: type-assertion pattern from `engine.go:163,216`: `if r, ok := e.stepRegistry.(*module.StepRegistry); ok { r.SetIaCProviderLoaded(iacLoaded) }`. Interface deliberately NOT widened — that would add a method burden to every downstream `StepRegistrar` for zero benefit. +- **I-2** End-of-PR checklist 1a still cited "T3 introduces a package-level atomic" — stale from cycle-3's pre-instance-field design → **fixed**. +- **m-1** `LegacyDORule()` was exported but all peer rule constructors (`hyphenStepsRule`, `dbQueryModeRule`, …) are unexported, and existing tests use `package modernize` (not `_test`) → **fixed**: renamed to `legacyDORule`; test file now uses internal `package modernize`; external `modernize` import dropped from the test. +- **Cycle 1/2/3/4 plan-phase fixes verified to hold.** + ### Cycle 4 (FAIL) — 2026-05-13 - **C-1** Cycle 3's "no import cycle" claim was wrong — `module` transitively imports `modernize` via `plugin` (`go list -deps github.com/GoCodeAlone/workflow/module | grep modernize` returns `modernize` because `plugin/manifest.go` and `plugin/engine_plugin.go` import it). Therefore `modernize → module` IS a cycle → **fixed**: shared constants/formatters moved to a new leaf package `internal/legacydo/types.go` that imports only stdlib. Both `module/` (via the engine guard) and `modernize/` (via the rewrite rule) import `internal/legacydo` cycle-free. From 5653c46c1c6b062cc6342ef8695d477f1906f8fe Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 03:24:03 -0400 Subject: [PATCH 13/26] docs(#617): revise plan per adversarial review cycle 6 (plan phase) C-1 fix (two parts): - Phantom schema.WithExtraStepTypes: schema.ValidateConfig only checks module types, not step types. Step migration guard at StepRegistry.Create is correctly the sole gate. Step-types schema-injection sentence/loop deleted from T3. - wfctl validate path: cmd/wfctl/validate.go and ci_validate.go call schema.ValidateConfig directly (not via engine.BuildFromConfig). Without a hook, AC3 fails on these commands. T2 now includes both files: inject legacydo.ModuleTypes into opts + add post-ValidateConfig legacy sweep emitting legacydo.Format{Module,Step}Error. I-1 fix: `if len(...) > 0 || true` replaced with unconditional code (staticcheck SA4010 was a CI lint blocker). m-1: cycle-5 history line referenced the now-removed step-types injection; implicit fix via T3 edit. Cycle 1-5 fixes verified to hold. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-13-issue-617-godo-removal.md | 77 +++++++++++++++---- 1 file changed, 61 insertions(+), 16 deletions(-) diff --git a/docs/plans/2026-05-13-issue-617-godo-removal.md b/docs/plans/2026-05-13-issue-617-godo-removal.md index e17b74a2..a116cbcf 100644 --- a/docs/plans/2026-05-13-issue-617-godo-removal.md +++ b/docs/plans/2026-05-13-issue-617-godo-removal.md @@ -154,6 +154,8 @@ EOF - Modify: `cmd/wfctl/deploy_providers.go:419-424` — drop the `"platform.do_app"` line from the `deployTargetTypes` slice. - Modify: `cmd/wfctl/ci_run_dryrun.go:178-183` — drop the `"platform.do_app"` line from the `deployTargetTypes` slice. - Modify: `cmd/wfctl/deploy.go:839,901` — the `wfctl deploy cloud` subcommand collects modules via `strings.HasPrefix(m.Type, "platform.")` and errors with `"no platform.* modules found"` when none match. Post-cutover the user's modern config uses `infra.*` types; both call sites must include `infra.*` as well. Replace the prefix check with `strings.HasPrefix(m.Type, "platform.") || strings.HasPrefix(m.Type, "infra.")` and update the error message to `"no platform.* or infra.* modules found in config — nothing to deploy"`. Header comment on line 781 updated to reference both prefixes. **Rename the local slice variable `platformModules` to `deployTargetModules`** in the same edit so the name reflects what it now contains. +- Modify: `cmd/wfctl/validate.go:145` — inject `legacydo.ModuleTypes` keys into the local `opts` slice via `schema.WithExtraModuleTypes(...)` before calling `schema.ValidateConfig`, then add a post-`ValidateConfig` legacy-type sweep that emits `legacydo.FormatModuleError` / `legacydo.FormatStepError` if any module / step type is legacy. Without this, schema rejects legacy types with the generic `"unknown module type"` message before the migration error can fire (cycle-6 C-1). +- Modify: `cmd/wfctl/ci_validate.go:134` — same edit pattern as `validate.go`. - Modify: `module/multi_region.go:123` — replace the error message text (see Step 3). - Modify: `cmd/wfctl/infra_apply_test.go:1990` — the negative-test YAML fixture uses `type: platform.do_app`. Replace with `type: example.legacy_unknown` (a synthetic type that will never be registered) so the test's intent (negative coverage for unknown types) is preserved without referencing a removed type. - Test: `cmd/wfctl/legacy_do_types_removed_test.go` (new — asserts the type registry no longer contains the legacy keys) @@ -537,30 +539,65 @@ if !exists { } ``` -**Schema-validation ordering caveat (critical):** `schema.ValidateConfig(cfg, valOpts...)` at `engine.go:400` runs BEFORE the factory loop at `:506`. After T2 removes the five legacy DO types from `schema/schema.go`'s allow-list, schema validation will reject the config with the generic `"unknown module type"` schema error before the factory guard ever runs — making `legacydo.FormatModuleError` unreachable for module types. To fix, **add the five legacy DO module types to the `WithExtraModuleTypes` call** so schema validation passes them through and the factory-lookup guard becomes the rejection point: +**Schema-validation ordering caveat (critical):** `schema.ValidateConfig(cfg, valOpts...)` at `engine.go:400` runs BEFORE the factory loop at `:506`. After T2 removes the five legacy DO types from `schema/schema.go`'s allow-list, schema validation will reject the config with the generic `"unknown module type"` schema error before the factory guard ever runs — making `legacydo.FormatModuleError` unreachable for module types. To fix, **add the five legacy DO module types to the `WithExtraModuleTypes` call** so schema validation passes them through and the factory-lookup guard becomes the rejection point. Restructure the existing guarded block (`engine.go:393-398`) into unconditional code (eliminates a `staticcheck SA4010` always-true-condition lint failure): ```go -// Modify engine.go around line 393-398: -if len(e.moduleFactories) > 0 || true { // always add extras for legacy DO types - extra := make([]string, 0, len(e.moduleFactories)+len(legacydo.ModuleTypes)) - for t := range e.moduleFactories { - extra = append(extra, t) - } - // Pass legacy DO module types through schema so the factory-loop guard - // (which emits legacydo.FormatModuleError) is the rejection point — - // schema rejection produces a generic error and would mask the - // actionable migration message (issue #617). - for t := range legacydo.ModuleTypes { - extra = append(extra, t) - } - valOpts = append(valOpts, schema.WithExtraModuleTypes(extra...)) +// Replace engine.go:393-398 with: +extra := make([]string, 0, len(e.moduleFactories)+len(legacydo.ModuleTypes)) +for t := range e.moduleFactories { + extra = append(extra, t) +} +// Pass legacy DO module types through schema so the factory-loop guard +// (which emits legacydo.FormatModuleError) is the rejection point — +// schema rejection produces a generic error and would mask the +// actionable migration message (issue #617). +for t := range legacydo.ModuleTypes { + extra = append(extra, t) } +valOpts = append(valOpts, schema.WithExtraModuleTypes(extra...)) ``` -The same applies to step types — schema validation runs `WithExtraStepTypes` too. Inspect the existing wiring around `engine.go:380-400` and add an equivalent loop appending `legacydo.StepTypes` keys to the step-types extras. The exact placement depends on whether `WithExtraStepTypes` is already called; if not, add the call. +**Step types do NOT need a schema-level injection:** `schema.ValidateConfig` does not validate `pipelines[*].steps[*].type` (no `WithExtraStepTypes` function exists; verified). The step migration guard at the `StepRegistry.Create` rejection point is therefore the only gate for legacy step types, which is exactly what we want. (Add `"github.com/GoCodeAlone/workflow/internal/legacydo"` to engine.go imports.) +**`wfctl validate` and `wfctl ci validate` (acceptance criterion #3):** these two commands call `schema.ValidateConfig` directly (`cmd/wfctl/validate.go:145`, `cmd/wfctl/ci_validate.go:134`) WITHOUT going through `engine.BuildFromConfig`. Without injecting `legacydo.ModuleTypes` into their local `opts` slices, they would emit the generic schema error instead of routing to the migration message. To satisfy AC3 on the validate paths, mirror the same injection in both wfctl commands. Add to **T2** (since these are wfctl-side registration / validation hooks alongside the other T2 wfctl edits): + +```go +// In cmd/wfctl/validate.go validateFile() — before line 145 schema.ValidateConfig call, +// after the existing opts slice is assembled: +for t := range legacydo.ModuleTypes { + opts = append(opts, schema.WithExtraModuleTypes(t)) +} +// Same edit in cmd/wfctl/ci_validate.go ciValidateFile() before line 134. +``` + +After these edits, `wfctl validate` will skip schema rejection for legacy DO module types — but it does not call `BuildFromConfig`, so the factory-loop migration error won't fire either. The validate command needs to ALSO emit the migration error directly. Pattern: + +```go +// After schema.ValidateConfig succeeds, add a post-pass that explicitly +// rejects legacy DO module types with the actionable message — wfctl +// validate's contract is "config is valid", and a legacy DO module type +// is NOT valid post-cutover even though we let schema pass it through. +for _, m := range cfg.Modules { + if legacydo.IsModuleType(m.Type) { + // wfctl validate has no engine, so the plugin-loaded flag is always + // false (validate doesn't know what plugins will be loaded at runtime). + return legacydo.FormatModuleError(m.Type, m.Name, false) + } +} +// Same pattern in pipeline steps: +for _, p := range cfg.Pipelines { + for _, s := range p.Steps { + if legacydo.IsStepType(s.Type) { + return legacydo.FormatStepError(s.Type, false) + } + } +} +``` + +Add `cmd/wfctl/validate.go` and `cmd/wfctl/ci_validate.go` to T2's Files list. + For the step path, **avoid the package-level global** that cycle 4 reviewer flagged as a logic-race risk: instead, attach the `iacProviderLoaded` boolean to the `StepRegistry` as a field set by the engine before pipeline construction. Modify `module/pipeline_step_registry.go`: ```go @@ -1374,6 +1411,14 @@ T4 is the only task with the runtime-launch-validation trigger (version-pin upda ## Adversarial review history (plan phase) +### Cycle 6 (FAIL) — 2026-05-13 + +- **C-1** Plan referenced a phantom `schema.WithExtraStepTypes` (no such function exists; `schema.ValidateConfig` only validates module types, not step types) → **fixed**: step-types schema injection removed; step migration guard at the `StepRegistry.Create` rejection point is the sole gate for legacy step types, which is correct because schema never validated them. +- **C-1 second part** `wfctl validate` (`cmd/wfctl/validate.go:145`) and `wfctl ci validate` (`cmd/wfctl/ci_validate.go:134`) call `schema.ValidateConfig` directly without going through `engine.BuildFromConfig`, so the migration error path is unreachable from validate → **fixed**: added both files to T2; pattern is (a) inject `legacydo.ModuleTypes` into local opts so schema passes legacy types through, (b) post-`ValidateConfig` sweep emits `legacydo.FormatModuleError` / `FormatStepError` for any legacy type found in modules / pipeline steps. AC3 now satisfied on the validate path. +- **I-1** `if len(e.moduleFactories) > 0 || true { ... }` triggers `staticcheck SA4010` always-true-condition → CI lint fails → **fixed**: replaced with unconditional code. +- **m-1** Cycle-5 history checklist line mentioned `WithExtraStepTypes` (which doesn't exist) → **fixed implicitly** by deleting the step-types schema injection from T3. +- **Cycle 1-5 plan-phase fixes verified to hold.** + ### Cycle 5 (FAIL) — 2026-05-13 - **C-1** `schema.ValidateConfig` at `engine.go:400` fires BEFORE the factory loop at `:506` — removing the 5 legacy module types from `schema/schema.go`'s allow-list (T2) would cause the generic schema error to be returned ahead of the actionable `legacydo.FormatModuleError`, making the migration message unreachable → **fixed**: T3 wiring now adds the 5 legacy DO module types (and 5 step types) to `schema.WithExtraModuleTypes` / `WithExtraStepTypes` so schema passes them through to the factory guard, which is the real rejection point. From 5f0f60a9ea67b7c5613e257520767839678511fc Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 03:31:05 -0400 Subject: [PATCH 14/26] docs(#617): revise plan per adversarial review cycle 7 (plan phase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-1 fix: validate/ci_validate post-pass step sweep was incorrect — cfg.Pipelines is map[string]any (verified config/config.go:149), not a typed slice. T2 now uses yaml.Marshal/Unmarshal pattern matching engine.go configurePipelines. Also separates ciValidateFile's accumulating errs=append from validateFile's early-return. I-1 fix: added TestValidateFile_LegacyDOModule_ReturnsActionableError and TestCIValidateFile_LegacyDOStep_ReturnsActionableError to T2 to give AC3 automated coverage on the validate path. Cycle 1-6 fixes verified to hold. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-13-issue-617-godo-removal.md | 114 +++++++++++++++++- 1 file changed, 110 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-05-13-issue-617-godo-removal.md b/docs/plans/2026-05-13-issue-617-godo-removal.md index a116cbcf..9d0a1022 100644 --- a/docs/plans/2026-05-13-issue-617-godo-removal.md +++ b/docs/plans/2026-05-13-issue-617-godo-removal.md @@ -579,6 +579,8 @@ After these edits, `wfctl validate` will skip schema rejection for legacy DO mod // rejects legacy DO module types with the actionable message — wfctl // validate's contract is "config is valid", and a legacy DO module type // is NOT valid post-cutover even though we let schema pass it through. +// +// For validate.go (return error directly): for _, m := range cfg.Modules { if legacydo.IsModuleType(m.Type) { // wfctl validate has no engine, so the plugin-loaded flag is always @@ -586,9 +588,21 @@ for _, m := range cfg.Modules { return legacydo.FormatModuleError(m.Type, m.Name, false) } } -// Same pattern in pipeline steps: -for _, p := range cfg.Pipelines { - for _, s := range p.Steps { + +// cfg.Pipelines is map[string]any (verified at config/config.go:149) — NOT a +// typed slice. Mirror the engine's existing pattern (engine.go configurePipelines): +// marshal each entry to YAML then unmarshal into config.PipelineConfig before +// accessing .Steps. The naive `p.Steps` access does not compile. +for _, rawPipeline := range cfg.Pipelines { + yamlBytes, err := yaml.Marshal(rawPipeline) + if err != nil { + continue + } + var pipeCfg config.PipelineConfig + if err := yaml.Unmarshal(yamlBytes, &pipeCfg); err != nil { + continue + } + for _, s := range pipeCfg.Steps { if legacydo.IsStepType(s.Type) { return legacydo.FormatStepError(s.Type, false) } @@ -596,7 +610,93 @@ for _, p := range cfg.Pipelines { } ``` -Add `cmd/wfctl/validate.go` and `cmd/wfctl/ci_validate.go` to T2's Files list. +For `ciValidateFile` (which returns `[]error`, accumulating), use `errs = append(errs, ...)` instead of `return`: + +```go +// In ci_validate.go ciValidateFile() — same post-pass, but accumulate: +for _, m := range cfg.Modules { + if legacydo.IsModuleType(m.Type) { + errs = append(errs, legacydo.FormatModuleError(m.Type, m.Name, false)) + } +} +for _, rawPipeline := range cfg.Pipelines { + yamlBytes, err := yaml.Marshal(rawPipeline) + if err != nil { + continue + } + var pipeCfg config.PipelineConfig + if err := yaml.Unmarshal(yamlBytes, &pipeCfg); err != nil { + continue + } + for _, s := range pipeCfg.Steps { + if legacydo.IsStepType(s.Type) { + errs = append(errs, legacydo.FormatStepError(s.Type, false)) + } + } +} +``` + +Add `cmd/wfctl/validate.go` and `cmd/wfctl/ci_validate.go` to T2's Files list (already listed above). + +**Automated test for the validate-path migration error** (T2): + +Add to `cmd/wfctl/legacy_do_types_removed_test.go`: + +```go +// TestValidateFile_LegacyDOModule_ReturnsActionableError verifies that +// wfctl validate emits the actionable migration error when the config +// references a removed legacy DO module type (issue #617). Covers AC3 +// on the validate path (the engine path is covered by +// TestLegacyDOModuleError_PluginNotLoaded in the workflow package). +func TestValidateFile_LegacyDOModule_ReturnsActionableError(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "legacy.yaml") + yaml := []byte("modules:\n - name: api\n type: platform.do_app\n config: {}\n") + if err := os.WriteFile(cfgPath, yaml, 0o600); err != nil { + t.Fatal(err) + } + err := validateFile(cfgPath) // direct call into the validate.go entry point + if err == nil { + t.Fatal("expected error for legacy DO module type") + } + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-digitalocean", + "infra.container_service", + } { + if !strings.Contains(msg, want) { + t.Errorf("error missing %q; got: %s", want, msg) + } + } +} + +// Step variant covering ciValidateFile's accumulating return. +func TestCIValidateFile_LegacyDOStep_ReturnsActionableError(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "legacy.yaml") + yaml := []byte("pipelines:\n deploy:\n steps:\n - type: step.do_deploy\n") + if err := os.WriteFile(cfgPath, yaml, 0o600); err != nil { + t.Fatal(err) + } + errs := ciValidateFile(cfgPath) + if len(errs) == 0 { + t.Fatal("expected error for legacy DO step type") + } + found := false + for _, e := range errs { + if strings.Contains(e.Error(), "step.iac_apply") && strings.Contains(e.Error(), "removed from workflow core") { + found = true + break + } + } + if !found { + t.Errorf("expected actionable migration error in errs; got: %v", errs) + } +} +``` + +(Confirm `validateFile` and `ciValidateFile` function signatures match — adapt argument list if the actual signatures take `*FileSystem` / context / different shape; the test bodies should compile against whatever the real signatures are.) For the step path, **avoid the package-level global** that cycle 4 reviewer flagged as a logic-race risk: instead, attach the `iacProviderLoaded` boolean to the `StepRegistry` as a field set by the engine before pipeline construction. Modify `module/pipeline_step_registry.go`: @@ -1411,6 +1511,12 @@ T4 is the only task with the runtime-launch-validation trigger (version-pin upda ## Adversarial review history (plan phase) +### Cycle 7 (FAIL) — 2026-05-13 + +- **C-1** validate/ci_validate post-pass step sweep used naive `for _, p := range cfg.Pipelines { for _, s := range p.Steps {` but `cfg.Pipelines` is `map[string]any` (verified at `config/config.go:149`), not `[]PipelineConfig` — won't compile → **fixed**: T2 now uses yaml.Marshal/Unmarshal pattern matching `engine.go configurePipelines`. Also split out the `ciValidateFile` accumulating variant (`errs = append`) from the `validateFile` early-return variant. +- **I-1** No automated test for the validate-path migration error (only checklist item 5 covered it manually) → **fixed**: added `TestValidateFile_LegacyDOModule_ReturnsActionableError` and `TestCIValidateFile_LegacyDOStep_ReturnsActionableError` to T2. +- **Cycle 1-6 plan-phase fixes verified to hold.** + ### Cycle 6 (FAIL) — 2026-05-13 - **C-1** Plan referenced a phantom `schema.WithExtraStepTypes` (no such function exists; `schema.ValidateConfig` only validates module types, not step types) → **fixed**: step-types schema injection removed; step migration guard at the `StepRegistry.Create` rejection point is the sole gate for legacy step types, which is correct because schema never validated them. From ed4215e7b90b096e10432ce2380c2eb04ee7c55c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 03:35:15 -0400 Subject: [PATCH 15/26] docs(#617): convert task headings to H3 for scope-manifest check plan-scope-check.sh requires "### Task N:" headings (H3); plan was using H2. PR Grouping rows reference Task 1-5 and the body must match. Now passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/plans/2026-05-13-issue-617-godo-removal.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-05-13-issue-617-godo-removal.md b/docs/plans/2026-05-13-issue-617-godo-removal.md index 9d0a1022..0e4759ac 100644 --- a/docs/plans/2026-05-13-issue-617-godo-removal.md +++ b/docs/plans/2026-05-13-issue-617-godo-removal.md @@ -35,7 +35,7 @@ --- -## Task 1: Delete legacy DO module + step files +### Task 1: Delete legacy DO module + step files **Files:** - Delete: `module/platform_do_app.go` @@ -141,7 +141,7 @@ EOF --- -## Task 2: Strip registration sites and remap detection hooks +### Task 2: Strip registration sites and remap detection hooks **Files:** - Modify: `plugins/platform/plugin.go` (drop 5 module factories + 5 step factories + 10 strings from `ModuleTypes` / `StepTypes` slices) @@ -252,7 +252,7 @@ EOF --- -## Task 3: Add load-time migration error guards (module + step) +### Task 3: Add load-time migration error guards (module + step) **Files:** - Modify: `engine.go:508` — replace the single `unknown module type` error with a legacy-DO-aware branch (see Step 3). @@ -784,7 +784,7 @@ EOF --- -## Task 4: `go mod tidy` (root + example) + CI grep gate +### Task 4: `go mod tidy` (root + example) + CI grep gate **Files:** - Modify: `go.mod` (drop `github.com/digitalocean/godo` direct require + transitive bumps via `go mod tidy`) @@ -874,7 +874,7 @@ EOF --- -## Task 5: Docs, CHANGELOG, migration guide, `wfctl modernize` rules + file follow-up issues +### Task 5: Docs, CHANGELOG, migration guide, `wfctl modernize` rules + file follow-up issues **Files:** - Modify: `DOCUMENTATION.md` (replace the 5 module rows + 5 step rows in the platform tables with a single paragraph pointing at the DO plugin) From f8ef76200a33d1e6702fadb5556d35f3bef52637 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 03:36:48 -0400 Subject: [PATCH 16/26] chore: lock scope for issue #617 godo removal (alignment passed) --- docs/plans/2026-05-13-issue-617-godo-removal.md | 2 +- docs/plans/2026-05-13-issue-617-godo-removal.md.scope-lock | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-05-13-issue-617-godo-removal.md.scope-lock diff --git a/docs/plans/2026-05-13-issue-617-godo-removal.md b/docs/plans/2026-05-13-issue-617-godo-removal.md index 0e4759ac..1386f623 100644 --- a/docs/plans/2026-05-13-issue-617-godo-removal.md +++ b/docs/plans/2026-05-13-issue-617-godo-removal.md @@ -31,7 +31,7 @@ |------|-------|-------|--------| | 1 | feat: remove godo from core (issue #617) | Task 1, Task 2, Task 3, Task 4, Task 5 | `feat/issue-617-godo-removal` | -**Status:** Draft +**Status:** Locked 2026-05-13T00:00:00Z --- diff --git a/docs/plans/2026-05-13-issue-617-godo-removal.md.scope-lock b/docs/plans/2026-05-13-issue-617-godo-removal.md.scope-lock new file mode 100644 index 00000000..f4496d70 --- /dev/null +++ b/docs/plans/2026-05-13-issue-617-godo-removal.md.scope-lock @@ -0,0 +1 @@ +7fcc5df5daafc2911a0df9c0a165057ac59b3cfb467ff98b71b1b2fa9c74f6a2 From 589ef78e4734db78c426b939934e61cb893625e2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 03:40:11 -0400 Subject: [PATCH 17/26] feat(#617): delete legacy DO modules (godo importers) Removes 12 files / ~3206 LOC. Registration sites cleaned in T2. * platform_do_app.go + test * platform_do_database.go + test * platform_do_dns.go + test * platform_do_networking.go + test * platform_doks.go + test * cloud_account_do.go (DO credential resolvers + doClient()) * pipeline_step_do.go (5 DO App Platform step types) Adds godo_absent_test.go as a regression gate inside module/. --- module/cloud_account_do.go | 74 ----- module/godo_absent_test.go | 30 ++ module/pipeline_step_do.go | 220 ------------- module/platform_do_app.go | 430 -------------------------- module/platform_do_app_test.go | 399 ------------------------ module/platform_do_database.go | 263 ---------------- module/platform_do_database_test.go | 66 ---- module/platform_do_dns.go | 357 --------------------- module/platform_do_dns_test.go | 270 ---------------- module/platform_do_networking.go | 370 ---------------------- module/platform_do_networking_test.go | 264 ---------------- module/platform_doks.go | 329 -------------------- module/platform_doks_test.go | 164 ---------- 13 files changed, 30 insertions(+), 3206 deletions(-) delete mode 100644 module/cloud_account_do.go create mode 100644 module/godo_absent_test.go delete mode 100644 module/pipeline_step_do.go delete mode 100644 module/platform_do_app.go delete mode 100644 module/platform_do_app_test.go delete mode 100644 module/platform_do_database.go delete mode 100644 module/platform_do_database_test.go delete mode 100644 module/platform_do_dns.go delete mode 100644 module/platform_do_dns_test.go delete mode 100644 module/platform_do_networking.go delete mode 100644 module/platform_do_networking_test.go delete mode 100644 module/platform_doks.go delete mode 100644 module/platform_doks_test.go diff --git a/module/cloud_account_do.go b/module/cloud_account_do.go deleted file mode 100644 index 12748c1c..00000000 --- a/module/cloud_account_do.go +++ /dev/null @@ -1,74 +0,0 @@ -package module - -import ( - "context" - "fmt" - "os" - - "github.com/digitalocean/godo" - "golang.org/x/oauth2" -) - -func init() { - RegisterCredentialResolver(&doStaticResolver{}) - RegisterCredentialResolver(&doEnvResolver{}) - RegisterCredentialResolver(&doAPITokenResolver{}) -} - -// doStaticResolver resolves DigitalOcean credentials from static config fields. -type doStaticResolver struct{} - -func (r *doStaticResolver) Provider() string { return "digitalocean" } -func (r *doStaticResolver) CredentialType() string { return "static" } - -func (r *doStaticResolver) Resolve(m *CloudAccount) error { - credsMap, _ := m.config["credentials"].(map[string]any) - if credsMap != nil { - m.creds.Token, _ = credsMap["token"].(string) - } - return nil -} - -// doEnvResolver resolves DigitalOcean credentials from environment variables. -type doEnvResolver struct{} - -func (r *doEnvResolver) Provider() string { return "digitalocean" } -func (r *doEnvResolver) CredentialType() string { return "env" } - -func (r *doEnvResolver) Resolve(m *CloudAccount) error { - m.creds.Token = os.Getenv("DIGITALOCEAN_TOKEN") - if m.creds.Token == "" { - m.creds.Token = os.Getenv("DO_TOKEN") - } - return nil -} - -// doAPITokenResolver resolves a DigitalOcean API token from explicit config. -type doAPITokenResolver struct{} - -func (r *doAPITokenResolver) Provider() string { return "digitalocean" } -func (r *doAPITokenResolver) CredentialType() string { return "api_token" } - -func (r *doAPITokenResolver) Resolve(m *CloudAccount) error { - credsMap, _ := m.config["credentials"].(map[string]any) - if credsMap == nil { - return fmt.Errorf("api_token credential requires 'token'") - } - token, _ := credsMap["token"].(string) - if token == "" { - return fmt.Errorf("api_token credential requires 'token'") - } - m.creds.Token = token - return nil -} - -// doClient returns a configured *godo.Client using the Token credential. -// The caller must have resolved credentials with provider=digitalocean before calling this. -func (m *CloudAccount) doClient() (*godo.Client, error) { - if m.creds == nil || m.creds.Token == "" { - return nil, fmt.Errorf("cloud.account %q: DigitalOcean token not set", m.name) - } - ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: m.creds.Token}) - httpClient := oauth2.NewClient(context.Background(), ts) - return godo.NewClient(httpClient), nil -} diff --git a/module/godo_absent_test.go b/module/godo_absent_test.go new file mode 100644 index 00000000..75a61531 --- /dev/null +++ b/module/godo_absent_test.go @@ -0,0 +1,30 @@ +package module_test + +import ( + "go/parser" + "go/token" + "path/filepath" + "strings" + "testing" +) + +// TestGodoNotImported_InModulePackage asserts no file under module/ imports +// github.com/digitalocean/godo. This is the regression gate for issue #617. +func TestGodoNotImported_InModulePackage(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatalf("glob: %v", err) + } + fset := token.NewFileSet() + for _, f := range files { + af, err := parser.ParseFile(fset, f, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", f, err) + } + for _, imp := range af.Imports { + if strings.Trim(imp.Path.Value, `"`) == "github.com/digitalocean/godo" { + t.Errorf("%s imports github.com/digitalocean/godo (issue #617 — moved to workflow-plugin-digitalocean)", f) + } + } + } +} diff --git a/module/pipeline_step_do.go b/module/pipeline_step_do.go deleted file mode 100644 index 7340c479..00000000 --- a/module/pipeline_step_do.go +++ /dev/null @@ -1,220 +0,0 @@ -package module - -import ( - "context" - "fmt" - - "github.com/GoCodeAlone/modular" -) - -// ─── do_deploy ──────────────────────────────────────────────────────────────── - -// DODeployStep deploys an app to DigitalOcean App Platform. -type DODeployStep struct { - name string - app string - svc modular.Application -} - -// NewDODeployStepFactory returns a StepFactory for step.do_deploy. -func NewDODeployStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - appName, _ := cfg["app"].(string) - if appName == "" { - return nil, fmt.Errorf("do_deploy step %q: 'app' is required", name) - } - return &DODeployStep{name: name, app: appName, svc: app}, nil - } -} - -func (s *DODeployStep) Name() string { return s.name } - -func (s *DODeployStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - m, err := resolveDOAppModule(s.svc, s.app, s.name) - if err != nil { - return nil, err - } - state, err := m.Deploy() - if err != nil { - return nil, fmt.Errorf("do_deploy step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "app": s.app, - "id": state.ID, - "status": state.Status, - "live_url": state.LiveURL, - "deployment_id": state.DeploymentID, - }}, nil -} - -// ─── do_status ──────────────────────────────────────────────────────────────── - -// DOStatusStep checks the status of a DO App Platform app. -type DOStatusStep struct { - name string - app string - svc modular.Application -} - -// NewDOStatusStepFactory returns a StepFactory for step.do_status. -func NewDOStatusStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - appName, _ := cfg["app"].(string) - if appName == "" { - return nil, fmt.Errorf("do_status step %q: 'app' is required", name) - } - return &DOStatusStep{name: name, app: appName, svc: app}, nil - } -} - -func (s *DOStatusStep) Name() string { return s.name } - -func (s *DOStatusStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - m, err := resolveDOAppModule(s.svc, s.app, s.name) - if err != nil { - return nil, err - } - state, err := m.Status() - if err != nil { - return nil, fmt.Errorf("do_status step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "app": s.app, - "status": state.Status, - "live_url": state.LiveURL, - "state": state, - }}, nil -} - -// ─── do_logs ────────────────────────────────────────────────────────────────── - -// DOLogsStep retrieves logs from a DO App Platform app. -type DOLogsStep struct { - name string - app string - svc modular.Application -} - -// NewDOLogsStepFactory returns a StepFactory for step.do_logs. -func NewDOLogsStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - appName, _ := cfg["app"].(string) - if appName == "" { - return nil, fmt.Errorf("do_logs step %q: 'app' is required", name) - } - return &DOLogsStep{name: name, app: appName, svc: app}, nil - } -} - -func (s *DOLogsStep) Name() string { return s.name } - -func (s *DOLogsStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - m, err := resolveDOAppModule(s.svc, s.app, s.name) - if err != nil { - return nil, err - } - logs, err := m.Logs() - if err != nil { - return nil, fmt.Errorf("do_logs step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "app": s.app, - "logs": logs, - }}, nil -} - -// ─── do_scale ───────────────────────────────────────────────────────────────── - -// DOScaleStep scales a DO App Platform app. -type DOScaleStep struct { - name string - app string - instances int - svc modular.Application -} - -// NewDOScaleStepFactory returns a StepFactory for step.do_scale. -func NewDOScaleStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - appName, _ := cfg["app"].(string) - if appName == "" { - return nil, fmt.Errorf("do_scale step %q: 'app' is required", name) - } - instances, _ := intFromAny(cfg["instances"]) - if instances <= 0 { - instances = 1 - } - return &DOScaleStep{name: name, app: appName, instances: instances, svc: app}, nil - } -} - -func (s *DOScaleStep) Name() string { return s.name } - -func (s *DOScaleStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - m, err := resolveDOAppModule(s.svc, s.app, s.name) - if err != nil { - return nil, err - } - state, err := m.Scale(s.instances) - if err != nil { - return nil, fmt.Errorf("do_scale step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "app": s.app, - "instances": s.instances, - "status": state.Status, - }}, nil -} - -// ─── do_destroy ─────────────────────────────────────────────────────────────── - -// DODestroyStep tears down a DO App Platform app. -type DODestroyStep struct { - name string - app string - svc modular.Application -} - -// NewDODestroyStepFactory returns a StepFactory for step.do_destroy. -func NewDODestroyStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - appName, _ := cfg["app"].(string) - if appName == "" { - return nil, fmt.Errorf("do_destroy step %q: 'app' is required", name) - } - return &DODestroyStep{name: name, app: appName, svc: app}, nil - } -} - -func (s *DODestroyStep) Name() string { return s.name } - -func (s *DODestroyStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - m, err := resolveDOAppModule(s.svc, s.app, s.name) - if err != nil { - return nil, err - } - if err := m.Destroy(); err != nil { - return nil, fmt.Errorf("do_destroy step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "app": s.app, - "destroyed": true, - }}, nil -} - -// ─── helpers ────────────────────────────────────────────────────────────────── - -func resolveDOAppModule(app modular.Application, appName, stepName string) (*PlatformDOApp, error) { - if app == nil { - return nil, fmt.Errorf("step %q: no application context", stepName) - } - svc, ok := app.SvcRegistry()[appName] - if !ok { - return nil, fmt.Errorf("step %q: app %q not found in registry", stepName, appName) - } - m, ok := svc.(*PlatformDOApp) - if !ok { - return nil, fmt.Errorf("step %q: app %q is not a *PlatformDOApp (got %T)", stepName, appName, svc) - } - return m, nil -} diff --git a/module/platform_do_app.go b/module/platform_do_app.go deleted file mode 100644 index f2f5ec24..00000000 --- a/module/platform_do_app.go +++ /dev/null @@ -1,430 +0,0 @@ -package module - -import ( - "context" - "fmt" - "time" - - "github.com/GoCodeAlone/modular" - "github.com/digitalocean/godo" -) - -// DOAppState holds the current state of a DigitalOcean App Platform app. -type DOAppState struct { - ID string `json:"id"` - Name string `json:"name"` - Region string `json:"region"` - Status string `json:"status"` // pending, deploying, running, error, deleted - LiveURL string `json:"liveUrl"` - Instances int `json:"instances"` - Image string `json:"image"` - DeployedAt time.Time `json:"deployedAt"` - DeploymentID string `json:"deploymentId"` -} - -// doAppBackend is the interface DO App Platform backends implement. -type doAppBackend interface { - deploy(m *PlatformDOApp) (*DOAppState, error) - status(m *PlatformDOApp) (*DOAppState, error) - logs(m *PlatformDOApp) (string, error) - scale(m *PlatformDOApp, instances int) (*DOAppState, error) - destroy(m *PlatformDOApp) error -} - -// PlatformDOApp manages DigitalOcean App Platform applications. -// Config: -// -// account: name of a cloud.account module (provider=digitalocean) -// provider: digitalocean | mock -// name: app name -// region: DO region slug (e.g. nyc) -// image: container image reference -// instances: number of instances (default: 1) -// http_port: container HTTP port (default: 8080) -// envs: environment variables map -type PlatformDOApp struct { - name string - config map[string]any - provider CloudCredentialProvider - state *DOAppState - backend doAppBackend -} - -// NewPlatformDOApp creates a new PlatformDOApp module. -func NewPlatformDOApp(name string, cfg map[string]any) *PlatformDOApp { - return &PlatformDOApp{name: name, config: cfg} -} - -// Name returns the module name. -func (m *PlatformDOApp) Name() string { return m.name } - -// Init resolves the cloud.account service and initializes the backend. -func (m *PlatformDOApp) Init(app modular.Application) error { - appName, _ := m.config["name"].(string) - if appName == "" { - appName = m.name - } - - region, _ := m.config["region"].(string) - if region == "" { - region = "nyc" - } - - image, _ := m.config["image"].(string) - - instances, _ := intFromAny(m.config["instances"]) - if instances == 0 { - instances = 1 - } - - accountName, _ := m.config["account"].(string) - providerType, _ := m.config["provider"].(string) - if providerType == "" { - providerType = "mock" - } - - if accountName != "" { - svc, ok := app.SvcRegistry()[accountName] - if !ok { - return fmt.Errorf("platform.do_app %q: account service %q not found", m.name, accountName) - } - prov, ok := svc.(CloudCredentialProvider) - if !ok { - return fmt.Errorf("platform.do_app %q: service %q does not implement CloudCredentialProvider", m.name, accountName) - } - m.provider = prov - if providerType == "mock" { - providerType = prov.Provider() - } - } - - m.state = &DOAppState{ - Name: appName, - Region: region, - Image: image, - Instances: instances, - Status: "pending", - } - - switch providerType { - case "mock": - m.backend = &doAppMockBackend{} - case "digitalocean": - acc, ok := app.SvcRegistry()[accountName].(*CloudAccount) - if !ok { - return fmt.Errorf("platform.do_app %q: account %q is not a *CloudAccount", m.name, accountName) - } - client, err := acc.doClient() - if err != nil { - return fmt.Errorf("platform.do_app %q: %w", m.name, err) - } - m.backend = &doAppRealBackend{client: client} - default: - return fmt.Errorf("platform.do_app %q: unsupported provider %q", m.name, providerType) - } - - if err := app.RegisterService(m.name, m); err != nil { - return err - } - return app.RegisterService(m.name+".iac", &DOAppPlatformAdapter{m}) -} - -// ProvidesServices declares the service this module provides. -func (m *PlatformDOApp) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "DO App: " + m.name, Instance: m}, - {Name: m.name + ".iac", Description: "DO App IaC adapter: " + m.name, Instance: &DOAppPlatformAdapter{m}}, - } -} - -// RequiresServices returns nil. -func (m *PlatformDOApp) RequiresServices() []modular.ServiceDependency { return nil } - -// Deploy deploys the application to App Platform. -func (m *PlatformDOApp) Deploy() (*DOAppState, error) { return m.backend.deploy(m) } - -// Status returns the current app deployment state. -func (m *PlatformDOApp) Status() (*DOAppState, error) { return m.backend.status(m) } - -// Logs retrieves recent application logs. -func (m *PlatformDOApp) Logs() (string, error) { return m.backend.logs(m) } - -// Scale sets the number of app instances. -func (m *PlatformDOApp) Scale(instances int) (*DOAppState, error) { - return m.backend.scale(m, instances) -} - -// Destroy tears down the application. -func (m *PlatformDOApp) Destroy() error { return m.backend.destroy(m) } - -// envVars parses environment variable config. -func (m *PlatformDOApp) envVars() map[string]string { - result := make(map[string]string) - raw, ok := m.config["envs"].(map[string]any) - if !ok { - return result - } - for k, v := range raw { - if s, ok := v.(string); ok { - result[k] = s - } - } - return result -} - -// httpPort returns the configured HTTP port. -func (m *PlatformDOApp) httpPort() int { - if p, ok := intFromAny(m.config["http_port"]); ok && p > 0 { - return p - } - return 8080 -} - -// buildAppSpec constructs a godo AppSpec from module config. -func (m *PlatformDOApp) buildAppSpec() *godo.AppSpec { - envs := m.envVars() - var appEnvs []*godo.AppVariableDefinition - for k, v := range envs { - appEnvs = append(appEnvs, &godo.AppVariableDefinition{ - Key: k, - Value: v, - Scope: godo.AppVariableScope_RunTime, - }) - } - - return &godo.AppSpec{ - Name: m.state.Name, - Region: m.state.Region, - Services: []*godo.AppServiceSpec{ - { - Name: m.state.Name, - Image: &godo.ImageSourceSpec{ - RegistryType: godo.ImageSourceSpecRegistryType_DockerHub, - Repository: m.state.Image, - }, - InstanceCount: int64(m.state.Instances), - InstanceSizeSlug: "basic-xxs", - HTTPPort: int64(m.httpPort()), - Envs: appEnvs, - }, - }, - } -} - -// ─── PlatformProvider adapter ────────────────────────────────────────────────── - -// DOAppPlatformAdapter wraps PlatformDOApp to implement PlatformProvider. -type DOAppPlatformAdapter struct { - *PlatformDOApp -} - -// Plan implements PlatformProvider. Returns a plan based on current state. -func (a *DOAppPlatformAdapter) Plan() (*PlatformPlan, error) { - actionType := "create" - detail := fmt.Sprintf("Deploy app %s to region %s (image: %s, instances: %d)", - a.state.Name, a.state.Region, a.state.Image, a.state.Instances) - if a.state.ID != "" { - actionType = "update" - detail = fmt.Sprintf("Update app %s (image: %s, instances: %d)", - a.state.Name, a.state.Image, a.state.Instances) - } - return &PlatformPlan{ - Provider: "digitalocean", - Resource: "app_platform", - Actions: []PlatformAction{ - {Type: actionType, Resource: a.state.Name, Detail: detail}, - }, - }, nil -} - -// Apply implements PlatformProvider. Deploys via the backend. -func (a *DOAppPlatformAdapter) Apply() (*PlatformResult, error) { - st, err := a.Deploy() - if err != nil { - return &PlatformResult{Success: false, Message: err.Error()}, err - } - return &PlatformResult{ - Success: true, - Message: fmt.Sprintf("App %s deployed (id: %s, url: %s)", st.Name, st.ID, st.LiveURL), - State: st, - }, nil -} - -// Status implements PlatformProvider. -func (a *DOAppPlatformAdapter) Status() (any, error) { - return a.PlatformDOApp.Status() -} - -// Destroy implements PlatformProvider. -func (a *DOAppPlatformAdapter) Destroy() error { - return a.PlatformDOApp.Destroy() -} - -// ─── mock backend ────────────────────────────────────────────────────────────── - -type doAppMockBackend struct{} - -func (b *doAppMockBackend) deploy(m *PlatformDOApp) (*DOAppState, error) { - m.state.ID = fmt.Sprintf("mock-app-%s", m.state.Name) - m.state.DeploymentID = fmt.Sprintf("mock-deploy-%s-%d", m.state.Name, time.Now().Unix()) - m.state.LiveURL = fmt.Sprintf("https://%s.ondigitalocean.app", m.state.Name) - m.state.Status = "running" - m.state.DeployedAt = time.Now() - return m.state, nil -} - -func (b *doAppMockBackend) status(m *PlatformDOApp) (*DOAppState, error) { - return m.state, nil -} - -func (b *doAppMockBackend) logs(m *PlatformDOApp) (string, error) { - if m.state.Status == "pending" { - return "", fmt.Errorf("do_app %q: app not deployed", m.state.Name) - } - return fmt.Sprintf("[mock] %s: app running on %s with %d instance(s)", m.state.Name, m.state.LiveURL, m.state.Instances), nil -} - -func (b *doAppMockBackend) scale(m *PlatformDOApp, instances int) (*DOAppState, error) { - m.state.Instances = instances - return m.state, nil -} - -func (b *doAppMockBackend) destroy(m *PlatformDOApp) error { - if m.state.Status == "deleted" { - return nil - } - m.state.Status = "deleted" - m.state.LiveURL = "" - return nil -} - -// ─── real backend ────────────────────────────────────────────────────────────── - -type doAppRealBackend struct { - client *godo.Client -} - -func (b *doAppRealBackend) deploy(m *PlatformDOApp) (*DOAppState, error) { - spec := m.buildAppSpec() - - if m.state.ID != "" { - // Update existing app. - updated, _, err := b.client.Apps.Update(context.Background(), m.state.ID, &godo.AppUpdateRequest{Spec: spec}) - if err != nil { - return nil, fmt.Errorf("do_app update: %w", err) - } - return doAppToState(updated), nil - } - - // Create new app. - created, _, err := b.client.Apps.Create(context.Background(), &godo.AppCreateRequest{Spec: spec}) - if err != nil { - return nil, fmt.Errorf("do_app create: %w", err) - } - state := doAppToState(created) - m.state.ID = state.ID - m.state.LiveURL = state.LiveURL - m.state.Status = state.Status - m.state.DeployedAt = state.DeployedAt - return m.state, nil -} - -func (b *doAppRealBackend) status(m *PlatformDOApp) (*DOAppState, error) { - if m.state.ID == "" { - return m.state, nil - } - a, _, err := b.client.Apps.Get(context.Background(), m.state.ID) - if err != nil { - return nil, fmt.Errorf("do_app get: %w", err) - } - state := doAppToState(a) - m.state.Status = state.Status - m.state.LiveURL = state.LiveURL - return m.state, nil -} - -func (b *doAppRealBackend) logs(m *PlatformDOApp) (string, error) { - if m.state.ID == "" || m.state.DeploymentID == "" { - return "", fmt.Errorf("do_app: not deployed") - } - logInfo, _, err := b.client.Apps.GetLogs( - context.Background(), - m.state.ID, - m.state.DeploymentID, - m.state.Name, - godo.AppLogTypeRun, - true, - 100, - ) - if err != nil { - return "", fmt.Errorf("do_app logs: %w", err) - } - if logInfo != nil && logInfo.LiveURL != "" { - return fmt.Sprintf("live log stream: %s", logInfo.LiveURL), nil - } - return "(no live log URL)", nil -} - -func (b *doAppRealBackend) scale(m *PlatformDOApp, instances int) (*DOAppState, error) { - if m.state.ID == "" { - return nil, fmt.Errorf("do_app scale: app not deployed") - } - spec := m.buildAppSpec() - if len(spec.Services) > 0 { - spec.Services[0].InstanceCount = int64(instances) - } - updated, _, err := b.client.Apps.Update(context.Background(), m.state.ID, &godo.AppUpdateRequest{Spec: spec}) - if err != nil { - return nil, fmt.Errorf("do_app scale: %w", err) - } - m.state.Instances = instances - return doAppToState(updated), nil -} - -func (b *doAppRealBackend) destroy(m *PlatformDOApp) error { - if m.state.ID == "" { - return nil - } - _, err := b.client.Apps.Delete(context.Background(), m.state.ID) - if err != nil { - return fmt.Errorf("do_app destroy: %w", err) - } - m.state.Status = "deleted" - m.state.LiveURL = "" - return nil -} - -// doAppToState converts a godo.App to DOAppState. -func doAppToState(a *godo.App) *DOAppState { - state := &DOAppState{ - ID: a.ID, - Status: "pending", - } - if a.Spec != nil { - state.Name = a.Spec.Name - state.Region = a.Spec.Region - if len(a.Spec.Services) > 0 { - state.Instances = int(a.Spec.Services[0].InstanceCount) - if a.Spec.Services[0].Image != nil { - state.Image = a.Spec.Services[0].Image.Repository - } - } - } - if a.LiveURL != "" { - state.LiveURL = a.LiveURL - } - if a.ActiveDeployment != nil { - state.DeploymentID = a.ActiveDeployment.ID - state.DeployedAt = a.ActiveDeployment.CreatedAt - switch a.ActiveDeployment.Phase { - case godo.DeploymentPhase_Active: - state.Status = "running" - case godo.DeploymentPhase_Deploying, godo.DeploymentPhase_PendingDeploy, - godo.DeploymentPhase_Building, godo.DeploymentPhase_PendingBuild: - state.Status = "deploying" - case godo.DeploymentPhase_Error: - state.Status = "error" - } - } - return state -} diff --git a/module/platform_do_app_test.go b/module/platform_do_app_test.go deleted file mode 100644 index b4d9f6dd..00000000 --- a/module/platform_do_app_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package module_test - -import ( - "context" - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -func newDOAppApp(t *testing.T) (*module.MockApplication, *module.PlatformDOApp) { - t.Helper() - app := module.NewMockApplication() - m := module.NewPlatformDOApp("my-app", map[string]any{ - "provider": "mock", - "name": "my-web-app", - "region": "nyc", - "image": "registry.example.com/my-app:v1.0.0", - "instances": 2, - "http_port": 8080, - "envs": map[string]any{ - "APP_ENV": "production", - "PORT": "8080", - }, - }) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - return app, m -} - -// ─── module lifecycle ───────────────────────────────────────────────────────── - -func TestDO_App_Init(t *testing.T) { - _, m := newDOAppApp(t) - if m.Name() != "my-app" { - t.Errorf("expected name=my-app, got %q", m.Name()) - } -} - -func TestDO_App_InitRegistersService(t *testing.T) { - app, _ := newDOAppApp(t) - svc, ok := app.Services["my-app"] - if !ok { - t.Fatal("expected my-app in service registry") - } - if _, ok := svc.(*module.PlatformDOApp); !ok { - t.Fatalf("registry entry is %T, want *PlatformDOApp", svc) - } -} - -func TestDO_App_Deploy(t *testing.T) { - _, m := newDOAppApp(t) - state, err := m.Deploy() - if err != nil { - t.Fatalf("Deploy: %v", err) - } - if state.Status != "running" { - t.Errorf("expected status=running, got %q", state.Status) - } - if state.ID == "" { - t.Error("expected non-empty app ID after deploy") - } - if state.LiveURL == "" { - t.Error("expected non-empty LiveURL after deploy") - } - if state.DeploymentID == "" { - t.Error("expected non-empty DeploymentID after deploy") - } -} - -func TestDO_App_Status(t *testing.T) { - _, m := newDOAppApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - state, err := m.Status() - if err != nil { - t.Fatalf("Status: %v", err) - } - if state.Status != "running" { - t.Errorf("expected status=running, got %q", state.Status) - } -} - -func TestDO_App_Logs(t *testing.T) { - _, m := newDOAppApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - logs, err := m.Logs() - if err != nil { - t.Fatalf("Logs: %v", err) - } - if logs == "" { - t.Error("expected non-empty logs") - } -} - -func TestDO_App_Logs_NotDeployed(t *testing.T) { - _, m := newDOAppApp(t) - _, err := m.Logs() - if err == nil { - t.Error("expected error for logs on undeployed app, got nil") - } -} - -func TestDO_App_Scale(t *testing.T) { - _, m := newDOAppApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - state, err := m.Scale(5) - if err != nil { - t.Fatalf("Scale: %v", err) - } - if state.Instances != 5 { - t.Errorf("expected instances=5, got %d", state.Instances) - } -} - -func TestDO_App_Destroy(t *testing.T) { - _, m := newDOAppApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - if err := m.Destroy(); err != nil { - t.Fatalf("Destroy: %v", err) - } - state, err := m.Status() - if err != nil { - t.Fatalf("Status after destroy: %v", err) - } - if state.Status != "deleted" { - t.Errorf("expected status=deleted, got %q", state.Status) - } - if state.LiveURL != "" { - t.Error("expected empty LiveURL after destroy") - } -} - -func TestDO_App_DestroyIdempotent(t *testing.T) { - _, m := newDOAppApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - if err := m.Destroy(); err != nil { - t.Fatalf("first Destroy: %v", err) - } - if err := m.Destroy(); err != nil { - t.Errorf("second Destroy should be idempotent, got: %v", err) - } -} - -// ─── PlatformProvider adapter ───────────────────────────────────────────────── - -func TestDO_App_AdapterImplementsPlatformProvider(t *testing.T) { - app, _ := newDOAppApp(t) - svc, ok := app.Services["my-app.iac"] - if !ok { - t.Fatal("expected my-app.iac in service registry") - } - if _, ok := svc.(module.PlatformProvider); !ok { - t.Fatalf("my-app.iac service (%T) does not implement PlatformProvider", svc) - } -} - -func TestDO_App_AdapterPlan(t *testing.T) { - app, _ := newDOAppApp(t) - prov := app.Services["my-app.iac"].(module.PlatformProvider) - plan, err := prov.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if plan.Provider != "digitalocean" { - t.Errorf("expected provider digitalocean, got %s", plan.Provider) - } - if plan.Resource != "app_platform" { - t.Errorf("expected resource app_platform, got %s", plan.Resource) - } - if len(plan.Actions) == 0 { - t.Fatal("expected at least one action") - } - if plan.Actions[0].Type != "create" { - t.Errorf("expected action type create, got %s", plan.Actions[0].Type) - } -} - -func TestDO_App_AdapterPlanUpdate(t *testing.T) { - app, _ := newDOAppApp(t) - m := app.Services["my-app"].(*module.PlatformDOApp) - // Deploy first so the app has an ID - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - prov := app.Services["my-app.iac"].(module.PlatformProvider) - plan, err := prov.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if plan.Actions[0].Type != "update" { - t.Errorf("expected action type update after deploy, got %s", plan.Actions[0].Type) - } -} - -func TestDO_App_AdapterApply(t *testing.T) { - app, _ := newDOAppApp(t) - prov := app.Services["my-app.iac"].(module.PlatformProvider) - result, err := prov.Apply() - if err != nil { - t.Fatalf("Apply() error: %v", err) - } - if !result.Success { - t.Errorf("expected success, got message: %s", result.Message) - } - if result.State == nil { - t.Error("expected non-nil state") - } -} - -func TestDO_App_AdapterStatus(t *testing.T) { - app, _ := newDOAppApp(t) - prov := app.Services["my-app.iac"].(module.PlatformProvider) - st, err := prov.Status() - if err != nil { - t.Fatalf("Status() error: %v", err) - } - if st == nil { - t.Error("expected non-nil status") - } -} - -func TestDO_App_AdapterDestroy(t *testing.T) { - app, _ := newDOAppApp(t) - prov := app.Services["my-app.iac"].(module.PlatformProvider) - // Deploy first - if _, err := prov.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := prov.Destroy(); err != nil { - t.Fatalf("Destroy() error: %v", err) - } - // Verify status shows deleted - st, err := prov.Status() - if err != nil { - t.Fatalf("Status after destroy: %v", err) - } - appState, ok := st.(*module.DOAppState) - if !ok { - t.Fatalf("expected *DOAppState, got %T", st) - } - if appState.Status != "deleted" { - t.Errorf("expected status deleted, got %s", appState.Status) - } -} - -func TestDO_App_UnsupportedProvider(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDOApp("bad-app", map[string]any{ - "provider": "gcp", - "name": "bad", - }) - if err := m.Init(app); err == nil { - t.Error("expected error for unsupported provider, got nil") - } -} - -func TestDO_App_InvalidAccountRef(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDOApp("fail-app", map[string]any{ - "provider": "mock", - "account": "nonexistent", - "name": "fail", - }) - if err := m.Init(app); err == nil { - t.Error("expected error for nonexistent account, got nil") - } -} - -// ─── pipeline steps ─────────────────────────────────────────────────────────── - -func setupDOAppStepApp(t *testing.T) (*module.MockApplication, *module.PlatformDOApp) { - t.Helper() - return newDOAppApp(t) -} - -func TestDO_DeployStep(t *testing.T) { - app, _ := setupDOAppStepApp(t) - factory := module.NewDODeployStepFactory() - step, err := factory("deploy", map[string]any{"app": "my-app"}, app) - if err != nil { - t.Fatalf("factory: %v", err) - } - result, err := step.Execute(context.Background(), &module.PipelineContext{Current: map[string]any{}}) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if result.Output["app"] != "my-app" { - t.Errorf("expected app=my-app, got %v", result.Output["app"]) - } - if result.Output["status"] != "running" { - t.Errorf("expected status=running, got %v", result.Output["status"]) - } -} - -func TestDO_StatusStep(t *testing.T) { - app, m := setupDOAppStepApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - factory := module.NewDOStatusStepFactory() - step, err := factory("status", map[string]any{"app": "my-app"}, app) - if err != nil { - t.Fatalf("factory: %v", err) - } - result, err := step.Execute(context.Background(), &module.PipelineContext{Current: map[string]any{}}) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if result.Output["app"] != "my-app" { - t.Errorf("expected app=my-app, got %v", result.Output["app"]) - } -} - -func TestDO_LogsStep(t *testing.T) { - app, m := setupDOAppStepApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - factory := module.NewDOLogsStepFactory() - step, err := factory("logs", map[string]any{"app": "my-app"}, app) - if err != nil { - t.Fatalf("factory: %v", err) - } - result, err := step.Execute(context.Background(), &module.PipelineContext{Current: map[string]any{}}) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if result.Output["logs"] == "" { - t.Error("expected non-empty logs in output") - } -} - -func TestDO_ScaleStep(t *testing.T) { - app, m := setupDOAppStepApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - factory := module.NewDOScaleStepFactory() - step, err := factory("scale", map[string]any{"app": "my-app", "instances": 4}, app) - if err != nil { - t.Fatalf("factory: %v", err) - } - result, err := step.Execute(context.Background(), &module.PipelineContext{Current: map[string]any{}}) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if result.Output["instances"] != 4 { - t.Errorf("expected instances=4, got %v", result.Output["instances"]) - } -} - -func TestDO_DestroyStep(t *testing.T) { - app, m := setupDOAppStepApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - factory := module.NewDODestroyStepFactory() - step, err := factory("destroy", map[string]any{"app": "my-app"}, app) - if err != nil { - t.Fatalf("factory: %v", err) - } - result, err := step.Execute(context.Background(), &module.PipelineContext{Current: map[string]any{}}) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if result.Output["destroyed"] != true { - t.Errorf("expected destroyed=true, got %v", result.Output["destroyed"]) - } -} - -func TestDO_DeployStep_MissingApp(t *testing.T) { - factory := module.NewDODeployStepFactory() - _, err := factory("deploy", map[string]any{}, module.NewMockApplication()) - if err == nil { - t.Error("expected error for missing app, got nil") - } -} - -func TestDO_DeployStep_AppNotFound(t *testing.T) { - factory := module.NewDODeployStepFactory() - step, err := factory("deploy", map[string]any{"app": "ghost"}, module.NewMockApplication()) - if err != nil { - t.Fatalf("factory: %v", err) - } - _, err = step.Execute(context.Background(), &module.PipelineContext{Current: map[string]any{}}) - if err == nil { - t.Error("expected error for missing app in registry, got nil") - } -} diff --git a/module/platform_do_database.go b/module/platform_do_database.go deleted file mode 100644 index a124ccb4..00000000 --- a/module/platform_do_database.go +++ /dev/null @@ -1,263 +0,0 @@ -package module - -import ( - "context" - "fmt" - "time" - - "github.com/GoCodeAlone/modular" - "github.com/digitalocean/godo" -) - -// DODatabaseState holds the current state of a DO Managed Database. -type DODatabaseState struct { - ID string `json:"id"` - Name string `json:"name"` - Engine string `json:"engine"` // pg, mysql, redis, mongodb, kafka - Version string `json:"version"` - Size string `json:"size"` // e.g. db-s-1vcpu-1gb - Region string `json:"region"` - NumNodes int `json:"numNodes"` - Status string `json:"status"` // pending, online, resizing, migrating, error - Host string `json:"host"` - Port int `json:"port"` - DatabaseName string `json:"databaseName"` - User string `json:"user"` - Password string `json:"password"` //nolint:gosec // G117: DigitalOcean database state DTO; password is a standard DB connection field - URI string `json:"uri"` - CreatedAt time.Time `json:"createdAt"` -} - -// doDatabaseBackend is the interface for DO managed database backends. -type doDatabaseBackend interface { - create(m *PlatformDODatabase) (*DODatabaseState, error) - status(m *PlatformDODatabase) (*DODatabaseState, error) - destroy(m *PlatformDODatabase) error -} - -// PlatformDODatabase manages DigitalOcean Managed Databases. -// Config: -// -// account: name of a cloud.account module (provider=digitalocean) -// provider: digitalocean | mock -// engine: pg | mysql | redis | mongodb | kafka -// version: engine version string (e.g. "16" for pg) -// size: droplet size slug (e.g. db-s-1vcpu-1gb) -// region: DO region slug (e.g. nyc1) -// num_nodes: number of nodes (default: 1) -// name: database cluster name -type PlatformDODatabase struct { - name string - config map[string]any - state *DODatabaseState - backend doDatabaseBackend -} - -// NewPlatformDODatabase creates a new PlatformDODatabase module. -func NewPlatformDODatabase(name string, cfg map[string]any) *PlatformDODatabase { - return &PlatformDODatabase{name: name, config: cfg} -} - -func (m *PlatformDODatabase) Name() string { return m.name } - -func (m *PlatformDODatabase) Init(app modular.Application) error { - dbName, _ := m.config["name"].(string) - if dbName == "" { - dbName = m.name - } - engine, _ := m.config["engine"].(string) - if engine == "" { - engine = "pg" - } - version, _ := m.config["version"].(string) - size, _ := m.config["size"].(string) - if size == "" { - size = "db-s-1vcpu-1gb" - } - region, _ := m.config["region"].(string) - if region == "" { - region = "nyc1" - } - numNodes, _ := intFromAny(m.config["num_nodes"]) - if numNodes == 0 { - numNodes = 1 - } - - m.state = &DODatabaseState{ - Name: dbName, - Engine: engine, - Version: version, - Size: size, - Region: region, - NumNodes: numNodes, - Status: "pending", - } - - providerType, _ := m.config["provider"].(string) - if providerType == "" { - providerType = "mock" - } - - switch providerType { - case "mock": - m.backend = &doDatabaseMockBackend{} - case "digitalocean": - accountName, _ := m.config["account"].(string) - acc, ok := app.SvcRegistry()[accountName].(*CloudAccount) - if !ok { - return fmt.Errorf("platform.do_database %q: account %q is not a *CloudAccount", m.name, accountName) - } - client, err := acc.doClient() - if err != nil { - return fmt.Errorf("platform.do_database %q: %w", m.name, err) - } - m.backend = &doDatabaseRealBackend{client: client} - default: - return fmt.Errorf("platform.do_database %q: unsupported provider %q", m.name, providerType) - } - - return app.RegisterService(m.name, m) -} - -func (m *PlatformDODatabase) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "DO Database: " + m.name, Instance: m}, - } -} - -func (m *PlatformDODatabase) RequiresServices() []modular.ServiceDependency { return nil } - -// PlatformProvider implementation — directly, no adapter needed since this is new. - -func (m *PlatformDODatabase) Plan() (*PlatformPlan, error) { - actionType := "create" - detail := fmt.Sprintf("Create %s %s database %q in %s (%s, %d nodes)", - m.state.Engine, m.state.Version, m.state.Name, m.state.Region, m.state.Size, m.state.NumNodes) - if m.state.ID != "" { - actionType = "update" - detail = fmt.Sprintf("Update database %q (size: %s, %d nodes)", - m.state.Name, m.state.Size, m.state.NumNodes) - } - return &PlatformPlan{ - Provider: "digitalocean", - Resource: "managed_database", - Actions: []PlatformAction{{Type: actionType, Resource: m.state.Name, Detail: detail}}, - }, nil -} - -func (m *PlatformDODatabase) Apply() (*PlatformResult, error) { - st, err := m.backend.create(m) - if err != nil { - return &PlatformResult{Success: false, Message: err.Error()}, err - } - m.state = st - return &PlatformResult{ - Success: true, - Message: fmt.Sprintf("Database %s online (host: %s:%d)", st.Name, st.Host, st.Port), - State: st, - }, nil -} - -func (m *PlatformDODatabase) Status() (any, error) { - return m.backend.status(m) -} - -func (m *PlatformDODatabase) Destroy() error { - return m.backend.destroy(m) -} - -// ─── mock backend ────────────────────────────────────────────────────────────── - -type doDatabaseMockBackend struct{} - -func (b *doDatabaseMockBackend) create(m *PlatformDODatabase) (*DODatabaseState, error) { - m.state.ID = "mock-db-" + m.state.Name - m.state.Status = "online" - m.state.Host = m.state.Name + ".db.ondigitalocean.com" - m.state.Port = 25060 - m.state.DatabaseName = "defaultdb" - m.state.User = "doadmin" - m.state.Password = "mock-password" - m.state.URI = fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=require", - m.state.User, m.state.Password, m.state.Host, m.state.Port, m.state.DatabaseName) - m.state.CreatedAt = time.Now().UTC() - return m.state, nil -} - -func (b *doDatabaseMockBackend) status(m *PlatformDODatabase) (*DODatabaseState, error) { - return m.state, nil -} - -func (b *doDatabaseMockBackend) destroy(m *PlatformDODatabase) error { - m.state.Status = "deleted" - m.state.ID = "" - return nil -} - -// ─── real backend ────────────────────────────────────────────────────────────── - -type doDatabaseRealBackend struct { - client *godo.Client -} - -func (b *doDatabaseRealBackend) create(m *PlatformDODatabase) (*DODatabaseState, error) { - req := &godo.DatabaseCreateRequest{ - Name: m.state.Name, - EngineSlug: m.state.Engine, - Version: m.state.Version, - SizeSlug: m.state.Size, - Region: m.state.Region, - NumNodes: m.state.NumNodes, - } - db, _, err := b.client.Databases.Create(context.Background(), req) - if err != nil { - return nil, fmt.Errorf("create database: %w", err) - } - return doDatabaseFromGodo(db), nil -} - -func (b *doDatabaseRealBackend) status(m *PlatformDODatabase) (*DODatabaseState, error) { - if m.state.ID == "" { - return m.state, nil - } - db, _, err := b.client.Databases.Get(context.Background(), m.state.ID) - if err != nil { - return nil, fmt.Errorf("get database: %w", err) - } - return doDatabaseFromGodo(db), nil -} - -func (b *doDatabaseRealBackend) destroy(m *PlatformDODatabase) error { - if m.state.ID == "" { - return nil - } - _, err := b.client.Databases.Delete(context.Background(), m.state.ID) - if err != nil { - return fmt.Errorf("delete database: %w", err) - } - m.state.Status = "deleted" - return nil -} - -func doDatabaseFromGodo(db *godo.Database) *DODatabaseState { - st := &DODatabaseState{ - ID: db.ID, - Name: db.Name, - Engine: db.EngineSlug, - Version: db.VersionSlug, - Size: db.SizeSlug, - Region: db.RegionSlug, - NumNodes: db.NumNodes, - Status: db.Status, - CreatedAt: db.CreatedAt, - } - if db.Connection != nil { - st.Host = db.Connection.Host - st.Port = db.Connection.Port - st.DatabaseName = db.Connection.Database - st.User = db.Connection.User - st.Password = db.Connection.Password - st.URI = db.Connection.URI - } - return st -} diff --git a/module/platform_do_database_test.go b/module/platform_do_database_test.go deleted file mode 100644 index b1a28dcd..00000000 --- a/module/platform_do_database_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package module - -import "testing" - -func TestPlatformDODatabase_MockBackend(t *testing.T) { - m := &PlatformDODatabase{ - name: "test-db", - config: map[string]any{ - "provider": "mock", - "engine": "pg", - "version": "16", - "size": "db-s-1vcpu-1gb", - "region": "nyc1", - "num_nodes": 1, - "name": "test-db", - }, - state: &DODatabaseState{ - Name: "test-db", - Engine: "pg", - Version: "16", - Size: "db-s-1vcpu-1gb", - Region: "nyc1", - NumNodes: 1, - Status: "pending", - }, - backend: &doDatabaseMockBackend{}, - } - - // Test PlatformProvider interface - var _ PlatformProvider = m - - // Plan - plan, err := m.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if plan.Provider != "digitalocean" { - t.Errorf("expected provider digitalocean, got %s", plan.Provider) - } - if plan.Resource != "managed_database" { - t.Errorf("expected resource managed_database, got %s", plan.Resource) - } - - // Apply - result, err := m.Apply() - if err != nil { - t.Fatalf("Apply() error: %v", err) - } - if !result.Success { - t.Error("expected success") - } - - // Status - st, err := m.Status() - if err != nil { - t.Fatalf("Status() error: %v", err) - } - if st == nil { - t.Error("expected non-nil status") - } - - // Destroy - if err := m.Destroy(); err != nil { - t.Fatalf("Destroy() error: %v", err) - } -} diff --git a/module/platform_do_dns.go b/module/platform_do_dns.go deleted file mode 100644 index 0f2cb2f2..00000000 --- a/module/platform_do_dns.go +++ /dev/null @@ -1,357 +0,0 @@ -package module - -import ( - "context" - "fmt" - "strings" - - "github.com/GoCodeAlone/modular" - "github.com/digitalocean/godo" -) - -// DODNSState holds the current state of DigitalOcean DNS. -type DODNSState struct { - DomainName string `json:"domainName"` - Records []DODNSRecordState `json:"records"` - Status string `json:"status"` // pending, active, deleting, deleted -} - -// DODNSRecordState describes a single DigitalOcean DNS record. -type DODNSRecordState struct { - ID int `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Data string `json:"data"` - TTL int `json:"ttl"` -} - -// doDNSBackend is the interface DO DNS backends implement. -type doDNSBackend interface { - plan(m *PlatformDODNS) (*DODNSPlan, error) - apply(m *PlatformDODNS) (*DODNSState, error) - status(m *PlatformDODNS) (*DODNSState, error) - destroy(m *PlatformDODNS) error -} - -// DODNSPlan describes planned DNS changes. -type DODNSPlan struct { - Domain string `json:"domain"` - Records []DODNSRecordState `json:"records"` - Changes []string `json:"changes"` -} - -// PlatformDODNS manages DigitalOcean domains and DNS records. -// Config: -// -// account: name of a cloud.account module (provider=digitalocean) -// provider: digitalocean | mock -// domain: domain name (e.g. example.com) -// records: list of DNS record definitions (name, type, data, ttl) -type PlatformDODNS struct { - name string - config map[string]any - provider CloudCredentialProvider - state *DODNSState - backend doDNSBackend -} - -// NewPlatformDODNS creates a new PlatformDODNS module. -func NewPlatformDODNS(name string, cfg map[string]any) *PlatformDODNS { - return &PlatformDODNS{name: name, config: cfg} -} - -// Name returns the module name. -func (m *PlatformDODNS) Name() string { return m.name } - -// Init resolves the cloud.account service and initializes the backend. -func (m *PlatformDODNS) Init(app modular.Application) error { - domain, _ := m.config["domain"].(string) - if domain == "" { - return fmt.Errorf("platform.do_dns %q: 'domain' is required", m.name) - } - - accountName, _ := m.config["account"].(string) - providerType, _ := m.config["provider"].(string) - if providerType == "" { - providerType = "mock" - } - - if accountName != "" { - svc, ok := app.SvcRegistry()[accountName] - if !ok { - return fmt.Errorf("platform.do_dns %q: account service %q not found", m.name, accountName) - } - prov, ok := svc.(CloudCredentialProvider) - if !ok { - return fmt.Errorf("platform.do_dns %q: service %q does not implement CloudCredentialProvider", m.name, accountName) - } - m.provider = prov - if providerType == "mock" { - providerType = prov.Provider() - } - } - - m.state = &DODNSState{ - DomainName: domain, - Status: "pending", - } - - switch providerType { - case "mock": - m.backend = &doDNSMockBackend{} - case "digitalocean": - acc, ok := app.SvcRegistry()[accountName].(*CloudAccount) - if !ok { - return fmt.Errorf("platform.do_dns %q: account %q is not a *CloudAccount", m.name, accountName) - } - client, err := acc.doClient() - if err != nil { - return fmt.Errorf("platform.do_dns %q: %w", m.name, err) - } - m.backend = &doDNSRealBackend{client: client} - default: - return fmt.Errorf("platform.do_dns %q: unsupported provider %q", m.name, providerType) - } - - if err := app.RegisterService(m.name, m); err != nil { - return err - } - return app.RegisterService(m.name+".iac", &DODNSPlatformAdapter{m}) -} - -// ProvidesServices declares the service this module provides. -func (m *PlatformDODNS) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "DO DNS: " + m.name, Instance: m}, - {Name: m.name + ".iac", Description: "DO DNS IaC adapter: " + m.name, Instance: &DODNSPlatformAdapter{m}}, - } -} - -// RequiresServices returns nil. -func (m *PlatformDODNS) RequiresServices() []modular.ServiceDependency { return nil } - -// Plan returns the planned DNS changes. -func (m *PlatformDODNS) Plan() (*DODNSPlan, error) { return m.backend.plan(m) } - -// Apply creates or updates the domain and records. -func (m *PlatformDODNS) Apply() (*DODNSState, error) { return m.backend.apply(m) } - -// Status returns the current DNS state. -func (m *PlatformDODNS) Status() (*DODNSState, error) { return m.backend.status(m) } - -// Destroy deletes the domain and all records. -func (m *PlatformDODNS) Destroy() error { return m.backend.destroy(m) } - -// recordConfigs parses DNS record configs from module config. -func (m *PlatformDODNS) recordConfigs() []DODNSRecordState { - raw, ok := m.config["records"].([]any) - if !ok { - return nil - } - var records []DODNSRecordState - for _, item := range raw { - rec, ok := item.(map[string]any) - if !ok { - continue - } - name, _ := rec["name"].(string) - rtype, _ := rec["type"].(string) - data, _ := rec["data"].(string) - ttl, _ := intFromAny(rec["ttl"]) - if ttl == 0 { - ttl = 300 - } - records = append(records, DODNSRecordState{ - Type: strings.ToUpper(rtype), - Name: name, - Data: data, - TTL: ttl, - }) - } - return records -} - -// ─── PlatformProvider adapter ────────────────────────────────────────────────── - -// DODNSPlatformAdapter wraps PlatformDODNS to implement PlatformProvider. -type DODNSPlatformAdapter struct { - *PlatformDODNS -} - -// Plan implements PlatformProvider. -func (a *DODNSPlatformAdapter) Plan() (*PlatformPlan, error) { - p, err := a.PlatformDODNS.Plan() - if err != nil { - return nil, err - } - var actions []PlatformAction - for _, change := range p.Changes { - actionType := "create" - if change == "no changes" { - actionType = "noop" - } - actions = append(actions, PlatformAction{ - Type: actionType, - Resource: p.Domain, - Detail: change, - }) - } - return &PlatformPlan{ - Provider: "digitalocean", - Resource: "dns", - Actions: actions, - }, nil -} - -// Apply implements PlatformProvider. -func (a *DODNSPlatformAdapter) Apply() (*PlatformResult, error) { - st, err := a.PlatformDODNS.Apply() - if err != nil { - return &PlatformResult{Success: false, Message: err.Error()}, err - } - return &PlatformResult{ - Success: true, - Message: fmt.Sprintf("DNS domain %s configured with %d records", st.DomainName, len(st.Records)), - State: st, - }, nil -} - -// Status implements PlatformProvider. -func (a *DODNSPlatformAdapter) Status() (any, error) { - return a.PlatformDODNS.Status() -} - -// Destroy implements PlatformProvider. -func (a *DODNSPlatformAdapter) Destroy() error { - return a.PlatformDODNS.Destroy() -} - -// ─── mock backend ────────────────────────────────────────────────────────────── - -type doDNSMockBackend struct{} - -func (b *doDNSMockBackend) plan(m *PlatformDODNS) (*DODNSPlan, error) { - if m.state.Status == "active" { - return &DODNSPlan{ - Domain: m.state.DomainName, - Changes: []string{"no changes"}, - }, nil - } - records := m.recordConfigs() - changes := []string{fmt.Sprintf("create domain %q", m.state.DomainName)} - for _, r := range records { - changes = append(changes, fmt.Sprintf("create %s record %q → %s", r.Type, r.Name, r.Data)) - } - return &DODNSPlan{ - Domain: m.state.DomainName, - Records: records, - Changes: changes, - }, nil -} - -func (b *doDNSMockBackend) apply(m *PlatformDODNS) (*DODNSState, error) { - if m.state.Status == "active" { - return m.state, nil - } - records := m.recordConfigs() - for i := range records { - records[i].ID = i + 1 - } - m.state.Records = records - m.state.Status = "active" - return m.state, nil -} - -func (b *doDNSMockBackend) status(m *PlatformDODNS) (*DODNSState, error) { - return m.state, nil -} - -func (b *doDNSMockBackend) destroy(m *PlatformDODNS) error { - if m.state.Status == "deleted" { - return nil - } - m.state.Records = nil - m.state.Status = "deleted" - return nil -} - -// ─── real backend ────────────────────────────────────────────────────────────── - -type doDNSRealBackend struct { - client *godo.Client -} - -func (b *doDNSRealBackend) plan(m *PlatformDODNS) (*DODNSPlan, error) { - records := m.recordConfigs() - changes := []string{fmt.Sprintf("create/update domain %q", m.state.DomainName)} - for _, r := range records { - changes = append(changes, fmt.Sprintf("create %s record %q → %s", r.Type, r.Name, r.Data)) - } - return &DODNSPlan{ - Domain: m.state.DomainName, - Records: records, - Changes: changes, - }, nil -} - -func (b *doDNSRealBackend) apply(m *PlatformDODNS) (*DODNSState, error) { - // Create domain if it doesn't exist. - _, _, err := b.client.Domains.Create(context.Background(), &godo.DomainCreateRequest{ - Name: m.state.DomainName, - }) - if err != nil { - // Domain may already exist — continue. - _ = err - } - - records := m.recordConfigs() - for i, r := range records { - req := &godo.DomainRecordEditRequest{ - Type: r.Type, - Name: r.Name, - Data: r.Data, - TTL: r.TTL, - } - created, _, err := b.client.Domains.CreateRecord(context.Background(), m.state.DomainName, req) - if err != nil { - return nil, fmt.Errorf("do_dns create record %q: %w", r.Name, err) - } - records[i].ID = created.ID - } - m.state.Records = records - m.state.Status = "active" - return m.state, nil -} - -func (b *doDNSRealBackend) status(m *PlatformDODNS) (*DODNSState, error) { - recs, _, err := b.client.Domains.Records(context.Background(), m.state.DomainName, nil) - if err != nil { - return nil, fmt.Errorf("do_dns list records: %w", err) - } - var records []DODNSRecordState - for _, r := range recs { - records = append(records, DODNSRecordState{ - ID: r.ID, - Type: r.Type, - Name: r.Name, - Data: r.Data, - TTL: r.TTL, - }) - } - m.state.Records = records - return m.state, nil -} - -func (b *doDNSRealBackend) destroy(m *PlatformDODNS) error { - for _, r := range m.state.Records { - if _, err := b.client.Domains.DeleteRecord(context.Background(), m.state.DomainName, r.ID); err != nil { - return fmt.Errorf("do_dns delete record %d: %w", r.ID, err) - } - } - if _, err := b.client.Domains.Delete(context.Background(), m.state.DomainName); err != nil { - return fmt.Errorf("do_dns delete domain: %w", err) - } - m.state.Records = nil - m.state.Status = "deleted" - return nil -} diff --git a/module/platform_do_dns_test.go b/module/platform_do_dns_test.go deleted file mode 100644 index 78d8c7f7..00000000 --- a/module/platform_do_dns_test.go +++ /dev/null @@ -1,270 +0,0 @@ -package module_test - -import ( - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -func newDODNSApp(t *testing.T) (*module.MockApplication, *module.PlatformDODNS) { - t.Helper() - app := module.NewMockApplication() - m := module.NewPlatformDODNS("prod-do-dns", map[string]any{ - "provider": "mock", - "domain": "example.com", - "records": []any{ - map[string]any{"name": "api", "type": "A", "data": "10.0.0.1", "ttl": 300}, - map[string]any{"name": "www", "type": "CNAME", "data": "example.com", "ttl": 3600}, - }, - }) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - return app, m -} - -// ─── module lifecycle ───────────────────────────────────────────────────────── - -func TestDO_DNS_Init(t *testing.T) { - _, m := newDODNSApp(t) - if m.Name() != "prod-do-dns" { - t.Errorf("expected name=prod-do-dns, got %q", m.Name()) - } -} - -func TestDO_DNS_InitRegistersService(t *testing.T) { - app, _ := newDODNSApp(t) - svc, ok := app.Services["prod-do-dns"] - if !ok { - t.Fatal("expected prod-do-dns in service registry") - } - if _, ok := svc.(*module.PlatformDODNS); !ok { - t.Fatalf("registry entry is %T, want *PlatformDODNS", svc) - } -} - -func TestDO_DNS_Plan_PendingState(t *testing.T) { - _, m := newDODNSApp(t) - plan, err := m.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) - } - if plan.Domain != "example.com" { - t.Errorf("expected domain=example.com, got %q", plan.Domain) - } - if len(plan.Changes) == 0 { - t.Error("expected changes in plan") - } - if len(plan.Records) != 2 { - t.Errorf("expected 2 records in plan, got %d", len(plan.Records)) - } -} - -func TestDO_DNS_Plan_NoopAfterApply(t *testing.T) { - _, m := newDODNSApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - plan, err := m.Plan() - if err != nil { - t.Fatalf("second Plan: %v", err) - } - if len(plan.Changes) == 0 || plan.Changes[0] != "no changes" { - t.Errorf("expected 'no changes', got %v", plan.Changes) - } -} - -func TestDO_DNS_Apply(t *testing.T) { - _, m := newDODNSApp(t) - state, err := m.Apply() - if err != nil { - t.Fatalf("Apply: %v", err) - } - if state.Status != "active" { - t.Errorf("expected status=active, got %q", state.Status) - } - if state.DomainName != "example.com" { - t.Errorf("expected domain=example.com, got %q", state.DomainName) - } - if len(state.Records) != 2 { - t.Errorf("expected 2 records after apply, got %d", len(state.Records)) - } -} - -func TestDO_DNS_Status(t *testing.T) { - _, m := newDODNSApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - state, err := m.Status() - if err != nil { - t.Fatalf("Status: %v", err) - } - if state.Status != "active" { - t.Errorf("expected status=active, got %q", state.Status) - } -} - -func TestDO_DNS_Destroy(t *testing.T) { - _, m := newDODNSApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := m.Destroy(); err != nil { - t.Fatalf("Destroy: %v", err) - } - state, err := m.Status() - if err != nil { - t.Fatalf("Status after destroy: %v", err) - } - if state.Status != "deleted" { - t.Errorf("expected status=deleted, got %q", state.Status) - } - if len(state.Records) != 0 { - t.Errorf("expected 0 records after destroy, got %d", len(state.Records)) - } -} - -func TestDO_DNS_DestroyIdempotent(t *testing.T) { - _, m := newDODNSApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := m.Destroy(); err != nil { - t.Fatalf("first Destroy: %v", err) - } - if err := m.Destroy(); err != nil { - t.Errorf("second Destroy should be idempotent, got: %v", err) - } -} - -// ─── PlatformProvider adapter ───────────────────────────────────────────────── - -func TestDO_DNS_AdapterImplementsPlatformProvider(t *testing.T) { - app, _ := newDODNSApp(t) - svc, ok := app.Services["prod-do-dns.iac"] - if !ok { - t.Fatal("expected prod-do-dns.iac in service registry") - } - if _, ok := svc.(module.PlatformProvider); !ok { - t.Fatalf("prod-do-dns.iac service (%T) does not implement PlatformProvider", svc) - } -} - -func TestDO_DNS_AdapterPlan(t *testing.T) { - app, _ := newDODNSApp(t) - prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) - plan, err := prov.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if plan.Provider != "digitalocean" { - t.Errorf("expected provider digitalocean, got %s", plan.Provider) - } - if plan.Resource != "dns" { - t.Errorf("expected resource dns, got %s", plan.Resource) - } - if len(plan.Actions) == 0 { - t.Fatal("expected at least one action") - } -} - -func TestDO_DNS_AdapterPlanNoop(t *testing.T) { - app, m := newDODNSApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) - plan, err := prov.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if len(plan.Actions) != 1 { - t.Fatalf("expected 1 noop action, got %d", len(plan.Actions)) - } - if plan.Actions[0].Type != "noop" { - t.Errorf("expected noop action after apply, got %s", plan.Actions[0].Type) - } -} - -func TestDO_DNS_AdapterApply(t *testing.T) { - app, _ := newDODNSApp(t) - prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) - result, err := prov.Apply() - if err != nil { - t.Fatalf("Apply() error: %v", err) - } - if !result.Success { - t.Errorf("expected success, got message: %s", result.Message) - } - if result.State == nil { - t.Error("expected non-nil state") - } -} - -func TestDO_DNS_AdapterStatus(t *testing.T) { - app, _ := newDODNSApp(t) - prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) - st, err := prov.Status() - if err != nil { - t.Fatalf("Status() error: %v", err) - } - if st == nil { - t.Error("expected non-nil status") - } -} - -func TestDO_DNS_AdapterDestroy(t *testing.T) { - app, _ := newDODNSApp(t) - prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) - if _, err := prov.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := prov.Destroy(); err != nil { - t.Fatalf("Destroy() error: %v", err) - } - st, err := prov.Status() - if err != nil { - t.Fatalf("Status after destroy: %v", err) - } - dnsState, ok := st.(*module.DODNSState) - if !ok { - t.Fatalf("expected *DODNSState, got %T", st) - } - if dnsState.Status != "deleted" { - t.Errorf("expected status deleted, got %s", dnsState.Status) - } -} - -func TestDO_DNS_MissingDomain(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDODNS("bad-dns", map[string]any{ - "provider": "mock", - }) - if err := m.Init(app); err == nil { - t.Error("expected error for missing domain, got nil") - } -} - -func TestDO_DNS_UnsupportedProvider(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDODNS("bad-dns", map[string]any{ - "provider": "aws", - "domain": "example.com", - }) - if err := m.Init(app); err == nil { - t.Error("expected error for unsupported provider, got nil") - } -} - -func TestDO_DNS_InvalidAccountRef(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDODNS("fail-dns", map[string]any{ - "provider": "mock", - "account": "nonexistent", - "domain": "example.com", - }) - if err := m.Init(app); err == nil { - t.Error("expected error for nonexistent account, got nil") - } -} diff --git a/module/platform_do_networking.go b/module/platform_do_networking.go deleted file mode 100644 index 1596f406..00000000 --- a/module/platform_do_networking.go +++ /dev/null @@ -1,370 +0,0 @@ -package module - -import ( - "context" - "fmt" - - "github.com/GoCodeAlone/modular" - "github.com/digitalocean/godo" -) - -// DOVPCState holds the current state of a DigitalOcean VPC. -type DOVPCState struct { - ID string `json:"id"` - Name string `json:"name"` - Region string `json:"region"` - IPRange string `json:"ipRange"` - Status string `json:"status"` // pending, active, deleting, deleted - FirewallIDs []string `json:"firewallIds"` - LBID string `json:"lbId"` - Tags map[string]string `json:"tags"` -} - -// DOFirewallRule describes a single firewall rule (inbound or outbound). -type DOFirewallRule struct { - Protocol string `json:"protocol"` // tcp, udp, icmp - PortRange string `json:"portRange"` // e.g. "80" or "8000-9000" - Sources string `json:"sources"` // CIDR, tag, or load_balancer_uid -} - -// DOFirewallConfig describes a DigitalOcean firewall. -type DOFirewallConfig struct { - Name string `json:"name"` - InboundRules []DOFirewallRule `json:"inboundRules"` - OutboundRules []DOFirewallRule `json:"outboundRules"` -} - -// DONetworkPlan describes planned networking changes. -type DONetworkPlan struct { - VPC string `json:"vpc"` - Firewalls []DOFirewallConfig `json:"firewalls"` - Changes []string `json:"changes"` -} - -// doNetworkingBackend is the interface DO networking backends implement. -type doNetworkingBackend interface { - plan(m *PlatformDONetworking) (*DONetworkPlan, error) - apply(m *PlatformDONetworking) (*DOVPCState, error) - status(m *PlatformDONetworking) (*DOVPCState, error) - destroy(m *PlatformDONetworking) error -} - -// PlatformDONetworking manages DigitalOcean VPCs, firewalls, and load balancers. -// Config: -// -// account: name of a cloud.account module (provider=digitalocean) -// provider: digitalocean | mock -// vpc: vpc config (name, region, ip_range) -// firewalls: list of firewall configs -type PlatformDONetworking struct { - name string - config map[string]any - provider CloudCredentialProvider - state *DOVPCState - backend doNetworkingBackend -} - -// NewPlatformDONetworking creates a new PlatformDONetworking module. -func NewPlatformDONetworking(name string, cfg map[string]any) *PlatformDONetworking { - return &PlatformDONetworking{name: name, config: cfg} -} - -// Name returns the module name. -func (m *PlatformDONetworking) Name() string { return m.name } - -// Init resolves the cloud.account service and initializes the backend. -func (m *PlatformDONetworking) Init(app modular.Application) error { - accountName, _ := m.config["account"].(string) - providerType, _ := m.config["provider"].(string) - if providerType == "" { - providerType = "mock" - } - - if accountName != "" { - svc, ok := app.SvcRegistry()[accountName] - if !ok { - return fmt.Errorf("platform.do_networking %q: account service %q not found", m.name, accountName) - } - prov, ok := svc.(CloudCredentialProvider) - if !ok { - return fmt.Errorf("platform.do_networking %q: service %q does not implement CloudCredentialProvider", m.name, accountName) - } - m.provider = prov - if providerType == "mock" { - providerType = prov.Provider() - } - } - - vpc := m.vpcConfig() - m.state = &DOVPCState{ - Name: vpc["name"], - Region: vpc["region"], - IPRange: vpc["ip_range"], - Status: "pending", - } - - switch providerType { - case "mock": - m.backend = &doNetworkingMockBackend{} - case "digitalocean": - acc, ok := app.SvcRegistry()[accountName].(*CloudAccount) - if !ok { - return fmt.Errorf("platform.do_networking %q: account %q is not a *CloudAccount", m.name, accountName) - } - client, err := acc.doClient() - if err != nil { - return fmt.Errorf("platform.do_networking %q: %w", m.name, err) - } - m.backend = &doNetworkingRealBackend{client: client} - default: - return fmt.Errorf("platform.do_networking %q: unsupported provider %q", m.name, providerType) - } - - if err := app.RegisterService(m.name, m); err != nil { - return err - } - return app.RegisterService(m.name+".iac", &DONetworkingPlatformAdapter{m}) -} - -// ProvidesServices declares the service this module provides. -func (m *PlatformDONetworking) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "DO networking: " + m.name, Instance: m}, - {Name: m.name + ".iac", Description: "DO networking IaC adapter: " + m.name, Instance: &DONetworkingPlatformAdapter{m}}, - } -} - -// RequiresServices returns nil. -func (m *PlatformDONetworking) RequiresServices() []modular.ServiceDependency { return nil } - -// Plan returns the planned networking changes. -func (m *PlatformDONetworking) Plan() (*DONetworkPlan, error) { return m.backend.plan(m) } - -// Apply creates or updates the VPC and firewalls. -func (m *PlatformDONetworking) Apply() (*DOVPCState, error) { return m.backend.apply(m) } - -// Status returns the current VPC state. -func (m *PlatformDONetworking) Status() (*DOVPCState, error) { return m.backend.status(m) } - -// Destroy deletes the VPC and associated resources. -func (m *PlatformDONetworking) Destroy() error { return m.backend.destroy(m) } - -// vpcConfig parses VPC config from module config. -func (m *PlatformDONetworking) vpcConfig() map[string]string { - result := map[string]string{ - "name": m.name, - "region": "nyc3", - "ip_range": "10.10.10.0/24", - } - raw, ok := m.config["vpc"].(map[string]any) - if !ok { - return result - } - if n, ok := raw["name"].(string); ok && n != "" { - result["name"] = n - } - if r, ok := raw["region"].(string); ok && r != "" { - result["region"] = r - } - if ip, ok := raw["ip_range"].(string); ok && ip != "" { - result["ip_range"] = ip - } - return result -} - -// firewallConfigs parses firewall configs from module config. -func (m *PlatformDONetworking) firewallConfigs() []DOFirewallConfig { - raw, ok := m.config["firewalls"].([]any) - if !ok { - return nil - } - var fws []DOFirewallConfig - for _, item := range raw { - fw, ok := item.(map[string]any) - if !ok { - continue - } - name, _ := fw["name"].(string) - fws = append(fws, DOFirewallConfig{Name: name}) - } - return fws -} - -// ─── PlatformProvider adapter ────────────────────────────────────────────────── - -// DONetworkingPlatformAdapter wraps PlatformDONetworking to implement PlatformProvider. -type DONetworkingPlatformAdapter struct { - *PlatformDONetworking -} - -// Plan implements PlatformProvider. -func (a *DONetworkingPlatformAdapter) Plan() (*PlatformPlan, error) { - p, err := a.PlatformDONetworking.Plan() - if err != nil { - return nil, err - } - var actions []PlatformAction - for _, change := range p.Changes { - actionType := "create" - if change == "no changes" { - actionType = "noop" - } - actions = append(actions, PlatformAction{ - Type: actionType, - Resource: p.VPC, - Detail: change, - }) - } - return &PlatformPlan{ - Provider: "digitalocean", - Resource: "networking", - Actions: actions, - }, nil -} - -// Apply implements PlatformProvider. -func (a *DONetworkingPlatformAdapter) Apply() (*PlatformResult, error) { - st, err := a.PlatformDONetworking.Apply() - if err != nil { - return &PlatformResult{Success: false, Message: err.Error()}, err - } - return &PlatformResult{ - Success: true, - Message: fmt.Sprintf("VPC %s created in %s", st.Name, st.Region), - State: st, - }, nil -} - -// Status implements PlatformProvider. -func (a *DONetworkingPlatformAdapter) Status() (any, error) { - return a.PlatformDONetworking.Status() -} - -// Destroy implements PlatformProvider. -func (a *DONetworkingPlatformAdapter) Destroy() error { - return a.PlatformDONetworking.Destroy() -} - -// ─── mock backend ────────────────────────────────────────────────────────────── - -type doNetworkingMockBackend struct{} - -func (b *doNetworkingMockBackend) plan(m *PlatformDONetworking) (*DONetworkPlan, error) { - if m.state.Status == "active" { - return &DONetworkPlan{ - VPC: m.state.Name, - Changes: []string{"no changes"}, - }, nil - } - fws := m.firewallConfigs() - changes := []string{fmt.Sprintf("create VPC %q in %s (%s)", m.state.Name, m.state.Region, m.state.IPRange)} - for _, fw := range fws { - changes = append(changes, fmt.Sprintf("create firewall %q", fw.Name)) - } - return &DONetworkPlan{ - VPC: m.state.Name, - Firewalls: fws, - Changes: changes, - }, nil -} - -func (b *doNetworkingMockBackend) apply(m *PlatformDONetworking) (*DOVPCState, error) { - if m.state.Status == "active" { - return m.state, nil - } - m.state.ID = fmt.Sprintf("mock-vpc-%s", m.state.Name) - m.state.Status = "active" - fws := m.firewallConfigs() - for i, fw := range fws { - m.state.FirewallIDs = append(m.state.FirewallIDs, fmt.Sprintf("mock-fw-%d-%s", i, fw.Name)) - } - return m.state, nil -} - -func (b *doNetworkingMockBackend) status(m *PlatformDONetworking) (*DOVPCState, error) { - return m.state, nil -} - -func (b *doNetworkingMockBackend) destroy(m *PlatformDONetworking) error { - if m.state.Status == "deleted" { - return nil - } - m.state.Status = "deleted" - m.state.FirewallIDs = nil - m.state.LBID = "" - return nil -} - -// ─── real backend ────────────────────────────────────────────────────────────── - -type doNetworkingRealBackend struct { - client *godo.Client -} - -func (b *doNetworkingRealBackend) plan(m *PlatformDONetworking) (*DONetworkPlan, error) { - fws := m.firewallConfigs() - changes := []string{fmt.Sprintf("create VPC %q in %s (%s)", m.state.Name, m.state.Region, m.state.IPRange)} - for _, fw := range fws { - changes = append(changes, fmt.Sprintf("create firewall %q", fw.Name)) - } - return &DONetworkPlan{ - VPC: m.state.Name, - Firewalls: fws, - Changes: changes, - }, nil -} - -func (b *doNetworkingRealBackend) apply(m *PlatformDONetworking) (*DOVPCState, error) { - req := &godo.VPCCreateRequest{ - Name: m.state.Name, - RegionSlug: m.state.Region, - IPRange: m.state.IPRange, - } - vpc, _, err := b.client.VPCs.Create(context.Background(), req) - if err != nil { - return nil, fmt.Errorf("do_networking create VPC: %w", err) - } - m.state.ID = vpc.ID - m.state.Status = "active" - - fws := m.firewallConfigs() - for _, fw := range fws { - fwReq := &godo.FirewallRequest{Name: fw.Name} - created, _, fwErr := b.client.Firewalls.Create(context.Background(), fwReq) - if fwErr != nil { - return nil, fmt.Errorf("do_networking create firewall %q: %w", fw.Name, fwErr) - } - m.state.FirewallIDs = append(m.state.FirewallIDs, created.ID) - } - return m.state, nil -} - -func (b *doNetworkingRealBackend) status(m *PlatformDONetworking) (*DOVPCState, error) { - if m.state.ID == "" { - return m.state, nil - } - vpc, _, err := b.client.VPCs.Get(context.Background(), m.state.ID) - if err != nil { - return nil, fmt.Errorf("do_networking get VPC: %w", err) - } - m.state.Name = vpc.Name - m.state.Region = vpc.RegionSlug - m.state.IPRange = vpc.IPRange - return m.state, nil -} - -func (b *doNetworkingRealBackend) destroy(m *PlatformDONetworking) error { - for _, fwID := range m.state.FirewallIDs { - if _, err := b.client.Firewalls.Delete(context.Background(), fwID); err != nil { - return fmt.Errorf("do_networking delete firewall %q: %w", fwID, err) - } - } - if m.state.ID != "" { - if _, err := b.client.VPCs.Delete(context.Background(), m.state.ID); err != nil { - return fmt.Errorf("do_networking delete VPC: %w", err) - } - } - m.state.Status = "deleted" - m.state.FirewallIDs = nil - return nil -} diff --git a/module/platform_do_networking_test.go b/module/platform_do_networking_test.go deleted file mode 100644 index 08667a08..00000000 --- a/module/platform_do_networking_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package module_test - -import ( - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -func newDONetworkingApp(t *testing.T) (*module.MockApplication, *module.PlatformDONetworking) { - t.Helper() - app := module.NewMockApplication() - m := module.NewPlatformDONetworking("staging-vpc", map[string]any{ - "provider": "mock", - "vpc": map[string]any{ - "name": "staging-vpc", - "region": "nyc3", - "ip_range": "10.20.0.0/16", - }, - "firewalls": []any{ - map[string]any{"name": "allow-web"}, - map[string]any{"name": "allow-db"}, - }, - }) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - return app, m -} - -// ─── module lifecycle ───────────────────────────────────────────────────────── - -func TestDO_Networking_Init(t *testing.T) { - _, m := newDONetworkingApp(t) - if m.Name() != "staging-vpc" { - t.Errorf("expected name=staging-vpc, got %q", m.Name()) - } -} - -func TestDO_Networking_InitRegistersService(t *testing.T) { - app, _ := newDONetworkingApp(t) - svc, ok := app.Services["staging-vpc"] - if !ok { - t.Fatal("expected staging-vpc in service registry") - } - if _, ok := svc.(*module.PlatformDONetworking); !ok { - t.Fatalf("registry entry is %T, want *PlatformDONetworking", svc) - } -} - -func TestDO_Networking_Plan_PendingState(t *testing.T) { - _, m := newDONetworkingApp(t) - plan, err := m.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) - } - if plan.VPC != "staging-vpc" { - t.Errorf("expected vpc=staging-vpc, got %q", plan.VPC) - } - if len(plan.Changes) == 0 { - t.Error("expected at least one change in plan") - } - if len(plan.Firewalls) != 2 { - t.Errorf("expected 2 firewalls, got %d", len(plan.Firewalls)) - } -} - -func TestDO_Networking_Plan_NoopAfterApply(t *testing.T) { - _, m := newDONetworkingApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - plan, err := m.Plan() - if err != nil { - t.Fatalf("second Plan: %v", err) - } - if len(plan.Changes) == 0 || plan.Changes[0] != "no changes" { - t.Errorf("expected 'no changes', got %v", plan.Changes) - } -} - -func TestDO_Networking_Apply(t *testing.T) { - _, m := newDONetworkingApp(t) - state, err := m.Apply() - if err != nil { - t.Fatalf("Apply: %v", err) - } - if state.Status != "active" { - t.Errorf("expected status=active, got %q", state.Status) - } - if state.ID == "" { - t.Error("expected non-empty VPC ID after apply") - } - if len(state.FirewallIDs) != 2 { - t.Errorf("expected 2 firewall IDs, got %d", len(state.FirewallIDs)) - } -} - -func TestDO_Networking_Status(t *testing.T) { - _, m := newDONetworkingApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - state, err := m.Status() - if err != nil { - t.Fatalf("Status: %v", err) - } - if state.Status != "active" { - t.Errorf("expected status=active, got %q", state.Status) - } -} - -func TestDO_Networking_Destroy(t *testing.T) { - _, m := newDONetworkingApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := m.Destroy(); err != nil { - t.Fatalf("Destroy: %v", err) - } - state, err := m.Status() - if err != nil { - t.Fatalf("Status after destroy: %v", err) - } - if state.Status != "deleted" { - t.Errorf("expected status=deleted after destroy, got %q", state.Status) - } - if len(state.FirewallIDs) != 0 { - t.Errorf("expected no firewall IDs after destroy, got %d", len(state.FirewallIDs)) - } -} - -func TestDO_Networking_DestroyIdempotent(t *testing.T) { - _, m := newDONetworkingApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := m.Destroy(); err != nil { - t.Fatalf("first Destroy: %v", err) - } - if err := m.Destroy(); err != nil { - t.Errorf("second Destroy should be idempotent, got: %v", err) - } -} - -// ─── PlatformProvider adapter ───────────────────────────────────────────────── - -func TestDO_Networking_AdapterImplementsPlatformProvider(t *testing.T) { - app, _ := newDONetworkingApp(t) - svc, ok := app.Services["staging-vpc.iac"] - if !ok { - t.Fatal("expected staging-vpc.iac in service registry") - } - if _, ok := svc.(module.PlatformProvider); !ok { - t.Fatalf("staging-vpc.iac service (%T) does not implement PlatformProvider", svc) - } -} - -func TestDO_Networking_AdapterPlan(t *testing.T) { - app, _ := newDONetworkingApp(t) - prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) - plan, err := prov.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if plan.Provider != "digitalocean" { - t.Errorf("expected provider digitalocean, got %s", plan.Provider) - } - if plan.Resource != "networking" { - t.Errorf("expected resource networking, got %s", plan.Resource) - } - if len(plan.Actions) == 0 { - t.Fatal("expected at least one action") - } -} - -func TestDO_Networking_AdapterPlanNoop(t *testing.T) { - app, m := newDONetworkingApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) - plan, err := prov.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if len(plan.Actions) != 1 { - t.Fatalf("expected 1 noop action, got %d", len(plan.Actions)) - } - if plan.Actions[0].Type != "noop" { - t.Errorf("expected noop action after apply, got %s", plan.Actions[0].Type) - } -} - -func TestDO_Networking_AdapterApply(t *testing.T) { - app, _ := newDONetworkingApp(t) - prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) - result, err := prov.Apply() - if err != nil { - t.Fatalf("Apply() error: %v", err) - } - if !result.Success { - t.Errorf("expected success, got message: %s", result.Message) - } - if result.State == nil { - t.Error("expected non-nil state") - } -} - -func TestDO_Networking_AdapterStatus(t *testing.T) { - app, _ := newDONetworkingApp(t) - prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) - st, err := prov.Status() - if err != nil { - t.Fatalf("Status() error: %v", err) - } - if st == nil { - t.Error("expected non-nil status") - } -} - -func TestDO_Networking_AdapterDestroy(t *testing.T) { - app, _ := newDONetworkingApp(t) - prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) - if _, err := prov.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := prov.Destroy(); err != nil { - t.Fatalf("Destroy() error: %v", err) - } - st, err := prov.Status() - if err != nil { - t.Fatalf("Status after destroy: %v", err) - } - vpcState, ok := st.(*module.DOVPCState) - if !ok { - t.Fatalf("expected *DOVPCState, got %T", st) - } - if vpcState.Status != "deleted" { - t.Errorf("expected status deleted, got %s", vpcState.Status) - } -} - -func TestDO_Networking_UnsupportedProvider(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDONetworking("bad-vpc", map[string]any{ - "provider": "azure", - "vpc": map[string]any{"name": "bad"}, - }) - if err := m.Init(app); err == nil { - t.Error("expected error for unsupported provider, got nil") - } -} - -func TestDO_Networking_InvalidAccountRef(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDONetworking("fail-vpc", map[string]any{ - "provider": "mock", - "account": "nonexistent", - "vpc": map[string]any{"name": "fail"}, - }) - if err := m.Init(app); err == nil { - t.Error("expected error for nonexistent account, got nil") - } -} diff --git a/module/platform_doks.go b/module/platform_doks.go deleted file mode 100644 index 5dbbd7b2..00000000 --- a/module/platform_doks.go +++ /dev/null @@ -1,329 +0,0 @@ -package module - -import ( - "context" - "fmt" - "time" - - "github.com/GoCodeAlone/modular" - "github.com/digitalocean/godo" -) - -// DOKSClusterState holds the current state of a managed DOKS cluster. -type DOKSClusterState struct { - ID string `json:"id"` - Name string `json:"name"` - Region string `json:"region"` - Version string `json:"version"` - Status string `json:"status"` // pending, creating, running, deleting, deleted - NodePools []DOKSNodePoolState `json:"nodePools"` - Endpoint string `json:"endpoint"` - CreatedAt time.Time `json:"createdAt"` -} - -// DOKSNodePoolState describes a DOKS node pool. -type DOKSNodePoolState struct { - ID string `json:"id"` - Name string `json:"name"` - Size string `json:"size"` - Count int `json:"count"` - AutoScale bool `json:"autoScale"` - MinNodes int `json:"minNodes"` - MaxNodes int `json:"maxNodes"` -} - -// doksBackend is the internal interface DOKS backends implement. -type doksBackend interface { - create(m *PlatformDOKS) (*DOKSClusterState, error) - get(m *PlatformDOKS) (*DOKSClusterState, error) - delete(m *PlatformDOKS) error - listNodePools(m *PlatformDOKS) ([]DOKSNodePoolState, error) -} - -// PlatformDOKS manages DigitalOcean Kubernetes (DOKS) clusters. -// Config: -// -// account: name of a cloud.account module (provider=digitalocean) -// cluster_name: DOKS cluster name -// region: DO region slug (e.g. nyc3) -// version: Kubernetes version slug (e.g. 1.29.1-do.0) -// node_pool: node pool config (size, count, auto_scale, min_nodes, max_nodes) -type PlatformDOKS struct { - name string - config map[string]any - provider CloudCredentialProvider - state *DOKSClusterState - backend doksBackend -} - -// NewPlatformDOKS creates a new PlatformDOKS module. -func NewPlatformDOKS(name string, cfg map[string]any) *PlatformDOKS { - return &PlatformDOKS{name: name, config: cfg} -} - -// Name returns the module name. -func (m *PlatformDOKS) Name() string { return m.name } - -// Init resolves the cloud.account service and initializes the backend. -func (m *PlatformDOKS) Init(app modular.Application) error { - clusterName, _ := m.config["cluster_name"].(string) - if clusterName == "" { - clusterName = m.name - } - - region, _ := m.config["region"].(string) - if region == "" { - region = "nyc3" - } - - version, _ := m.config["version"].(string) - if version == "" { - version = "1.29.1-do.0" - } - - accountName, _ := m.config["account"].(string) - providerType := "mock" - - if accountName != "" { - svc, ok := app.SvcRegistry()[accountName] - if !ok { - return fmt.Errorf("platform.doks %q: account service %q not found", m.name, accountName) - } - prov, ok := svc.(CloudCredentialProvider) - if !ok { - return fmt.Errorf("platform.doks %q: service %q does not implement CloudCredentialProvider", m.name, accountName) - } - m.provider = prov - providerType = prov.Provider() - } - - m.state = &DOKSClusterState{ - Name: clusterName, - Region: region, - Version: version, - Status: "pending", - } - - switch providerType { - case "digitalocean": - acc, ok := app.SvcRegistry()[accountName].(*CloudAccount) - if !ok { - return fmt.Errorf("platform.doks %q: account %q is not a *CloudAccount", m.name, accountName) - } - client, err := acc.doClient() - if err != nil { - return fmt.Errorf("platform.doks %q: %w", m.name, err) - } - m.backend = &doksRealBackend{client: client} - default: - m.backend = &doksMockBackend{} - } - - return app.RegisterService(m.name, m) -} - -// ProvidesServices declares the service this module provides. -func (m *PlatformDOKS) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "DOKS cluster: " + m.name, Instance: m}, - } -} - -// RequiresServices returns nil. -func (m *PlatformDOKS) RequiresServices() []modular.ServiceDependency { return nil } - -// Create creates the DOKS cluster. -func (m *PlatformDOKS) Create() (*DOKSClusterState, error) { - return m.backend.create(m) -} - -// Get returns the current cluster state. -func (m *PlatformDOKS) Get() (*DOKSClusterState, error) { - return m.backend.get(m) -} - -// Delete removes the DOKS cluster. -func (m *PlatformDOKS) Delete() error { - return m.backend.delete(m) -} - -// ListNodePools returns the node pools for the cluster. -func (m *PlatformDOKS) ListNodePools() ([]DOKSNodePoolState, error) { - return m.backend.listNodePools(m) -} - -// nodePoolConfig parses node pool config from module config. -func (m *PlatformDOKS) nodePoolConfig() DOKSNodePoolState { - raw, ok := m.config["node_pool"].(map[string]any) - if !ok { - return DOKSNodePoolState{Name: "default", Size: "s-2vcpu-2gb", Count: 3} - } - name, _ := raw["name"].(string) - if name == "" { - name = "default" - } - size, _ := raw["size"].(string) - if size == "" { - size = "s-2vcpu-2gb" - } - count, _ := intFromAny(raw["count"]) - if count == 0 { - count = 3 - } - autoScale, _ := raw["auto_scale"].(bool) - minNodes, _ := intFromAny(raw["min_nodes"]) - maxNodes, _ := intFromAny(raw["max_nodes"]) - return DOKSNodePoolState{ - Name: name, - Size: size, - Count: count, - AutoScale: autoScale, - MinNodes: minNodes, - MaxNodes: maxNodes, - } -} - -// ─── mock backend ────────────────────────────────────────────────────────────── - -type doksMockBackend struct{} - -func (b *doksMockBackend) create(m *PlatformDOKS) (*DOKSClusterState, error) { - if m.state.Status == "running" { - return m.state, nil - } - m.state.Status = "creating" - m.state.ID = fmt.Sprintf("mock-doks-%s", m.state.Name) - m.state.Endpoint = fmt.Sprintf("https://%s.k8s.ondigitalocean.com", m.state.Name) - m.state.CreatedAt = time.Now() - np := m.nodePoolConfig() - np.ID = fmt.Sprintf("mock-pool-%s", np.Name) - m.state.NodePools = []DOKSNodePoolState{np} - m.state.Status = "running" - return m.state, nil -} - -func (b *doksMockBackend) get(m *PlatformDOKS) (*DOKSClusterState, error) { - return m.state, nil -} - -func (b *doksMockBackend) delete(m *PlatformDOKS) error { - if m.state.Status == "deleted" { - return nil - } - m.state.Status = "deleted" - m.state.NodePools = nil - return nil -} - -func (b *doksMockBackend) listNodePools(m *PlatformDOKS) ([]DOKSNodePoolState, error) { - return m.state.NodePools, nil -} - -// ─── real backend ────────────────────────────────────────────────────────────── - -// doksRealBackend uses godo to manage real DOKS clusters. -type doksRealBackend struct { - client *godo.Client -} - -func (b *doksRealBackend) create(m *PlatformDOKS) (*DOKSClusterState, error) { - np := m.nodePoolConfig() - nodePool := &godo.KubernetesNodePoolCreateRequest{ - Name: np.Name, - Size: np.Size, - Count: np.Count, - AutoScale: np.AutoScale, - MinNodes: np.MinNodes, - MaxNodes: np.MaxNodes, - } - - req := &godo.KubernetesClusterCreateRequest{ - Name: m.state.Name, - RegionSlug: m.state.Region, - VersionSlug: m.state.Version, - NodePools: []*godo.KubernetesNodePoolCreateRequest{nodePool}, - } - - cluster, _, err := b.client.Kubernetes.Create(context.Background(), req) - if err != nil { - return nil, fmt.Errorf("doks create: %w", err) - } - - return doksClusterToState(cluster), nil -} - -func (b *doksRealBackend) get(m *PlatformDOKS) (*DOKSClusterState, error) { - if m.state.ID == "" { - return m.state, nil - } - cluster, _, err := b.client.Kubernetes.Get(context.Background(), m.state.ID) - if err != nil { - return nil, fmt.Errorf("doks get: %w", err) - } - state := doksClusterToState(cluster) - m.state = state - return state, nil -} - -func (b *doksRealBackend) delete(m *PlatformDOKS) error { - if m.state.ID == "" { - return nil - } - _, err := b.client.Kubernetes.Delete(context.Background(), m.state.ID) - if err != nil { - return fmt.Errorf("doks delete: %w", err) - } - m.state.Status = "deleted" - m.state.NodePools = nil - return nil -} - -func (b *doksRealBackend) listNodePools(m *PlatformDOKS) ([]DOKSNodePoolState, error) { - if m.state.ID == "" { - return nil, nil - } - pools, _, err := b.client.Kubernetes.ListNodePools(context.Background(), m.state.ID, nil) - if err != nil { - return nil, fmt.Errorf("doks list node pools: %w", err) - } - var result []DOKSNodePoolState - for _, p := range pools { - result = append(result, DOKSNodePoolState{ - ID: p.ID, - Name: p.Name, - Size: p.Size, - Count: p.Count, - AutoScale: p.AutoScale, - MinNodes: p.MinNodes, - MaxNodes: p.MaxNodes, - }) - } - return result, nil -} - -// doksClusterToState converts a godo.KubernetesCluster to DOKSClusterState. -func doksClusterToState(c *godo.KubernetesCluster) *DOKSClusterState { - state := &DOKSClusterState{ - ID: c.ID, - Name: c.Name, - Region: c.RegionSlug, - Version: c.VersionSlug, - Status: string(c.Status.State), - CreatedAt: c.CreatedAt, - } - if c.Endpoint != "" { - state.Endpoint = c.Endpoint - } - for _, p := range c.NodePools { - state.NodePools = append(state.NodePools, DOKSNodePoolState{ - ID: p.ID, - Name: p.Name, - Size: p.Size, - Count: p.Count, - AutoScale: p.AutoScale, - MinNodes: p.MinNodes, - MaxNodes: p.MaxNodes, - }) - } - return state -} diff --git a/module/platform_doks_test.go b/module/platform_doks_test.go deleted file mode 100644 index 8509fdbe..00000000 --- a/module/platform_doks_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package module_test - -import ( - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -func newDOKSApp(t *testing.T) (*module.MockApplication, *module.PlatformDOKS) { - t.Helper() - app := module.NewMockApplication() - m := module.NewPlatformDOKS("my-cluster", map[string]any{ - "cluster_name": "staging-cluster", - "region": "nyc3", - "version": "1.29.1-do.0", - "node_pool": map[string]any{ - "name": "default", - "size": "s-2vcpu-2gb", - "count": 3, - }, - }) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - return app, m -} - -// ─── module lifecycle ───────────────────────────────────────────────────────── - -func TestDO_DOKS_Init(t *testing.T) { - _, m := newDOKSApp(t) - if m.Name() != "my-cluster" { - t.Errorf("expected name=my-cluster, got %q", m.Name()) - } -} - -func TestDO_DOKS_InitRegistersService(t *testing.T) { - app, _ := newDOKSApp(t) - svc, ok := app.Services["my-cluster"] - if !ok { - t.Fatal("expected my-cluster in service registry") - } - if _, ok := svc.(*module.PlatformDOKS); !ok { - t.Fatalf("registry entry is %T, want *PlatformDOKS", svc) - } -} - -func TestDO_DOKS_Create(t *testing.T) { - _, m := newDOKSApp(t) - state, err := m.Create() - if err != nil { - t.Fatalf("Create: %v", err) - } - if state.Status != "running" { - t.Errorf("expected status=running, got %q", state.Status) - } - if state.ID == "" { - t.Error("expected non-empty ID after create") - } - if state.Endpoint == "" { - t.Error("expected non-empty Endpoint after create") - } - if len(state.NodePools) == 0 { - t.Error("expected at least one node pool after create") - } -} - -func TestDO_DOKS_Get(t *testing.T) { - _, m := newDOKSApp(t) - if _, err := m.Create(); err != nil { - t.Fatalf("Create: %v", err) - } - state, err := m.Get() - if err != nil { - t.Fatalf("Get: %v", err) - } - if state.Status != "running" { - t.Errorf("expected status=running, got %q", state.Status) - } -} - -func TestDO_DOKS_ListNodePools(t *testing.T) { - _, m := newDOKSApp(t) - if _, err := m.Create(); err != nil { - t.Fatalf("Create: %v", err) - } - pools, err := m.ListNodePools() - if err != nil { - t.Fatalf("ListNodePools: %v", err) - } - if len(pools) == 0 { - t.Error("expected at least one node pool") - } - if pools[0].Name != "default" { - t.Errorf("expected pool name=default, got %q", pools[0].Name) - } - if pools[0].Count != 3 { - t.Errorf("expected count=3, got %d", pools[0].Count) - } -} - -func TestDO_DOKS_Delete(t *testing.T) { - _, m := newDOKSApp(t) - if _, err := m.Create(); err != nil { - t.Fatalf("Create: %v", err) - } - if err := m.Delete(); err != nil { - t.Fatalf("Delete: %v", err) - } - state, err := m.Get() - if err != nil { - t.Fatalf("Get after delete: %v", err) - } - if state.Status != "deleted" { - t.Errorf("expected status=deleted, got %q", state.Status) - } - if len(state.NodePools) != 0 { - t.Errorf("expected no node pools after delete, got %d", len(state.NodePools)) - } -} - -func TestDO_DOKS_DeleteIdempotent(t *testing.T) { - _, m := newDOKSApp(t) - if _, err := m.Create(); err != nil { - t.Fatalf("Create: %v", err) - } - if err := m.Delete(); err != nil { - t.Fatalf("first Delete: %v", err) - } - if err := m.Delete(); err != nil { - t.Errorf("second Delete should be idempotent, got: %v", err) - } -} - -func TestDO_DOKS_DefaultNodePool(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDOKS("default-cluster", map[string]any{ - "cluster_name": "default-cluster", - }) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - state, err := m.Create() - if err != nil { - t.Fatalf("Create: %v", err) - } - if len(state.NodePools) == 0 { - t.Error("expected default node pool") - } - if state.NodePools[0].Size != "s-2vcpu-2gb" { - t.Errorf("expected default size=s-2vcpu-2gb, got %q", state.NodePools[0].Size) - } -} - -func TestDO_DOKS_InvalidAccountRef(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDOKS("fail-cluster", map[string]any{ - "cluster_name": "fail-cluster", - "account": "nonexistent", - }) - if err := m.Init(app); err == nil { - t.Error("expected error for nonexistent account, got nil") - } -} From b09726bde7fdb0d34796b7142398e71f9af1a5da Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 04:18:46 -0400 Subject: [PATCH 18/26] feat(#617): strip DO registration sites + remap wfctl detection hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * plugins/platform: drop 5 module + 5 step factories and manifest entries. * schema/*: drop 10 entries from module/step type lists + schema descriptions. Update editor-schemas.golden.json to match. * cmd/wfctl/type_registry.go: drop 10 legacy DO type entries. * cmd/wfctl/{infra.go,deploy_providers.go,ci_run_dryrun.go}: remap isContainerType and deployTargetTypes to remove platform.do_app. * cmd/wfctl/deploy.go: extend prefix check to include infra.* + rename platformModules → deployTargetModules + update error message. * module/multi_region.go: rewrite DOKS multi-region hint to point at infra.k8s_cluster + workflow-plugin-digitalocean. * cmd/wfctl/infra_apply_test.go: replace platform.do_app negative-test fixture with example.legacy_unknown synthetic type. * cmd/wfctl/{validate.go,ci_validate.go}: inject legacydo.ModuleTypes into schema opts + post-ValidateConfig sweep emits actionable migration errors. * cmd/wfctl/deploy_test.go: update error message assertion. Creates internal/legacydo/types.go (leaf package — stdlib only) with the legacy-DO type maps and message formatters needed by T3's engine/step-registry guards and this task's wfctl validate edits. Adds legacy_do_types_removed_test.go (registry-absence regression gate) + TestValidateFile_LegacyDOModule_ReturnsActionableError and TestCIValidateFile_LegacyDOStep_ReturnsActionableError (validate-path AC3). --- cmd/wfctl/ci_run_dryrun.go | 1 - cmd/wfctl/ci_validate.go | 29 +++ cmd/wfctl/deploy.go | 20 +- cmd/wfctl/deploy_providers.go | 5 +- cmd/wfctl/deploy_test.go | 2 +- cmd/wfctl/infra.go | 2 +- cmd/wfctl/infra_apply_test.go | 2 +- cmd/wfctl/legacy_do_types_removed_test.go | 87 +++++++ cmd/wfctl/type_registry.go | 59 +---- cmd/wfctl/validate.go | 31 +++ internal/legacydo/types.go | 95 +++++++ module/multi_region.go | 2 +- plugins/platform/plugin.go | 103 +------- plugins/platform/plugin_test.go | 10 - schema/module_schema.go | 94 ------- schema/schema.go | 10 - schema/step_schema_builtins.go | 74 ------ schema/testdata/editor-schemas.golden.json | 286 --------------------- 18 files changed, 261 insertions(+), 651 deletions(-) create mode 100644 cmd/wfctl/legacy_do_types_removed_test.go create mode 100644 internal/legacydo/types.go diff --git a/cmd/wfctl/ci_run_dryrun.go b/cmd/wfctl/ci_run_dryrun.go index ab007f35..556b255f 100644 --- a/cmd/wfctl/ci_run_dryrun.go +++ b/cmd/wfctl/ci_run_dryrun.go @@ -177,7 +177,6 @@ func resolveDeployInfoFromConfig(wfCfg *config.WorkflowConfig, envName, provider // references the provider. deployTargetTypes := []string{ "infra.container_service", - "platform.do_app", "platform.app_platform", "infra.k8s_cluster", } diff --git a/cmd/wfctl/ci_validate.go b/cmd/wfctl/ci_validate.go index 4dac981f..e79583e2 100644 --- a/cmd/wfctl/ci_validate.go +++ b/cmd/wfctl/ci_validate.go @@ -10,8 +10,10 @@ import ( "time" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/internal/legacydo" "github.com/GoCodeAlone/workflow/schema" "github.com/GoCodeAlone/workflow/validation" + "gopkg.in/yaml.v3" ) // ciFileResult holds the outcome of validating a single config file. @@ -131,10 +133,37 @@ func ciValidateFile(cfgPath string, strict, immutableConfig bool, immutableSecti opts = append(opts, schema.WithAllowEmptyModules()) } opts = append(opts, schema.WithSkipWorkflowTypeCheck(), schema.WithSkipTriggerTypeCheck()) + // Pass legacy DO module types through schema so the migration error fires + // below instead of a generic "unknown module type" (issue #617). + for t := range legacydo.ModuleTypes { + opts = append(opts, schema.WithExtraModuleTypes(t)) + } if err := schema.ValidateConfig(cfg, opts...); err != nil { errs = append(errs, fmt.Errorf("schema: %w", err)) } + // Post-validate sweep: accumulate legacy DO module/step errors (issue #617). + for _, m := range cfg.Modules { + if legacydo.IsModuleType(m.Type) { + errs = append(errs, legacydo.FormatModuleError(m.Type, m.Name, false)) + } + } + for _, rawPipeline := range cfg.Pipelines { + yamlBytes, err := yaml.Marshal(rawPipeline) + if err != nil { + continue + } + var pipeCfg config.PipelineConfig + if err := yaml.Unmarshal(yamlBytes, &pipeCfg); err != nil { + continue + } + for _, s := range pipeCfg.Steps { + if legacydo.IsStepType(s.Type) { + errs = append(errs, legacydo.FormatStepError(s.Type, false)) + } + } + } + // CI config check. if immutableConfig { if cfg.CI == nil { diff --git a/cmd/wfctl/deploy.go b/cmd/wfctl/deploy.go index e88de93f..20fdf38f 100644 --- a/cmd/wfctl/deploy.go +++ b/cmd/wfctl/deploy.go @@ -778,7 +778,7 @@ func runDeployCloud(args []string) error { fmt.Fprintf(fs.Output(), `Usage: wfctl deploy cloud [options] Deploy infrastructure defined in a workflow config to a cloud environment. -Discovers cloud.account and platform.* modules, validates credentials, +Discovers cloud.account, platform.*, and infra.* modules, validates credentials, shows a deployment plan, and applies changes. Options: @@ -829,15 +829,15 @@ Options: return fmt.Errorf("parse config %s: %w", cfg, yamlErr) } - // Discover cloud accounts and platform modules + // Discover cloud accounts and deploy-target modules (platform.* or infra.*) var cloudAccounts []moduleEntry - var platformModules []moduleEntry + var deployTargetModules []moduleEntry for _, m := range parsed.Modules { if m.Type == "cloud.account" { cloudAccounts = append(cloudAccounts, m) } - if strings.HasPrefix(m.Type, "platform.") { - platformModules = append(platformModules, m) + if strings.HasPrefix(m.Type, "platform.") || strings.HasPrefix(m.Type, "infra.") { + deployTargetModules = append(deployTargetModules, m) } } @@ -896,13 +896,13 @@ Options: fmt.Println() } - // Report platform modules (deployment plan) - if len(platformModules) == 0 { - return fmt.Errorf("no platform.* modules found in config — nothing to deploy") + // Report deploy-target modules (deployment plan) + if len(deployTargetModules) == 0 { + return fmt.Errorf("no platform.* or infra.* modules found in config — nothing to deploy") } - fmt.Printf("Infrastructure Modules (%d):\n", len(platformModules)) - for _, pm := range platformModules { + fmt.Printf("Infrastructure Modules (%d):\n", len(deployTargetModules)) + for _, pm := range deployTargetModules { account, _ := pm.Config["account"].(string) detail := pm.Type if account != "" { diff --git a/cmd/wfctl/deploy_providers.go b/cmd/wfctl/deploy_providers.go index 395c96a9..0177d108 100644 --- a/cmd/wfctl/deploy_providers.go +++ b/cmd/wfctl/deploy_providers.go @@ -86,8 +86,8 @@ var resolveIaCProvider = discoverAndLoadIaCProvider // double parse — and either may be empty without affecting the // other. type iacPluginManifest struct { - Name string `json:"name"` - Version string `json:"version"` + Name string `json:"name"` + Version string `json:"version"` Capabilities struct { IaCProvider struct { Name string `json:"name"` @@ -418,7 +418,6 @@ func newPluginDeployProvider(providerName string, wfCfg *config.WorkflowConfig, // behaviour is predictable rather than silently wrong. deployTargetTypes := []string{ "infra.container_service", - "platform.do_app", "platform.app_platform", "infra.k8s_cluster", } diff --git a/cmd/wfctl/deploy_test.go b/cmd/wfctl/deploy_test.go index e30c1518..b6a38b45 100644 --- a/cmd/wfctl/deploy_test.go +++ b/cmd/wfctl/deploy_test.go @@ -92,7 +92,7 @@ func TestRunDeployCloudValidTarget(t *testing.T) { if err == nil { t.Fatal("expected error when no platform modules found") } - if !strings.Contains(err.Error(), "no platform.* modules found") { + if !strings.Contains(err.Error(), "no platform.* or infra.* modules found") { t.Errorf("expected no platform modules error, got: %v", err) } } diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 644ebc40..b852ab51 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -574,7 +574,7 @@ func planResourcesForEnv(path, envName string) ([]*config.ResolvedModule, error) } func isContainerType(t string) bool { - return t == "infra.container_service" || t == "platform.do_app" + return t == "infra.container_service" } // loadCurrentState loads ResourceStates from the configured iac.state backend. diff --git a/cmd/wfctl/infra_apply_test.go b/cmd/wfctl/infra_apply_test.go index 944248d7..c8e29b69 100644 --- a/cmd/wfctl/infra_apply_test.go +++ b/cmd/wfctl/infra_apply_test.go @@ -1987,7 +1987,7 @@ modules: if err := os.WriteFile(legacyOnly, []byte(` modules: - name: app - type: platform.do_app + type: example.legacy_unknown config: {} `), 0o600); err != nil { t.Fatal(err) diff --git a/cmd/wfctl/legacy_do_types_removed_test.go b/cmd/wfctl/legacy_do_types_removed_test.go new file mode 100644 index 00000000..b109eb7e --- /dev/null +++ b/cmd/wfctl/legacy_do_types_removed_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestLegacyDOTypesAbsent_FromTypeRegistry locks the post-cutover state of +// cmd/wfctl/type_registry.go for issue #617. If any legacy type leaks back in, +// this test fires and the CI gate fires. +func TestLegacyDOTypesAbsent_FromTypeRegistry(t *testing.T) { + modules := KnownModuleTypes() + steps := KnownStepTypes() + legacyModules := []string{ + "platform.do_app", "platform.do_database", "platform.do_dns", + "platform.do_networking", "platform.doks", + } + legacySteps := []string{ + "step.do_deploy", "step.do_status", "step.do_logs", + "step.do_scale", "step.do_destroy", + } + for _, tname := range legacyModules { + if _, ok := modules[tname]; ok { + t.Errorf("module type registry still contains legacy DO type %q (issue #617)", tname) + } + } + for _, tname := range legacySteps { + if _, ok := steps[tname]; ok { + t.Errorf("step type registry still contains legacy DO type %q (issue #617)", tname) + } + } +} + +// TestValidateFile_LegacyDOModule_ReturnsActionableError verifies that +// wfctl validate emits the actionable migration error when the config +// references a removed legacy DO module type (issue #617). Covers AC3 +// on the validate path (the engine path is covered by +// TestLegacyDOModuleError_PluginNotLoaded in the workflow package). +func TestValidateFile_LegacyDOModule_ReturnsActionableError(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "legacy.yaml") + yamlContent := []byte("modules:\n - name: api\n type: platform.do_app\n config: {}\n") + if err := os.WriteFile(cfgPath, yamlContent, 0o600); err != nil { + t.Fatal(err) + } + err := validateFile(cfgPath, false, false, false) + if err == nil { + t.Fatal("expected error for legacy DO module type") + } + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-digitalocean", + "infra.container_service", + } { + if !strings.Contains(msg, want) { + t.Errorf("error missing %q; got: %s", want, msg) + } + } +} + +// TestCIValidateFile_LegacyDOStep_ReturnsActionableError covers ciValidateFile's +// accumulating return for legacy DO step types. +func TestCIValidateFile_LegacyDOStep_ReturnsActionableError(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "legacy.yaml") + yamlContent := []byte("pipelines:\n deploy:\n steps:\n - type: step.do_deploy\n") + if err := os.WriteFile(cfgPath, yamlContent, 0o600); err != nil { + t.Fatal(err) + } + errs := ciValidateFile(cfgPath, false, false, "") + if len(errs) == 0 { + t.Fatal("expected error for legacy DO step type") + } + found := false + for _, e := range errs { + if strings.Contains(e.Error(), "step.iac_apply") && strings.Contains(e.Error(), "removed from workflow core") { + found = true + break + } + } + if !found { + t.Errorf("expected actionable migration error in errs; got: %v", errs) + } +} diff --git a/cmd/wfctl/type_registry.go b/cmd/wfctl/type_registry.go index 6f7b9280..8945ceed 100644 --- a/cmd/wfctl/type_registry.go +++ b/cmd/wfctl/type_registry.go @@ -506,43 +506,13 @@ func KnownModuleTypes() map[string]ModuleTypeInfo { ConfigKeys: []string{"format"}, }, - // platform plugin (region router + DigitalOcean) + // platform plugin (region router) "platform.region_router": { Type: "platform.region_router", Plugin: "platform", Stateful: false, ConfigKeys: []string{"module", "mode"}, }, - "platform.doks": { - Type: "platform.doks", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "cluster_name", "region", "version", "node_pool"}, - }, - "platform.do_networking": { - Type: "platform.do_networking", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "provider", "vpc", "firewalls"}, - }, - "platform.do_dns": { - Type: "platform.do_dns", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "provider", "domain", "records"}, - }, - "platform.do_app": { - Type: "platform.do_app", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "provider", "name", "region", "image", "instances", "http_port", "envs"}, - }, - "platform.do_database": { - Type: "platform.do_database", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "provider", "engine", "size", "region", "nodes"}, - }, "platform.kubernetes": { Type: "platform.kubernetes", Plugin: "platform", @@ -1427,33 +1397,6 @@ func KnownStepTypes() map[string]StepTypeInfo { ConfigKeys: []string{"service", "label_selector"}, }, - // platform plugin steps (DigitalOcean) - "step.do_deploy": { - Type: "step.do_deploy", - Plugin: "platform", - ConfigKeys: []string{"app", "image"}, - }, - "step.do_status": { - Type: "step.do_status", - Plugin: "platform", - ConfigKeys: []string{"app"}, - }, - "step.do_logs": { - Type: "step.do_logs", - Plugin: "platform", - ConfigKeys: []string{"app"}, - }, - "step.do_scale": { - Type: "step.do_scale", - Plugin: "platform", - ConfigKeys: []string{"app", "instances"}, - }, - "step.do_destroy": { - Type: "step.do_destroy", - Plugin: "platform", - ConfigKeys: []string{"app"}, - }, - // platform plugin steps (platform template) "step.platform_template": { Type: "step.platform_template", diff --git a/cmd/wfctl/validate.go b/cmd/wfctl/validate.go index 450725b7..4e6eaa04 100644 --- a/cmd/wfctl/validate.go +++ b/cmd/wfctl/validate.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/internal/legacydo" "github.com/GoCodeAlone/workflow/schema" "gopkg.in/yaml.v3" ) @@ -142,10 +143,40 @@ func validateFile(cfgPath string, strict, skipUnknownTypes, allowNoEntryPoints b opts = append(opts, schema.WithAllowNoEntryPoints()) } + // Pass legacy DO module types through schema validation so the actionable + // migration error fires below instead of a generic "unknown module type". + for t := range legacydo.ModuleTypes { + opts = append(opts, schema.WithExtraModuleTypes(t)) + } + if err := schema.ValidateConfig(cfg, opts...); err != nil { return err } + // Post-validate sweep: reject legacy DO module/step types with actionable + // migration errors (issue #617). wfctl validate has no engine, so the + // iacProviderLoaded flag is always false here. + for _, m := range cfg.Modules { + if legacydo.IsModuleType(m.Type) { + return legacydo.FormatModuleError(m.Type, m.Name, false) + } + } + for _, rawPipeline := range cfg.Pipelines { + yamlBytes, err := yaml.Marshal(rawPipeline) + if err != nil { + continue + } + var pipeCfg config.PipelineConfig + if err := yaml.Unmarshal(yamlBytes, &pipeCfg); err != nil { + continue + } + for _, s := range pipeCfg.Steps { + if legacydo.IsStepType(s.Type) { + return legacydo.FormatStepError(s.Type, false) + } + } + } + // Validate ci:, environments:, and secrets: sections when present. if cfg.CI != nil { if err := cfg.CI.Validate(); err != nil { diff --git a/internal/legacydo/types.go b/internal/legacydo/types.go new file mode 100644 index 00000000..1a17ba2b --- /dev/null +++ b/internal/legacydo/types.go @@ -0,0 +1,95 @@ +// Package legacydo holds the read-only data and message formatters for the +// legacy DigitalOcean module + step types removed in issue #617. Lives in +// internal/ so that both module/ and modernize/ can import it without a +// cycle (module transitively imports modernize via plugin, so modernize +// cannot import module). +package legacydo + +import ( + "fmt" + "sort" + "strings" +) + +// RemovedInVersion is the workflow tag that ships issue #617's force-cutover. +// Used in every legacy-DO migration error and in the wfctl modernize rule. +// Update both this constant and the docs/migrations/v-godo-removal.md +// filename when the release tag is finalised. +const RemovedInVersion = "v0.52.0" + +// ModuleTypes maps each removed legacy DigitalOcean module type to its +// infra.* IaC successor (issue #617). +var ModuleTypes = map[string]string{ + "platform.do_app": "infra.container_service", + "platform.do_database": "infra.database", + "platform.do_dns": "infra.dns", + "platform.do_networking": "infra.vpc + infra.firewall", + "platform.doks": "infra.k8s_cluster", +} + +// StepTypes maps each removed legacy DigitalOcean step type to its +// successor or to a workaround when no 1:1 successor exists. +var StepTypes = map[string]string{ + "step.do_deploy": "step.iac_apply (against an infra.container_service module)", + "step.do_status": "step.iac_status (against an infra.container_service module)", + "step.do_destroy": "step.iac_destroy (against an infra.container_service module)", + "step.do_logs": "no direct pipeline-step equivalent; use `wfctl infra logs` ad-hoc, or rely on the DO plugin's Troubleshoot hook on step.iac_apply failure", + "step.do_scale": "no direct pipeline-step equivalent; update instance_count in the infra.container_service module config and re-run step.iac_apply", +} + +// IsModuleType reports whether t is a removed legacy DO module type. +func IsModuleType(t string) bool { _, ok := ModuleTypes[t]; return ok } + +// IsStepType reports whether t is a removed legacy DO step type. +func IsStepType(t string) bool { _, ok := StepTypes[t]; return ok } + +// FormatModuleError builds the actionable migration error for a legacy +// DO module type. iacProviderLoaded indicates whether the iac.provider factory +// is registered in the engine — used to branch between the "install plugin" +// and "config-only issue" messages. +func FormatModuleError(legacyType, moduleName string, iacProviderLoaded bool) error { + successor, ok := ModuleTypes[legacyType] + if !ok { + return nil + } + pluginLine := "Install workflow-plugin-digitalocean: https://github.com/GoCodeAlone/workflow-plugin-digitalocean" + if iacProviderLoaded { + pluginLine = "workflow-plugin-digitalocean is already loaded; your config still references the legacy module name." + } + var b strings.Builder + fmt.Fprintf(&b, "unsupported legacy module type %q (module %q): this type was removed from workflow core in %s — DigitalOcean IaC moved to workflow-plugin-digitalocean.\n\n", legacyType, moduleName, RemovedInVersion) + b.WriteString(pluginLine) + b.WriteString("\n\nMigrate this module to: ") + b.WriteString(successor) + b.WriteString(" (provider: digitalocean)\n\nFull mapping:\n") + keys := make([]string, 0, len(ModuleTypes)) + for k := range ModuleTypes { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&b, " %s → %s\n", k, ModuleTypes[k]) + } + b.WriteString("\nSee docs/migrations/v0.52.0-godo-removal.md") + return fmt.Errorf("%s", b.String()) +} + +// FormatStepError builds the actionable migration error for a legacy +// DO step type. +func FormatStepError(legacyType string, iacProviderLoaded bool) error { + successor, ok := StepTypes[legacyType] + if !ok { + return nil + } + pluginLine := "Install workflow-plugin-digitalocean: https://github.com/GoCodeAlone/workflow-plugin-digitalocean" + if iacProviderLoaded { + pluginLine = "workflow-plugin-digitalocean is already loaded; your config still references the legacy step name." + } + var b strings.Builder + fmt.Fprintf(&b, "unsupported legacy step type %q: this step was removed from workflow core in %s — DigitalOcean IaC moved to workflow-plugin-digitalocean.\n\n", legacyType, RemovedInVersion) + b.WriteString(pluginLine) + b.WriteString("\n\nMigrate this step to: ") + b.WriteString(successor) + b.WriteString("\n\nSee docs/migrations/v0.52.0-godo-removal.md") + return fmt.Errorf("%s", b.String()) +} diff --git a/module/multi_region.go b/module/multi_region.go index 725e0e08..e29082ca 100644 --- a/module/multi_region.go +++ b/module/multi_region.go @@ -120,7 +120,7 @@ func (m *MultiRegionModule) Init(app modular.Application) error { case "azure": return fmt.Errorf("platform.region %q: provider %q is not yet supported; use AKS modules with Azure Traffic Manager for multi-region routing", m.name, providerType) case "digitalocean": - return fmt.Errorf("platform.region %q: provider %q is not yet supported; use platform.doks modules per region for DigitalOcean multi-region deployments", m.name, providerType) + return fmt.Errorf("platform.region %q: provider %q is not yet supported; for DigitalOcean multi-region, use infra.k8s_cluster modules per region with provider: digitalocean (requires workflow-plugin-digitalocean)", m.name, providerType) default: return fmt.Errorf("platform.region %q: unsupported provider %q (supported: mock)", m.name, providerType) } diff --git a/plugins/platform/plugin.go b/plugins/platform/plugin.go index 0b021ee2..c416a25a 100644 --- a/plugins/platform/plugin.go +++ b/plugins/platform/plugin.go @@ -31,8 +31,8 @@ func New() *Plugin { Author: "GoCodeAlone", Description: "Platform infrastructure modules, workflow handler, reconciliation trigger, and template step", Tier: plugin.TierCore, - ModuleTypes: []string{"platform.provider", "platform.resource", "platform.context", "platform.kubernetes", "platform.ecs", "platform.dns", "platform.networking", "platform.apigateway", "platform.autoscaling", "platform.region", "platform.region_router", "platform.doks", "platform.do_networking", "platform.do_dns", "platform.do_app", "platform.do_database", "iac.state", "app.container", "argo.workflows"}, - StepTypes: []string{"step.platform_template", "step.k8s_plan", "step.k8s_apply", "step.k8s_status", "step.k8s_destroy", "step.ecs_plan", "step.ecs_apply", "step.ecs_status", "step.ecs_destroy", "step.iac_plan", "step.iac_apply", "step.iac_status", "step.iac_destroy", "step.iac_drift_detect", "step.dns_plan", "step.dns_apply", "step.dns_status", "step.network_plan", "step.network_apply", "step.network_status", "step.apigw_plan", "step.apigw_apply", "step.apigw_status", "step.apigw_destroy", "step.scaling_plan", "step.scaling_apply", "step.scaling_status", "step.scaling_destroy", "step.app_deploy", "step.app_status", "step.app_rollback", "step.region_deploy", "step.region_promote", "step.region_failover", "step.region_status", "step.region_weight", "step.region_sync", "step.argo_submit", "step.argo_status", "step.argo_logs", "step.argo_delete", "step.argo_list", "step.do_deploy", "step.do_status", "step.do_logs", "step.do_scale", "step.do_destroy"}, + ModuleTypes: []string{"platform.provider", "platform.resource", "platform.context", "platform.kubernetes", "platform.ecs", "platform.dns", "platform.networking", "platform.apigateway", "platform.autoscaling", "platform.region", "platform.region_router", "iac.state", "app.container", "argo.workflows"}, + StepTypes: []string{"step.platform_template", "step.k8s_plan", "step.k8s_apply", "step.k8s_status", "step.k8s_destroy", "step.ecs_plan", "step.ecs_apply", "step.ecs_status", "step.ecs_destroy", "step.iac_plan", "step.iac_apply", "step.iac_status", "step.iac_destroy", "step.iac_drift_detect", "step.dns_plan", "step.dns_apply", "step.dns_status", "step.network_plan", "step.network_apply", "step.network_status", "step.apigw_plan", "step.apigw_apply", "step.apigw_status", "step.apigw_destroy", "step.scaling_plan", "step.scaling_apply", "step.scaling_status", "step.scaling_destroy", "step.app_deploy", "step.app_status", "step.app_rollback", "step.region_deploy", "step.region_promote", "step.region_failover", "step.region_status", "step.region_weight", "step.region_sync", "step.argo_submit", "step.argo_status", "step.argo_logs", "step.argo_delete", "step.argo_list"}, TriggerTypes: []string{"reconciliation"}, WorkflowTypes: []string{"platform"}, }, @@ -94,21 +94,6 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { "argo.workflows": func(name string, cfg map[string]any) modular.Module { return module.NewArgoWorkflowsModule(name, cfg) }, - "platform.doks": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformDOKS(name, cfg) - }, - "platform.do_networking": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformDONetworking(name, cfg) - }, - "platform.do_dns": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformDODNS(name, cfg) - }, - "platform.do_app": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformDOApp(name, cfg) - }, - "platform.do_database": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformDODatabase(name, cfg) - }, "platform.region_router": func(name string, cfg map[string]any) modular.Module { return module.NewMultiRegionRoutingModule(name, cfg) }, @@ -244,21 +229,6 @@ func (p *Plugin) StepFactories() map[string]plugin.StepFactory { "step.argo_list": func(name string, cfg map[string]any, app modular.Application) (any, error) { return module.NewArgoListStepFactory()(name, cfg, app) }, - "step.do_deploy": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewDODeployStepFactory()(name, cfg, app) - }, - "step.do_status": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewDOStatusStepFactory()(name, cfg, app) - }, - "step.do_logs": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewDOLogsStepFactory()(name, cfg, app) - }, - "step.do_scale": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewDOScaleStepFactory()(name, cfg, app) - }, - "step.do_destroy": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewDODestroyStepFactory()(name, cfg, app) - }, } } @@ -425,74 +395,5 @@ func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { {Key: "regions", Label: "Regions", Type: schema.FieldTypeArray, Required: true, Description: "List of region definitions (name, provider, endpoint, priority, health_check)"}, }, }, - { - Type: "platform.doks", - Label: "DigitalOcean Kubernetes (DOKS)", - Category: "infrastructure", - Description: "Manages DigitalOcean Kubernetes Service clusters (mock or real DO backend)", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (provider=digitalocean)"}, - {Key: "cluster_name", Label: "Cluster Name", Type: schema.FieldTypeString, Description: "DOKS cluster name"}, - {Key: "region", Label: "Region", Type: schema.FieldTypeString, Description: "DO region slug (e.g. nyc3)"}, - {Key: "version", Label: "Kubernetes Version", Type: schema.FieldTypeString, Description: "Kubernetes version slug (e.g. 1.29.1-do.0)"}, - {Key: "node_pool", Label: "Node Pool", Type: schema.FieldTypeMap, Description: "Node pool config (size, count, auto_scale, min_nodes, max_nodes)"}, - }, - }, - { - Type: "platform.do_networking", - Label: "DigitalOcean VPC & Firewalls", - Category: "infrastructure", - Description: "Manages DigitalOcean VPCs, firewalls, and load balancers (mock or real DO backend)", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (provider=digitalocean)"}, - {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | digitalocean"}, - {Key: "vpc", Label: "VPC Config", Type: schema.FieldTypeMap, Required: true, Description: "VPC configuration (name, region, ip_range)"}, - {Key: "firewalls", Label: "Firewalls", Type: schema.FieldTypeArray, Description: "List of firewall definitions"}, - }, - }, - { - Type: "platform.do_dns", - Label: "DigitalOcean DNS", - Category: "infrastructure", - Description: "Manages DigitalOcean domains and DNS records (mock or real DO backend)", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (provider=digitalocean)"}, - {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | digitalocean"}, - {Key: "domain", Label: "Domain", Type: schema.FieldTypeString, Required: true, Description: "Domain name (e.g. example.com)"}, - {Key: "records", Label: "Records", Type: schema.FieldTypeArray, Description: "List of DNS record definitions (name, type, data, ttl)"}, - }, - }, - { - Type: "platform.do_app", - Label: "DigitalOcean App Platform", - Category: "application", - Description: "Deploys containerized apps to DigitalOcean App Platform (mock or real DO backend)", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (provider=digitalocean)"}, - {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | digitalocean"}, - {Key: "name", Label: "App Name", Type: schema.FieldTypeString, Description: "App Platform application name"}, - {Key: "region", Label: "Region", Type: schema.FieldTypeString, Description: "DO region slug (e.g. nyc)"}, - {Key: "image", Label: "Container Image", Type: schema.FieldTypeString, Description: "Container image reference"}, - {Key: "instances", Label: "Instances", Type: schema.FieldTypeNumber, Description: "Number of instances (default: 1)"}, - {Key: "http_port", Label: "HTTP Port", Type: schema.FieldTypeNumber, Description: "Container HTTP port (default: 8080)"}, - {Key: "envs", Label: "Environment Variables", Type: schema.FieldTypeMap, Description: "Environment variables for the app"}, - }, - }, - { - Type: "platform.do_database", - Label: "DigitalOcean Managed Database", - Category: "infrastructure", - Description: "Manages DigitalOcean Managed Databases (PostgreSQL, MySQL, Redis, MongoDB, Kafka)", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (provider=digitalocean)"}, - {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | digitalocean"}, - {Key: "engine", Label: "Engine", Type: schema.FieldTypeString, Description: "Database engine: pg | mysql | redis | mongodb | kafka"}, - {Key: "version", Label: "Version", Type: schema.FieldTypeString, Description: "Engine version (e.g. 16 for PostgreSQL)"}, - {Key: "size", Label: "Size", Type: schema.FieldTypeString, Description: "Droplet size slug (e.g. db-s-1vcpu-1gb)"}, - {Key: "region", Label: "Region", Type: schema.FieldTypeString, Description: "DO region slug (e.g. nyc1)"}, - {Key: "num_nodes", Label: "Node Count", Type: schema.FieldTypeNumber, Description: "Number of nodes (default: 1)"}, - {Key: "name", Label: "Cluster Name", Type: schema.FieldTypeString, Description: "Database cluster name"}, - }, - }, } } diff --git a/plugins/platform/plugin_test.go b/plugins/platform/plugin_test.go index 155e585f..42b83253 100644 --- a/plugins/platform/plugin_test.go +++ b/plugins/platform/plugin_test.go @@ -73,11 +73,6 @@ func TestStepFactories(t *testing.T) { "step.argo_logs", "step.argo_delete", "step.argo_list", - "step.do_deploy", - "step.do_status", - "step.do_logs", - "step.do_scale", - "step.do_destroy", } for _, stepType := range expectedSteps { @@ -109,11 +104,6 @@ func TestModuleFactories(t *testing.T) { "app.container", "platform.region", "argo.workflows", - "platform.doks", - "platform.do_networking", - "platform.do_dns", - "platform.do_app", - "platform.do_database", "platform.region_router", } diff --git a/schema/module_schema.go b/schema/module_schema.go index 10b8ba09..1015d47c 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -2685,95 +2685,6 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { }, }) - // ---- Platform DigitalOcean App ---- - - r.Register(&ModuleSchema{ - Type: "platform.do_app", - Label: "DigitalOcean App Platform", - Category: "infrastructure", - Description: "Deploys containerized apps to DigitalOcean App Platform (mock or real DO backend)", - Outputs: []ServiceIODef{{Name: "app", Type: "JSON", Description: "Deployed app endpoint and status on DigitalOcean App Platform"}}, - ConfigFields: []ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "provider", Label: "Provider", Type: FieldTypeString, Description: "mock | digitalocean"}, - {Key: "name", Label: "App Name", Type: FieldTypeString, Description: "App Platform application name"}, - {Key: "region", Label: "Region", Type: FieldTypeString, Description: "DO region slug (e.g. nyc)"}, - {Key: "image", Label: "Container Image", Type: FieldTypeString, Description: "Container image reference"}, - {Key: "instances", Label: "Instances", Type: FieldTypeNumber, Description: "Number of instances"}, - {Key: "http_port", Label: "HTTP Port", Type: FieldTypeNumber, Description: "Container HTTP port"}, - {Key: "envs", Label: "Environment Variables", Type: FieldTypeMap, Description: "Environment variables for the app"}, - }, - }) - - // ---- Platform DigitalOcean Database ---- - - r.Register(&ModuleSchema{ - Type: "platform.do_database", - Label: "DigitalOcean Managed Database", - Category: "infrastructure", - Description: "Manages DigitalOcean Managed Databases (PostgreSQL, MySQL, Redis, MongoDB, Kafka)", - Outputs: []ServiceIODef{{Name: "database", Type: "sql.DB", Description: "Managed database connection for DigitalOcean database cluster"}}, - ConfigFields: []ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "provider", Label: "Provider", Type: FieldTypeString, Description: "mock | digitalocean"}, - {Key: "engine", Label: "Engine", Type: FieldTypeString, Description: "Database engine: pg | mysql | redis | mongodb | kafka"}, - {Key: "version", Label: "Version", Type: FieldTypeString, Description: "Engine version"}, - {Key: "size", Label: "Size", Type: FieldTypeString, Description: "Droplet size slug"}, - {Key: "region", Label: "Region", Type: FieldTypeString, Description: "DO region slug"}, - {Key: "num_nodes", Label: "Node Count", Type: FieldTypeNumber, Description: "Number of nodes"}, - {Key: "name", Label: "Cluster Name", Type: FieldTypeString, Description: "Database cluster name"}, - }, - }) - - // ---- Platform DigitalOcean DNS ---- - - r.Register(&ModuleSchema{ - Type: "platform.do_dns", - Label: "DigitalOcean DNS", - Category: "infrastructure", - Description: "Manages DigitalOcean domains and DNS records (mock or real DO backend)", - Outputs: []ServiceIODef{{Name: "zone", Type: "JSON", Description: "Provisioned DigitalOcean DNS zone and records"}}, - ConfigFields: []ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "provider", Label: "Provider", Type: FieldTypeString, Description: "mock | digitalocean"}, - {Key: "domain", Label: "Domain", Type: FieldTypeString, Required: true, Description: "Domain name (e.g. example.com)"}, - {Key: "records", Label: "Records", Type: FieldTypeArray, Description: "List of DNS record definitions"}, - }, - }) - - // ---- Platform DigitalOcean Networking ---- - - r.Register(&ModuleSchema{ - Type: "platform.do_networking", - Label: "DigitalOcean VPC & Firewalls", - Category: "infrastructure", - Description: "Manages DigitalOcean VPCs, firewalls, and load balancers (mock or real DO backend)", - Outputs: []ServiceIODef{{Name: "vpc", Type: "JSON", Description: "Provisioned DigitalOcean VPC and firewall configuration"}}, - ConfigFields: []ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "provider", Label: "Provider", Type: FieldTypeString, Description: "mock | digitalocean"}, - {Key: "vpc", Label: "VPC Config", Type: FieldTypeMap, Required: true, Description: "VPC configuration (name, region, ip_range)"}, - {Key: "firewalls", Label: "Firewalls", Type: FieldTypeArray, Description: "List of firewall definitions"}, - }, - }) - - // ---- Platform DOKS ---- - - r.Register(&ModuleSchema{ - Type: "platform.doks", - Label: "DigitalOcean Kubernetes (DOKS)", - Category: "infrastructure", - Description: "Manages DigitalOcean Kubernetes Service clusters (mock or real DO backend)", - Outputs: []ServiceIODef{{Name: "cluster", Type: "JSON", Description: "Provisioned DOKS cluster endpoint and kubeconfig"}}, - ConfigFields: []ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "cluster_name", Label: "Cluster Name", Type: FieldTypeString, Description: "DOKS cluster name"}, - {Key: "region", Label: "Region", Type: FieldTypeString, Description: "DO region slug (e.g. nyc3)"}, - {Key: "version", Label: "Kubernetes Version", Type: FieldTypeString, Description: "Kubernetes version slug"}, - {Key: "node_pool", Label: "Node Pool", Type: FieldTypeMap, Description: "Node pool config"}, - }, - }) - // ---- Platform ECS ---- r.Register(&ModuleSchema{ @@ -3002,11 +2913,6 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { {"step.dns_apply", "DNS Apply", "Applies DNS zone and record changes"}, {"step.dns_plan", "DNS Plan", "Plans DNS changes without applying them"}, {"step.dns_status", "DNS Status", "Gets the current status of a DNS zone"}, - {"step.do_deploy", "DO Deploy", "Deploys to DigitalOcean App Platform"}, - {"step.do_destroy", "DO Destroy", "Destroys a DigitalOcean App Platform application"}, - {"step.do_logs", "DO Logs", "Retrieves logs from DigitalOcean App Platform"}, - {"step.do_scale", "DO Scale", "Scales a DigitalOcean App Platform application"}, - {"step.do_status", "DO Status", "Gets the status of a DigitalOcean App Platform application"}, {"step.ecs_apply", "ECS Apply", "Applies ECS Fargate service deployment"}, {"step.ecs_destroy", "ECS Destroy", "Destroys an ECS Fargate service"}, {"step.ecs_plan", "ECS Plan", "Plans ECS service deployment changes"}, diff --git a/schema/schema.go b/schema/schema.go index d92c9d00..fe6f3029 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -208,11 +208,6 @@ var coreModuleTypes = []string{ "platform.autoscaling", "platform.context", "platform.dns", - "platform.do_app", - "platform.do_database", - "platform.do_dns", - "platform.do_networking", - "platform.doks", "platform.ecs", "platform.kubernetes", "platform.networking", @@ -296,11 +291,6 @@ var coreModuleTypes = []string{ "step.dns_apply", "step.dns_plan", "step.dns_status", - "step.do_deploy", - "step.do_destroy", - "step.do_logs", - "step.do_scale", - "step.do_status", "step.docker_build", "step.docker_push", "step.docker_run", diff --git a/schema/step_schema_builtins.go b/schema/step_schema_builtins.go index 674be6bd..1195ef92 100644 --- a/schema/step_schema_builtins.go +++ b/schema/step_schema_builtins.go @@ -1896,80 +1896,6 @@ func (r *StepSchemaRegistry) registerBuiltins() { }, }) - // ---- DigitalOcean Deploy ---- - - r.Register(&StepSchema{ - Type: "step.do_deploy", - Plugin: "platform", - Description: "Deploys an application to DigitalOcean App Platform.", - ConfigFields: []ConfigFieldDef{ - {Key: "app", Type: FieldTypeString, Description: "Name of the platform.do_app module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "app_id", Type: "string", Description: "DigitalOcean app ID"}, - {Key: "live_url", Type: "string", Description: "App live URL"}, - }, - }) - - // ---- DigitalOcean Destroy ---- - - r.Register(&StepSchema{ - Type: "step.do_destroy", - Plugin: "platform", - Description: "Destroys a DigitalOcean App Platform application.", - ConfigFields: []ConfigFieldDef{ - {Key: "app", Type: FieldTypeString, Description: "Name of the platform.do_app module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "destroyed", Type: "boolean", Description: "Whether the app was destroyed"}, - }, - }) - - // ---- DigitalOcean Logs ---- - - r.Register(&StepSchema{ - Type: "step.do_logs", - Plugin: "platform", - Description: "Retrieves logs from a DigitalOcean App Platform application.", - ConfigFields: []ConfigFieldDef{ - {Key: "app", Type: FieldTypeString, Description: "Name of the platform.do_app module", Required: true}, - {Key: "component", Type: FieldTypeString, Description: "App component name"}, - }, - Outputs: []StepOutputDef{ - {Key: "logs", Type: "string", Description: "Application log output"}, - }, - }) - - // ---- DigitalOcean Scale ---- - - r.Register(&StepSchema{ - Type: "step.do_scale", - Plugin: "platform", - Description: "Scales a DigitalOcean App Platform application.", - ConfigFields: []ConfigFieldDef{ - {Key: "app", Type: FieldTypeString, Description: "Name of the platform.do_app module", Required: true}, - {Key: "instances", Type: FieldTypeNumber, Description: "Desired instance count", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "instances", Type: "number", Description: "New instance count"}, - }, - }) - - // ---- DigitalOcean Status ---- - - r.Register(&StepSchema{ - Type: "step.do_status", - Plugin: "platform", - Description: "Gets the status of a DigitalOcean App Platform application.", - ConfigFields: []ConfigFieldDef{ - {Key: "app", Type: FieldTypeString, Description: "Name of the platform.do_app module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "phase", Type: "string", Description: "App deployment phase"}, - {Key: "live_url", Type: "string", Description: "App live URL"}, - }, - }) - // ---- ECS Apply ---- r.Register(&StepSchema{ diff --git a/schema/testdata/editor-schemas.golden.json b/schema/testdata/editor-schemas.golden.json index 9d602760..bd69e44a 100644 --- a/schema/testdata/editor-schemas.golden.json +++ b/schema/testdata/editor-schemas.golden.json @@ -2859,257 +2859,6 @@ } ] }, - "platform.do_app": { - "type": "platform.do_app", - "label": "DigitalOcean App Platform", - "category": "infrastructure", - "description": "Deploys containerized apps to DigitalOcean App Platform (mock or real DO backend)", - "outputs": [ - { - "name": "app", - "type": "JSON", - "description": "Deployed app endpoint and status on DigitalOcean App Platform" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "provider", - "label": "Provider", - "type": "string", - "description": "mock | digitalocean" - }, - { - "key": "name", - "label": "App Name", - "type": "string", - "description": "App Platform application name" - }, - { - "key": "region", - "label": "Region", - "type": "string", - "description": "DO region slug (e.g. nyc)" - }, - { - "key": "image", - "label": "Container Image", - "type": "string", - "description": "Container image reference" - }, - { - "key": "instances", - "label": "Instances", - "type": "number", - "description": "Number of instances" - }, - { - "key": "http_port", - "label": "HTTP Port", - "type": "number", - "description": "Container HTTP port" - }, - { - "key": "envs", - "label": "Environment Variables", - "type": "map", - "description": "Environment variables for the app" - } - ] - }, - "platform.do_database": { - "type": "platform.do_database", - "label": "DigitalOcean Managed Database", - "category": "infrastructure", - "description": "Manages DigitalOcean Managed Databases (PostgreSQL, MySQL, Redis, MongoDB, Kafka)", - "outputs": [ - { - "name": "database", - "type": "sql.DB", - "description": "Managed database connection for DigitalOcean database cluster" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "provider", - "label": "Provider", - "type": "string", - "description": "mock | digitalocean" - }, - { - "key": "engine", - "label": "Engine", - "type": "string", - "description": "Database engine: pg | mysql | redis | mongodb | kafka" - }, - { - "key": "version", - "label": "Version", - "type": "string", - "description": "Engine version" - }, - { - "key": "size", - "label": "Size", - "type": "string", - "description": "Droplet size slug" - }, - { - "key": "region", - "label": "Region", - "type": "string", - "description": "DO region slug" - }, - { - "key": "num_nodes", - "label": "Node Count", - "type": "number", - "description": "Number of nodes" - }, - { - "key": "name", - "label": "Cluster Name", - "type": "string", - "description": "Database cluster name" - } - ] - }, - "platform.do_dns": { - "type": "platform.do_dns", - "label": "DigitalOcean DNS", - "category": "infrastructure", - "description": "Manages DigitalOcean domains and DNS records (mock or real DO backend)", - "outputs": [ - { - "name": "zone", - "type": "JSON", - "description": "Provisioned DigitalOcean DNS zone and records" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "provider", - "label": "Provider", - "type": "string", - "description": "mock | digitalocean" - }, - { - "key": "domain", - "label": "Domain", - "type": "string", - "description": "Domain name (e.g. example.com)", - "required": true - }, - { - "key": "records", - "label": "Records", - "type": "array", - "description": "List of DNS record definitions" - } - ] - }, - "platform.do_networking": { - "type": "platform.do_networking", - "label": "DigitalOcean VPC \u0026 Firewalls", - "category": "infrastructure", - "description": "Manages DigitalOcean VPCs, firewalls, and load balancers (mock or real DO backend)", - "outputs": [ - { - "name": "vpc", - "type": "JSON", - "description": "Provisioned DigitalOcean VPC and firewall configuration" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "provider", - "label": "Provider", - "type": "string", - "description": "mock | digitalocean" - }, - { - "key": "vpc", - "label": "VPC Config", - "type": "map", - "description": "VPC configuration (name, region, ip_range)", - "required": true - }, - { - "key": "firewalls", - "label": "Firewalls", - "type": "array", - "description": "List of firewall definitions" - } - ] - }, - "platform.doks": { - "type": "platform.doks", - "label": "DigitalOcean Kubernetes (DOKS)", - "category": "infrastructure", - "description": "Manages DigitalOcean Kubernetes Service clusters (mock or real DO backend)", - "outputs": [ - { - "name": "cluster", - "type": "JSON", - "description": "Provisioned DOKS cluster endpoint and kubeconfig" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "cluster_name", - "label": "Cluster Name", - "type": "string", - "description": "DOKS cluster name" - }, - { - "key": "region", - "label": "Region", - "type": "string", - "description": "DO region slug (e.g. nyc3)" - }, - { - "key": "version", - "label": "Kubernetes Version", - "type": "string", - "description": "Kubernetes version slug" - }, - { - "key": "node_pool", - "label": "Node Pool", - "type": "map", - "description": "Node pool config" - } - ] - }, "platform.ecs": { "type": "platform.ecs", "label": "ECS Fargate Service", @@ -5672,41 +5421,6 @@ "description": "Gets the current status of a DNS zone", "configFields": [] }, - "step.do_deploy": { - "type": "step.do_deploy", - "label": "DO Deploy", - "category": "pipeline", - "description": "Deploys to DigitalOcean App Platform", - "configFields": [] - }, - "step.do_destroy": { - "type": "step.do_destroy", - "label": "DO Destroy", - "category": "pipeline", - "description": "Destroys a DigitalOcean App Platform application", - "configFields": [] - }, - "step.do_logs": { - "type": "step.do_logs", - "label": "DO Logs", - "category": "pipeline", - "description": "Retrieves logs from DigitalOcean App Platform", - "configFields": [] - }, - "step.do_scale": { - "type": "step.do_scale", - "label": "DO Scale", - "category": "pipeline", - "description": "Scales a DigitalOcean App Platform application", - "configFields": [] - }, - "step.do_status": { - "type": "step.do_status", - "label": "DO Status", - "category": "pipeline", - "description": "Gets the status of a DigitalOcean App Platform application", - "configFields": [] - }, "step.docker_build": { "type": "step.docker_build", "label": "Docker Build", From ee6fca2dd2fdae1fc70bde590ce82ceffb15a375 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 04:45:46 -0400 Subject: [PATCH 19/26] feat(#617): actionable migration errors for legacy DO types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds legacydo.FormatModuleError + legacydo.FormatStepError (already in internal/legacydo from T2) and wires them into two rejection points: engine.go:508 (module path) — factory-loop guard now emits the actionable migration error for the 5 removed legacy DO module types, branching on whether iac.provider is already registered in the engine. pipeline_step_registry.go:Create (step path) — unknown-step guard now emits the actionable migration error for the 5 removed legacy DO step types, using the per-registry iacProviderLoaded field set via SetIaCProviderLoaded before pipeline construction. engine.go:393-398 — guarded WithExtraModuleTypes block replaced with unconditional injection that also includes legacydo.ModuleTypes so that schema.ValidateConfig passes legacy DO module types through to the factory-loop guard (schema rejection would mask the migration message). SetIaCProviderLoaded bridges the boolean from engine to module package via type assertion (interface deliberately NOT widened — no method burden on alternate StepRegistrar implementors). Each step type gets a per-step message; step.do_logs and step.do_scale carry GAP messages with workarounds because no 1:1 pipeline-step successor exists yet (follow-up issues in T5). Tests: 5 module × 2 branches + 5 step × 2 branches = 12 sub-cases. Co-Authored-By: Claude Sonnet 4.6 --- engine.go | 30 +++++-- engine_legacy_do_migration_test.go | 79 +++++++++++++++++++ .../pipeline_step_legacy_do_migration_test.go | 57 +++++++++++++ module/pipeline_step_registry.go | 15 +++- 4 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 engine_legacy_do_migration_test.go create mode 100644 module/pipeline_step_legacy_do_migration_test.go diff --git a/engine.go b/engine.go index 055baac8..e96565c2 100644 --- a/engine.go +++ b/engine.go @@ -15,6 +15,7 @@ import ( "github.com/GoCodeAlone/workflow/dynamic" "github.com/GoCodeAlone/workflow/infra" "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/internal/legacydo" "github.com/GoCodeAlone/workflow/module" "github.com/GoCodeAlone/workflow/plugin" "github.com/GoCodeAlone/workflow/schema" @@ -390,13 +391,18 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { schema.WithSkipWorkflowTypeCheck(), schema.WithSkipTriggerTypeCheck(), } - if len(e.moduleFactories) > 0 { - extra := make([]string, 0, len(e.moduleFactories)) - for t := range e.moduleFactories { - extra = append(extra, t) - } - valOpts = append(valOpts, schema.WithExtraModuleTypes(extra...)) + extra := make([]string, 0, len(e.moduleFactories)+len(legacydo.ModuleTypes)) + for t := range e.moduleFactories { + extra = append(extra, t) + } + // Pass legacy DO module types through schema so the factory-loop guard + // (which emits legacydo.FormatModuleError) is the rejection point — + // schema rejection produces a generic error and would mask the + // actionable migration message (issue #617). + for t := range legacydo.ModuleTypes { + extra = append(extra, t) } + valOpts = append(valOpts, schema.WithExtraModuleTypes(extra...)) if err := schema.ValidateConfig(cfg, valOpts...); err != nil { return fmt.Errorf("config validation failed: %w", err) } @@ -505,6 +511,10 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { // Look up the module factory from the registry (populated by LoadPlugin) factory, exists := e.moduleFactories[modCfg.Type] if !exists { + if legacydo.IsModuleType(modCfg.Type) { + _, iacLoaded := e.moduleFactories["iac.provider"] + return legacydo.FormatModuleError(modCfg.Type, modCfg.Name, iacLoaded) + } return fmt.Errorf("unknown module type %q for module %q — ensure the required plugin is loaded", modCfg.Type, modCfg.Name) } e.logger.Debug("Using factory for module type: " + modCfg.Type) @@ -562,6 +572,14 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { return fmt.Errorf("failed to configure triggers: %w", err) } + // Inform the step registry whether the iac.provider module factory is loaded. + // This lets StepRegistry.Create emit an actionable migration error for legacy + // DO step types instead of the generic "unknown step type" message (issue #617). + _, iacLoaded := e.moduleFactories["iac.provider"] + if r, ok := e.stepRegistry.(*module.StepRegistry); ok { + r.SetIaCProviderLoaded(iacLoaded) + } + // Configure pipelines (composable step-based workflows) if len(cfg.Pipelines) > 0 { if err := e.configurePipelines(cfg.Pipelines); err != nil { diff --git a/engine_legacy_do_migration_test.go b/engine_legacy_do_migration_test.go new file mode 100644 index 00000000..deeec128 --- /dev/null +++ b/engine_legacy_do_migration_test.go @@ -0,0 +1,79 @@ +package workflow + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/config" +) + +// newIsolatedEngine builds a plugin-free StdEngine — required so that the +// iac.provider factory-map lookup is deterministically absent in the +// "plugin not loaded" tests, and so that the manual AddModuleType stub in +// the "plugin loaded" test is the only factory registered. This differs from +// setupEngineTest (engine_test.go) which calls loadAllPlugins, and from +// newTestEngine (engine_multi_config_test.go) which loads pipelinesteps. +// Reuses the `mockLogger` type already defined in engine_test.go — both files +// are in package workflow so the type is visible at compile time. DO NOT +// redeclare it here. +func newIsolatedEngine(t *testing.T) *StdEngine { + t.Helper() + logger := &mockLogger{} + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + if err := app.Init(); err != nil { + t.Fatalf("app.Init: %v", err) + } + return NewStdEngine(app, logger) +} + +func TestLegacyDOModuleError_PluginNotLoaded(t *testing.T) { + cases := []struct{ legacyType, hint string }{ + {"platform.do_app", "infra.container_service"}, + {"platform.do_database", "infra.database"}, + {"platform.do_dns", "infra.dns"}, + {"platform.do_networking", "infra.vpc"}, + {"platform.doks", "infra.k8s_cluster"}, + } + for _, tc := range cases { + t.Run(tc.legacyType, func(t *testing.T) { + e := newIsolatedEngine(t) + cfg := &config.WorkflowConfig{Modules: []config.ModuleConfig{{Name: "x", Type: tc.legacyType, Config: map[string]any{}}}} + err := e.BuildFromConfig(cfg) + if err == nil { + t.Fatalf("expected error for legacy type %q", tc.legacyType) + } + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-digitalocean", + "Install workflow-plugin-digitalocean", + tc.hint, + } { + if !strings.Contains(msg, want) { + t.Errorf("error for %q missing %q; got: %s", tc.legacyType, want, msg) + } + } + }) + } +} + +func TestLegacyDOModuleError_PluginLoaded(t *testing.T) { + e := newIsolatedEngine(t) + // Register a stub iac.provider factory to simulate workflow-plugin-digitalocean + // being loaded. ModuleFactory signature: func(name string, config map[string]any) modular.Module. + e.AddModuleType("iac.provider", func(name string, cfg map[string]any) modular.Module { return nil }) + + cfg := &config.WorkflowConfig{Modules: []config.ModuleConfig{{Name: "x", Type: "platform.do_app", Config: map[string]any{}}}} + err := e.BuildFromConfig(cfg) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "already loaded") { + t.Errorf("plugin-loaded branch must say 'already loaded'; got: %s", msg) + } + if strings.Contains(msg, "Install workflow-plugin-digitalocean") { + t.Errorf("plugin-loaded branch must NOT instruct install; got: %s", msg) + } +} diff --git a/module/pipeline_step_legacy_do_migration_test.go b/module/pipeline_step_legacy_do_migration_test.go new file mode 100644 index 00000000..2163aacc --- /dev/null +++ b/module/pipeline_step_legacy_do_migration_test.go @@ -0,0 +1,57 @@ +package module_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/module" +) + +func TestLegacyDOStepError_PluginNotLoaded(t *testing.T) { + // step.do_logs / step.do_scale have GAP messages; the others map 1:1 to step.iac_*. + cases := []struct{ step, mustContain string }{ + {"step.do_deploy", "step.iac_apply"}, + {"step.do_status", "step.iac_status"}, + {"step.do_destroy", "step.iac_destroy"}, + {"step.do_logs", "wfctl infra logs"}, + {"step.do_scale", "instance_count"}, + } + for _, tc := range cases { + t.Run(tc.step, func(t *testing.T) { + r := module.NewStepRegistry() // fresh registry — iacProviderLoaded defaults to false + _, err := r.Create(tc.step, "x", map[string]any{}, nil) + if err == nil { + t.Fatalf("expected error for %q", tc.step) + } + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-digitalocean", + "Install workflow-plugin-digitalocean", + tc.mustContain, + } { + if !strings.Contains(msg, want) { + t.Errorf("error for %q missing %q; got: %s", tc.step, want, msg) + } + } + }) + } +} + +func TestLegacyDOStepError_PluginLoaded(t *testing.T) { + // Symmetric to TestLegacyDOModuleError_PluginLoaded — flips the per-registry + // flag and confirms the step guard's "already loaded" branch fires. + r := module.NewStepRegistry() + r.SetIaCProviderLoaded(true) + _, err := r.Create("step.do_deploy", "x", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "already loaded") { + t.Errorf("plugin-loaded branch must say 'already loaded'; got: %s", msg) + } + if strings.Contains(msg, "Install workflow-plugin-digitalocean") { + t.Errorf("plugin-loaded branch must NOT instruct install; got: %s", msg) + } +} diff --git a/module/pipeline_step_registry.go b/module/pipeline_step_registry.go index e5468d10..50d3a3b4 100644 --- a/module/pipeline_step_registry.go +++ b/module/pipeline_step_registry.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/internal/legacydo" ) // StepFactory creates a PipelineStep from its name and config. @@ -11,7 +12,8 @@ type StepFactory func(name string, config map[string]any, app modular.Applicatio // StepRegistry maps step type strings to factory functions. type StepRegistry struct { - factories map[string]StepFactory + factories map[string]StepFactory + iacProviderLoaded bool // set by SetIaCProviderLoaded; consumed by Create } // NewStepRegistry creates an empty StepRegistry. @@ -26,12 +28,23 @@ func (r *StepRegistry) Register(stepType string, factory StepFactory) { r.factories[stepType] = factory } +// SetIaCProviderLoaded is called by the engine after module factory registration +// is complete and before pipeline construction. Per-registry state — no global — +// so parallel test runs that build independent StepRegistry instances do not +// share or race the flag. +func (r *StepRegistry) SetIaCProviderLoaded(loaded bool) { + r.iacProviderLoaded = loaded +} + // Create instantiates a PipelineStep of the given type. // app must be a modular.Application; it is typed as any to satisfy // the interfaces.StepRegistrar interface without an import cycle. func (r *StepRegistry) Create(stepType, name string, config map[string]any, app any) (PipelineStep, error) { factory, ok := r.factories[stepType] if !ok { + if legacydo.IsStepType(stepType) { + return nil, legacydo.FormatStepError(stepType, r.iacProviderLoaded) + } return nil, fmt.Errorf("unknown step type: %s", stepType) } a, _ := app.(modular.Application) From 78ea62544ac81146181eaa6bb9874fc4f96472dc Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 04:52:53 -0400 Subject: [PATCH 20/26] feat(#617): drop godo from go.mod + add CI grep gate * go mod tidy on root and example/ drops github.com/digitalocean/godo (direct from root, indirect from example/). * New CI job 'godo-banned' fails the build on any *.go import of godo OR any mention of godo in go.mod files. Excludes _worktrees, .worktrees, .claude (local agent state) and godo_absent_test.go (T1 regression gate that references the import path as a string literal, not an actual import). This satisfies acceptance criterion #4 (dependabot bumps target the provider repo, not workflow core). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 17 +++++++++++++++++ example/go.mod | 2 -- example/go.sum | 5 ----- go.mod | 2 -- go.sum | 5 ----- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 935fb1d6..9b23cfa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -376,3 +376,20 @@ jobs: echo "" echo "Results: $total configs, $passed passed, $warned warnings" + + godo-banned: + name: Verify godo is not imported (issue #617) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Grep gate — *.go files must not import godo + run: | + ! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + --exclude="godo_absent_test.go" \ + "digitalocean/godo" . + - name: Grep gate — go.mod files must not list godo + run: | + ! grep -qH "digitalocean/godo" go.mod example/go.mod diff --git a/example/go.mod b/example/go.mod index 99dc0e51..a8a8052f 100644 --- a/example/go.mod +++ b/example/go.mod @@ -77,7 +77,6 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deckarep/golang-set/v2 v2.9.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/digitalocean/godo v1.184.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/go-connections v0.7.0 // indirect @@ -116,7 +115,6 @@ require ( github.com/golobby/cast v1.3.3 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.1 // indirect - github.com/google/go-querystring v1.2.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect diff --git a/example/go.sum b/example/go.sum index a9205a20..4170ba52 100644 --- a/example/go.sum +++ b/example/go.sum @@ -195,8 +195,6 @@ github.com/deckarep/golang-set/v2 v2.9.0 h1:prva4eP9UysWagLyKrtn074ughi0NnkIf0A4 github.com/deckarep/golang-set/v2 v2.9.0/go.mod h1:EWknQXbs0mcFpat2QOoXV0Ee57cD+w6ZEN76BR2JVrM= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/digitalocean/godo v1.184.0 h1:2B2CQhxftlf3xa24Nrzn5CBQlaQjyaWqi3XbbnJlG3w= -github.com/digitalocean/godo v1.184.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= @@ -330,11 +328,8 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= -github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/go.mod b/go.mod index ce0d364b..7426df87 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sqs v1.42.21 github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 github.com/cucumber/godog v0.15.1 - github.com/digitalocean/godo v1.178.0 github.com/docker/docker v28.5.2+incompatible github.com/expr-lang/expr v1.17.8 github.com/fsnotify/fsnotify v1.9.0 @@ -183,7 +182,6 @@ require ( github.com/golobby/cast v1.3.3 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.1 // indirect - github.com/google/go-querystring v1.2.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect diff --git a/go.sum b/go.sum index 4d7b7175..7f99e311 100644 --- a/go.sum +++ b/go.sum @@ -240,8 +240,6 @@ github.com/deckarep/golang-set/v2 v2.9.0 h1:prva4eP9UysWagLyKrtn074ughi0NnkIf0A4 github.com/deckarep/golang-set/v2 v2.9.0/go.mod h1:EWknQXbs0mcFpat2QOoXV0Ee57cD+w6ZEN76BR2JVrM= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/digitalocean/godo v1.178.0 h1:+B4xGOaoFwwwpM7TKhoyGHdmFg5eF9zDB1YfOLvNJ2E= -github.com/digitalocean/godo v1.178.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= @@ -382,11 +380,8 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= -github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= From 9cc47bdfb1781c833679b18f47d221f8fb3e075a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 05:03:31 -0400 Subject: [PATCH 21/26] feat(#617): wfctl modernize rule + migration guide + CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * New modernize rule "legacy-do-types": auto-rewrites 5 module types and 3 of 5 step types to infra.*; flags but does not modify the two GAP step types (step.do_logs, step.do_scale) and the 1→2 platform.do_networking split. Registered in AllRules(). * testdata/legacy-do-config.yaml: smoke-test fixture exercising all 10 legacy types; testdata/legacy-do-config.expected.yaml: golden post-Fix output (types renamed, GAP types preserved, provider NOT auto-injected). * CHANGELOG: v0.52.0 BREAKING entry. * docs/migrations/v0.52.0-godo-removal.md: full migration guide with mapping tables, before/after YAML, error reference, rollback note. workflow-plugin-digitalocean follow-up issue URLs wired in: step.do_logs GAP → GoCodeAlone/workflow-plugin-digitalocean#107 step.do_scale GAP → GoCodeAlone/workflow-plugin-digitalocean#108 * DOCUMENTATION.md: replace 10 legacy DO rows with pointers to the plugin and the migration guide. * Comment hygiene: drop "legacy" framing from hasPlatformModules and parseInfraResourceSpecs doc comments (both functions correctly handle the surviving platform.kubernetes / platform.ecs module types). Follow-up issues filed: GoCodeAlone/workflow-plugin-digitalocean#107 — step.iac_logs GAP GoCodeAlone/workflow-plugin-digitalocean#108 — step.iac_scale GAP GoCodeAlone/workflow#653 — AWS SDK audit (continuation of #617) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 16 ++ DOCUMENTATION.md | 21 +-- cmd/wfctl/infra.go | 2 +- cmd/wfctl/infra_apply.go | 4 +- docs/migrations/v0.52.0-godo-removal.md | 150 ++++++++++++++++++ modernize/legacy_do_rule.go | 139 ++++++++++++++++ modernize/legacy_do_rule_test.go | 110 +++++++++++++ modernize/modernize.go | 1 + .../testdata/legacy-do-config.expected.yaml | 45 ++++++ modernize/testdata/legacy-do-config.yaml | 45 ++++++ 10 files changed, 520 insertions(+), 13 deletions(-) create mode 100644 docs/migrations/v0.52.0-godo-removal.md create mode 100644 modernize/legacy_do_rule.go create mode 100644 modernize/legacy_do_rule_test.go create mode 100644 modernize/testdata/legacy-do-config.expected.yaml create mode 100644 modernize/testdata/legacy-do-config.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 83510c28..385e4e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## v0.52.0 (2026-05-13) — BREAKING + +### Removed (issue #617) + +- All legacy DigitalOcean IaC modules (`platform.do_app`, `platform.do_database`, `platform.do_dns`, `platform.do_networking`, `platform.doks`) and the DO credential resolver `cloud_account_do.go`. +- All legacy DigitalOcean pipeline steps (`step.do_deploy`, `step.do_status`, `step.do_logs`, `step.do_scale`, `step.do_destroy`). +- The `github.com/digitalocean/godo` dependency from `go.mod` (root and `example/`). + +### Migration + +DigitalOcean IaC moved to [`workflow-plugin-digitalocean`](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) v0.12.0+. After loading the plugin, replace legacy module types with `infra.*` types and `provider: digitalocean`. Run `wfctl modernize --apply ` to auto-rewrite supported types — **then manually add `provider: digitalocean` to each rewritten module's `config:` block** (the modernize rule does not inject the provider key; see the [migration guide](docs/migrations/v0.52.0-godo-removal.md) for the exact recipe). Two step types (`step.do_logs`, `step.do_scale`) have no 1:1 pipeline successor — workarounds documented in the migration guide. + +Configs that still reference the legacy types now fail to load with an actionable error pointing to the plugin and the relevant `infra.*` successor. + +--- + ## [Unreleased] ### Added diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b67613f0..b4294526 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -322,11 +322,11 @@ flowchart TD | `step.argo_logs` | Retrieves logs from an Argo Workflow | platform | | `step.argo_delete` | Deletes an Argo Workflow | platform | | `step.argo_list` | Lists Argo Workflows for a namespace | platform | -| `step.do_deploy` | Deploys to DigitalOcean App Platform | platform | -| `step.do_status` | Retrieves DigitalOcean App Platform deployment status | platform | -| `step.do_logs` | Fetches DigitalOcean App Platform runtime logs | platform | -| `step.do_scale` | Scales a DigitalOcean App Platform component | platform | -| `step.do_destroy` | Destroys a DigitalOcean App Platform deployment | platform | + +**DigitalOcean IaC steps** were removed from workflow core in v0.52.0 and moved to the +[workflow-plugin-digitalocean](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) +external plugin. After loading the plugin, use the generic `step.iac_*` pipeline steps. +See [v0.52.0 migration guide](docs/migrations/v0.52.0-godo-removal.md). ### Expression Syntax @@ -510,11 +510,12 @@ Strict mode applies to **both** direct dot-access (`{{ .steps.auth.field }}`) an | `platform.autoscaling` | Auto-scaling policy and target management | platform | | `platform.region` | Multi-region deployment configuration | platform | | `platform.region_router` | Routes traffic across regions by weight, latency, or failover | platform | -| `platform.doks` | DigitalOcean Kubernetes Service (DOKS) deployment | platform | -| `platform.do_app` | DigitalOcean App Platform deployment (deploy, scale, logs, destroy) | platform | -| `platform.do_networking` | DigitalOcean VPC and firewall management | platform | -| `platform.do_dns` | DigitalOcean domain and DNS record management | platform | -| `platform.do_database` | DigitalOcean Managed Database (PostgreSQL, MySQL, Redis) | platform | + +**DigitalOcean IaC modules** were removed from workflow core in v0.52.0 and moved to the +[workflow-plugin-digitalocean](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) +external plugin. After loading the plugin, use the generic `infra.*` module +types with `provider: digitalocean` and the generic `step.iac_*` pipeline +steps. See [v0.52.0 migration guide](docs/migrations/v0.52.0-godo-removal.md). | `iac.provider` | Cloud provider configuration (aws, gcp, azure, digitalocean) for IaC operations | platform | | `iac.state` | IaC state persistence (memory, filesystem, spaces, gcs, azure_blob, postgres) | platform | | `infra.vpc` | Virtual Private Cloud and subnet management | platform | diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index b852ab51..6d51634f 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -457,7 +457,7 @@ func secretGenKeys(cfg *config.WorkflowConfig) []string { } // parseInfraResourceSpecs reads an infra config (resolving imports:) and -// returns ResourceSpecs for all infra.* and platform.* modules. +// returns ResourceSpecs for all infra.* and platform.* (e.g., platform.kubernetes, platform.ecs) modules. func parseInfraResourceSpecs(cfgFile string) ([]interfaces.ResourceSpec, error) { cfg, err := config.LoadFromFile(cfgFile) if err != nil { diff --git a/cmd/wfctl/infra_apply.go b/cmd/wfctl/infra_apply.go index 8cff45e6..e1243c0c 100644 --- a/cmd/wfctl/infra_apply.go +++ b/cmd/wfctl/infra_apply.go @@ -127,8 +127,8 @@ func hasInfraModules(cfgFile string) bool { return false } -// hasPlatformModules reports whether cfgFile contains any modules with the legacy -// platform.* type prefix. +// hasPlatformModules reports whether cfgFile contains any modules with the +// platform.* type prefix (e.g., platform.kubernetes, platform.ecs). func hasPlatformModules(cfgFile string) bool { cfg, err := config.LoadFromFile(cfgFile) if err != nil { diff --git a/docs/migrations/v0.52.0-godo-removal.md b/docs/migrations/v0.52.0-godo-removal.md new file mode 100644 index 00000000..7c4fc901 --- /dev/null +++ b/docs/migrations/v0.52.0-godo-removal.md @@ -0,0 +1,150 @@ +# v0.52.0 — Removing godo from workflow core (issue #617) + +## What changed + +The five legacy `platform.do_*` modules, the `cloud.account` DO credential +resolver, and the five legacy `step.do_*` pipeline steps were removed from +workflow core. The `github.com/digitalocean/godo` dependency is no longer +pulled by the workflow module. + +DigitalOcean IaC functionality moved entirely to +[`workflow-plugin-digitalocean`](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) +v0.12.0+, which exposes the same resources through the generic `infra.*` IaC +type system with `provider: digitalocean`. + +## Why + +Workflow core should own IaC interfaces and orchestration, not provider SDKs. +Dependabot bumps to godo now target the DO plugin repo, not core. See the +design plan at `docs/plans/2026-05-13-issue-617-godo-removal.md`. + +## Migration recipe + +1. Install the DO plugin (v0.12.0+): + ```yaml + plugins: + - name: digitalocean + source: github.com/GoCodeAlone/workflow-plugin-digitalocean + version: ">=0.12.0" + ``` + +2. Run the modernizer over each affected YAML config: + ```sh + wfctl modernize --apply ./config/*.yaml + ``` + This **renames the type field** for 4 module types and 3 step types + automatically. Two step types (`step.do_logs`, `step.do_scale`) and one + module type (`platform.do_networking`) are flagged but not auto-rewritten + — see below. + +3. **Add `provider: digitalocean` to each rewritten module's `config:` + block.** The modernize rule does NOT auto-inject this key, because the + `config:` block typically contains operator-authored settings that + shouldn't be silently modified. Example: + + ```yaml + # After modernize (type renamed, provider absent): + modules: + - name: api + type: infra.container_service + config: + region: nyc # <-- modernize left this alone + + # Operator adds provider key manually: + modules: + - name: api + type: infra.container_service + config: + provider: digitalocean # <-- ADD THIS + region: nyc + ``` + + Forgetting this produces a load-time error: + `infra module "api" (infra.container_service): 'provider' config is required`. + +4. Manually address the GAP types listed below. + +5. Re-run `wfctl validate` and `wfctl infra plan` to confirm the rewritten + config loads and produces the same plan. + +## Module type mapping + +| Legacy type | Successor | Auto-fix | +|--------------------------|-----------------------------------|----------| +| `platform.do_app` | `infra.container_service` | Yes | +| `platform.do_database` | `infra.database` | Yes | +| `platform.do_dns` | `infra.dns` | Yes | +| `platform.do_networking` | `infra.vpc` + `infra.firewall` | **No** — splits 1→2, manual review required | +| `platform.doks` | `infra.k8s_cluster` | Yes | + +All successors require `config.provider: digitalocean`. + +## Step type mapping + +| Legacy type | Successor | Auto-fix | +|--------------------|--------------------------------------------------------------------|----------| +| `step.do_deploy` | `step.iac_apply` (against an `infra.container_service` module) | Yes | +| `step.do_status` | `step.iac_status` (against an `infra.container_service` module) | Yes | +| `step.do_destroy` | `step.iac_destroy` (against an `infra.container_service` module) | Yes | +| `step.do_logs` | **GAP** — no pipeline step successor; use `wfctl infra logs` ad-hoc, or rely on the DO plugin's Troubleshoot hook on `step.iac_apply` failure. Tracked: [workflow-plugin-digitalocean#107](https://github.com/GoCodeAlone/workflow-plugin-digitalocean/issues/107) | **No** | +| `step.do_scale` | **GAP** — no pipeline step successor; update `instance_count` in the `infra.container_service` module config and re-run `step.iac_apply`. Tracked: [workflow-plugin-digitalocean#108](https://github.com/GoCodeAlone/workflow-plugin-digitalocean/issues/108) | **No** | + +## Before / after examples + +### App Platform + +Before: +```yaml +modules: + - name: api + type: platform.do_app + config: + region: nyc + services: + - name: web + image: registry.digitalocean.com/myorg/api:latest +``` + +After: +```yaml +modules: + - name: api + type: infra.container_service + config: + provider: digitalocean + region: nyc + services: + - name: web + image: registry.digitalocean.com/myorg/api:latest +``` + +### Pipeline step + +Before: +```yaml +pipelines: + - id: deploy + steps: + - type: step.do_deploy + config: { app: api } +``` + +After: +```yaml +pipelines: + - id: deploy + steps: + - type: step.iac_apply + config: { module: api } +``` + +## Errors you may see + +* `unsupported legacy module type "platform.do_app" (module "api"): this type was removed from workflow core in v0.52.0 — DigitalOcean IaC moved to workflow-plugin-digitalocean.` — fix the config per the table above; install the plugin if not already loaded. +* `unsupported legacy step type "step.do_logs": ...` — see GAP entry above; remove the step and use `wfctl infra logs` ad-hoc, or wait for `step.iac_logs` ([workflow-plugin-digitalocean#107](https://github.com/GoCodeAlone/workflow-plugin-digitalocean/issues/107)). + +## Rollback + +If your environment cannot upgrade in this cycle, pin to the previous workflow +core tag (`go get github.com/GoCodeAlone/workflow@v0.51.3`). The legacy modules +remain available there. diff --git a/modernize/legacy_do_rule.go b/modernize/legacy_do_rule.go new file mode 100644 index 00000000..f128ca70 --- /dev/null +++ b/modernize/legacy_do_rule.go @@ -0,0 +1,139 @@ +package modernize + +import ( + "fmt" + + "github.com/GoCodeAlone/workflow/internal/legacydo" + "gopkg.in/yaml.v3" +) + +// Import note: `modernize` MUST NOT import `module` directly. `module` +// transitively imports `modernize` via `plugin` (plugin/manifest.go + +// plugin/engine_plugin.go), so `modernize → module` creates an import cycle. +// Shared constants live in `internal/legacydo`, a leaf package that imports +// only stdlib and is safe for both `module` and `modernize` to consume. + +// legacyDORule rewrites legacy DigitalOcean module + step types to their +// infra.* IaC successors (issue #617). +// +// IMPORTANT: The Fix function ONLY renames the `type:` key — it does NOT +// inject the required `config.provider: digitalocean` setting, because that +// requires modifying a sibling mapping that may already contain unrelated +// keys the operator must review. The rule's Check Message and the migration +// guide both instruct the operator to add the provider key manually after +// running modernize. The committed `testdata/legacy-do-config.expected.yaml` +// fixture asserts the post-modernize shape: types renamed, provider NOT +// auto-added. Adding provider injection in a future iteration is tracked as +// a follow-up (see migration guide). +// +// Auto-fixable for 4 of 5 modules (platform.do_app/database/dns/doks) and +// 3 of 5 steps (step.do_deploy/status/destroy). The GAP types (do_networking +// splits 1→2; step.do_logs/scale have no pipeline-step successor) are flagged +// but not modified. +func legacyDORule() Rule { + moduleMap := map[string]string{ + "platform.do_app": "infra.container_service", + "platform.do_database": "infra.database", + "platform.do_dns": "infra.dns", + "platform.doks": "infra.k8s_cluster", + // platform.do_networking is intentionally NOT auto-fixed: it splits + // 1→2 (infra.vpc + infra.firewall), which requires structural + // rewrite the operator must review. + } + stepMap := map[string]string{ + "step.do_deploy": "step.iac_apply", + "step.do_status": "step.iac_status", + "step.do_destroy": "step.iac_destroy", + } + gapTypes := map[string]string{ + "platform.do_networking": "splits into infra.vpc + infra.firewall — manual rewrite required", + "step.do_logs": "no pipeline-step successor; use `wfctl infra logs` or rely on DO plugin Troubleshoot", + "step.do_scale": "no pipeline-step successor; edit instance_count and re-run step.iac_apply", + } + + return Rule{ + ID: "legacy-do-types", + Description: "Rewrite legacy DigitalOcean module/step types to infra.* IaC successors (issue #617).", + Severity: "error", + Check: func(root *yaml.Node, raw []byte) []Finding { + var out []Finding + walkTypeNodes(root, func(typeVal *yaml.Node) { + if successor, ok := moduleMap[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in %s; rewrite to %s (provider: digitalocean) — requires workflow-plugin-digitalocean", typeVal.Value, legacydo.RemovedInVersion, successor), + Fixable: true, + }) + } + if successor, ok := stepMap[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in %s; rewrite to %s — requires workflow-plugin-digitalocean", typeVal.Value, legacydo.RemovedInVersion, successor), + Fixable: true, + }) + } + if reason, ok := gapTypes[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in %s — %s", typeVal.Value, legacydo.RemovedInVersion, reason), + Fixable: false, + }) + } + }) + return out + }, + Fix: func(root *yaml.Node) []Change { + var out []Change + walkTypeNodes(root, func(typeVal *yaml.Node) { + if successor, ok := moduleMap[typeVal.Value]; ok { + old := typeVal.Value + typeVal.Value = successor + out = append(out, Change{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Description: fmt.Sprintf("rewrote %s → %s", old, successor), + }) + } + if successor, ok := stepMap[typeVal.Value]; ok { + old := typeVal.Value + typeVal.Value = successor + out = append(out, Change{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Description: fmt.Sprintf("rewrote %s → %s", old, successor), + }) + } + // gapTypes are intentionally not modified. + }) + return out + }, + } +} + +// walkTypeNodes traverses a YAML AST and invokes visit on every value node +// whose parent mapping key is "type". This differs from the package's existing +// walkNodes helper which visits every node — extracted as a separate helper +// because the type-key constraint produces tighter visitor code at call sites. +// If a future refactor unifies the two, prefer adding a key-filter parameter +// to walkNodes over keeping the duplication. +func walkTypeNodes(n *yaml.Node, visit func(*yaml.Node)) { + if n == nil { + return + } + if n.Kind == yaml.MappingNode { + for i := 0; i+1 < len(n.Content); i += 2 { + k, v := n.Content[i], n.Content[i+1] + if k.Value == "type" && v.Kind == yaml.ScalarNode { + visit(v) + } + walkTypeNodes(v, visit) + } + return + } + for _, c := range n.Content { + walkTypeNodes(c, visit) + } +} diff --git a/modernize/legacy_do_rule_test.go b/modernize/legacy_do_rule_test.go new file mode 100644 index 00000000..d5bed91e --- /dev/null +++ b/modernize/legacy_do_rule_test.go @@ -0,0 +1,110 @@ +package modernize + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestLegacyDORule_Rewrites(t *testing.T) { + cases := []struct { + name string + yamlIn string + wantNew string // must appear in fixed YAML + wantDrop string // must NOT appear in fixed YAML (the legacy type) + }{ + { + name: "platform.do_app → infra.container_service (provider NOT auto-injected)", + yamlIn: "modules:\n - name: api\n type: platform.do_app\n config:\n region: nyc\n", + wantNew: "infra.container_service", + wantDrop: "platform.do_app", + }, + { + name: "platform.do_database → infra.database", + yamlIn: "modules:\n - name: db\n type: platform.do_database\n config: {}\n", + wantNew: "infra.database", + wantDrop: "platform.do_database", + }, + { + name: "platform.do_dns → infra.dns", + yamlIn: "modules:\n - name: dns\n type: platform.do_dns\n config: {}\n", + wantNew: "infra.dns", + wantDrop: "platform.do_dns", + }, + { + name: "platform.doks → infra.k8s_cluster", + yamlIn: "modules:\n - name: k8s\n type: platform.doks\n config: {}\n", + wantNew: "infra.k8s_cluster", + wantDrop: "platform.doks", + }, + { + name: "step.do_deploy → step.iac_apply", + yamlIn: "pipelines:\n - steps:\n - type: step.do_deploy\n", + wantNew: "step.iac_apply", + wantDrop: "step.do_deploy", + }, + } + rule := legacyDORule() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var root yaml.Node + if err := yaml.Unmarshal([]byte(tc.yamlIn), &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } + findings := rule.Check(&root, []byte(tc.yamlIn)) + if len(findings) == 0 { + t.Fatalf("expected a finding, got 0") + } + rule.Fix(&root) + out, err := yaml.Marshal(&root) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(out) + if !strings.Contains(s, tc.wantNew) { + t.Errorf("fixed YAML missing %q; got:\n%s", tc.wantNew, s) + } + if strings.Contains(s, tc.wantDrop) { + t.Errorf("fixed YAML still contains legacy %q; got:\n%s", tc.wantDrop, s) + } + }) + } +} + +func TestLegacyDORule_GapTypesFlaggedNotRewritten(t *testing.T) { + // step.do_logs, step.do_scale, and platform.do_networking have NO 1:1 + // auto-fixable successor. Rule must: + // - flag them as findings, + // - NOT modify the YAML (no silent loss). + cases := []struct { + name string + legacy string + yamlIn string + }{ + {"step.do_logs", "step.do_logs", "pipelines:\n - steps:\n - type: step.do_logs\n"}, + {"step.do_scale", "step.do_scale", "pipelines:\n - steps:\n - type: step.do_scale\n"}, + {"platform.do_networking", "platform.do_networking", "modules:\n - name: net\n type: platform.do_networking\n config: {}\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var root yaml.Node + if err := yaml.Unmarshal([]byte(tc.yamlIn), &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } + rule := legacyDORule() + findings := rule.Check(&root, []byte(tc.yamlIn)) + if len(findings) == 0 { + t.Fatalf("expected a finding for %q", tc.legacy) + } + if findings[0].Fixable { + t.Errorf("%q must be marked Fixable: false (no auto-rewrite); got Fixable: true", tc.legacy) + } + rule.Fix(&root) + out, _ := yaml.Marshal(&root) + if !strings.Contains(string(out), tc.legacy) { + t.Errorf("Fix MUST NOT remove legacy %q; got:\n%s", tc.legacy, out) + } + }) + } +} diff --git a/modernize/modernize.go b/modernize/modernize.go index 0c90a0f9..bba531f4 100644 --- a/modernize/modernize.go +++ b/modernize/modernize.go @@ -43,6 +43,7 @@ func AllRules() []Rule { emptyRoutesRule(), camelCaseConfigRule(), requestParseConfigRule(), + legacyDORule(), } } diff --git a/modernize/testdata/legacy-do-config.expected.yaml b/modernize/testdata/legacy-do-config.expected.yaml new file mode 100644 index 00000000..218e8491 --- /dev/null +++ b/modernize/testdata/legacy-do-config.expected.yaml @@ -0,0 +1,45 @@ +modules: + - name: api + type: infra.container_service + config: + region: nyc + - name: db + type: infra.database + config: + engine: pg + size: db-s-1vcpu-1gb + - name: dns + type: infra.dns + config: + domain: example.com + - name: net + type: platform.do_networking + config: + vpc_cidr: 10.0.0.0/16 + - name: k8s + type: infra.k8s_cluster + config: + region: nyc3 + node_pool: + size: s-2vcpu-4gb + count: 3 + +pipelines: + deploy: + steps: + - type: step.iac_apply + config: + app: api + - type: step.iac_status + config: + app: api + - type: step.iac_destroy + config: + app: api + - type: step.do_logs + config: + app: api + - type: step.do_scale + config: + app: api + instance_count: 5 diff --git a/modernize/testdata/legacy-do-config.yaml b/modernize/testdata/legacy-do-config.yaml new file mode 100644 index 00000000..1772cd2a --- /dev/null +++ b/modernize/testdata/legacy-do-config.yaml @@ -0,0 +1,45 @@ +modules: + - name: api + type: platform.do_app + config: + region: nyc + - name: db + type: platform.do_database + config: + engine: pg + size: db-s-1vcpu-1gb + - name: dns + type: platform.do_dns + config: + domain: example.com + - name: net + type: platform.do_networking + config: + vpc_cidr: 10.0.0.0/16 + - name: k8s + type: platform.doks + config: + region: nyc3 + node_pool: + size: s-2vcpu-4gb + count: 3 + +pipelines: + deploy: + steps: + - type: step.do_deploy + config: + app: api + - type: step.do_status + config: + app: api + - type: step.do_destroy + config: + app: api + - type: step.do_logs + config: + app: api + - type: step.do_scale + config: + app: api + instance_count: 5 From fe6214006db39af0a7bf41362ce7e7745065ffee Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 05:14:37 -0400 Subject: [PATCH 22/26] test(#617): bump modernize rule-count expectation to include legacy-do-types T5 appended legacyDORule (id: legacy-do-types) to AllRules() but missed this counter test in cmd/wfctl/modernize_test.go. Single-line fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/wfctl/modernize_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/wfctl/modernize_test.go b/cmd/wfctl/modernize_test.go index dbe2f482..8bed2692 100644 --- a/cmd/wfctl/modernize_test.go +++ b/cmd/wfctl/modernize_test.go @@ -552,6 +552,7 @@ func TestModernizeAllRulesRegistered(t *testing.T) { "empty-routes", "camelcase-config", "request-parse-config", + "legacy-do-types", } if len(rules) != len(expectedIDs) { t.Errorf("expected %d rules, got %d", len(expectedIDs), len(rules)) From 407c6a9ff9c6c339fffcc95646514f1e7da36408 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 05:33:22 -0400 Subject: [PATCH 23/26] fix(#617): make legacy DO step modernize findings non-fixable; fix migration guide step example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit step.do_deploy/status/destroy require different config keys in their successors (platform + state_store vs legacy app:) — auto-rewriting the type alone produces an invalid config. Mark step findings Fixable: false, remove step rewrites from Fix(), update testdata fixture and tests to reflect unchanged step types post-modernize. Also update FormatStepError to include required config keys in the migration error message, and fix the migration guide pipeline-step example to show the correct step.iac_apply config shape. Addresses Copilot review comments: - r3232996570: make step findings non-fixable (option a) - r3232996648: fix migration guide step example config shape - r3232996683: fix testdata fixture to leave step types unchanged - r3232996732: add required config keys to step migration error Co-Authored-By: Claude Sonnet 4.6 --- docs/migrations/v0.52.0-godo-removal.md | 12 +++- internal/legacydo/types.go | 6 +- modernize/legacy_do_rule.go | 67 +++++++++++-------- modernize/legacy_do_rule_test.go | 22 +++--- .../testdata/legacy-do-config.expected.yaml | 6 +- 5 files changed, 67 insertions(+), 46 deletions(-) diff --git a/docs/migrations/v0.52.0-godo-removal.md b/docs/migrations/v0.52.0-godo-removal.md index 7c4fc901..62a3b73e 100644 --- a/docs/migrations/v0.52.0-godo-removal.md +++ b/docs/migrations/v0.52.0-godo-removal.md @@ -135,9 +135,19 @@ pipelines: - id: deploy steps: - type: step.iac_apply - config: { module: api } + config: + platform: digitalocean # name of the iac.provider service in the registry + state_store: mystore # name of an iac.state backend module ``` +> **Note:** `step.iac_apply/status/destroy` require `platform` (the name of the +> `iac.provider` service registered by workflow-plugin-digitalocean) and +> `state_store` (the name of an IaC state backend module). The legacy `app:` +> config key is not used. The `wfctl modernize` tool flags these steps but does +> **not** auto-rewrite them because the config shape change cannot be done +> automatically — you must supply the correct `platform` and `state_store` values +> for your deployment. + ## Errors you may see * `unsupported legacy module type "platform.do_app" (module "api"): this type was removed from workflow core in v0.52.0 — DigitalOcean IaC moved to workflow-plugin-digitalocean.` — fix the config per the table above; install the plugin if not already loaded. diff --git a/internal/legacydo/types.go b/internal/legacydo/types.go index 1a17ba2b..07948829 100644 --- a/internal/legacydo/types.go +++ b/internal/legacydo/types.go @@ -30,9 +30,9 @@ var ModuleTypes = map[string]string{ // StepTypes maps each removed legacy DigitalOcean step type to its // successor or to a workaround when no 1:1 successor exists. var StepTypes = map[string]string{ - "step.do_deploy": "step.iac_apply (against an infra.container_service module)", - "step.do_status": "step.iac_status (against an infra.container_service module)", - "step.do_destroy": "step.iac_destroy (against an infra.container_service module)", + "step.do_deploy": "step.iac_apply (against an infra.container_service module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.do_status": "step.iac_status (against an infra.container_service module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.do_destroy": "step.iac_destroy (against an infra.container_service module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", "step.do_logs": "no direct pipeline-step equivalent; use `wfctl infra logs` ad-hoc, or rely on the DO plugin's Troubleshoot hook on step.iac_apply failure", "step.do_scale": "no direct pipeline-step equivalent; update instance_count in the infra.container_service module config and re-run step.iac_apply", } diff --git a/modernize/legacy_do_rule.go b/modernize/legacy_do_rule.go index f128ca70..5adcc248 100644 --- a/modernize/legacy_do_rule.go +++ b/modernize/legacy_do_rule.go @@ -13,23 +13,29 @@ import ( // Shared constants live in `internal/legacydo`, a leaf package that imports // only stdlib and is safe for both `module` and `modernize` to consume. -// legacyDORule rewrites legacy DigitalOcean module + step types to their -// infra.* IaC successors (issue #617). +// legacyDORule flags legacy DigitalOcean module + step types and rewrites +// module types to their infra.* IaC successors (issue #617). // -// IMPORTANT: The Fix function ONLY renames the `type:` key — it does NOT -// inject the required `config.provider: digitalocean` setting, because that -// requires modifying a sibling mapping that may already contain unrelated -// keys the operator must review. The rule's Check Message and the migration -// guide both instruct the operator to add the provider key manually after -// running modernize. The committed `testdata/legacy-do-config.expected.yaml` -// fixture asserts the post-modernize shape: types renamed, provider NOT -// auto-added. Adding provider injection in a future iteration is tracked as -// a follow-up (see migration guide). +// IMPORTANT: The Fix function ONLY renames the `type:` key for module types +// — it does NOT inject the required `config.provider: digitalocean` setting, +// because that requires modifying a sibling mapping that may already contain +// unrelated keys the operator must review. The rule's Check Message and the +// migration guide both instruct the operator to add the provider key manually +// after running modernize. The `testdata/legacy-do-config.expected.yaml` +// fixture documents the post-modernize shape: module types renamed, provider NOT +// auto-added, step types unchanged (non-fixable). Adding provider injection in +// a future iteration is tracked as a follow-up (see migration guide). // -// Auto-fixable for 4 of 5 modules (platform.do_app/database/dns/doks) and -// 3 of 5 steps (step.do_deploy/status/destroy). The GAP types (do_networking -// splits 1→2; step.do_logs/scale have no pipeline-step successor) are flagged -// but not modified. +// Step types (step.do_deploy/status/destroy) are flagged but NOT auto-rewritten +// because step.iac_apply/status/destroy require different config keys +// (platform + state_store) rather than the legacy app: key. Auto-rewriting +// the type alone produces an invalid config. The operator must rewrite step +// config manually per the migration guide (docs/migrations/v0.52.0-godo-removal.md). +// +// Auto-fixable: 4 of 5 modules (platform.do_app/database/dns/doks). +// Not auto-fixable: platform.do_networking (1→2 split), all 5 step types +// (step.do_deploy/status/destroy config shape mismatch; step.do_logs/scale +// have no pipeline-step successor). func legacyDORule() Rule { moduleMap := map[string]string{ "platform.do_app": "infra.container_service", @@ -40,6 +46,10 @@ func legacyDORule() Rule { // 1→2 (infra.vpc + infra.firewall), which requires structural // rewrite the operator must review. } + // stepMap holds the successor type name for the migration error message only. + // These findings are NOT auto-fixable: step.iac_apply/status/destroy require + // different config keys (platform + state_store) vs the legacy app: key, so + // rewriting type alone would produce an invalid config. stepMap := map[string]string{ "step.do_deploy": "step.iac_apply", "step.do_status": "step.iac_status", @@ -68,10 +78,14 @@ func legacyDORule() Rule { } if successor, ok := stepMap[typeVal.Value]; ok { out = append(out, Finding{ - RuleID: "legacy-do-types", - Line: typeVal.Line, - Message: fmt.Sprintf("%s removed in %s; rewrite to %s — requires workflow-plugin-digitalocean", typeVal.Value, legacydo.RemovedInVersion, successor), - Fixable: true, + RuleID: "legacy-do-types", + Line: typeVal.Line, + // Fixable is false: step.iac_apply/status/destroy require + // different config keys (platform + state_store) compared + // to the legacy app: key. Rewriting the type alone would + // produce an invalid config. Operator must rewrite manually. + Message: fmt.Sprintf("%s removed in %s; manually rewrite to %s with config keys platform + state_store (see docs/migrations/v0.52.0-godo-removal.md) — requires workflow-plugin-digitalocean", typeVal.Value, legacydo.RemovedInVersion, successor), + Fixable: false, }) } if reason, ok := gapTypes[typeVal.Value]; ok { @@ -97,16 +111,11 @@ func legacyDORule() Rule { Description: fmt.Sprintf("rewrote %s → %s", old, successor), }) } - if successor, ok := stepMap[typeVal.Value]; ok { - old := typeVal.Value - typeVal.Value = successor - out = append(out, Change{ - RuleID: "legacy-do-types", - Line: typeVal.Line, - Description: fmt.Sprintf("rewrote %s → %s", old, successor), - }) - } - // gapTypes are intentionally not modified. + // stepMap types are intentionally NOT rewritten: step.iac_apply/ + // status/destroy require different config keys (platform + + // state_store) vs the legacy app: key. Auto-rewriting the type + // alone produces an invalid config; operator must rewrite manually. + // gapTypes are also intentionally not modified. }) return out }, diff --git a/modernize/legacy_do_rule_test.go b/modernize/legacy_do_rule_test.go index d5bed91e..d2acf452 100644 --- a/modernize/legacy_do_rule_test.go +++ b/modernize/legacy_do_rule_test.go @@ -38,12 +38,6 @@ func TestLegacyDORule_Rewrites(t *testing.T) { wantNew: "infra.k8s_cluster", wantDrop: "platform.doks", }, - { - name: "step.do_deploy → step.iac_apply", - yamlIn: "pipelines:\n - steps:\n - type: step.do_deploy\n", - wantNew: "step.iac_apply", - wantDrop: "step.do_deploy", - }, } rule := legacyDORule() for _, tc := range cases { @@ -73,10 +67,15 @@ func TestLegacyDORule_Rewrites(t *testing.T) { } func TestLegacyDORule_GapTypesFlaggedNotRewritten(t *testing.T) { - // step.do_logs, step.do_scale, and platform.do_networking have NO 1:1 - // auto-fixable successor. Rule must: - // - flag them as findings, - // - NOT modify the YAML (no silent loss). + // Non-fixable types: the rule must flag them as findings (Fixable: false) + // and must NOT modify the YAML after Fix() runs. + // + // Includes: + // - step.do_logs/scale: no 1:1 pipeline-step successor (GAP types). + // - platform.do_networking: splits 1→2, manual rewrite required. + // - step.do_deploy/status/destroy: step.iac_apply/status/destroy require + // different config keys (platform + state_store vs legacy app:), so + // auto-rewriting the type alone produces an invalid config. cases := []struct { name string legacy string @@ -85,6 +84,9 @@ func TestLegacyDORule_GapTypesFlaggedNotRewritten(t *testing.T) { {"step.do_logs", "step.do_logs", "pipelines:\n - steps:\n - type: step.do_logs\n"}, {"step.do_scale", "step.do_scale", "pipelines:\n - steps:\n - type: step.do_scale\n"}, {"platform.do_networking", "platform.do_networking", "modules:\n - name: net\n type: platform.do_networking\n config: {}\n"}, + {"step.do_deploy", "step.do_deploy", "pipelines:\n - steps:\n - type: step.do_deploy\n config:\n app: api\n"}, + {"step.do_status", "step.do_status", "pipelines:\n - steps:\n - type: step.do_status\n config:\n app: api\n"}, + {"step.do_destroy", "step.do_destroy", "pipelines:\n - steps:\n - type: step.do_destroy\n config:\n app: api\n"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/modernize/testdata/legacy-do-config.expected.yaml b/modernize/testdata/legacy-do-config.expected.yaml index 218e8491..f1d8c586 100644 --- a/modernize/testdata/legacy-do-config.expected.yaml +++ b/modernize/testdata/legacy-do-config.expected.yaml @@ -27,13 +27,13 @@ modules: pipelines: deploy: steps: - - type: step.iac_apply + - type: step.do_deploy config: app: api - - type: step.iac_status + - type: step.do_status config: app: api - - type: step.iac_destroy + - type: step.do_destroy config: app: api - type: step.do_logs From d52429b21eebc4c3a6606e4fd83641b7213daa8e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 05:34:29 -0400 Subject: [PATCH 24/26] docs(#617): update migration guide step table to reflect non-fixable step rewrites step.do_deploy/status/destroy are now flagged-not-rewritten (Fixable: false) because their successors use different config keys. Update the step mapping table Auto-fix column and the recipe description to match. Co-Authored-By: Claude Sonnet 4.6 --- docs/migrations/v0.52.0-godo-removal.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/migrations/v0.52.0-godo-removal.md b/docs/migrations/v0.52.0-godo-removal.md index 62a3b73e..21b7a62d 100644 --- a/docs/migrations/v0.52.0-godo-removal.md +++ b/docs/migrations/v0.52.0-godo-removal.md @@ -32,10 +32,11 @@ design plan at `docs/plans/2026-05-13-issue-617-godo-removal.md`. ```sh wfctl modernize --apply ./config/*.yaml ``` - This **renames the type field** for 4 module types and 3 step types - automatically. Two step types (`step.do_logs`, `step.do_scale`) and one - module type (`platform.do_networking`) are flagged but not auto-rewritten - — see below. + This **renames the type field** for 4 module types automatically. + All 5 step types and one module type (`platform.do_networking`) are flagged + but **not** auto-rewritten — see below. Step types require a manual + config rewrite because `step.iac_apply/status/destroy` use different config + keys (`platform` + `state_store`) than the legacy `app:` key. 3. **Add `provider: digitalocean` to each rewritten module's `config:` block.** The modernize rule does NOT auto-inject this key, because the @@ -83,9 +84,9 @@ All successors require `config.provider: digitalocean`. | Legacy type | Successor | Auto-fix | |--------------------|--------------------------------------------------------------------|----------| -| `step.do_deploy` | `step.iac_apply` (against an `infra.container_service` module) | Yes | -| `step.do_status` | `step.iac_status` (against an `infra.container_service` module) | Yes | -| `step.do_destroy` | `step.iac_destroy` (against an `infra.container_service` module) | Yes | +| `step.do_deploy` | `step.iac_apply` (against an `infra.container_service` module); requires `platform` + `state_store` config keys | **No** — config shape change; manual rewrite required | +| `step.do_status` | `step.iac_status` (against an `infra.container_service` module); requires `platform` + `state_store` config keys | **No** — config shape change; manual rewrite required | +| `step.do_destroy` | `step.iac_destroy` (against an `infra.container_service` module); requires `platform` + `state_store` config keys | **No** — config shape change; manual rewrite required | | `step.do_logs` | **GAP** — no pipeline step successor; use `wfctl infra logs` ad-hoc, or rely on the DO plugin's Troubleshoot hook on `step.iac_apply` failure. Tracked: [workflow-plugin-digitalocean#107](https://github.com/GoCodeAlone/workflow-plugin-digitalocean/issues/107) | **No** | | `step.do_scale` | **GAP** — no pipeline step successor; update `instance_count` in the `infra.container_service` module config and re-run `step.iac_apply`. Tracked: [workflow-plugin-digitalocean#108](https://github.com/GoCodeAlone/workflow-plugin-digitalocean/issues/108) | **No** | From 3f9045170005721dbf23cdbfc18cdf0b8ea3a73c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 05:42:18 -0400 Subject: [PATCH 25/26] fix(lint): add return after t.Fatal to resolve SA5011 nil-dereference false positives staticcheck SA5011 flags t.Fatal()/t.Fatalf() as non-terminating because testing.T.Fatal calls runtime.Goexit (not a panic/return), which staticcheck does not model as a definite exit. Adding an unreachable `return` statement after each t.Fatal in iac/conformance scenarios makes the nil-guard pattern unambiguous to static analysis: execution cannot reach the pointer dereference if result/res is nil. Affected files: - iac/conformance/scenario_delete_action.go - iac/conformance/scenario_grpc_roundtrip.go - iac/conformance/scenario_replace_cascade_preserves_dependents.go - iac/conformance/scenario_upsert_on_already_exists.go Co-Authored-By: Claude Sonnet 4.6 --- iac/conformance/scenario_delete_action.go | 1 + iac/conformance/scenario_grpc_roundtrip.go | 1 + iac/conformance/scenario_replace_cascade_preserves_dependents.go | 1 + iac/conformance/scenario_upsert_on_already_exists.go | 1 + 4 files changed, 4 insertions(+) diff --git a/iac/conformance/scenario_delete_action.go b/iac/conformance/scenario_delete_action.go index de4decdc..ab86e12a 100644 --- a/iac/conformance/scenario_delete_action.go +++ b/iac/conformance/scenario_delete_action.go @@ -77,6 +77,7 @@ func scenarioDeleteActionInApplyInvokesDriverDelete(t *testing.T, cfg Config) { } if result == nil { t.Fatal("ApplyPlan returned nil result") + return } if len(result.Errors) != 0 { // Most likely failure mode: provider's dispatch lacks a diff --git a/iac/conformance/scenario_grpc_roundtrip.go b/iac/conformance/scenario_grpc_roundtrip.go index ca338551..6e7e2414 100644 --- a/iac/conformance/scenario_grpc_roundtrip.go +++ b/iac/conformance/scenario_grpc_roundtrip.go @@ -102,6 +102,7 @@ func scenarioDiffSurvivesGRPCRoundTrip(t *testing.T, cfg Config) { } if res == nil { t.Fatal("roundtripDriver.Diff returned nil DiffResult after structpb roundtrip; the response decode must yield a non-nil value") + return } // Each Change that survived the response-side roundtrip must diff --git a/iac/conformance/scenario_replace_cascade_preserves_dependents.go b/iac/conformance/scenario_replace_cascade_preserves_dependents.go index bb4e3c54..f750137a 100644 --- a/iac/conformance/scenario_replace_cascade_preserves_dependents.go +++ b/iac/conformance/scenario_replace_cascade_preserves_dependents.go @@ -95,6 +95,7 @@ func scenarioReplaceCascadePreservesDependents(t *testing.T, cfg Config) { } if result == nil { t.Fatal("ApplyPlan returned nil result") + return } if len(result.Errors) != 0 { t.Errorf("expected no per-action errors (cascade JIT must resolve "+ diff --git a/iac/conformance/scenario_upsert_on_already_exists.go b/iac/conformance/scenario_upsert_on_already_exists.go index cf36115e..d55caf3b 100644 --- a/iac/conformance/scenario_upsert_on_already_exists.go +++ b/iac/conformance/scenario_upsert_on_already_exists.go @@ -91,6 +91,7 @@ func scenarioUpsertOnAlreadyExists(t *testing.T, cfg Config) { } if result == nil { t.Fatal("ApplyPlan returned nil result") + return } if len(result.Errors) != 0 { t.Errorf("expected no per-action errors (UpsertSupporter must recover from "+ From d868c0551b61a2c63fb0c9cb126b836d885044c5 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 05:55:35 -0400 Subject: [PATCH 26/26] =?UTF-8?q?fix:=20address=20Copilot=20review=20round?= =?UTF-8?q?=202=20=E2=80=94=20doc=20YAML=20shape=20+=20test=20coverage=20g?= =?UTF-8?q?ap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections from Copilot's second-pass review: 1. docs/migrations/v0.52.0-godo-removal.md: plugin install snippet used a top-level `plugins:` sequence with `source:` which does not match the app config schema (ExternalPluginDecl has no source field; PluginsConfig wraps external under plugins.external:). Replace with the correct `wfctl plugin install` CLI command + wfctl.yaml manifest form (WfctlPluginEntry has source). 2. module/godo_absent_test.go: `filepath.Glob("*.go")` is non-recursive and only checks the current directory, not subdirectories. The comment claimed it covered "no file under module/", which was misleading. Switch to `filepath.WalkDir(".", ...)` to make the assertion match the comment's intent and guard against future subdirectory additions. Co-Authored-By: Claude Sonnet 4.6 --- docs/migrations/v0.52.0-godo-removal.md | 21 ++++++++++++++++++--- module/godo_absent_test.go | 19 +++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/docs/migrations/v0.52.0-godo-removal.md b/docs/migrations/v0.52.0-godo-removal.md index 21b7a62d..b79d4740 100644 --- a/docs/migrations/v0.52.0-godo-removal.md +++ b/docs/migrations/v0.52.0-godo-removal.md @@ -21,11 +21,26 @@ design plan at `docs/plans/2026-05-13-issue-617-godo-removal.md`. ## Migration recipe 1. Install the DO plugin (v0.12.0+): + ```sh + wfctl plugin install workflow-plugin-digitalocean@0.12.0 + ``` + Or declare it in your workflow config under `plugins.external` so the engine + auto-fetches it from the registry when it isn't already in the local plugin + directory: ```yaml plugins: - - name: digitalocean - source: github.com/GoCodeAlone/workflow-plugin-digitalocean - version: ">=0.12.0" + external: + - name: workflow-plugin-digitalocean + version: ">=0.12.0" + autoFetch: true + ``` + + To declare the dependency without auto-fetch, list the plugin name under + `requires.plugins`: + ```yaml + requires: + plugins: + - workflow-plugin-digitalocean ``` 2. Run the modernizer over each affected YAML config: diff --git a/module/godo_absent_test.go b/module/godo_absent_test.go index 75a61531..e86dcd25 100644 --- a/module/godo_absent_test.go +++ b/module/godo_absent_test.go @@ -3,17 +3,28 @@ package module_test import ( "go/parser" "go/token" + "io/fs" "path/filepath" "strings" "testing" ) -// TestGodoNotImported_InModulePackage asserts no file under module/ imports -// github.com/digitalocean/godo. This is the regression gate for issue #617. +// TestGodoNotImported_InModulePackage asserts no file under module/ (including +// subdirectories) imports github.com/digitalocean/godo. This is the regression +// gate for issue #617. func TestGodoNotImported_InModulePackage(t *testing.T) { - files, err := filepath.Glob("*.go") + var files []string + err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.HasSuffix(path, ".go") { + files = append(files, path) + } + return nil + }) if err != nil { - t.Fatalf("glob: %v", err) + t.Fatalf("walk: %v", err) } fset := token.NewFileSet() for _, f := range files {