From fc17398b8b43872b2e53ab6a0eb36fbcc2cce78f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 07:38:01 -0400 Subject: [PATCH 01/18] docs(#653): design doc for Phase 1 AWS IaC cutover Force-cutover of 6 AWS IaC modules to workflow-plugin-aws v0.2.0. Key divergence from #617: cloud_account_aws.go stays (AWSConfigProvider used by Phase 2 out-of-scope files). platform.dns module type stays; only Route53 backend is removed. Co-Authored-By: Claude Sonnet 4.6 --- ...-13-issue-653-phase1-aws-cutover-design.md | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md diff --git a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md new file mode 100644 index 00000000..960c6707 --- /dev/null +++ b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md @@ -0,0 +1,320 @@ +# Issue #653 Phase 1 — Remove AWS IaC Modules from Workflow Core + +**Status:** Draft for adversarial review +**Owner:** autonomous pipeline +**Issue:** [GoCodeAlone/workflow#653](https://github.com/GoCodeAlone/workflow/issues/653) +**Date:** 2026-05-13 +**Precedent:** `docs/plans/2026-05-13-issue-617-godo-removal-design.md` (#617 godo removal, merged c55a56e5) + +--- + +## Summary + +Workflow core directly imports `github.com/aws/aws-sdk-go-v2` service packages to back six legacy AWS IaC modules: `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.dns` (AWS Route53 backend only), `platform.networking`, and the standalone `AWSAPIGateway` helper (not registered as a module type). The same IaC surface is already implemented in `workflow-plugin-aws` v0.2.0 as proper IaC provider plugin drivers. + +This design proposes a **single-PR force-cutover** that deletes the legacy AWS IaC surface, removes the now-unused AWS service SDK packages from `go.mod`, and emits actionable migration errors. + +**Critical architectural finding:** `cloud_account_aws.go` and `cloud_account_aws_creds.go` are **NOT deleted** in Phase 1. They provide `AWSConfigProvider` interface + `AWSConfig()` method + credential resolvers used by out-of-scope files (`codebuild.go`, `platform_kubernetes_kind.go`, `secrets_aws.go`, `module/pipeline_step_s3_upload.go`). Deleting them would break these Phase 2 files which are explicitly out-of-scope. The correct parallel is `cloud_account_azure.go` (which is never deleted because Azure credential resolution stays in core). This is the primary divergence from #617's scope: the AWS credential resolver stays in core; only the IaC module implementations move. + +**Critical finding: platform.dns is generic, not AWS-only.** The `platform.dns` module type supports pluggable backends (mock + aws/route53). The module registration, `platform_dns.go`, `pipeline_step_dns.go`, and the `step.dns_*` step types are generic and **stay**. Only the Route53 backend implementation in `platform_dns_backends.go` is deleted. The file is replaced by `platform_dns_backend_mock.go` (the `mockDNSBackend` from the deleted file) + a migration stub that emits the actionable error when `provider: aws` is configured. + +--- + +## Goals (acceptance criteria from #653) + +1. Workflow core no longer imports `service/ecs`, `service/apigatewayv2`, `service/applicationautoscaling`, `service/route53`, `service/ec2` for IaC module behavior. +2. AWS IaC behavior remains available through `workflow-plugin-aws` v0.2.0+. +3. `wfctl` errors remain actionable when a legacy AWS module type is referenced. +4. `go mod tidy` drops the freed service packages. Base packages (`aws-sdk-go-v2`, `config`, `credentials`, `service/sts`) remain because they are used by out-of-scope files. + +## Non-goals + +- Removing `cloud_account_aws.go` / `cloud_account_aws_creds.go` (Phase 2, blocked on codebuild/platform_kubernetes_kind removal). +- Removing the generic `platform.dns` module type or `step.dns_*` step types. +- Removing `codebuild.go`, `nosql_dynamodb.go`, `pipeline_step_s3_upload.go`, `platform_kubernetes_kind.go` (Phase 2 operational tooling audit). +- Removing `platform/providers/aws/drivers/` (Phase 3 architectural question). +- Backwards-compatible shim modules — force-cutover, no compat layer. +- Migration tooling beyond actionable load-time error + wfctl modernize rules. + +--- + +## Current state — surface to remove + +### Module files (deleted in Phase 1) + +| File | Lines | Module type / purpose | +|------|-------|----------------------| +| `module/platform_ecs.go` | 571 | `platform.ecs` — AWS ECS/Fargate module | +| `module/platform_ecs_test.go` | ~150 | Unit tests | +| `module/pipeline_step_ecs.go` | ~120 | `step.ecs_plan/apply/status/destroy` factories | +| `module/platform_apigateway.go` | 519 | `platform.apigateway` — AWS API GW module | +| `module/platform_apigateway_test.go` | ~200 | Unit tests | +| `module/pipeline_step_apigateway.go` | ~100 | `step.apigw_plan/apply/status/destroy` factories | +| `module/aws_api_gateway.go` | 277 | `AWSAPIGateway` helper (unregistered, used only in tests) | +| `module/api_gateway_test.go` (partial) | 558 total | **Partial delete**: remove 3 `TestAWSAPIGateway_*` tests; keep 19 generic HTTP gateway tests | +| `module/platform_autoscaling.go` | 485 | `platform.autoscaling` — AWS App Auto Scaling module | +| `module/platform_autoscaling_test.go` | ~150 | Unit tests | +| `module/pipeline_step_autoscaling.go` | ~120 | `step.scaling_plan/apply/status/destroy` factories | +| `module/platform_networking.go` | 638 | `platform.networking` — AWS EC2/VPC module | +| `module/platform_networking_test.go` | ~200 | Unit tests | +| `module/pipeline_step_networking.go` | ~100 | `step.network_plan/apply/status/destroy` factories | +| `module/platform_dns_backends.go` | 358 | Route53 backend + mock backend | +| `module/platform_aws_integration_test.go` | ~140 | Integration tests for ECS + networking + DNS | + +**Modified files (partial edit, not deleted):** + +| File | Edit | +|------|------| +| `module/api_gateway_test.go` | Remove 3 `TestAWSAPIGateway_*` test functions only; keep all 19 generic HTTP gateway tests | +| `module/platform_dns_backends.go` | **Replace entire file**: delete route53Backend implementation; keep mockDNSBackend + add AWS route removed error stub | + +**New file (split from platform_dns_backends.go):** + +| File | Purpose | +|------|---------| +| (none — mock backend stays in platform_dns_backends.go after route53 removal) | See edit above | + +### Registration / schema sites + +| File | Edit | +|------|------| +| `plugins/platform/plugin.go` | Drop `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking` from `ModuleTypes`; drop 4 module factories; drop `step.ecs_*`, `step.apigw_*`, `step.scaling_*`, `step.network_*` from `StepTypes`; drop 16 step factories. Keep `platform.dns`, `step.dns_*`, and all other types. | +| `plugins/platform/plugin_test.go` | Drop the 4 module type + 16 step type string assertions. | +| `schema/schema.go` | Drop `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking` from module type list. | +| `schema/module_schema.go` | Drop 4 module schemas. | +| `schema/step_schema_builtins.go` | Drop 16 step schema `Register` calls (ecs, apigw, scaling, network steps). | +| `cmd/wfctl/type_registry.go` | Drop `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking` entries. | +| `schema/testdata/editor-schemas.golden.json` | Update golden file (regenerate via `go generate ./schema/...` or `-update` flag). | +| `module/multi_region.go:117` | Update error message that references `platform.ecs` to use `infra.container_service`. | +| `DOCUMENTATION.md` | Remove 4 module rows + 16 step rows; add paragraph pointing at `workflow-plugin-aws`. | +| `go.mod` / `go.sum` | `go mod tidy` after deletion drops freed service packages. | + +### Step types removed (16 total) + +| Module | Steps removed | +|--------|---------------| +| `platform.ecs` | `step.ecs_plan`, `step.ecs_apply`, `step.ecs_status`, `step.ecs_destroy` | +| `platform.apigateway` | `step.apigw_plan`, `step.apigw_apply`, `step.apigw_status`, `step.apigw_destroy` | +| `platform.autoscaling` | `step.scaling_plan`, `step.scaling_apply`, `step.scaling_status`, `step.scaling_destroy` | +| `platform.networking` | `step.network_plan`, `step.network_apply`, `step.network_status` (no destroy step) | + +**Remaining (stays):** `step.dns_plan`, `step.dns_apply`, `step.dns_status` — these back the generic `platform.dns` module which stays. + +--- + +## Migration errors + +### Module guard (4 types) + +In `engine.go BuildFromConfig` (unknown-module-type branch, after legacydo check): + +``` +unsupported legacy module type %q (module %q): this type was removed from workflow core in v. + +AWS IaC moved to workflow-plugin-aws. +%s + +Migrate this module to the equivalent infra.* IaC type: + platform.ecs → infra.container_service (provider: aws) + platform.apigateway → infra.api_gateway (provider: aws) + platform.autoscaling → infra.autoscaling_group (provider: aws) + platform.networking → infra.vpc + infra.firewall (provider: aws) + +See docs/migrations/v-aws-iac-removal.md. +``` + +The `%s` line branches on plugin-loaded detection (mirrors #617 pattern): +- `_, iacLoaded := e.moduleFactories["iac.provider"]` → if true: `"workflow-plugin-aws is already loaded; your config still references the legacy module name."` else: `"Install workflow-plugin-aws: https://github.com/GoCodeAlone/workflow-plugin-aws"` + +### Step guard (16 types) + +In `module/pipeline_step_registry.go Create()` (unknown-step-type branch, after legacydo check): + +``` +step.ecs_plan/apply/status/destroy → step.iac_plan/apply/status/destroy (against an infra.container_service module) +step.apigw_plan/apply/status/destroy → step.iac_plan/apply/status/destroy (against an infra.api_gateway module) +step.scaling_plan/apply/status/destroy → step.iac_plan/apply/status/destroy (against an infra.autoscaling_group module) +step.network_plan/apply/status → step.iac_plan/apply/status (against an infra.vpc + infra.firewall module) +``` + +Same plugin-loaded detection branching as #617. + +### platform.dns provider: aws guard + +In the `platform.dns` Init() when `provider: aws` is configured but Route53 backend was removed: + +``` +platform.dns %q: AWS Route53 backend removed from workflow core in v. +Migrate to: infra.dns (provider: aws) with workflow-plugin-aws v0.2.0+. +Install: https://github.com/GoCodeAlone/workflow-plugin-aws +See docs/migrations/v-aws-iac-removal.md. +``` + +This is a runtime error in `platform.dns` Init(), not an engine-level unknown-type error. The `platform.dns` module type still loads; only the Route53 backend is unavailable. + +--- + +## go.mod impact + +After deleting the in-scope files, these packages become unreferenced in `module/` and are also not used anywhere else in core: + +| Package | Freed by deletion of | +|---------|---------------------| +| `service/apigatewayv2` | platform_apigateway.go + aws_api_gateway.go | +| `service/applicationautoscaling` | platform_autoscaling.go | +| `service/route53` | platform_dns_backends.go | +| `service/ec2` | **Stays** — also used by `platform/providers/aws/drivers/vpc.go` | +| `service/ecs` | **Stays** — also used by `provider/aws/plugin.go` | +| `service/sts` | **Stays** — also used by `iam/aws.go`, `platform/providers/aws/`, `provider/aws/`, AND `cloud_account_aws.go` (kept) | +| `credentials/stscreds` | **Stays** — also used by `provider/aws/plugin.go` AND `cloud_account_aws.go` (kept) | +| `service/cloudwatch` | **Stays** — used by `provider/aws/plugin.go` | +| `aws-sdk-go-v2` (base) | **Stays** — used by everything above | +| `config` | **Stays** — used by `cloud_account_aws.go`, `iac_state_spaces.go`, etc. | +| `credentials` | **Stays** — used by `cloud_account_aws.go`, `cloud_account_aws_creds.go` | + +`go mod tidy` drops exactly: `service/apigatewayv2`, `service/applicationautoscaling`, `service/route53` (and their transitive-only deps if any become unreferenced). + +--- + +## internal/legacyaws package + +Following the `internal/legacydo` precedent (plan cycle-4 finding from #617 retro), constants and formatters for the legacy AWS types must live in a new leaf package `internal/legacyaws/types.go`. This prevents the `module → plugin → modernize` import cycle that would occur if `modernize/legacy_aws_rule.go` imported from `module/`. + +`internal/legacyaws/types.go`: +- `RemovedInVersion = "v0.53.0"` (next minor after v0.52.0) +- `ModuleTypes` — 4 legacy module types + successors +- `StepTypes` — 16 legacy step types + successors +- `IsModuleType()`, `IsStepType()`, `FormatModuleError()`, `FormatStepError()` + +--- + +## wfctl modernize rules + +A new `modernize/legacy_aws_rule.go` mirrors `legacy_do_rule.go`: + +Auto-fixable (rename type only, no provider injection): +- `platform.ecs` → `infra.container_service` +- `platform.apigateway` → `infra.api_gateway` +- `platform.autoscaling` → `infra.autoscaling_group` + +Not auto-fixable (1→2 split, operator must review): +- `platform.networking` → splits into `infra.vpc` + `infra.firewall` + +Not auto-fixable (step config-shape mismatch, per #617 retro lesson): +- All 16 step types → flagged with message; NOT auto-rewritten (step.iac_* require different config keys: `platform` + `state_store` instead of the legacy `service`/`cluster` keys). Operator must rewrite manually per migration guide. + +--- + +## Parity matrix — legacy core type → plugin replacement + +| Legacy core type | workflow-plugin-aws v0.2.0 successor | Notes | +|-----------------|---------------------------------------|-------| +| `platform.ecs` | `infra.container_service` (provider: aws) | ECS/Fargate driver in plugin | +| `platform.apigateway` | `infra.api_gateway` (provider: aws) | API GW v2 driver in plugin | +| `AWSAPIGateway` helper | N/A — deleted as internal helper; not a registered module type | Not user-facing | +| `platform.autoscaling` | `infra.autoscaling_group` (provider: aws) | **New resource type** added to plugin in workflow-plugin-aws#9 (per task context) | +| `platform.dns` (AWS Route53) | `infra.dns` (provider: aws) | Route53 driver in plugin; generic platform.dns + mock backend stay in core | +| `platform.networking` | `infra.vpc` + `infra.firewall` (provider: aws) | VPC + SG driver in plugin; 1→2 split same as DO networking | +| `cloud.account` (AWS resolver) | Stays in core — cloud_account_aws.go not deleted | AWSConfigProvider needed by Phase 2 files | +| `step.ecs_*` (4 steps) | `step.iac_plan/apply/status/destroy` against infra.container_service | Config key change: `service:` → `platform:` + `state_store:` | +| `step.apigw_*` (4 steps) | `step.iac_plan/apply/status/destroy` against infra.api_gateway | Config key change | +| `step.scaling_*` (4 steps) | `step.iac_plan/apply/status/destroy` against infra.autoscaling_group | Config key change | +| `step.network_*` (3 steps) | `step.iac_plan/apply/status` against infra.vpc / infra.firewall | Config key change; networking gap: no `step.iac_destroy` available for multi-resource split | + +--- + +## Considered approaches + +### Option A — Single-PR force-cutover (RECOMMENDED) + +Delete 6 module files, companion tests, companion step files; replace platform_dns_backends.go; edit 8 registration sites; add `internal/legacyaws`; add migration errors + modernize rules; `go mod tidy` drops 3 service packages. Keep `cloud_account_aws.go` + `cloud_account_aws_creds.go` explicitly in core. + +**Pros:** Mirrors #617 precedent; clean git history; Dependabot stops touching freed packages immediately. +**Cons:** Breaks configs using legacy types on engine upgrade. Mitigated by actionable error + migration guide. + +### Option B — Also delete cloud_account_aws.go (REJECTED) + +Delete all 8 original-scope files including credential files. + +**Rejected:** `codebuild.go`, `platform_kubernetes_kind.go`, `secrets_aws.go` all call `AWSConfig()` + `awsProviderFrom()`. Deleting these files causes compile failures in out-of-scope Phase 2 code. Phase 2 is a separate audit with no clean precedent — it cannot be force-cutover'd in the same PR. + +### Option C — Keep platform_dns_backends.go intact, register AWS removal at init (REJECTED) + +Keep Route53 backend file but have it panic/error at call time. + +**Rejected:** The AWS SDK imports remain in go.mod if the file stays. Goal #1 requires removing `service/route53`. Replace file with mock-only version instead. + +--- + +## Assumptions (load-bearing) + +1. **Plugin parity:** `workflow-plugin-aws` v0.2.0 covers all 6 deleted module types via their `infra.*` successors. `infra.autoscaling_group` was added in workflow-plugin-aws#9 per task context. *Test:* parity matrix above; implementer verifies plugin manifest before PR. + +2. **No downstream consumer uses platform.ecs/apigateway/autoscaling/networking directly:** Grep `buymywishlist`, `core-dump`, `workflow-cloud`, `workflow-scenarios` before opening PR. + +3. **`cloud_account_aws.go` stays explicitly:** The `AWSConfigProvider` interface, `AWSConfig()` method, and credential resolvers are part of `cloud.account`'s multi-cloud support, not IaC module implementations. Parallel to `cloud_account_azure.go`. Removing them is Phase 2 scope. + +4. **platform.dns module type stays:** The generic DNS provisioner with mock backend is not AWS-specific. Only the Route53 backend is removed. + +5. **`go mod tidy` drops exactly 3 service packages:** `apigatewayv2`, `applicationautoscaling`, `route53`. All other AWS SDK packages have surviving in-scope-keepers. + +6. **engine v0.53.0 bump is acceptable:** This is a breaking change; CHANGELOG + minor-version bump. + +7. **step config-shape mismatch (from #617 retro):** All 16 step types are NOT auto-rewritten by modernize. Step schemas differ (legacy uses `service:` or `gateway:` etc; `step.iac_*` uses `platform:` + `state_store:`). This was the #617 gate miss — applied pre-emptively here. + +8. **WalkDir regression test (from #617 retro):** The AWS SDK absent test MUST use `filepath.WalkDir` (recursive), NOT `filepath.Glob("*.go")` (flat). + +--- + +## Self-challenge round + +1. **Laziest solution?** Could we add build tags to the 6 files so the production binary excludes them while keeping go.mod clean? No — go.mod is tidy-based; build tags don't affect `go mod tidy`. Build tags that exclude `*.go` files still leave their imports in the module graph. Option A remains the only path that removes `service/route53` from go.mod. + +2. **Most fragile assumption?** Assumption #3 — keeping `cloud_account_aws.go` in core. If the user intended it to be deleted (as the original scope manifest suggests), this design deviates. Mitigation: the original scope comment in issue #653 says "cloud.account (AWS resolver) → plugin owns credential broker" — but this was written before knowing that `AWSConfigProvider` is consumed by Phase 2 files. Phase 2 files cannot be touched. The right call is to keep the file and document the divergence explicitly. + +3. **What does this design solve that wasn't asked?** `platform_aws_integration_test.go` deletion — it tests the deleted modules and must go, even though it wasn't listed in the 8 original files. This is correctness, not scope-creep. + +**Top 3 doubts for adversarial review:** +1. Does `platform.dns`'s `provider: aws` guard (runtime init error) fire before or after `platform.dns` is successfully registered as a module? If registered successfully then the engine doesn't emit the module-guard error — the DNS module would silently skip Route53 operations. The init error must fire hard. +2. Does `schema/testdata/editor-schemas.golden.json` need manual update or is it auto-regenerated? Getting this wrong causes test failures. +3. Is there a `cloud_account_integration_test.go` that tests `ValidateCredentials()` (which calls `AWSConfig()`)? Yes — and it stays (correctly) because those functions stay in core. + +--- + +## Implementation plan (preview — full plan written by writing-plans skill) + +Single PR, 6 tasks: + +**T1 — Delete 15 files, partially edit api_gateway_test.go** +Delete: platform_ecs.go, platform_ecs_test.go, pipeline_step_ecs.go, platform_apigateway.go, platform_apigateway_test.go, pipeline_step_apigateway.go, aws_api_gateway.go, platform_autoscaling.go, platform_autoscaling_test.go, pipeline_step_autoscaling.go, platform_networking.go, platform_networking_test.go, pipeline_step_networking.go, platform_aws_integration_test.go. +Edit: api_gateway_test.go (remove 3 TestAWSAPIGateway_* functions, keep 19 others). +New: module/aws_absent_test.go (regression gate using filepath.WalkDir). + +**T2 — Replace platform_dns_backends.go with mock-only version + error stub** +New file content: keep mockDNSBackend; delete route53Backend; add awsRoute53ErrorBackend that returns migration error when provider: aws is configured. Update platform_dns.go init() to use awsRoute53ErrorBackend instead of route53Backend. New: platform_dns_test.go assertion that provider: aws returns migration error. + +**T3 — Strip registration sites (9 files)** +plugins/platform/plugin.go, plugins/platform/plugin_test.go, schema/schema.go, schema/module_schema.go, schema/step_schema_builtins.go, cmd/wfctl/type_registry.go, module/multi_region.go:117, DOCUMENTATION.md, schema/testdata/editor-schemas.golden.json (regenerate). + +**T4 — Add internal/legacyaws + migration errors** +New internal/legacyaws/types.go. Wire into engine.go (module guard), module/pipeline_step_registry.go (step guard), cmd/wfctl/validate.go, cmd/wfctl/ci_validate.go. Test: engine and validate paths for all 4 module types + 16 step types. + +**T5 — wfctl modernize rule + migration doc** +New modernize/legacy_aws_rule.go. New docs/migrations/v0.53.0-aws-iac-removal.md. Register rule in modernize/rules.go. + +**T6 — go mod tidy + CI grep gate** +Run go mod tidy (root + example/ if needed). Verify service/apigatewayv2, service/applicationautoscaling, service/route53 drop from go.mod. Add CI grep gate: `! grep -rn --include="*.go" --exclude-dir=_worktrees --exclude-dir=.worktrees --exclude-dir=.claude "aws-sdk-go-v2/service/apigatewayv2\|aws-sdk-go-v2/service/applicationautoscaling\|aws-sdk-go-v2/service/route53" .` + +--- + +## Rollback + +- **Pre-merge:** revert branch; no consumer impact. +- **Post-merge, pre-tag:** revert PR; force a new minor without the change. +- **Post-tag:** consumers pin previous tag. Migration error guides them back. `cloud_account_aws.go` was never removed, so AWS credential resolution continues. + +--- + +## Adversarial review history + +*(To be filled in by adversarial-design-review cycles)* From 68c876b6d1d1005afca966e0b9a6d91d992ebe3c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 07:43:59 -0400 Subject: [PATCH 02/18] docs(#653): revise design doc per adversarial review cycle 1 Fixes: infra.autoscaling_group missing from core (C-1), platform.dns validate-path guard gap (C-2), step count 15 not 16 (I-1), example/go.mod tidy + grep gate (I-2), platform.dns schema description stale (I-3), T1 file list ambiguity (m-1), T2 backend alternative justification (m-2), T3 DNS row keep/remove clarity (m-3). Co-Authored-By: Claude Sonnet 4.6 --- ...-13-issue-653-phase1-aws-cutover-design.md | 122 +++++++++++++----- 1 file changed, 91 insertions(+), 31 deletions(-) diff --git a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md index 960c6707..bc4adc30 100644 --- a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md +++ b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md @@ -78,28 +78,31 @@ This design proposes a **single-PR force-cutover** that deletes the legacy AWS I | File | Edit | |------|------| -| `plugins/platform/plugin.go` | Drop `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking` from `ModuleTypes`; drop 4 module factories; drop `step.ecs_*`, `step.apigw_*`, `step.scaling_*`, `step.network_*` from `StepTypes`; drop 16 step factories. Keep `platform.dns`, `step.dns_*`, and all other types. | -| `plugins/platform/plugin_test.go` | Drop the 4 module type + 16 step type string assertions. | +| `plugins/platform/plugin.go` | Drop `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking` from `ModuleTypes`; drop 4 module factories; drop `step.ecs_*`, `step.apigw_*`, `step.scaling_*`, `step.network_*` from `StepTypes`; drop **15** step factories. Keep `platform.dns`, `step.dns_*`, and all other types. | +| `plugins/platform/plugin_test.go` | Drop the 4 module type + **15** step type string assertions. | +| `plugins/infra/plugin.go` | **ADD** `"infra.autoscaling_group"` to `infraTypes` slice (new 14th infra type). Required so that configs migrated from `platform.autoscaling` validate correctly. | | `schema/schema.go` | Drop `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking` from module type list. | -| `schema/module_schema.go` | Drop 4 module schemas. | -| `schema/step_schema_builtins.go` | Drop 16 step schema `Register` calls (ecs, apigw, scaling, network steps). | -| `cmd/wfctl/type_registry.go` | Drop `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking` entries. | -| `schema/testdata/editor-schemas.golden.json` | Update golden file (regenerate via `go generate ./schema/...` or `-update` flag). | +| `schema/module_schema.go` | Drop 4 module schemas. **Update** `platform.dns` ConfigFieldDef for `provider` to `Description: "mock (aws Route53 backend removed; use infra.dns with workflow-plugin-aws)"`. **ADD** `infra.autoscaling_group` ModuleSchema (mirrors other infra.* schemas: label, category: infrastructure, description, configFields for provider + resource). | +| `schema/step_schema_builtins.go` | Drop **15** step schema `Register` calls (ecs, apigw, scaling, network steps). | +| `cmd/wfctl/type_registry.go` | Drop `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking` entries. **ADD** `"infra.autoscaling_group"` entry (mirrors other infra.* entries). | +| `schema/testdata/editor-schemas.golden.json` | Update golden file via `UPDATE_GOLDEN=1 go test ./schema/ -run TestEditorSchemasGoldenFile`. | | `module/multi_region.go:117` | Update error message that references `platform.ecs` to use `infra.container_service`. | -| `DOCUMENTATION.md` | Remove 4 module rows + 16 step rows; add paragraph pointing at `workflow-plugin-aws`. | +| `DOCUMENTATION.md` | Remove 4 module rows + **15** step rows; keep `platform.dns` and `step.dns_*` rows; add paragraph pointing at `workflow-plugin-aws`. | | `go.mod` / `go.sum` | `go mod tidy` after deletion drops freed service packages. | -### Step types removed (16 total) +### Step types removed (15 total) | Module | Steps removed | |--------|---------------| | `platform.ecs` | `step.ecs_plan`, `step.ecs_apply`, `step.ecs_status`, `step.ecs_destroy` | | `platform.apigateway` | `step.apigw_plan`, `step.apigw_apply`, `step.apigw_status`, `step.apigw_destroy` | | `platform.autoscaling` | `step.scaling_plan`, `step.scaling_apply`, `step.scaling_status`, `step.scaling_destroy` | -| `platform.networking` | `step.network_plan`, `step.network_apply`, `step.network_status` (no destroy step) | +| `platform.networking` | `step.network_plan`, `step.network_apply`, `step.network_status` (**no `step.network_destroy` exists**) | **Remaining (stays):** `step.dns_plan`, `step.dns_apply`, `step.dns_status` — these back the generic `platform.dns` module which stays. +Count: 4 (ecs) + 4 (apigw) + 4 (scaling) + 3 (networking) = **15 step types**. Verified: `schema/step_schema_builtins.go` and `plugins/platform/plugin.go` StepFactories both have exactly 15 entries for these step types. + --- ## Migration errors @@ -126,23 +129,25 @@ See docs/migrations/v-aws-iac-removal.md. The `%s` line branches on plugin-loaded detection (mirrors #617 pattern): - `_, iacLoaded := e.moduleFactories["iac.provider"]` → if true: `"workflow-plugin-aws is already loaded; your config still references the legacy module name."` else: `"Install workflow-plugin-aws: https://github.com/GoCodeAlone/workflow-plugin-aws"` -### Step guard (16 types) +### Step guard (15 types) In `module/pipeline_step_registry.go Create()` (unknown-step-type branch, after legacydo check): ``` -step.ecs_plan/apply/status/destroy → step.iac_plan/apply/status/destroy (against an infra.container_service module) -step.apigw_plan/apply/status/destroy → step.iac_plan/apply/status/destroy (against an infra.api_gateway module) +step.ecs_plan/apply/status/destroy → step.iac_plan/apply/status/destroy (against an infra.container_service module) +step.apigw_plan/apply/status/destroy → step.iac_plan/apply/status/destroy (against an infra.api_gateway module) step.scaling_plan/apply/status/destroy → step.iac_plan/apply/status/destroy (against an infra.autoscaling_group module) -step.network_plan/apply/status → step.iac_plan/apply/status (against an infra.vpc + infra.firewall module) +step.network_plan/apply/status → step.iac_plan/apply/status (against an infra.vpc + infra.firewall module) + (note: step.network_destroy never existed; no mapping needed) ``` Same plugin-loaded detection branching as #617. ### platform.dns provider: aws guard -In the `platform.dns` Init() when `provider: aws` is configured but Route53 backend was removed: +The Route53 backend removal is implemented in two places: +**Runtime guard** (in `platform.dns` Init(), via the `awsRoute53ErrorBackend` registered for provider `"aws"`): ``` platform.dns %q: AWS Route53 backend removed from workflow core in v. Migrate to: infra.dns (provider: aws) with workflow-plugin-aws v0.2.0+. @@ -150,7 +155,9 @@ Install: https://github.com/GoCodeAlone/workflow-plugin-aws See docs/migrations/v-aws-iac-removal.md. ``` -This is a runtime error in `platform.dns` Init(), not an engine-level unknown-type error. The `platform.dns` module type still loads; only the Route53 backend is unavailable. +**Validate-path guard** (in `cmd/wfctl/validate.go` and `cmd/wfctl/ci_validate.go` post-ValidateConfig sweep): For any module with `type: platform.dns` and `config.provider: aws`, emit the same migration error string. This is required for goal #3 (actionable `wfctl` errors) — the runtime Init() error fires too late for `wfctl validate`. + +Implementation: the post-ValidateConfig loop in `validate.go:161` (already exists for legacydo types) is extended to also check for `type: platform.dns` + `provider: aws` config key. The same extension applies to `ci_validate.go:148`. --- @@ -183,8 +190,9 @@ Following the `internal/legacydo` precedent (plan cycle-4 finding from #617 retr `internal/legacyaws/types.go`: - `RemovedInVersion = "v0.53.0"` (next minor after v0.52.0) - `ModuleTypes` — 4 legacy module types + successors -- `StepTypes` — 16 legacy step types + successors -- `IsModuleType()`, `IsStepType()`, `FormatModuleError()`, `FormatStepError()` +- `StepTypes` — **15** legacy step types + successors (note: `step.network_destroy` never existed) +- `DNSProviderAWSError` — migration error string for `platform.dns` provider: aws +- `IsModuleType()`, `IsStepType()`, `FormatModuleError()`, `FormatStepError()`, `FormatDNSProviderAWSError()` --- @@ -201,7 +209,10 @@ Not auto-fixable (1→2 split, operator must review): - `platform.networking` → splits into `infra.vpc` + `infra.firewall` Not auto-fixable (step config-shape mismatch, per #617 retro lesson): -- All 16 step types → flagged with message; NOT auto-rewritten (step.iac_* require different config keys: `platform` + `state_store` instead of the legacy `service`/`cluster` keys). Operator must rewrite manually per migration guide. +- All **15** step types → flagged with message; NOT auto-rewritten (step.iac_* require different config keys: `platform` + `state_store` instead of the legacy `service`/`cluster` keys). Operator must rewrite manually per migration guide. + +Not auto-fixable (provider sub-key, not a type rename): +- `platform.dns` with `provider: aws` → flagged in the DNS-backend check pass; modernize emits a comment-style finding (not a type rewrite) pointing at `infra.dns` + `workflow-plugin-aws`. --- @@ -219,7 +230,7 @@ Not auto-fixable (step config-shape mismatch, per #617 retro lesson): | `step.ecs_*` (4 steps) | `step.iac_plan/apply/status/destroy` against infra.container_service | Config key change: `service:` → `platform:` + `state_store:` | | `step.apigw_*` (4 steps) | `step.iac_plan/apply/status/destroy` against infra.api_gateway | Config key change | | `step.scaling_*` (4 steps) | `step.iac_plan/apply/status/destroy` against infra.autoscaling_group | Config key change | -| `step.network_*` (3 steps) | `step.iac_plan/apply/status` against infra.vpc / infra.firewall | Config key change; networking gap: no `step.iac_destroy` available for multi-resource split | +| `step.network_*` (3 steps) | `step.iac_plan/apply/status` against infra.vpc / infra.firewall | Config key change; networking gap: no `step.iac_destroy` available for multi-resource split; `step.network_destroy` never existed so no 4th mapping needed | --- @@ -248,7 +259,7 @@ Keep Route53 backend file but have it panic/error at call time. ## Assumptions (load-bearing) -1. **Plugin parity:** `workflow-plugin-aws` v0.2.0 covers all 6 deleted module types via their `infra.*` successors. `infra.autoscaling_group` was added in workflow-plugin-aws#9 per task context. *Test:* parity matrix above; implementer verifies plugin manifest before PR. +1. **Plugin parity:** `workflow-plugin-aws` v0.2.0 covers all 6 deleted module types via their `infra.*` successors. `infra.autoscaling_group` was added in workflow-plugin-aws#9 per task context. *Test:* parity matrix above; implementer verifies plugin manifest before PR. Note: `infra.autoscaling_group` is also added to workflow core's infra plugin in T3 so `wfctl validate` accepts it post-migration. 2. **No downstream consumer uses platform.ecs/apigateway/autoscaling/networking directly:** Grep `buymywishlist`, `core-dump`, `workflow-cloud`, `workflow-scenarios` before opening PR. @@ -285,25 +296,63 @@ Keep Route53 backend file but have it panic/error at call time. Single PR, 6 tasks: -**T1 — Delete 15 files, partially edit api_gateway_test.go** -Delete: platform_ecs.go, platform_ecs_test.go, pipeline_step_ecs.go, platform_apigateway.go, platform_apigateway_test.go, pipeline_step_apigateway.go, aws_api_gateway.go, platform_autoscaling.go, platform_autoscaling_test.go, pipeline_step_autoscaling.go, platform_networking.go, platform_networking_test.go, pipeline_step_networking.go, platform_aws_integration_test.go. -Edit: api_gateway_test.go (remove 3 TestAWSAPIGateway_* functions, keep 19 others). -New: module/aws_absent_test.go (regression gate using filepath.WalkDir). +**T1 — Delete 14 files; partially edit api_gateway_test.go; add regression gate** +Delete (14 full deletions): +- module/platform_ecs.go, module/platform_ecs_test.go, module/pipeline_step_ecs.go +- module/platform_apigateway.go, module/platform_apigateway_test.go, module/pipeline_step_apigateway.go +- module/aws_api_gateway.go +- module/platform_autoscaling.go, module/platform_autoscaling_test.go, module/pipeline_step_autoscaling.go +- module/platform_networking.go, module/platform_networking_test.go, module/pipeline_step_networking.go +- module/platform_aws_integration_test.go -**T2 — Replace platform_dns_backends.go with mock-only version + error stub** -New file content: keep mockDNSBackend; delete route53Backend; add awsRoute53ErrorBackend that returns migration error when provider: aws is configured. Update platform_dns.go init() to use awsRoute53ErrorBackend instead of route53Backend. New: platform_dns_test.go assertion that provider: aws returns migration error. +Partial edit (NOT deleted — keep 19 generic HTTP gateway tests, remove 3): +- module/api_gateway_test.go: remove `TestAWSAPIGateway_Basic`, `TestAWSAPIGateway_SyncRoutesStub`, `TestAWSAPIGateway_SyncRoutesRequiresAPIID` -**T3 — Strip registration sites (9 files)** -plugins/platform/plugin.go, plugins/platform/plugin_test.go, schema/schema.go, schema/module_schema.go, schema/step_schema_builtins.go, cmd/wfctl/type_registry.go, module/multi_region.go:117, DOCUMENTATION.md, schema/testdata/editor-schemas.golden.json (regenerate). +New: module/aws_absent_test.go (regression gate using filepath.WalkDir for the 3 freed service packages). + +**T2 — Replace platform_dns_backends.go with mock-only version + error stub** +New file content: keep mockDNSBackend; delete route53Backend; add `awsRoute53ErrorBackend` (a struct implementing `dnsBackend`) that returns the migration error from all methods. Alternative considered: simply unregister "aws" from dnsBackendRegistry — rejected because the existing `"unsupported provider"` generic error is not actionable; the migration error must name `infra.dns` + `workflow-plugin-aws`. Update `platform_dns.go`'s `init()`: replace `return &route53Backend{}, nil` with `return &awsRoute53ErrorBackend{}, nil` (no import change needed — types stay in the same package). Update `schema/module_schema.go` platform.dns ConfigFieldDef for provider description (see T3). Add test in `module/platform_dns_test.go`: assert `provider: aws` returns error containing "infra.dns" and "workflow-plugin-aws". + +**T3 — Strip registration sites, add infra.autoscaling_group, regenerate golden** +Edit these files: +- `plugins/platform/plugin.go`: drop 4 module + 15 step factories/type strings +- `plugins/platform/plugin_test.go`: drop 4 module + 15 step type assertions +- `plugins/infra/plugin.go`: ADD `"infra.autoscaling_group"` to infraTypes slice +- `schema/schema.go`: drop 4 platform.* module type strings +- `schema/module_schema.go`: drop 4 platform.* schemas; UPDATE platform.dns provider description; ADD infra.autoscaling_group schema +- `schema/step_schema_builtins.go`: drop 15 step schema Register calls +- `cmd/wfctl/type_registry.go`: drop 4 platform.* entries; ADD infra.autoscaling_group entry +- `module/multi_region.go:117`: update error message (platform.ecs → infra.container_service) +- `DOCUMENTATION.md`: remove 4 module rows + 15 step rows; **keep** platform.dns row and step.dns_* rows (3 rows); add paragraph pointing at workflow-plugin-aws; add infra.autoscaling_group row +- Regenerate `schema/testdata/editor-schemas.golden.json` via `UPDATE_GOLDEN=1 go test ./schema/ -run TestEditorSchemasGoldenFile` **T4 — Add internal/legacyaws + migration errors** -New internal/legacyaws/types.go. Wire into engine.go (module guard), module/pipeline_step_registry.go (step guard), cmd/wfctl/validate.go, cmd/wfctl/ci_validate.go. Test: engine and validate paths for all 4 module types + 16 step types. +New `internal/legacyaws/types.go` (4 module types, 15 step types, DNS provider error). Wire into: +- `engine.go` (module guard — existing legacydo pattern, add legacyaws check) +- `module/pipeline_step_registry.go` (step guard — add legacyaws check after legacydo check) +- `cmd/wfctl/validate.go` (module guard + step guard + DNS provider: aws sweep) +- `cmd/wfctl/ci_validate.go` (same additions) +Tests: engine path for all 4 module types; step path for all 15 step types; validate path for 4 module types; DNS provider: aws guard for both validate.go and ci_validate.go. **T5 — wfctl modernize rule + migration doc** New modernize/legacy_aws_rule.go. New docs/migrations/v0.53.0-aws-iac-removal.md. Register rule in modernize/rules.go. **T6 — go mod tidy + CI grep gate** -Run go mod tidy (root + example/ if needed). Verify service/apigatewayv2, service/applicationautoscaling, service/route53 drop from go.mod. Add CI grep gate: `! grep -rn --include="*.go" --exclude-dir=_worktrees --exclude-dir=.worktrees --exclude-dir=.claude "aws-sdk-go-v2/service/apigatewayv2\|aws-sdk-go-v2/service/applicationautoscaling\|aws-sdk-go-v2/service/route53" .` +Run `go mod tidy` on root AND `(cd example && go mod tidy)` (example/go.mod lists service/apigatewayv2 + service/applicationautoscaling as indirect; they must drop). Verify service/apigatewayv2, service/applicationautoscaling, service/route53 drop from BOTH go.mod and example/go.mod. + +Add CI grep gate (two parts — mirrors #617's godo-banned gate): +```sh +# *.go files must not import the freed service packages: +! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + --exclude="aws_absent_test.go" \ + "aws-sdk-go-v2/service/apigatewayv2\|aws-sdk-go-v2/service/applicationautoscaling\|aws-sdk-go-v2/service/route53" . + +# go.mod files must not list the freed service packages: +! grep -qH "aws-sdk-go-v2/service/apigatewayv2\|aws-sdk-go-v2/service/applicationautoscaling\|aws-sdk-go-v2/service/route53" go.mod example/go.mod +``` --- @@ -317,4 +366,15 @@ Run go mod tidy (root + example/ if needed). Verify service/apigatewayv2, servic ## Adversarial review history -*(To be filled in by adversarial-design-review cycles)* +### Cycle 1 (FAIL → revised) — 2026-05-13 + +- **C-1** `infra.autoscaling_group` missing from core infra type registry (plugins/infra/plugin.go, schema/module_schema.go, cmd/wfctl/type_registry.go) → **fixed**: T3 now adds it to all three sites. +- **C-2** `platform.dns` provider: aws guard only fires at runtime (Init()), bypassing `wfctl validate` → **fixed**: validate-path guard added to validate.go + ci_validate.go in T4; FormatDNSProviderAWSError added to internal/legacyaws. +- **I-1** Step count 15 not 16 (step.network_destroy never existed) → **fixed**: all "16" corrected to "15" throughout. +- **I-2** example/go.mod tidy + go.mod grep gate missing → **fixed**: T6 now explicitly runs `(cd example && go mod tidy)` and adds a second grep gate checking go.mod + example/go.mod. +- **I-3** platform.dns schema description not updated after Route53 backend removal → **fixed**: T3 now updates provider ConfigFieldDef description. +- **m-1** T1 file count ambiguous → **fixed**: explicit 14-deletion + 1-partial-edit list. +- **m-2** awsRoute53ErrorBackend vs simple unregister not justified → **fixed**: T2 now documents the rejected alternative and justification. +- **m-3** DOCUMENTATION.md DNS step rows not called out as staying → **fixed**: T3 explicitly says keep platform.dns row + step.dns_* rows. + +### Cycle 2 — pending From c44660dd37cfca15623102f52e92bbb251c4dd5c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 07:49:01 -0400 Subject: [PATCH 03/18] docs: fix C-3 in aws-cutover design (app_container.go compile break) - Add module/app_container.go as partial-edit target in T1: move ECSContainer struct in from platform_ecs.go, remove case *PlatformECS type switch branch, update default error message. - Record cycle 2 findings in adversarial review history. Co-Authored-By: Claude Sonnet 4.6 --- ...05-13-issue-653-phase1-aws-cutover-design.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md index bc4adc30..674c665b 100644 --- a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md +++ b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md @@ -67,6 +67,7 @@ This design proposes a **single-PR force-cutover** that deletes the legacy AWS I |------|------| | `module/api_gateway_test.go` | Remove 3 `TestAWSAPIGateway_*` test functions only; keep all 19 generic HTTP gateway tests | | `module/platform_dns_backends.go` | **Replace entire file**: delete route53Backend implementation; keep mockDNSBackend + add AWS route removed error stub | +| `module/app_container.go` | **C-3 fix**: Move `ECSContainer` struct in from `platform_ecs.go` (3 plain fields, no SDK imports); remove `case *PlatformECS:` type switch branch; update default-case error message to reference `infra.container_service`. The `ecsAppBackend` struct + `buildECSManifests()` (pure Go, no SDK imports) stay in `app_container.go`. | **New file (split from platform_dns_backends.go):** @@ -296,7 +297,7 @@ Keep Route53 backend file but have it panic/error at call time. Single PR, 6 tasks: -**T1 — Delete 14 files; partially edit api_gateway_test.go; add regression gate** +**T1 — Delete 14 files; partially edit api_gateway_test.go and app_container.go; add regression gate** Delete (14 full deletions): - module/platform_ecs.go, module/platform_ecs_test.go, module/pipeline_step_ecs.go - module/platform_apigateway.go, module/platform_apigateway_test.go, module/pipeline_step_apigateway.go @@ -305,8 +306,12 @@ Delete (14 full deletions): - module/platform_networking.go, module/platform_networking_test.go, module/pipeline_step_networking.go - module/platform_aws_integration_test.go -Partial edit (NOT deleted — keep 19 generic HTTP gateway tests, remove 3): -- module/api_gateway_test.go: remove `TestAWSAPIGateway_Basic`, `TestAWSAPIGateway_SyncRoutesStub`, `TestAWSAPIGateway_SyncRoutesRequiresAPIID` +Partial edits (NOT deleted): +- module/api_gateway_test.go: remove `TestAWSAPIGateway_Basic`, `TestAWSAPIGateway_SyncRoutesStub`, `TestAWSAPIGateway_SyncRoutesRequiresAPIID`; keep all 19 generic HTTP gateway tests. +- **module/app_container.go** (C-3 fix): `app_container.go` references both `PlatformECS` (type switch at line 130) and `ECSContainer` (used in `ECSAppTaskDef.Containers` at lines 88, 639). `ECSContainer` is defined in `platform_ecs.go` (line 39) — deleting that file causes a compile failure. + - Move `ECSContainer` struct definition (3 plain fields, no SDK imports) from `platform_ecs.go` into `app_container.go`. + - Remove `case *PlatformECS: m.backend = &ecsAppBackend{}; m.platformType = "ecs"` from the `Init()` type switch (line 130). The `ecsAppBackend` struct + `buildECSManifests()` function in `app_container.go` have zero AWS SDK imports and can stay — they are pure Go struct manifest generators that work for any container runtime. The `platformType = "ecs"` value is still valid even without the real ECS module backing it. + - Update the default-case error message at line 134 to remove the `platform.ecs` reference: `"environment %q is not a platform.kubernetes module (got %T); platform.ecs was removed — see infra.container_service (workflow-plugin-aws)"`. New: module/aws_absent_test.go (regression gate using filepath.WalkDir for the 3 freed service packages). @@ -377,4 +382,8 @@ Add CI grep gate (two parts — mirrors #617's godo-banned gate): - **m-2** awsRoute53ErrorBackend vs simple unregister not justified → **fixed**: T2 now documents the rejected alternative and justification. - **m-3** DOCUMENTATION.md DNS step rows not called out as staying → **fixed**: T3 explicitly says keep platform.dns row + step.dns_* rows. -### Cycle 2 — pending +### Cycle 2 (FAIL → revised) — 2026-05-13 + +- **C-3** `module/app_container.go` has `case *PlatformECS:` type switch (line 130) and uses `ECSContainer` struct (lines 88, 639) which is defined in `platform_ecs.go`. Deleting `platform_ecs.go` causes compile failure in `app_container.go`. This file was not in the original modification list. → **Fixed**: T1 now includes `module/app_container.go` as a partial edit: move `ECSContainer` struct into `app_container.go`, remove `case *PlatformECS:` branch, update default error message. The `ecsAppBackend` + `buildECSManifests()` in `app_container.go` have zero SDK imports and are retained as manifest-generation helpers. + +### Cycle 3 — pending From 1642311f36ded31a120e664b2188df9abaf97a83 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 07:50:49 -0400 Subject: [PATCH 04/18] docs: remove dead ECS code from app_container.go in design (cycle 2 fix refinement) After removing case *PlatformECS, all ECS-specific structs and methods in app_container.go become dead code. Design updated to remove them entirely rather than leave dead code in place. Co-Authored-By: Claude Sonnet 4.6 --- ...026-05-13-issue-653-phase1-aws-cutover-design.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md index 674c665b..09ba77b3 100644 --- a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md +++ b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md @@ -67,7 +67,7 @@ This design proposes a **single-PR force-cutover** that deletes the legacy AWS I |------|------| | `module/api_gateway_test.go` | Remove 3 `TestAWSAPIGateway_*` test functions only; keep all 19 generic HTTP gateway tests | | `module/platform_dns_backends.go` | **Replace entire file**: delete route53Backend implementation; keep mockDNSBackend + add AWS route removed error stub | -| `module/app_container.go` | **C-3 fix**: Move `ECSContainer` struct in from `platform_ecs.go` (3 plain fields, no SDK imports); remove `case *PlatformECS:` type switch branch; update default-case error message to reference `infra.container_service`. The `ecsAppBackend` struct + `buildECSManifests()` (pure Go, no SDK imports) stay in `app_container.go`. | +| `module/app_container.go` | **C-3 fix**: Remove all ECS-specific code (`ECSAppManifests`, `ECSAppTaskDef`, `ECSAppServiceCfg`, `ecsAppBackend` + methods, `buildECSManifests()`); remove `case *PlatformECS:` type switch branch; update default-case error message. After edit: supports platform.kubernetes only; zero AWS SDK imports; compiles cleanly. | **New file (split from platform_dns_backends.go):** @@ -308,10 +308,11 @@ Delete (14 full deletions): Partial edits (NOT deleted): - module/api_gateway_test.go: remove `TestAWSAPIGateway_Basic`, `TestAWSAPIGateway_SyncRoutesStub`, `TestAWSAPIGateway_SyncRoutesRequiresAPIID`; keep all 19 generic HTTP gateway tests. -- **module/app_container.go** (C-3 fix): `app_container.go` references both `PlatformECS` (type switch at line 130) and `ECSContainer` (used in `ECSAppTaskDef.Containers` at lines 88, 639). `ECSContainer` is defined in `platform_ecs.go` (line 39) — deleting that file causes a compile failure. - - Move `ECSContainer` struct definition (3 plain fields, no SDK imports) from `platform_ecs.go` into `app_container.go`. - - Remove `case *PlatformECS: m.backend = &ecsAppBackend{}; m.platformType = "ecs"` from the `Init()` type switch (line 130). The `ecsAppBackend` struct + `buildECSManifests()` function in `app_container.go` have zero AWS SDK imports and can stay — they are pure Go struct manifest generators that work for any container runtime. The `platformType = "ecs"` value is still valid even without the real ECS module backing it. - - Update the default-case error message at line 134 to remove the `platform.ecs` reference: `"environment %q is not a platform.kubernetes module (got %T); platform.ecs was removed — see infra.container_service (workflow-plugin-aws)"`. +- **module/app_container.go** (C-3 fix): `app_container.go` references both `PlatformECS` (type switch at line 130) and `ECSContainer` (used in `ECSAppTaskDef.Containers` at lines 88, 639). `ECSContainer` is defined in `platform_ecs.go` (line 39) — deleting that file causes a compile failure. Additionally, after removing `case *PlatformECS:`, these ECS-specific declarations in `app_container.go` become dead code: `ECSAppManifests`, `ECSAppTaskDef`, `ECSAppServiceCfg`, `ecsAppBackend`, `buildECSManifests()`, `ECSContainer` (if moved in). Dead code should be removed, not left in place. + - Remove all ECS-specific declarations from `app_container.go`: `ECSAppManifests`, `ECSAppTaskDef`, `ECSAppServiceCfg` (struct types at lines 77-97), `ecsAppBackend` struct + all its methods (lines 590-648), `buildECSManifests()` function (lines 628-648). `ECSContainer` is NOT moved in — it becomes dead along with the above and is deleted. + - Remove `case *PlatformECS: m.backend = &ecsAppBackend{}; m.platformType = "ecs"` from the `Init()` type switch (line 130). + - Update the default-case error message at line 134 to remove the `platform.ecs` reference: `"environment %q is not a platform.kubernetes module (got %T); platform.ecs was removed — use infra.container_service with workflow-plugin-aws"`. + - **Result**: `app_container.go` supports only `platform.kubernetes` backends post-deletion; ECS manifest generation is completely removed. No AWS SDK imports are introduced. `app_container.go` compiles cleanly. New: module/aws_absent_test.go (regression gate using filepath.WalkDir for the 3 freed service packages). @@ -384,6 +385,6 @@ Add CI grep gate (two parts — mirrors #617's godo-banned gate): ### Cycle 2 (FAIL → revised) — 2026-05-13 -- **C-3** `module/app_container.go` has `case *PlatformECS:` type switch (line 130) and uses `ECSContainer` struct (lines 88, 639) which is defined in `platform_ecs.go`. Deleting `platform_ecs.go` causes compile failure in `app_container.go`. This file was not in the original modification list. → **Fixed**: T1 now includes `module/app_container.go` as a partial edit: move `ECSContainer` struct into `app_container.go`, remove `case *PlatformECS:` branch, update default error message. The `ecsAppBackend` + `buildECSManifests()` in `app_container.go` have zero SDK imports and are retained as manifest-generation helpers. +- **C-3** `module/app_container.go` has `case *PlatformECS:` type switch (line 130) and uses `ECSContainer` struct (lines 88, 639) which is defined in `platform_ecs.go`. Deleting `platform_ecs.go` causes compile failure in `app_container.go`. This file was not in the original modification list. Additionally, after removing the `case *PlatformECS:` branch, `ECSAppManifests`, `ECSAppTaskDef`, `ECSAppServiceCfg`, `ecsAppBackend`, and `buildECSManifests()` all become dead code in `app_container.go`. → **Fixed**: T1 now includes `module/app_container.go` as a partial edit: remove ALL ECS-specific declarations (structs + methods + `buildECSManifests()`), remove `case *PlatformECS:` branch, update default error message. Result: `app_container.go` supports platform.kubernetes only; compiles cleanly; zero AWS SDK imports. ### Cycle 3 — pending From c7dbd3e8cf88659f4da51514c3d4a122e4bdc310 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 07:52:31 -0400 Subject: [PATCH 05/18] =?UTF-8?q?docs:=20adversarial=20cycle=203=20PASS=20?= =?UTF-8?q?=E2=80=94=20finalize=20aws-cutover=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix schema: infra.autoscaling_group auto-generated from infraTypes, no manual module_schema.go entry needed. - Add app_container.go comment lines to string-update list. - Record cycle 3 PASS in adversarial review history. Co-Authored-By: Claude Sonnet 4.6 --- ...-13-issue-653-phase1-aws-cutover-design.md | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md index 09ba77b3..474a199e 100644 --- a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md +++ b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md @@ -83,11 +83,12 @@ This design proposes a **single-PR force-cutover** that deletes the legacy AWS I | `plugins/platform/plugin_test.go` | Drop the 4 module type + **15** step type string assertions. | | `plugins/infra/plugin.go` | **ADD** `"infra.autoscaling_group"` to `infraTypes` slice (new 14th infra type). Required so that configs migrated from `platform.autoscaling` validate correctly. | | `schema/schema.go` | Drop `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking` from module type list. | -| `schema/module_schema.go` | Drop 4 module schemas. **Update** `platform.dns` ConfigFieldDef for `provider` to `Description: "mock (aws Route53 backend removed; use infra.dns with workflow-plugin-aws)"`. **ADD** `infra.autoscaling_group` ModuleSchema (mirrors other infra.* schemas: label, category: infrastructure, description, configFields for provider + resource). | +| `schema/module_schema.go` | Drop 4 module schemas (`platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking`). **Update** `platform.dns` ConfigFieldDef for `provider` to `Description: "mock (aws Route53 backend removed; use infra.dns with workflow-plugin-aws)"`. **Note:** `infra.autoscaling_group` schema is auto-generated from `infraTypes` in `plugins/infra/plugin.go`'s `ModuleSchemas()` — no manual schema entry needed in `module_schema.go`. | | `schema/step_schema_builtins.go` | Drop **15** step schema `Register` calls (ecs, apigw, scaling, network steps). | | `cmd/wfctl/type_registry.go` | Drop `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking` entries. **ADD** `"infra.autoscaling_group"` entry (mirrors other infra.* entries). | | `schema/testdata/editor-schemas.golden.json` | Update golden file via `UPDATE_GOLDEN=1 go test ./schema/ -run TestEditorSchemasGoldenFile`. | | `module/multi_region.go:117` | Update error message that references `platform.ecs` to use `infra.container_service`. | +| `module/app_container.go` comment lines | Update doc comment (line 14) to remove `platform.ecs` mention; update logger.Warn hint (line 147) to remove `platform.ecs` mention. These are string-only changes covered in T1 alongside the code edits. | | `DOCUMENTATION.md` | Remove 4 module rows + **15** step rows; keep `platform.dns` and `step.dns_*` rows; add paragraph pointing at `workflow-plugin-aws`. | | `go.mod` / `go.sum` | `go mod tidy` after deletion drops freed service packages. | @@ -325,7 +326,7 @@ Edit these files: - `plugins/platform/plugin_test.go`: drop 4 module + 15 step type assertions - `plugins/infra/plugin.go`: ADD `"infra.autoscaling_group"` to infraTypes slice - `schema/schema.go`: drop 4 platform.* module type strings -- `schema/module_schema.go`: drop 4 platform.* schemas; UPDATE platform.dns provider description; ADD infra.autoscaling_group schema +- `schema/module_schema.go`: drop 4 platform.* schemas; UPDATE platform.dns provider description. (`infra.autoscaling_group` schema auto-generated from infraTypes — no manual schema entry needed here.) - `schema/step_schema_builtins.go`: drop 15 step schema Register calls - `cmd/wfctl/type_registry.go`: drop 4 platform.* entries; ADD infra.autoscaling_group entry - `module/multi_region.go:117`: update error message (platform.ecs → infra.container_service) @@ -387,4 +388,27 @@ Add CI grep gate (two parts — mirrors #617's godo-banned gate): - **C-3** `module/app_container.go` has `case *PlatformECS:` type switch (line 130) and uses `ECSContainer` struct (lines 88, 639) which is defined in `platform_ecs.go`. Deleting `platform_ecs.go` causes compile failure in `app_container.go`. This file was not in the original modification list. Additionally, after removing the `case *PlatformECS:` branch, `ECSAppManifests`, `ECSAppTaskDef`, `ECSAppServiceCfg`, `ecsAppBackend`, and `buildECSManifests()` all become dead code in `app_container.go`. → **Fixed**: T1 now includes `module/app_container.go` as a partial edit: remove ALL ECS-specific declarations (structs + methods + `buildECSManifests()`), remove `case *PlatformECS:` branch, update default error message. Result: `app_container.go` supports platform.kubernetes only; compiles cleanly; zero AWS SDK imports. -### Cycle 3 — pending +### Cycle 3 (PASS) — 2026-05-13 + +Bug-class scan: + +| Class | Result | Note | +|---|---|---| +| Unstated assumptions | Clean | Assumption 1 (infra.autoscaling_group parity) verified: workflow-plugin-aws v0.2.0 release notes confirm driver shipped. | +| Repo-precedent conflicts | Clean | Mirrors #617 godo pattern throughout. | +| YAGNI violations | Clean | awsRoute53ErrorBackend justified over simple unregister (actionable error message). | +| Missing failure modes | Clean | example/go.mod confirmed has the 3 freed packages as indirect; go mod tidy will drop them. | +| Security / privacy | Clean | Deletion removes SDK surface; no new auth boundaries introduced. | +| Rollback story | Clean | Pre/post-merge rollback documented. | +| Simpler alternative | Clean | Build-tag alternative considered and correctly rejected. | +| User-intent drift | Clean | Design solves exactly what #653 requests. | +| Over/under-decomposition | Clean | 6 tasks match complexity; each is ~5-30 min scope. | +| Verification-class mismatch | Clean | go build, go test, grep gate, golden regeneration all match their change classes. | +| Hidden serial deps | Clean | T1→T3 dep (delete before registration edit), T6 last (go mod tidy). All explicit. | +| Missing rollback wiring | Clean | Rollback section present and actionable. | + +Additional refinements applied in cycle 3 (not findings, just precision): +- `schema/module_schema.go` entry for `infra.autoscaling_group` correctly noted as NOT needed — schema auto-generated from `infraTypes` in `plugins/infra/plugin.go:ModuleSchemas()`. +- `module/app_container.go` comment lines 14 and 147 added to string-update list. + +**PASS — zero Critical findings. Design approved for writing-plans.** From 4342c5a816e187f97ad79fc3643ac74e17a76c7d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 07:59:05 -0400 Subject: [PATCH 06/18] docs(#653): implementation plan for Phase 1 AWS IaC cutover 6 tasks, single PR. Covers: - T1: delete 14 files + app_container.go C-3 fix + regression gate - T2: replace Route53 backend with migration error stub - T3: strip registration sites + add infra.autoscaling_group - T4: internal/legacyaws + migration errors in engine + wfctl - T5: modernize legacy-aws-types rule + migration doc - T6: go mod tidy + CI grep gate Co-Authored-By: Claude Sonnet 4.6 --- ...2026-05-13-issue-653-phase1-aws-cutover.md | 1277 +++++++++++++++++ 1 file changed, 1277 insertions(+) create mode 100644 docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md diff --git a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md new file mode 100644 index 00000000..6757c65a --- /dev/null +++ b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md @@ -0,0 +1,1277 @@ +# Issue #653 Phase 1 — Remove AWS IaC Modules from Workflow Core Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Force-cutover 6 legacy AWS IaC module types from workflow core to workflow-plugin-aws v0.2.0, deleting 14 files, stripping registration sites, adding actionable migration errors, and dropping freed AWS SDK packages from go.mod. + +**Architecture:** Single-PR deletion of legacy modules with no compat layer, mirroring #617 godo removal. New `internal/legacyaws` leaf package carries shared constants/formatters. New `modernize/legacy_aws_rule.go` mirrors `modernize/legacy_do_rule.go`. The `platform.dns` module type stays (generic + mock backend); only the Route53 backend is replaced with a migration error stub. `cloud_account_aws.go` + `cloud_account_aws_creds.go` are explicitly NOT deleted (Phase 2 scope). + +**Tech Stack:** Go, `go mod tidy`, `filepath.WalkDir`, `go/parser` for regression test, `golangci-lint`, `gopkg.in/yaml.v3`. + +**Base branch:** origin/main (tracking remote, includes #617 godo removal at c55a56e5) + +--- + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 6 +**Estimated Lines of Change:** ~2500 deletions + ~500 additions + +**Out of scope:** +- Removing `cloud_account_aws.go` / `cloud_account_aws_creds.go` (Phase 2) +- Removing the generic `platform.dns` module type or `step.dns_*` step types +- Removing `codebuild.go`, `nosql_dynamodb.go`, `pipeline_step_s3_upload.go`, `platform_kubernetes_kind.go` (Phase 2) +- Removing `platform/providers/aws/drivers/` (Phase 3) +- Backwards-compatible shim modules +- Removing `service/ec2`, `service/ecs`, `service/sts`, `credentials/stscreds`, `service/cloudwatch` (all have surviving users) + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | feat(#653): remove legacy AWS IaC modules from workflow core | Task 1, Task 2, Task 3, Task 4, Task 5, Task 6 | feat/issue-653-aws-iac-cutover-v2 | + +**Status:** Draft + +--- + +## Key codebase facts for implementer + +**Read the design doc first:** `docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md` — it contains the complete file deletion manifest, parity matrix, migration error strings, and architectural decisions. + +**Exact precedent files to mirror:** +- `internal/legacydo/types.go` → new `internal/legacyaws/types.go` +- `modernize/legacy_do_rule.go` → new `modernize/legacy_aws_rule.go` +- `engine.go` lines 394-402, 514-516 → legacyaws check goes in the same location +- `module/pipeline_step_registry.go` lines 45-46 → legacyaws check after legacydo check +- `cmd/wfctl/validate.go` lines 146-178 → legacyaws module+step sweep + DNS provider check +- `cmd/wfctl/ci_validate.go` lines 136-165 → same pattern +- `modernize/modernize.go` `AllRules()` line 46 → add `legacyAWSRule()` after `legacyDORule()` + +**Exact engine.go pattern for unknown module type (lines 506-509 and 514-516):** +```go +factory, exists := e.moduleFactories[modCfg.Type] +if !exists { + // legacydo check first (already present): + if legacydo.IsModuleType(modCfg.Type) { + _, iacLoaded := e.moduleFactories["iac.provider"] + return legacydo.FormatModuleError(modCfg.Type, modCfg.Name, iacLoaded) + } + // legacyaws check (new — mirrors legacydo pattern): + if legacyaws.IsModuleType(modCfg.Type) { + _, iacLoaded := e.moduleFactories["iac.provider"] + return legacyaws.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) +} +``` + +**platform_dns.go `init()` function (lines 66-73) — replace aws backend registration:** +```go +func init() { + RegisterDNSBackend("mock", func(_ map[string]any) (dnsBackend, error) { + return &mockDNSBackend{}, nil + }) + // BEFORE: returned &route53Backend{} + // AFTER: return migration error + RegisterDNSBackend("aws", func(_ map[string]any) (dnsBackend, error) { + return &awsRoute53ErrorBackend{}, nil + }) +} +``` + +**RemovedInVersion for legacyaws:** `"v0.53.0"` (next minor after v0.52.0 godo removal) + +**Module types to migrate (4):** +- `platform.ecs` → `infra.container_service` +- `platform.apigateway` → `infra.api_gateway` +- `platform.autoscaling` → `infra.autoscaling_group` (1→1, not auto-injecting provider) +- `platform.networking` → `infra.vpc` + `infra.firewall` (1→2 split, not auto-fixable) + +**Step types to guard (15, none auto-rewritten):** +- ecs: `step.ecs_plan`, `step.ecs_apply`, `step.ecs_status`, `step.ecs_destroy` +- apigw: `step.apigw_plan`, `step.apigw_apply`, `step.apigw_status`, `step.apigw_destroy` +- scaling: `step.scaling_plan`, `step.scaling_apply`, `step.scaling_status`, `step.scaling_destroy` +- networking: `step.network_plan`, `step.network_apply`, `step.network_status` (no step.network_destroy) + +**`infra.autoscaling_group` in `plugins/infra/plugin.go`** — just add the string to `infraTypes []string`. The `ModuleSchemas()` function auto-generates the schema from `infraTypes`; no manual schema entry needed. + +--- + +### Task 1: Delete 14 files + partial edits to api_gateway_test.go and app_container.go + +**Files:** +- Delete: `module/platform_ecs.go` +- Delete: `module/platform_ecs_test.go` +- Delete: `module/pipeline_step_ecs.go` +- Delete: `module/platform_apigateway.go` +- Delete: `module/platform_apigateway_test.go` +- Delete: `module/pipeline_step_apigateway.go` +- Delete: `module/aws_api_gateway.go` +- Delete: `module/platform_autoscaling.go` +- Delete: `module/platform_autoscaling_test.go` +- Delete: `module/pipeline_step_autoscaling.go` +- Delete: `module/platform_networking.go` +- Delete: `module/platform_networking_test.go` +- Delete: `module/pipeline_step_networking.go` +- Delete: `module/platform_aws_integration_test.go` +- Modify: `module/api_gateway_test.go` — remove 3 TestAWSAPIGateway_* functions +- Modify: `module/app_container.go` — remove ECS-specific code (C-3 fix) +- Create: `module/aws_absent_test.go` + +**Step 1: Delete the 14 files** + +```bash +git rm module/platform_ecs.go module/platform_ecs_test.go module/pipeline_step_ecs.go +git rm module/platform_apigateway.go module/platform_apigateway_test.go module/pipeline_step_apigateway.go +git rm module/aws_api_gateway.go +git rm module/platform_autoscaling.go module/platform_autoscaling_test.go module/pipeline_step_autoscaling.go +git rm module/platform_networking.go module/platform_networking_test.go module/pipeline_step_networking.go +git rm module/platform_aws_integration_test.go +``` + +Expected: 14 files removed from git index. + +**Step 2: Edit module/api_gateway_test.go — remove the 3 TestAWSAPIGateway_* functions** + +Read the file first, then identify and delete these three test functions entirely: +- `TestAWSAPIGateway_Basic` +- `TestAWSAPIGateway_SyncRoutesStub` +- `TestAWSAPIGateway_SyncRoutesRequiresAPIID` + +Keep all other test functions (19 generic HTTP gateway tests). Remove any `import` for `"github.com/aws/aws-sdk-go-v2/service/apigatewayv2"` or similar if it only served those 3 tests. + +**Step 3: Edit module/app_container.go — remove all ECS-specific code (C-3 fix)** + +`app_container.go` currently has: +- `ECSAppManifests` struct (lines ~77-81) +- `ECSAppTaskDef` struct (lines ~83-89, uses `ECSContainer` type from deleted platform_ecs.go) +- `ECSAppServiceCfg` struct (lines ~91-97) +- `case *PlatformECS:` branch in `Init()` type switch (line ~130) +- Error message at line ~134 references "platform.ecs" +- Comment at line ~14 references "platform.ecs" +- Logger.Warn at line ~147 references "platform.ecs" +- `ecsAppBackend` struct (line ~593) and all its methods +- `buildECSManifests()` function + +Remove all of the above. The resulting `Init()` type switch should only have `case *PlatformKubernetes:` and the `default:` error. Update the default error to: +```go +return fmt.Errorf("app.container %q: environment %q is not a platform.kubernetes module (got %T); platform.ecs was removed — use infra.container_service with workflow-plugin-aws", m.name, envName, svc) +``` + +Update the doc comment at top (line ~14) to remove `platform.ecs` reference: +```go +// environment: name of a platform.kubernetes module (service registry) +``` + +Update logger.Warn hint (line ~147): +```go +"hint", "set 'environment' to a platform.kubernetes module, or ensure KUBECONFIG / ~/.kube/config is present") +``` + +**Step 4: Write the regression gate test module/aws_absent_test.go** + +This test verifies the freed AWS SDK service packages are never re-imported. Uses `filepath.WalkDir` (NOT `filepath.Glob`) per #617 retro lesson. + +```go +package module_test + +import ( + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" +) + +// TestAWSServicePackagesAbsent verifies that the freed AWS SDK service packages +// are not imported anywhere in the module/ directory (issue #653). +// Uses filepath.WalkDir (recursive) — NOT filepath.Glob — per #617 retro. +func TestAWSServicePackagesAbsent(t *testing.T) { + freed := []string{ + "aws-sdk-go-v2/service/apigatewayv2", + "aws-sdk-go-v2/service/applicationautoscaling", + "aws-sdk-go-v2/service/route53", + } + + fset := token.NewFileSet() + err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + if strings.HasSuffix(path, "aws_absent_test.go") { + return nil // skip self + } + f, parseErr := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if parseErr != nil { + return nil // skip unparseable files + } + for _, imp := range f.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + for _, pkg := range freed { + if strings.Contains(importPath, pkg) { + t.Errorf("%s imports freed package %q", path, importPath) + } + } + } + return nil + }) + if err != nil { + t.Fatalf("WalkDir: %v", err) + } +} +``` + +**Step 5: Run the tests to verify they compile (registration sites will fail, expected)** + +```bash +cd /Users/jon/workspace/workflow && go build ./module/... 2>&1 | head -40 +``` + +Expected: compile errors in `plugins/platform/plugin.go` (references deleted factories) — these are expected and will be fixed in Task 3. The `module/` package itself should compile cleanly after the edits. + +If `module/` package has compile errors (other than missing platform_ecs types), diagnose and fix before committing. + +**Step 6: Run the regression test to verify it passes** + +```bash +cd /Users/jon/workspace/workflow/module && go test -run TestAWSServicePackagesAbsent -v 2>&1 +``` + +Expected: `PASS` — module/ has no imports for the freed packages. + +**Step 7: Commit Task 1** + +```bash +git add module/aws_absent_test.go module/api_gateway_test.go module/app_container.go +git commit -m "feat(#653): T1 — delete legacy AWS IaC module files + regression gate" +``` + +--- + +### Task 2: Replace platform_dns_backends.go with mock-only version + error stub + +**Files:** +- Replace: `module/platform_dns_backends.go` (full rewrite — delete route53Backend, add awsRoute53ErrorBackend) +- Modify: `module/platform_dns.go` (update init() — replace route53Backend with awsRoute53ErrorBackend) +- Modify: `module/platform_dns_test.go` (add test for provider: aws migration error) + +**Step 1: Write the failing test for the AWS provider migration error** + +Add to `module/platform_dns_test.go`: + +```go +func TestPlatformDNS_AWSBackendMigrationError(t *testing.T) { + cfg := map[string]any{"provider": "aws", "zone": map[string]any{"name": "example.com"}} + m := NewPlatformDNS("test-dns", cfg) + app := mock.NewMockApplication() + err := m.Init(app) + if err == nil { + t.Fatal("expected migration error for provider: aws, got nil") + } + errStr := err.Error() + if !strings.Contains(errStr, "infra.dns") { + t.Errorf("error should mention infra.dns, got: %s", errStr) + } + if !strings.Contains(errStr, "workflow-plugin-aws") { + t.Errorf("error should mention workflow-plugin-aws, got: %s", errStr) + } +} +``` + +Run: `cd /Users/jon/workspace/workflow && go test ./module/ -run TestPlatformDNS_AWSBackendMigrationError -v 2>&1` +Expected: compile error (awsRoute53ErrorBackend doesn't exist yet) or test FAIL (mock backend registered under "aws" currently would not return an error). + +**Step 2: Replace module/platform_dns_backends.go** + +Write the new file keeping only `mockDNSBackend` and adding `awsRoute53ErrorBackend`: + +```go +package module + +import ( + "fmt" +) + +// ─── Mock backend ───────────────────────────────────────────────────────────── + +// mockDNSBackend is an in-memory DNS backend for testing and local use. +// No real DNS API calls are made; state is tracked in memory. +type mockDNSBackend struct{} + +func (b *mockDNSBackend) planDNS(m *PlatformDNS) (*DNSPlan, error) { + zone := m.zoneConfig() + records := m.recordConfigs() + plan := &DNSPlan{ + Zone: zone, + Records: records, + } + + switch m.state.Status { + case "pending": + plan.Changes = append(plan.Changes, fmt.Sprintf("create zone %q", zone.Name)) + for _, r := range records { + plan.Changes = append(plan.Changes, fmt.Sprintf("create %s record %q -> %q", r.Type, r.Name, r.Value)) + } + case "active": + // diff existing records vs desired + existing := map[string]DNSRecordConfig{} + for _, r := range m.state.Records { + existing[r.Name+"/"+r.Type] = r + } + for _, r := range records { + key := r.Name + "/" + r.Type + if e, ok := existing[key]; !ok { + plan.Changes = append(plan.Changes, fmt.Sprintf("create %s record %q -> %q", r.Type, r.Name, r.Value)) + } else if e.Value != r.Value || e.TTL != r.TTL { + plan.Changes = append(plan.Changes, fmt.Sprintf("update %s record %q: %q -> %q", r.Type, r.Name, e.Value, r.Value)) + } + } + if len(plan.Changes) == 0 { + plan.Changes = []string{"no changes"} + } + case "deleted": + plan.Changes = append(plan.Changes, fmt.Sprintf("create zone %q (previously deleted)", zone.Name)) + for _, r := range records { + plan.Changes = append(plan.Changes, fmt.Sprintf("create %s record %q -> %q", r.Type, r.Name, r.Value)) + } + default: + plan.Changes = []string{fmt.Sprintf("zone status=%s, no action", m.state.Status)} + } + + return plan, nil +} + +func (b *mockDNSBackend) applyDNS(m *PlatformDNS) (*DNSState, error) { + if m.state.Status == "active" { + m.state.Records = m.recordConfigs() + return m.state, nil + } + + zone := m.zoneConfig() + m.state.ZoneID = fmt.Sprintf("mock-zone-%s", zone.Name) + m.state.ZoneName = zone.Name + m.state.Records = m.recordConfigs() + m.state.Status = "active" + return m.state, nil +} + +func (b *mockDNSBackend) statusDNS(m *PlatformDNS) (*DNSState, error) { + return m.state, nil +} + +func (b *mockDNSBackend) destroyDNS(m *PlatformDNS) error { + if m.state.Status == "deleted" { + return nil + } + m.state.Status = "deleting" + m.state.Records = nil + m.state.Status = "deleted" + return nil +} + +// ─── AWS Route53 migration error backend ────────────────────────────────────── + +// awsRoute53ErrorBackend is registered under provider "aws" after the Route53 +// backend was removed from workflow core in v0.53.0 (issue #653). +// All methods return the actionable migration error directing the operator to +// infra.dns + workflow-plugin-aws. +type awsRoute53ErrorBackend struct{} + +func (b *awsRoute53ErrorBackend) planDNS(m *PlatformDNS) (*DNSPlan, error) { + return nil, b.err(m) +} + +func (b *awsRoute53ErrorBackend) applyDNS(m *PlatformDNS) (*DNSState, error) { + return nil, b.err(m) +} + +func (b *awsRoute53ErrorBackend) statusDNS(m *PlatformDNS) (*DNSState, error) { + return nil, b.err(m) +} + +func (b *awsRoute53ErrorBackend) destroyDNS(m *PlatformDNS) error { + return b.err(m) +} + +func (b *awsRoute53ErrorBackend) err(m *PlatformDNS) error { + return fmt.Errorf( + "platform.dns %q: AWS Route53 backend removed from workflow core in v0.53.0 (issue #653).\n"+ + "Migrate to: infra.dns (provider: aws) with workflow-plugin-aws v0.2.0+.\n"+ + "Install: https://github.com/GoCodeAlone/workflow-plugin-aws\n"+ + "See docs/migrations/v0.53.0-aws-iac-removal.md", + m.name, + ) +} +``` + +**Step 3: Update platform_dns.go init() to use the error backend** + +In `module/platform_dns.go` at the `init()` function (lines 66-73), change: +```go +RegisterDNSBackend("aws", func(_ map[string]any) (dnsBackend, error) { + return &route53Backend{}, nil +}) +``` +to: +```go +RegisterDNSBackend("aws", func(_ map[string]any) (dnsBackend, error) { + return &awsRoute53ErrorBackend{}, nil +}) +``` + +No import changes needed — `awsRoute53ErrorBackend` is in the same `module` package. + +**Step 4: Run the failing test to verify it now passes** + +```bash +cd /Users/jon/workspace/workflow && go test ./module/ -run TestPlatformDNS_AWSBackendMigrationError -v 2>&1 +``` + +Expected: +``` +--- PASS: TestPlatformDNS_AWSBackendMigrationError (0.00s) +``` + +**Step 5: Run all module DNS tests** + +```bash +cd /Users/jon/workspace/workflow && go test ./module/ -run TestPlatformDNS -v 2>&1 +``` + +Expected: all DNS tests pass (mock backend tests should pass unchanged). + +**Step 6: Commit Task 2** + +```bash +git add module/platform_dns_backends.go module/platform_dns.go module/platform_dns_test.go +git commit -m "feat(#653): T2 — replace Route53 backend with migration error stub" +``` + +--- + +### Task 3: Strip registration sites + add infra.autoscaling_group + regenerate golden + +**Files:** +- Modify: `plugins/platform/plugin.go` +- Modify: `plugins/platform/plugin_test.go` +- Modify: `plugins/infra/plugin.go` +- Modify: `schema/schema.go` +- Modify: `schema/module_schema.go` +- Modify: `schema/step_schema_builtins.go` +- Modify: `cmd/wfctl/type_registry.go` +- Modify: `module/multi_region.go` (error message string) +- Modify: `DOCUMENTATION.md` +- Update: `schema/testdata/editor-schemas.golden.json` (auto-regenerated) + +**Step 1: Edit plugins/platform/plugin.go** + +Remove these entries from `ModuleTypes` (map or slice — read the actual file first): +- `platform.ecs` +- `platform.apigateway` +- `platform.autoscaling` +- `platform.networking` + +Remove the corresponding 4 module factory cases (switch or map entries for those types). + +Remove these 15 step types from `StepTypes`/`StepFactories`: +- `step.ecs_plan`, `step.ecs_apply`, `step.ecs_status`, `step.ecs_destroy` +- `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.network_plan`, `step.network_apply`, `step.network_status` + +Keep all `platform.dns`, `step.dns_*`, and other types. + +**Step 2: Edit plugins/platform/plugin_test.go** + +Remove the 4 module type string assertions for the deleted types. +Remove the 15 step type string assertions for the deleted steps. + +**Step 3: Edit plugins/infra/plugin.go — add infra.autoscaling_group** + +In `var infraTypes = []string{...}` (currently 13 entries, lines 15-28), add: +```go +"infra.autoscaling_group", +``` + +After this addition, `infraTypes` has 14 entries. The `ModuleSchemas()` function auto-generates the schema — no other changes needed in this file. + +**Step 4: Edit schema/schema.go** + +Remove these 4 strings from the module type list: +- `"platform.ecs"` +- `"platform.apigateway"` +- `"platform.autoscaling"` +- `"platform.networking"` + +**Step 5: Edit schema/module_schema.go** + +1. Remove the 4 module schema structs/entries for `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking`. Read the file to find the exact struct literals and remove them. + +2. Update the `platform.dns` provider `ConfigFieldDef` description. Find the `platform.dns` schema entry and update the `provider` field description to: +``` +"Provider backend: mock | aws (aws Route53 backend removed in v0.53.0; use infra.dns + workflow-plugin-aws)" +``` + +**Step 6: Edit schema/step_schema_builtins.go** + +Remove the 15 `schema.Register(...)` calls for: +- `step.ecs_plan`, `step.ecs_apply`, `step.ecs_status`, `step.ecs_destroy` +- `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.network_plan`, `step.network_apply`, `step.network_status` + +**Step 7: Edit cmd/wfctl/type_registry.go** + +Remove entries for `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking`. + +Add entry for `"infra.autoscaling_group"` — mirror the pattern used for other `infra.*` entries in that file. + +**Step 8: Edit module/multi_region.go line ~117** + +Change the error string from referencing `platform.ecs` to `infra.container_service`: +```go +return fmt.Errorf("platform.region %q: provider %q is not yet supported; use AWS ALB directly via platform.kubernetes or infra.container_service modules (workflow-plugin-aws)", m.name, providerType) +``` + +**Step 9: Edit DOCUMENTATION.md** + +Remove these module rows: `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking`. +Remove these 15 step rows: all `step.ecs_*`, `step.apigw_*`, `step.scaling_*`, `step.network_*` entries. +Keep `platform.dns` module row and `step.dns_plan`, `step.dns_apply`, `step.dns_status` rows. +Add `infra.autoscaling_group` to the infra module types table. +Add a paragraph after the platform module types section: + +``` +> **AWS IaC modules removed (v0.53.0):** `platform.ecs`, `platform.apigateway`, `platform.autoscaling`, `platform.networking` were removed from workflow core and are now provided by [workflow-plugin-aws](https://github.com/GoCodeAlone/workflow-plugin-aws) v0.2.0+ as `infra.container_service`, `infra.api_gateway`, `infra.autoscaling_group`, `infra.vpc`/`infra.firewall`. See `docs/migrations/v0.53.0-aws-iac-removal.md`. +``` + +**Step 10: Verify go build compiles after registration edits** + +```bash +cd /Users/jon/workspace/workflow && go build ./... 2>&1 | grep -v "_worktrees\|.claude" | head -30 +``` + +Expected: 0 compile errors (T4 migration errors will be wired in next task, so the `if !exists` branch just returns the generic error for now — that's fine). + +**Step 11: Regenerate the golden file** + +```bash +cd /Users/jon/workspace/workflow && UPDATE_GOLDEN=1 go test ./schema/ -run TestEditorSchemasGoldenFile -v 2>&1 +``` + +Expected: `--- PASS: TestEditorSchemasGoldenFile` with "updated golden file" log line. + +**Step 12: Run schema tests** + +```bash +cd /Users/jon/workspace/workflow && go test ./schema/ -v 2>&1 | tail -20 +``` + +Expected: all schema tests PASS. + +**Step 13: Run platform plugin tests** + +```bash +cd /Users/jon/workspace/workflow && go test ./plugins/platform/... -v 2>&1 | tail -20 +``` + +Expected: all platform plugin tests PASS. + +**Step 14: Commit Task 3** + +```bash +git add plugins/platform/plugin.go plugins/platform/plugin_test.go \ + plugins/infra/plugin.go schema/schema.go schema/module_schema.go \ + schema/step_schema_builtins.go cmd/wfctl/type_registry.go \ + module/multi_region.go DOCUMENTATION.md \ + schema/testdata/editor-schemas.golden.json +git commit -m "feat(#653): T3 — strip registration sites, add infra.autoscaling_group, regen golden" +``` + +--- + +### Task 4: Add internal/legacyaws + wire migration errors + +**Files:** +- Create: `internal/legacyaws/types.go` +- Modify: `engine.go` +- Modify: `module/pipeline_step_registry.go` +- Modify: `cmd/wfctl/validate.go` +- Modify: `cmd/wfctl/ci_validate.go` +- Create: `engine_legacyaws_test.go` (or add to existing engine test file) +- Modify: `module/pipeline_step_registry_test.go` (add legacyaws step tests) +- Modify: `cmd/wfctl/validate_test.go` (add legacyaws validate tests) + +**Step 1: Write the failing tests first (TDD)** + +Add to engine tests (create `engine_legacyaws_test.go`): + +```go +package workflow_test + +import ( + "strings" + "testing" +) + +func TestBuildFromConfig_LegacyAWSModuleError(t *testing.T) { + legacyTypes := []string{"platform.ecs", "platform.apigateway", "platform.autoscaling", "platform.networking"} + for _, typ := range legacyTypes { + t.Run(typ, func(t *testing.T) { + cfg := minimalConfigWithModule(typ, "my-module") // helper from engine_test.go + _, err := BuildFromConfig(cfg) + if err == nil { + t.Fatalf("expected migration error for %q, got nil", typ) + } + errStr := err.Error() + if !strings.Contains(errStr, "v0.53.0") { + t.Errorf("error for %q missing version, got: %s", typ, errStr) + } + if !strings.Contains(errStr, "workflow-plugin-aws") { + t.Errorf("error for %q missing plugin hint, got: %s", typ, errStr) + } + }) + } +} +``` + +Add to pipeline step registry tests: +```go +func TestStepRegistry_LegacyAWSStepError(t *testing.T) { + legacySteps := []string{ + "step.ecs_plan", "step.ecs_apply", "step.ecs_status", "step.ecs_destroy", + "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.network_plan", "step.network_apply", "step.network_status", + } + r := NewStepRegistry() // read actual constructor from pipeline_step_registry.go + for _, st := range legacySteps { + t.Run(st, func(t *testing.T) { + _, err := r.Create(st, nil, nil) + if err == nil { + t.Fatalf("expected migration error for %q, got nil", st) + } + if !strings.Contains(err.Error(), "v0.53.0") { + t.Errorf("error for %q missing version: %s", st, err) + } + }) + } +} +``` + +Run: `go test ./... -run "TestBuildFromConfig_LegacyAWS|TestStepRegistry_LegacyAWS" 2>&1 | head -20` +Expected: compile error (legacyaws package doesn't exist yet). + +**Step 2: Create internal/legacyaws/types.go** + +```go +// Package legacyaws holds the read-only data and message formatters for the +// legacy AWS IaC module + step types removed in issue #653. 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 legacyaws + +import ( + "fmt" + "sort" + "strings" +) + +// RemovedInVersion is the workflow tag that ships issue #653's force-cutover. +const RemovedInVersion = "v0.53.0" + +// ModuleTypes maps each removed legacy AWS module type to its infra.* successor. +var ModuleTypes = map[string]string{ + "platform.ecs": "infra.container_service", + "platform.apigateway": "infra.api_gateway", + "platform.autoscaling": "infra.autoscaling_group", + "platform.networking": "infra.vpc + infra.firewall", +} + +// StepTypes maps each removed legacy AWS step type to its successor. +// step.network_destroy is intentionally absent — it never existed. +var StepTypes = map[string]string{ + "step.ecs_plan": "step.iac_plan (against an infra.container_service module); required config keys: platform + state_store", + "step.ecs_apply": "step.iac_apply (against an infra.container_service module); required config keys: platform + state_store", + "step.ecs_status": "step.iac_status (against an infra.container_service module); required config keys: platform + state_store", + "step.ecs_destroy": "step.iac_destroy (against an infra.container_service module); required config keys: platform + state_store", + "step.apigw_plan": "step.iac_plan (against an infra.api_gateway module); required config keys: platform + state_store", + "step.apigw_apply": "step.iac_apply (against an infra.api_gateway module); required config keys: platform + state_store", + "step.apigw_status": "step.iac_status (against an infra.api_gateway module); required config keys: platform + state_store", + "step.apigw_destroy": "step.iac_destroy (against an infra.api_gateway module); required config keys: platform + state_store", + "step.scaling_plan": "step.iac_plan (against an infra.autoscaling_group module); required config keys: platform + state_store", + "step.scaling_apply": "step.iac_apply (against an infra.autoscaling_group module); required config keys: platform + state_store", + "step.scaling_status": "step.iac_status (against an infra.autoscaling_group module); required config keys: platform + state_store", + "step.scaling_destroy": "step.iac_destroy (against an infra.autoscaling_group module); required config keys: platform + state_store", + "step.network_plan": "step.iac_plan (against an infra.vpc or infra.firewall module); required config keys: platform + state_store", + "step.network_apply": "step.iac_apply (against an infra.vpc or infra.firewall module); required config keys: platform + state_store", + "step.network_status": "step.iac_status (against an infra.vpc or infra.firewall module); required config keys: platform + state_store", +} + +// IsModuleType reports whether t is a removed legacy AWS module type. +func IsModuleType(t string) bool { _, ok := ModuleTypes[t]; return ok } + +// IsStepType reports whether t is a removed legacy AWS step type. +func IsStepType(t string) bool { _, ok := StepTypes[t]; return ok } + +// FormatModuleError builds the actionable migration error for a legacy AWS module type. +func FormatModuleError(legacyType, moduleName string, iacProviderLoaded bool) error { + successor, ok := ModuleTypes[legacyType] + if !ok { + return nil + } + pluginLine := "Install workflow-plugin-aws: https://github.com/GoCodeAlone/workflow-plugin-aws" + if iacProviderLoaded { + pluginLine = "workflow-plugin-aws 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 — AWS IaC moved to workflow-plugin-aws.\n\n", legacyType, moduleName, RemovedInVersion) + b.WriteString(pluginLine) + b.WriteString("\n\nMigrate this module to: ") + b.WriteString(successor) + b.WriteString(" (provider: aws)\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.53.0-aws-iac-removal.md") + return fmt.Errorf("%s", b.String()) +} + +// FormatStepError builds the actionable migration error for a legacy AWS step type. +func FormatStepError(legacyType string, iacProviderLoaded bool) error { + successor, ok := StepTypes[legacyType] + if !ok { + return nil + } + pluginLine := "Install workflow-plugin-aws: https://github.com/GoCodeAlone/workflow-plugin-aws" + if iacProviderLoaded { + pluginLine = "workflow-plugin-aws 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 — AWS IaC moved to workflow-plugin-aws.\n\n", legacyType, RemovedInVersion) + b.WriteString(pluginLine) + b.WriteString("\n\nMigrate this step to: ") + b.WriteString(successor) + b.WriteString("\n\nSee docs/migrations/v0.53.0-aws-iac-removal.md") + return fmt.Errorf("%s", b.String()) +} + +// FormatDNSProviderAWSError returns the migration error for platform.dns +// configured with provider: aws (Route53 backend removed in issue #653). +func FormatDNSProviderAWSError(moduleName string) error { + return fmt.Errorf( + "platform.dns %q: AWS Route53 backend removed from workflow core in %s (issue #653).\n"+ + "Migrate to: infra.dns (provider: aws) with workflow-plugin-aws v0.2.0+.\n"+ + "Install: https://github.com/GoCodeAlone/workflow-plugin-aws\n"+ + "See docs/migrations/v0.53.0-aws-iac-removal.md", + moduleName, RemovedInVersion, + ) +} +``` + +**Step 3: Wire legacyaws into engine.go** + +In `engine.go`, add the import: +```go +"github.com/GoCodeAlone/workflow/internal/legacyaws" +``` + +In `BuildFromConfig()`, in the `WithExtraModuleTypes` injection loop (around line 394-402, where legacydo types are injected), add legacyaws types: +```go +for t := range legacyaws.ModuleTypes { + extra = append(extra, t) +} +``` + +In the `if !exists` branch (around line 514-516), after the legacydo check, add: +```go +if legacyaws.IsModuleType(modCfg.Type) { + _, iacLoaded := e.moduleFactories["iac.provider"] + return legacyaws.FormatModuleError(modCfg.Type, modCfg.Name, iacLoaded) +} +``` + +**Step 4: Wire legacyaws into module/pipeline_step_registry.go** + +Add import: +```go +"github.com/GoCodeAlone/workflow/internal/legacyaws" +``` + +In `Create()`, after the legacydo check (line ~45-46), add: +```go +if legacyaws.IsStepType(stepType) { + return nil, legacyaws.FormatStepError(stepType, r.iacProviderLoaded) +} +``` + +**Step 5: Wire legacyaws into cmd/wfctl/validate.go** + +Add import `"github.com/GoCodeAlone/workflow/internal/legacyaws"`. + +In the `WithExtraModuleTypes` injection loop (line ~146-150, currently only legacydo), add: +```go +for t := range legacyaws.ModuleTypes { + opts = append(opts, schema.WithExtraModuleTypes(t)) +} +``` + +In the post-validate module sweep (line ~159-163), after the legacydo module check, add: +```go +if legacyaws.IsModuleType(m.Type) { + return legacyaws.FormatModuleError(m.Type, m.Name, false) +} +// DNS provider: aws check +if m.Type == "platform.dns" { + if cfg, ok := m.Config.(map[string]any); ok { + if provider, _ := cfg["provider"].(string); provider == "aws" { + return legacyaws.FormatDNSProviderAWSError(m.Name) + } + } +} +``` + +In the post-validate step sweep (line ~173-177), add: +```go +if legacyaws.IsStepType(s.Type) { + return legacyaws.FormatStepError(s.Type, false) +} +``` + +**Step 6: Wire legacyaws into cmd/wfctl/ci_validate.go** + +Same additions as validate.go — mirror exactly. + +**Step 7: Run the tests** + +```bash +cd /Users/jon/workspace/workflow && go test ./... -run "TestBuildFromConfig_LegacyAWS|TestStepRegistry_LegacyAWS|TestValidate.*Legacy" -v 2>&1 | tail -30 +``` + +Expected: all tests PASS. + +**Step 8: Run full test suite** + +```bash +cd /Users/jon/workspace/workflow && go test ./... 2>&1 | tail -30 +``` + +Expected: all tests PASS. If failures, diagnose and fix before committing. + +**Step 9: Commit Task 4** + +```bash +git add internal/legacyaws/types.go engine.go module/pipeline_step_registry.go \ + cmd/wfctl/validate.go cmd/wfctl/ci_validate.go \ + engine_legacyaws_test.go module/pipeline_step_registry_test.go \ + cmd/wfctl/validate_test.go +git commit -m "feat(#653): T4 — add internal/legacyaws + wire migration errors in engine + wfctl" +``` + +--- + +### Task 5: wfctl modernize rule + migration doc + +**Files:** +- Create: `modernize/legacy_aws_rule.go` +- Modify: `modernize/modernize.go` (add legacyAWSRule to AllRules) +- Create: `docs/migrations/v0.53.0-aws-iac-removal.md` +- Create: `modernize/legacy_aws_rule_test.go` (tests for the rule) + +**Step 1: Write the failing tests for the modernize rule** + +Create `modernize/legacy_aws_rule_test.go`: + +```go +package modernize_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/modernize" +) + +func TestLegacyAWSRule_Check(t *testing.T) { + rule := findRule("legacy-aws-types") // read AllRules, find by ID + tests := []struct { + name string + yaml string + wantMsg string + fixable bool + }{ + { + name: "platform.ecs detected", + yaml: "modules:\n - name: my-ecs\n type: platform.ecs\n", + wantMsg: "infra.container_service", + fixable: true, + }, + { + name: "platform.networking not auto-fixable", + yaml: "modules:\n - name: my-net\n type: platform.networking\n", + wantMsg: "infra.vpc", + fixable: false, + }, + { + name: "step.ecs_apply not auto-fixable", + yaml: "pipelines:\n deploy:\n steps:\n - type: step.ecs_apply\n", + wantMsg: "step.iac_apply", + fixable: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + findings := check(rule, tt.yaml) + if len(findings) == 0 { + t.Fatal("expected findings, got none") + } + if !strings.Contains(findings[0].Message, tt.wantMsg) { + t.Errorf("message %q doesn't contain %q", findings[0].Message, tt.wantMsg) + } + if findings[0].Fixable != tt.fixable { + t.Errorf("fixable: got %v, want %v", findings[0].Fixable, tt.fixable) + } + }) + } +} + +func TestLegacyAWSRule_Fix(t *testing.T) { + rule := findRule("legacy-aws-types") + // Auto-fixable: platform.ecs → infra.container_service + input := "modules:\n - name: my-ecs\n type: platform.ecs\n" + result, changes := fix(rule, input) + if len(changes) == 0 { + t.Fatal("expected changes, got none") + } + if !strings.Contains(result, "infra.container_service") { + t.Errorf("result %q doesn't contain infra.container_service", result) + } + // platform.networking NOT auto-fixed + input2 := "modules:\n - name: my-net\n type: platform.networking\n" + _, changes2 := fix(rule, input2) + if len(changes2) != 0 { + t.Error("platform.networking should not be auto-fixed") + } +} +``` + +Read `modernize/legacy_do_rule_test.go` to find the `findRule`, `check`, `fix` helpers and mirror them. + +Run: `go test ./modernize/ -run TestLegacyAWSRule -v 2>&1` +Expected: compile error (rule doesn't exist yet). + +**Step 2: Create modernize/legacy_aws_rule.go** + +Mirror `modernize/legacy_do_rule.go` exactly. Key differences: +- Rule ID: `"legacy-aws-types"` +- Description: `"Rewrite legacy AWS module/step types to infra.* IaC successors (issue #653)."` +- `moduleMap`: `platform.ecs→infra.container_service`, `platform.apigateway→infra.api_gateway`, `platform.autoscaling→infra.autoscaling_group` (auto-fixable; NOT `platform.networking` — 1→2 split) +- `gapTypes`: `platform.networking → "splits into infra.vpc + infra.firewall — manual rewrite required"` +- `stepMap`: all 15 step types with successors (all marked `Fixable: false` — step config-shape mismatch per #617 retro) +- Import: `"github.com/GoCodeAlone/workflow/internal/legacyaws"` (for `legacyaws.RemovedInVersion`) +- Use `walkTypeNodes` (already defined in legacy_do_rule.go — same package, usable directly) + +```go +package modernize + +import ( + "fmt" + + "github.com/GoCodeAlone/workflow/internal/legacyaws" + "gopkg.in/yaml.v3" +) + +// legacyAWSRule flags legacy AWS module + step types and rewrites +// module types to their infra.* IaC successors (issue #653). +// +// IMPORTANT: The Fix function ONLY renames the type: key for 3 module types +// (ecs, apigateway, autoscaling). platform.networking is NOT auto-rewritten +// (1→2 split). All 15 step types are NOT auto-rewritten: step.iac_* require +// different config keys (platform + state_store) vs legacy keys. Operator must +// rewrite step config manually per migration guide (docs/migrations/v0.53.0-aws-iac-removal.md). +func legacyAWSRule() Rule { + moduleMap := map[string]string{ + "platform.ecs": "infra.container_service", + "platform.apigateway": "infra.api_gateway", + "platform.autoscaling": "infra.autoscaling_group", + // platform.networking intentionally NOT auto-fixed: 1→2 split + } + stepMap := map[string]string{ + "step.ecs_plan": "step.iac_plan", + "step.ecs_apply": "step.iac_apply", + "step.ecs_status": "step.iac_status", + "step.ecs_destroy": "step.iac_destroy", + "step.apigw_plan": "step.iac_plan", + "step.apigw_apply": "step.iac_apply", + "step.apigw_status": "step.iac_status", + "step.apigw_destroy": "step.iac_destroy", + "step.scaling_plan": "step.iac_plan", + "step.scaling_apply": "step.iac_apply", + "step.scaling_status": "step.iac_status", + "step.scaling_destroy": "step.iac_destroy", + "step.network_plan": "step.iac_plan", + "step.network_apply": "step.iac_apply", + "step.network_status": "step.iac_status", + } + gapTypes := map[string]string{ + "platform.networking": "splits into infra.vpc + infra.firewall — manual rewrite required", + } + + return Rule{ + ID: "legacy-aws-types", + Description: "Rewrite legacy AWS module/step types to infra.* IaC successors (issue #653).", + 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-aws-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in %s; rewrite to %s (provider: aws) — requires workflow-plugin-aws", typeVal.Value, legacyaws.RemovedInVersion, successor), + Fixable: true, + }) + } + if successor, ok := stepMap[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-aws-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in %s; manually rewrite to %s with config keys platform + state_store (see docs/migrations/v0.53.0-aws-iac-removal.md) — requires workflow-plugin-aws", typeVal.Value, legacyaws.RemovedInVersion, successor), + Fixable: false, + }) + } + if reason, ok := gapTypes[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-aws-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in %s — %s", typeVal.Value, legacyaws.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-aws-types", + Line: typeVal.Line, + Description: fmt.Sprintf("rewrote %s → %s", old, successor), + }) + } + // stepMap and gapTypes are intentionally NOT rewritten. + }) + return out + }, + } +} +``` + +**Step 3: Register in modernize/modernize.go** + +In `AllRules()` (line 36-48), add `legacyAWSRule()` after `legacyDORule()`: +```go +legacyDORule(), +legacyAWSRule(), +``` + +**Step 4: Run the modernize rule tests** + +```bash +cd /Users/jon/workspace/workflow && go test ./modernize/ -run TestLegacyAWSRule -v 2>&1 +``` + +Expected: `--- PASS: TestLegacyAWSRule_Check` and `--- PASS: TestLegacyAWSRule_Fix`. + +**Step 5: Create docs/migrations/v0.53.0-aws-iac-removal.md** + +Write a migration guide. Keep it concise. Cover: +- What changed and when (v0.53.0, issue #653) +- The 4 module type renames (with config diff examples) +- The 15 step type changes (manual — different config keys) +- The platform.dns provider: aws change (→ infra.dns) +- wfctl modernize command usage +- workflow-plugin-aws install link + +**Step 6: Run all modernize tests** + +```bash +cd /Users/jon/workspace/workflow && go test ./modernize/... -v 2>&1 | tail -20 +``` + +Expected: all tests PASS. + +**Step 7: Commit Task 5** + +```bash +git add modernize/legacy_aws_rule.go modernize/legacy_aws_rule_test.go \ + modernize/modernize.go docs/migrations/v0.53.0-aws-iac-removal.md +git commit -m "feat(#653): T5 — wfctl modernize rule legacy-aws-types + migration doc" +``` + +--- + +### Task 6: go mod tidy + CI grep gate + +**Files:** +- Modify: `go.mod`, `go.sum` +- Modify: `example/go.mod`, `example/go.sum` +- Modify: `.github/workflows/ci.yml` (add aws-sdk-banned job) + +**Step 1: Run go mod tidy on root** + +```bash +cd /Users/jon/workspace/workflow && go mod tidy 2>&1 +``` + +Expected: no errors. Verify: + +```bash +grep "aws-sdk-go-v2/service/apigatewayv2\|aws-sdk-go-v2/service/applicationautoscaling\|aws-sdk-go-v2/service/route53" go.mod +``` + +Expected: 0 matches (all 3 freed packages dropped). + +**Step 2: Run go mod tidy on example/** + +```bash +cd /Users/jon/workspace/workflow/example && go mod tidy 2>&1 +``` + +Expected: no errors. Verify: + +```bash +grep "aws-sdk-go-v2/service/apigatewayv2\|aws-sdk-go-v2/service/applicationautoscaling\|aws-sdk-go-v2/service/route53" /Users/jon/workspace/workflow/example/go.mod +``` + +Expected: 0 matches. + +**Step 3: Run full test suite post-tidy to confirm nothing broken** + +```bash +cd /Users/jon/workspace/workflow && go test ./... 2>&1 | tail -30 +``` + +Expected: all tests PASS. + +**Step 4: Add the CI grep gate to .github/workflows/ci.yml** + +Find the `godo-banned` job in `.github/workflows/ci.yml` (around lines 380-395) and add a parallel `aws-sdk-banned` job immediately after it with the same structure: + +```yaml + aws-sdk-banned: + name: aws-sdk-service-packages-banned + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify freed AWS SDK service packages absent from Go source + run: | + ! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + --exclude="aws_absent_test.go" \ + "aws-sdk-go-v2/service/apigatewayv2\|aws-sdk-go-v2/service/applicationautoscaling\|aws-sdk-go-v2/service/route53" . + - name: Verify freed AWS SDK service packages absent from go.mod files + run: | + ! grep -qH "aws-sdk-go-v2/service/apigatewayv2\|aws-sdk-go-v2/service/applicationautoscaling\|aws-sdk-go-v2/service/route53" go.mod example/go.mod +``` + +**Step 5: Run the grep gate locally to verify it passes** + +```bash +cd /Users/jon/workspace/workflow && ! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + --exclude="aws_absent_test.go" \ + "aws-sdk-go-v2/service/apigatewayv2\|aws-sdk-go-v2/service/applicationautoscaling\|aws-sdk-go-v2/service/route53" . && echo "PASS: no freed imports found" +``` + +Expected: `PASS: no freed imports found` + +```bash +! grep -qH "aws-sdk-go-v2/service/apigatewayv2\|aws-sdk-go-v2/service/applicationautoscaling\|aws-sdk-go-v2/service/route53" /Users/jon/workspace/workflow/go.mod /Users/jon/workspace/workflow/example/go.mod && echo "PASS: go.mod clean" +``` + +Expected: `PASS: go.mod clean` + +**Step 6: Run go build for final compile check** + +```bash +cd /Users/jon/workspace/workflow && go build ./... 2>&1 | grep -v "_worktrees\|.claude" +``` + +Expected: 0 errors. + +**Step 7: Downstream consumer grep (Assumption #2 check)** + +```bash +for repo in buymywishlist core-dump workflow-cloud workflow-scenarios; do + if [ -d "/Users/jon/workspace/$repo" ]; then + echo "=== $repo ===" + grep -rn "platform\.ecs\|platform\.apigateway\|platform\.autoscaling\|platform\.networking" /Users/jon/workspace/$repo --include="*.yaml" --include="*.yml" | grep -v "node_modules\|vendor\|_worktrees" || echo "clean" + fi +done +``` + +Expected: "clean" for all repos (or document any findings before opening PR). + +**Step 8: Commit Task 6** + +```bash +git add go.mod go.sum example/go.mod example/go.sum .github/workflows/ci.yml +git commit -m "feat(#653): T6 — go mod tidy drops freed AWS SDK service packages + CI grep gate" +``` + +--- + +## Pre-PR verification (run after all 6 tasks) + +```bash +# Full test suite +cd /Users/jon/workspace/workflow && go test ./... 2>&1 | tail -20 + +# Race detector +cd /Users/jon/workspace/workflow && go test -race ./... 2>&1 | tail -10 + +# Lint +cd /Users/jon/workspace/workflow && go fmt ./... && golangci-lint run 2>&1 | tail -20 + +# Build both binaries +cd /Users/jon/workspace/workflow && go build -o /tmp/workflow-server ./cmd/server && echo "server OK" +cd /Users/jon/workspace/workflow && go build -o /tmp/wfctl ./cmd/wfctl && echo "wfctl OK" + +# Smoke: wfctl modernize detects platform.ecs +echo "modules:\n - name: my-app\n type: platform.ecs" > /tmp/test-legacy-aws.yaml +/tmp/wfctl modernize /tmp/test-legacy-aws.yaml 2>&1 + +# Smoke: wfctl validate rejects platform.ecs with actionable error +/tmp/wfctl validate /tmp/test-legacy-aws.yaml 2>&1 | grep "v0.53.0" +``` + +Expected for each: +- Test suite: all PASS +- Race: all PASS +- Lint: 0 errors +- `server OK`, `wfctl OK` +- modernize: shows finding with `infra.container_service` in message +- validate: error contains `v0.53.0` + +Rollback: `git revert ` to revert if any post-merge issue; consumers pin previous tag; `cloud_account_aws.go` was never removed so AWS credential resolution continues unaffected. From 90d7c17417260fa88c12428a01e3e0d6d916814e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 08:03:28 -0400 Subject: [PATCH 07/18] fix(plan#653): fix T2 DNS test assertion + T4 test stub (adversarial cycle 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T2: Init() succeeds for provider:aws (factory returns struct not error); migration error fires at Plan() time — fix test to call m.Plan() not m.Init(); fix mock import (module.NewMockApplication not mock.); remove deleted Route53 tests from file list - T4: replace nonexistent minimalConfigWithModule() with newIsolatedEngine(t) + config.WorkflowConfig pattern from engine_legacy_do_migration_test.go; fix package (workflow not workflow_test); fix Create() call to 4-arg signature; add PluginLoaded variant mirroring DO test Co-Authored-By: Claude Sonnet 4.6 --- ...2026-05-13-issue-653-phase1-aws-cutover.md | 114 ++++++++++++++---- 1 file changed, 88 insertions(+), 26 deletions(-) diff --git a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md index 6757c65a..aa971ac3 100644 --- a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md +++ b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md @@ -259,20 +259,31 @@ git commit -m "feat(#653): T1 — delete legacy AWS IaC module files + regressio **Files:** - Replace: `module/platform_dns_backends.go` (full rewrite — delete route53Backend, add awsRoute53ErrorBackend) - Modify: `module/platform_dns.go` (update init() — replace route53Backend with awsRoute53ErrorBackend) -- Modify: `module/platform_dns_test.go` (add test for provider: aws migration error) +- Modify: `module/platform_dns_test.go` (remove TestPlatformDNS_Route53_PlanReturnsStub and TestPlatformDNS_Route53_ApplyNotImplemented; add TestPlatformDNS_AWSBackendMigrationError) **Step 1: Write the failing test for the AWS provider migration error** -Add to `module/platform_dns_test.go`: +Add to `module/platform_dns_test.go`, and remove the two existing Route53 stub tests +(`TestPlatformDNS_Route53_PlanReturnsStub` and `TestPlatformDNS_Route53_ApplyNotImplemented`) +that test the backend being deleted. + +New test (note: `Init()` succeeds because the `awsRoute53ErrorBackend` factory returns a struct, +not an error; the migration error fires at operation time via `Plan()`): ```go func TestPlatformDNS_AWSBackendMigrationError(t *testing.T) { - cfg := map[string]any{"provider": "aws", "zone": map[string]any{"name": "example.com"}} - m := NewPlatformDNS("test-dns", cfg) - app := mock.NewMockApplication() - err := m.Init(app) + app := module.NewMockApplication() + m := module.NewPlatformDNS("test-dns", map[string]any{ + "provider": "aws", + "zone": map[string]any{"name": "example.com"}, + }) + if err := m.Init(app); err != nil { + t.Fatalf("Init should succeed (backend registered): %v", err) + } + // Migration error fires at operation time, not Init time. + _, err := m.Plan() if err == nil { - t.Fatal("expected migration error for provider: aws, got nil") + t.Fatal("expected migration error from Plan() for provider: aws, got nil") } errStr := err.Error() if !strings.Contains(errStr, "infra.dns") { @@ -285,7 +296,7 @@ func TestPlatformDNS_AWSBackendMigrationError(t *testing.T) { ``` Run: `cd /Users/jon/workspace/workflow && go test ./module/ -run TestPlatformDNS_AWSBackendMigrationError -v 2>&1` -Expected: compile error (awsRoute53ErrorBackend doesn't exist yet) or test FAIL (mock backend registered under "aws" currently would not return an error). +Expected: test FAIL — route53Backend.Plan() currently returns a stub (not a migration error). **Step 2: Replace module/platform_dns_backends.go** @@ -610,38 +621,86 @@ git commit -m "feat(#653): T3 — strip registration sites, add infra.autoscalin **Step 1: Write the failing tests first (TDD)** -Add to engine tests (create `engine_legacyaws_test.go`): +Create `engine_legacyaws_test.go`. Use package `workflow` (same as `engine_legacy_do_migration_test.go`) +and `newIsolatedEngine(t)` (defined in that file — visible at compile time since both are in package `workflow`). +Do NOT use `workflow_test` package or `minimalConfigWithModule()` (that helper does not exist). ```go -package workflow_test +package workflow import ( "strings" "testing" + + "github.com/GoCodeAlone/workflow/config" ) -func TestBuildFromConfig_LegacyAWSModuleError(t *testing.T) { - legacyTypes := []string{"platform.ecs", "platform.apigateway", "platform.autoscaling", "platform.networking"} - for _, typ := range legacyTypes { - t.Run(typ, func(t *testing.T) { - cfg := minimalConfigWithModule(typ, "my-module") // helper from engine_test.go - _, err := BuildFromConfig(cfg) - if err == nil { - t.Fatalf("expected migration error for %q, got nil", typ) +func TestLegacyAWSModuleError_PluginNotLoaded(t *testing.T) { + cases := []struct{ legacyType, hint string }{ + {"platform.ecs", "infra.container_service"}, + {"platform.apigateway", "infra.api_gateway"}, + {"platform.autoscaling", "infra.autoscaling_group"}, + {"platform.networking", "infra.vpc"}, + } + 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{}}, + }, } - errStr := err.Error() - if !strings.Contains(errStr, "v0.53.0") { - t.Errorf("error for %q missing version, got: %s", typ, errStr) + err := e.BuildFromConfig(cfg) + if err == nil { + t.Fatalf("expected error for legacy type %q", tc.legacyType) } - if !strings.Contains(errStr, "workflow-plugin-aws") { - t.Errorf("error for %q missing plugin hint, got: %s", typ, errStr) + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-aws", + "Install workflow-plugin-aws", + tc.hint, + } { + if !strings.Contains(msg, want) { + t.Errorf("error for %q missing %q; got: %s", tc.legacyType, want, msg) + } } }) } } + +func TestLegacyAWSModuleError_PluginLoaded(t *testing.T) { + e := newIsolatedEngine(t) + // Register stub iac.provider factory to simulate workflow-plugin-aws being loaded. + 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.ecs", 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-aws") { + t.Errorf("plugin-loaded branch must NOT instruct install; got: %s", msg) + } +} ``` -Add to pipeline step registry tests: +Note: `modular` must be imported — check engine_legacy_do_migration_test.go for the exact import path. +Mirror that file exactly (it imports `"github.com/GoCodeAlone/modular"` for `modular.Module` in `AddModuleType`). + +Add to pipeline step registry tests (in `module/pipeline_step_registry_test.go`): + +Add to pipeline step registry tests (package `module`, in `module/pipeline_step_registry_test.go`). +`Create()` signature: `(stepType, name string, config map[string]any, app any) (PipelineStep, error)`. + ```go func TestStepRegistry_LegacyAWSStepError(t *testing.T) { legacySteps := []string{ @@ -650,16 +709,19 @@ func TestStepRegistry_LegacyAWSStepError(t *testing.T) { "step.scaling_plan", "step.scaling_apply", "step.scaling_status", "step.scaling_destroy", "step.network_plan", "step.network_apply", "step.network_status", } - r := NewStepRegistry() // read actual constructor from pipeline_step_registry.go + r := NewStepRegistry() for _, st := range legacySteps { t.Run(st, func(t *testing.T) { - _, err := r.Create(st, nil, nil) + _, err := r.Create(st, "test", nil, nil) if err == nil { t.Fatalf("expected migration error for %q, got nil", st) } if !strings.Contains(err.Error(), "v0.53.0") { t.Errorf("error for %q missing version: %s", st, err) } + if !strings.Contains(err.Error(), "workflow-plugin-aws") { + t.Errorf("error for %q missing plugin hint: %s", st, err) + } }) } } From 57b8d4cf83e8025d979b5213457a68f8feb2d1a2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 08:05:54 -0400 Subject: [PATCH 08/18] fix(plan#653): fix T5 modernize test stub (adversarial cycle 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T5 test used nonexistent findRule/check/fix helpers and wrong package (modernize_test); actual pattern is package modernize, direct legacyAWSRule() call, yaml.Unmarshal + rule.Check/Fix — mirrors legacy_do_rule_test.go exactly - Remove duplicate "Add to pipeline step registry tests" paragraph - Fix yaml.Marshal round-trip in Fix test to match actual pattern Co-Authored-By: Claude Sonnet 4.6 --- ...2026-05-13-issue-653-phase1-aws-cutover.md | 66 ++++++++++++------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md index aa971ac3..d384902d 100644 --- a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md +++ b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md @@ -696,8 +696,6 @@ func TestLegacyAWSModuleError_PluginLoaded(t *testing.T) { Note: `modular` must be imported — check engine_legacy_do_migration_test.go for the exact import path. Mirror that file exactly (it imports `"github.com/GoCodeAlone/modular"` for `modular.Module` in `AddModuleType`). -Add to pipeline step registry tests (in `module/pipeline_step_registry_test.go`): - Add to pipeline step registry tests (package `module`, in `module/pipeline_step_registry_test.go`). `Create()` signature: `(stepType, name string, config map[string]any, app any) (PipelineStep, error)`. @@ -954,50 +952,56 @@ git commit -m "feat(#653): T4 — add internal/legacyaws + wire migration errors **Step 1: Write the failing tests for the modernize rule** -Create `modernize/legacy_aws_rule_test.go`: +Create `modernize/legacy_aws_rule_test.go`. Use package `modernize` (NOT `modernize_test`) and call +`legacyAWSRule()` directly — same pattern as `modernize/legacy_do_rule_test.go`, which does NOT have +`findRule`/`check`/`fix` helpers; it calls the rule constructor directly and uses `yaml.Unmarshal`. ```go -package modernize_test +package modernize import ( "strings" "testing" - "github.com/GoCodeAlone/workflow/modernize" + "gopkg.in/yaml.v3" ) func TestLegacyAWSRule_Check(t *testing.T) { - rule := findRule("legacy-aws-types") // read AllRules, find by ID + rule := legacyAWSRule() tests := []struct { name string - yaml string + yamlIn string wantMsg string fixable bool }{ { - name: "platform.ecs detected", - yaml: "modules:\n - name: my-ecs\n type: platform.ecs\n", + name: "platform.ecs detected", + yamlIn: "modules:\n - name: my-ecs\n type: platform.ecs\n", wantMsg: "infra.container_service", fixable: true, }, { - name: "platform.networking not auto-fixable", - yaml: "modules:\n - name: my-net\n type: platform.networking\n", + name: "platform.networking not auto-fixable", + yamlIn: "modules:\n - name: my-net\n type: platform.networking\n", wantMsg: "infra.vpc", fixable: false, }, { - name: "step.ecs_apply not auto-fixable", - yaml: "pipelines:\n deploy:\n steps:\n - type: step.ecs_apply\n", + name: "step.ecs_apply not auto-fixable", + yamlIn: "pipelines:\n deploy:\n steps:\n - type: step.ecs_apply\n", wantMsg: "step.iac_apply", fixable: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - findings := check(rule, tt.yaml) + var root yaml.Node + if err := yaml.Unmarshal([]byte(tt.yamlIn), &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } + findings := rule.Check(&root, []byte(tt.yamlIn)) if len(findings) == 0 { - t.Fatal("expected findings, got none") + t.Fatalf("expected findings, got 0") } if !strings.Contains(findings[0].Message, tt.wantMsg) { t.Errorf("message %q doesn't contain %q", findings[0].Message, tt.wantMsg) @@ -1010,27 +1014,41 @@ func TestLegacyAWSRule_Check(t *testing.T) { } func TestLegacyAWSRule_Fix(t *testing.T) { - rule := findRule("legacy-aws-types") + rule := legacyAWSRule() // Auto-fixable: platform.ecs → infra.container_service input := "modules:\n - name: my-ecs\n type: platform.ecs\n" - result, changes := fix(rule, input) + var root yaml.Node + if err := yaml.Unmarshal([]byte(input), &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } + changes := rule.Fix(&root) if len(changes) == 0 { t.Fatal("expected changes, got none") } - if !strings.Contains(result, "infra.container_service") { - t.Errorf("result %q doesn't contain infra.container_service", result) + out, err := yaml.Marshal(&root) + if err != nil { + t.Fatalf("marshal: %v", err) } - // platform.networking NOT auto-fixed + if !strings.Contains(string(out), "infra.container_service") { + t.Errorf("fixed YAML missing infra.container_service; got:\n%s", out) + } + if strings.Contains(string(out), "platform.ecs") { + t.Errorf("fixed YAML still has platform.ecs; got:\n%s", out) + } + + // platform.networking NOT auto-fixed (1→2 split) input2 := "modules:\n - name: my-net\n type: platform.networking\n" - _, changes2 := fix(rule, input2) + var root2 yaml.Node + if err := yaml.Unmarshal([]byte(input2), &root2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + changes2 := rule.Fix(&root2) if len(changes2) != 0 { - t.Error("platform.networking should not be auto-fixed") + t.Error("platform.networking should not be auto-fixed (1→2 split requires manual rewrite)") } } ``` -Read `modernize/legacy_do_rule_test.go` to find the `findRule`, `check`, `fix` helpers and mirror them. - Run: `go test ./modernize/ -run TestLegacyAWSRule -v 2>&1` Expected: compile error (rule doesn't exist yet). From 845612878edfc19517c4c2e101a9e4242ccce291 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 08:07:07 -0400 Subject: [PATCH 09/18] chore: lock scope for issue-653-aws-iac-cutover (alignment passed) --- docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md | 2 +- .../plans/2026-05-13-issue-653-phase1-aws-cutover.md.scope-lock | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md.scope-lock diff --git a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md index d384902d..f2badc4f 100644 --- a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md +++ b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md @@ -32,7 +32,7 @@ |------|-------|-------|--------| | 1 | feat(#653): remove legacy AWS IaC modules from workflow core | Task 1, Task 2, Task 3, Task 4, Task 5, Task 6 | feat/issue-653-aws-iac-cutover-v2 | -**Status:** Draft +**Status:** Locked 2026-05-13T00:00:00Z --- diff --git a/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md.scope-lock b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md.scope-lock new file mode 100644 index 00000000..1b52b911 --- /dev/null +++ b/docs/plans/2026-05-13-issue-653-phase1-aws-cutover.md.scope-lock @@ -0,0 +1 @@ +56c18e9e32e60e0122c7c482ef637cce11322af28aca3cddc0438c43cd92a4f4 From 5d837c589775f1c4f5e16b80e8ef0a60b9f8279e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 08:09:29 -0400 Subject: [PATCH 10/18] =?UTF-8?q?feat(#653):=20T1=20=E2=80=94=20delete=20l?= =?UTF-8?q?egacy=20AWS=20IaC=20module=20files=20+=20regression=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/api_gateway_test.go | 43 -- module/app_container.go | 93 +--- module/aws_absent_test.go | 50 ++ module/aws_api_gateway.go | 277 ---------- module/pipeline_step_apigateway.go | 179 ------- module/pipeline_step_autoscaling.go | 177 ------- module/pipeline_step_ecs.go | 177 ------- module/pipeline_step_networking.go | 140 ------ module/platform_apigateway.go | 519 ------------------- module/platform_apigateway_test.go | 335 ------------- module/platform_autoscaling.go | 485 ------------------ module/platform_autoscaling_test.go | 334 ------------- module/platform_aws_integration_test.go | 153 ------ module/platform_ecs.go | 571 --------------------- module/platform_ecs_test.go | 368 -------------- module/platform_networking.go | 638 ------------------------ module/platform_networking_test.go | 408 --------------- 17 files changed, 53 insertions(+), 4894 deletions(-) create mode 100644 module/aws_absent_test.go delete mode 100644 module/aws_api_gateway.go delete mode 100644 module/pipeline_step_apigateway.go delete mode 100644 module/pipeline_step_autoscaling.go delete mode 100644 module/pipeline_step_ecs.go delete mode 100644 module/pipeline_step_networking.go delete mode 100644 module/platform_apigateway.go delete mode 100644 module/platform_apigateway_test.go delete mode 100644 module/platform_autoscaling.go delete mode 100644 module/platform_autoscaling_test.go delete mode 100644 module/platform_aws_integration_test.go delete mode 100644 module/platform_ecs.go delete mode 100644 module/platform_ecs_test.go delete mode 100644 module/platform_networking.go delete mode 100644 module/platform_networking_test.go diff --git a/module/api_gateway_test.go b/module/api_gateway_test.go index 948f217f..b8ce72d7 100644 --- a/module/api_gateway_test.go +++ b/module/api_gateway_test.go @@ -457,49 +457,6 @@ func TestGatewayTimeout(t *testing.T) { } } -func TestAWSAPIGateway_Basic(t *testing.T) { - aws := NewAWSAPIGateway("aws-gw") - if aws.Name() != "aws-gw" { - t.Errorf("expected name %q, got %q", "aws-gw", aws.Name()) - } - - aws.SetConfig("us-east-1", "abc123", "prod") - if aws.Region() != "us-east-1" { - t.Errorf("expected region %q, got %q", "us-east-1", aws.Region()) - } - if aws.APIID() != "abc123" { - t.Errorf("expected api_id %q, got %q", "abc123", aws.APIID()) - } - if aws.Stage() != "prod" { - t.Errorf("expected stage %q, got %q", "prod", aws.Stage()) - } -} - -func TestAWSAPIGateway_SyncRoutesStub(t *testing.T) { - t.Skip("requires real AWS credentials and API Gateway") - aws := NewAWSAPIGateway("aws-gw") - aws.SetConfig("us-east-1", "abc123", "prod") - - err := aws.SyncRoutes([]GatewayRoute{ - {PathPrefix: "/api", Backend: "http://localhost:8080", Methods: []string{"GET"}}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestAWSAPIGateway_SyncRoutesRequiresAPIID(t *testing.T) { - aws := NewAWSAPIGateway("aws-gw") - // Don't set api_id - - err := aws.SyncRoutes([]GatewayRoute{ - {PathPrefix: "/api", Backend: "http://localhost:8080"}, - }) - if err == nil { - t.Fatal("expected error when api_id is empty") - } -} - func TestGatewayRateLimiter_BucketEviction(t *testing.T) { rl := newGatewayRateLimiter(60, 5) diff --git a/module/app_container.go b/module/app_container.go index ed2e2629..3744f5fb 100644 --- a/module/app_container.go +++ b/module/app_container.go @@ -11,7 +11,7 @@ import ( // AppContainerModule manages application containers on top of platform modules. // Config: // -// environment: name of a platform.kubernetes or platform.ecs module (service registry) +// environment: name of a platform.kubernetes module (service registry) // image: container image (required) // replicas: desired replica count (default: 1) // ports: list of container ports @@ -74,28 +74,6 @@ type K8sManifests struct { Ingress *K8sIngressManifest `json:"ingress,omitempty"` } -// ECSAppManifests holds the generated ECS task definition and service config. -type ECSAppManifests struct { - TaskDefinition ECSAppTaskDef `json:"taskDefinition"` - Service ECSAppServiceCfg `json:"service"` -} - -// ECSAppTaskDef represents an ECS task definition for an app container. -type ECSAppTaskDef struct { - Family string `json:"family"` - CPU string `json:"cpu"` - Memory string `json:"memory"` - Containers []ECSContainer `json:"containers"` -} - -// ECSAppServiceCfg represents ECS service configuration for an app container. -type ECSAppServiceCfg struct { - Name string `json:"name"` - TaskDefinition string `json:"taskDefinition"` - DesiredCount int `json:"desiredCount"` - LaunchType string `json:"launchType"` -} - // appContainerBackend is the interface implemented by platform-specific backends. type appContainerBackend interface { deploy(a *AppContainerModule) (*AppDeployResult, error) @@ -127,11 +105,8 @@ func (m *AppContainerModule) Init(app modular.Application) error { case *PlatformKubernetes: m.backend = &k8sAppBackend{} m.platformType = "kubernetes" - case *PlatformECS: - m.backend = &ecsAppBackend{} - m.platformType = "ecs" default: - return fmt.Errorf("app.container %q: environment %q is not a platform.kubernetes or platform.ecs module (got %T)", m.name, envName, svc) + return fmt.Errorf("app.container %q: environment %q is not a platform.kubernetes module (got %T); platform.ecs was removed — use infra.container_service with workflow-plugin-aws", m.name, envName, svc) } } else { // No environment configured: choose backend based on whether a kubeconfig is available. @@ -144,7 +119,7 @@ func (m *AppContainerModule) Init(app modular.Application) error { } else { m.logger.Warn("app.container: no environment configured and no kubeconfig found; defaulting to mock kubernetes backend", "module", m.name, - "hint", "set 'environment' to a platform.kubernetes or platform.ecs module, or ensure KUBECONFIG / ~/.kube/config is present") + "hint", "set 'environment' to a platform.kubernetes module, or ensure KUBECONFIG / ~/.kube/config is present") m.backend = &k8sAppBackend{} m.platformType = "kubernetes" } @@ -584,65 +559,3 @@ func buildK8sManifests(a *AppContainerModule) *K8sManifests { return manifests } - -// ─── ECS backend ────────────────────────────────────────────────────────────── - -// ecsAppBackend implements appContainerBackend for platform.ecs environments. -// Generates ECS task definition and service config as Go structs -// (no real ECS API calls). -type ecsAppBackend struct{} - -func (b *ecsAppBackend) deploy(a *AppContainerModule) (*AppDeployResult, error) { - return &AppDeployResult{ - Platform: a.platformType, - Name: a.name, - Status: "active", - Endpoint: fmt.Sprintf("http://%s.ecs.local:80", a.name), - Replicas: a.spec.Replicas, - Image: a.spec.Image, - }, nil -} - -func (b *ecsAppBackend) status(a *AppContainerModule) (*AppDeployResult, error) { - return a.current, nil -} - -func (b *ecsAppBackend) rollback(a *AppContainerModule, image string) (*AppDeployResult, error) { - if image == "" { - return nil, fmt.Errorf("rollback: image is required") - } - return &AppDeployResult{ - Platform: a.platformType, - Name: a.name, - Status: "rolled_back", - Endpoint: fmt.Sprintf("http://%s.ecs.local:80", a.name), - Replicas: a.spec.Replicas, - Image: image, - }, nil -} - -func (b *ecsAppBackend) manifests(a *AppContainerModule) (any, error) { - return buildECSManifests(a), nil -} - -func buildECSManifests(a *AppContainerModule) *ECSAppManifests { - port := 0 - if len(a.spec.Ports) > 0 { - port = a.spec.Ports[0] - } - - return &ECSAppManifests{ - TaskDefinition: ECSAppTaskDef{ - Family: a.name + "-task", - CPU: a.spec.CPU, - Memory: a.spec.Memory, - Containers: []ECSContainer{{Name: a.name, Image: a.spec.Image, Port: port}}, - }, - Service: ECSAppServiceCfg{ - Name: a.name, - TaskDefinition: a.name + "-task", - DesiredCount: a.spec.Replicas, - LaunchType: "FARGATE", - }, - } -} diff --git a/module/aws_absent_test.go b/module/aws_absent_test.go new file mode 100644 index 00000000..d954a3fc --- /dev/null +++ b/module/aws_absent_test.go @@ -0,0 +1,50 @@ +package module_test + +import ( + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" +) + +// TestAWSServicePackagesAbsent verifies that the freed AWS SDK service packages +// are not imported anywhere in the module/ directory (issue #653). +// Uses filepath.WalkDir (recursive) — NOT filepath.Glob — per #617 retro. +func TestAWSServicePackagesAbsent(t *testing.T) { + freed := []string{ + "aws-sdk-go-v2/service/apigatewayv2", + "aws-sdk-go-v2/service/applicationautoscaling", + "aws-sdk-go-v2/service/route53", + } + + fset := token.NewFileSet() + err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + if strings.HasSuffix(path, "aws_absent_test.go") { + return nil // skip self + } + f, parseErr := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if parseErr != nil { + return nil // skip unparseable files + } + for _, imp := range f.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + for _, pkg := range freed { + if strings.Contains(importPath, pkg) { + t.Errorf("%s imports freed package %q", path, importPath) + } + } + } + return nil + }) + if err != nil { + t.Fatalf("WalkDir: %v", err) + } +} diff --git a/module/aws_api_gateway.go b/module/aws_api_gateway.go deleted file mode 100644 index fc6c907a..00000000 --- a/module/aws_api_gateway.go +++ /dev/null @@ -1,277 +0,0 @@ -package module - -import ( - "context" - "fmt" - "log/slog" - "strings" - - "github.com/GoCodeAlone/modular" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/service/apigatewayv2" - apigwv2types "github.com/aws/aws-sdk-go-v2/service/apigatewayv2/types" -) - -// AWSAPIGateway is a module that syncs workflow HTTP routes to -// AWS API Gateway v2 (HTTP API) using aws-sdk-go-v2. -type AWSAPIGateway struct { - name string - region string - apiID string - stage string - provider CloudCredentialProvider - logger *slog.Logger -} - -// NewAWSAPIGateway creates a new AWS API Gateway sync module. -func NewAWSAPIGateway(name string) *AWSAPIGateway { - return &AWSAPIGateway{ - name: name, - logger: slog.Default(), - } -} - -// SetConfig configures the AWS API Gateway module. -func (a *AWSAPIGateway) SetConfig(region, apiID, stage string) { - a.region = region - a.apiID = apiID - a.stage = stage -} - -// SetProvider sets the cloud credential provider for AWS API calls. -func (a *AWSAPIGateway) SetProvider(p CloudCredentialProvider) { - a.provider = p -} - -// Name returns the module name. -func (a *AWSAPIGateway) Name() string { return a.name } - -// Init initializes the module. -func (a *AWSAPIGateway) Init(_ modular.Application) error { return nil } - -// Start logs that the module has started. -func (a *AWSAPIGateway) Start(_ context.Context) error { - a.logger.Info("AWS API Gateway sync module started", - "region", a.region, - "api_id", a.apiID, - "stage", a.stage, - ) - return nil -} - -// Stop is a no-op. -func (a *AWSAPIGateway) Stop(_ context.Context) error { return nil } - -// ProvidesServices returns the services provided by this module. -func (a *AWSAPIGateway) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - { - Name: a.name, - Description: "AWS API Gateway Sync", - Instance: a, - }, - } -} - -// RequiresServices returns no dependencies. -func (a *AWSAPIGateway) RequiresServices() []modular.ServiceDependency { return nil } - -// SyncRoutes syncs the given routes to AWS API Gateway v2. -// For each route it upserts an HTTP_PROXY integration and route in the HTTP API. -func (a *AWSAPIGateway) SyncRoutes(routes []GatewayRoute) error { - if a.apiID == "" { - return fmt.Errorf("aws_api_gateway %q: api_id is required", a.name) - } - - ctx := context.Background() - - // Build API Gateway client — prefer cloud account credentials, fall back to default chain. - var apiCfg aws.Config - var cfgErr error - - awsProv, hasAWS := awsProviderFrom(a.provider) - if hasAWS { - apiCfg, cfgErr = awsProv.AWSConfig(ctx) - } else { - var opts []func(*config.LoadOptions) error - if a.region != "" { - opts = append(opts, config.WithRegion(a.region)) - } - apiCfg, cfgErr = config.LoadDefaultConfig(ctx, opts...) - } - if cfgErr != nil { - return fmt.Errorf("aws_api_gateway %q: loading AWS config: %w", a.name, cfgErr) - } - - client := apigatewayv2.NewFromConfig(apiCfg) - - // Fetch existing integrations and routes to enable idempotent upserts. - existingIntegrations, err := a.listIntegrations(ctx, client) - if err != nil { - return fmt.Errorf("aws_api_gateway %q: listing integrations: %w", a.name, err) - } - existingRoutes, err := a.listRoutes(ctx, client) - if err != nil { - return fmt.Errorf("aws_api_gateway %q: listing routes: %w", a.name, err) - } - - for _, route := range routes { - integrationID, err := a.ensureIntegration(ctx, client, existingIntegrations, route) - if err != nil { - return fmt.Errorf("aws_api_gateway %q: ensuring integration for %q: %w", a.name, route.PathPrefix, err) - } - if err := a.upsertRoutes(ctx, client, existingRoutes, route, integrationID); err != nil { - return fmt.Errorf("aws_api_gateway %q: upserting route %q: %w", a.name, route.PathPrefix, err) - } - } - - return nil -} - -// listIntegrations fetches all integrations for the API, returning a map from -// integration URI to integration ID. -func (a *AWSAPIGateway) listIntegrations(ctx context.Context, client *apigatewayv2.Client) (map[string]string, error) { - result := make(map[string]string) - var nextToken *string - for { - out, err := client.GetIntegrations(ctx, &apigatewayv2.GetIntegrationsInput{ - ApiId: aws.String(a.apiID), - NextToken: nextToken, - }) - if err != nil { - return nil, fmt.Errorf("GetIntegrations: %w", err) - } - for i := range out.Items { - item := &out.Items[i] - if item.IntegrationUri != nil && item.IntegrationId != nil { - result[aws.ToString(item.IntegrationUri)] = aws.ToString(item.IntegrationId) - } - } - if out.NextToken == nil { - break - } - nextToken = out.NextToken - } - return result, nil -} - -// listRoutes fetches all routes for the API, returning a map from route key -// (e.g. "GET /foo") to route ID. -func (a *AWSAPIGateway) listRoutes(ctx context.Context, client *apigatewayv2.Client) (map[string]string, error) { - result := make(map[string]string) - var nextToken *string - for { - out, err := client.GetRoutes(ctx, &apigatewayv2.GetRoutesInput{ - ApiId: aws.String(a.apiID), - NextToken: nextToken, - }) - if err != nil { - return nil, fmt.Errorf("GetRoutes: %w", err) - } - for i := range out.Items { - item := &out.Items[i] - if item.RouteKey != nil && item.RouteId != nil { - result[aws.ToString(item.RouteKey)] = aws.ToString(item.RouteId) - } - } - if out.NextToken == nil { - break - } - nextToken = out.NextToken - } - return result, nil -} - -// ensureIntegration finds an existing HTTP_PROXY integration for the route's backend -// URI, or creates a new one. Returns the integration ID. -func (a *AWSAPIGateway) ensureIntegration( - ctx context.Context, - client *apigatewayv2.Client, - existing map[string]string, - route GatewayRoute, -) (string, error) { - integrationURI := route.Backend - if !strings.HasPrefix(integrationURI, "http://") && !strings.HasPrefix(integrationURI, "https://") { - integrationURI = "http://" + integrationURI - } - - if id, ok := existing[integrationURI]; ok { - return id, nil - } - - out, err := client.CreateIntegration(ctx, &apigatewayv2.CreateIntegrationInput{ - ApiId: aws.String(a.apiID), - IntegrationType: apigwv2types.IntegrationTypeHttpProxy, - IntegrationUri: aws.String(integrationURI), - IntegrationMethod: aws.String("ANY"), - PayloadFormatVersion: aws.String("1.0"), - }) - if err != nil { - return "", fmt.Errorf("CreateIntegration: %w", err) - } - id := aws.ToString(out.IntegrationId) - existing[integrationURI] = id - a.logger.Info("Created API Gateway integration", - "api_id", a.apiID, "uri", integrationURI, "integration_id", id) - return id, nil -} - -// upsertRoutes creates or updates routes in API Gateway for a workflow route. -// One route is created per HTTP method (or a single ANY route if none specified). -func (a *AWSAPIGateway) upsertRoutes( - ctx context.Context, - client *apigatewayv2.Client, - existing map[string]string, - route GatewayRoute, - integrationID string, -) error { - target := fmt.Sprintf("integrations/%s", integrationID) - path := route.PathPrefix - if path == "" { - path = "/" - } - - methods := route.Methods - if len(methods) == 0 { - methods = []string{"ANY"} - } - - for _, method := range methods { - routeKey := fmt.Sprintf("%s %s", strings.ToUpper(method), path) - - if existingID, ok := existing[routeKey]; ok { - if _, err := client.UpdateRoute(ctx, &apigatewayv2.UpdateRouteInput{ - ApiId: aws.String(a.apiID), - RouteId: aws.String(existingID), - Target: aws.String(target), - }); err != nil { - return fmt.Errorf("UpdateRoute %q: %w", routeKey, err) - } - a.logger.Info("Updated API Gateway route", "api_id", a.apiID, "route_key", routeKey) - } else { - out, err := client.CreateRoute(ctx, &apigatewayv2.CreateRouteInput{ - ApiId: aws.String(a.apiID), - RouteKey: aws.String(routeKey), - Target: aws.String(target), - }) - if err != nil { - return fmt.Errorf("CreateRoute %q: %w", routeKey, err) - } - existing[routeKey] = aws.ToString(out.RouteId) - a.logger.Info("Created API Gateway route", - "api_id", a.apiID, "route_key", routeKey, "route_id", aws.ToString(out.RouteId)) - } - } - - return nil -} - -// Region returns the configured AWS region. -func (a *AWSAPIGateway) Region() string { return a.region } - -// APIID returns the configured API ID. -func (a *AWSAPIGateway) APIID() string { return a.apiID } - -// Stage returns the configured deployment stage. -func (a *AWSAPIGateway) Stage() string { return a.stage } diff --git a/module/pipeline_step_apigateway.go b/module/pipeline_step_apigateway.go deleted file mode 100644 index fe7d1880..00000000 --- a/module/pipeline_step_apigateway.go +++ /dev/null @@ -1,179 +0,0 @@ -package module - -import ( - "context" - "fmt" - - "github.com/GoCodeAlone/modular" -) - -// ─── apigw_plan ─────────────────────────────────────────────────────────────── - -// ApigwPlanStep calls Plan() on a named platform.apigateway module. -type ApigwPlanStep struct { - name string - gateway string - app modular.Application -} - -// NewApigwPlanStepFactory returns a StepFactory for step.apigw_plan. -func NewApigwPlanStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - gateway, _ := cfg["gateway"].(string) - if gateway == "" { - return nil, fmt.Errorf("apigw_plan step %q: 'gateway' is required", name) - } - return &ApigwPlanStep{name: name, gateway: gateway, app: app}, nil - } -} - -func (s *ApigwPlanStep) Name() string { return s.name } - -func (s *ApigwPlanStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - gw, err := resolveAPIGatewayModule(s.app, s.gateway, s.name) - if err != nil { - return nil, err - } - plan, err := gw.Plan() - if err != nil { - return nil, fmt.Errorf("apigw_plan step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "plan": plan, - "gateway": s.gateway, - "name": plan.Name, - "stage": plan.Stage, - "changes": plan.Changes, - "routes": plan.Routes, - }}, nil -} - -// ─── apigw_apply ────────────────────────────────────────────────────────────── - -// ApigwApplyStep calls Apply() on a named platform.apigateway module. -type ApigwApplyStep struct { - name string - gateway string - app modular.Application -} - -// NewApigwApplyStepFactory returns a StepFactory for step.apigw_apply. -func NewApigwApplyStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - gateway, _ := cfg["gateway"].(string) - if gateway == "" { - return nil, fmt.Errorf("apigw_apply step %q: 'gateway' is required", name) - } - return &ApigwApplyStep{name: name, gateway: gateway, app: app}, nil - } -} - -func (s *ApigwApplyStep) Name() string { return s.name } - -func (s *ApigwApplyStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - gw, err := resolveAPIGatewayModule(s.app, s.gateway, s.name) - if err != nil { - return nil, err - } - state, err := gw.Apply() - if err != nil { - return nil, fmt.Errorf("apigw_apply step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "state": state, - "gateway": s.gateway, - "id": state.ID, - "endpoint": state.Endpoint, - "status": state.Status, - }}, nil -} - -// ─── apigw_status ───────────────────────────────────────────────────────────── - -// ApigwStatusStep calls Status() on a named platform.apigateway module. -type ApigwStatusStep struct { - name string - gateway string - app modular.Application -} - -// NewApigwStatusStepFactory returns a StepFactory for step.apigw_status. -func NewApigwStatusStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - gateway, _ := cfg["gateway"].(string) - if gateway == "" { - return nil, fmt.Errorf("apigw_status step %q: 'gateway' is required", name) - } - return &ApigwStatusStep{name: name, gateway: gateway, app: app}, nil - } -} - -func (s *ApigwStatusStep) Name() string { return s.name } - -func (s *ApigwStatusStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - gw, err := resolveAPIGatewayModule(s.app, s.gateway, s.name) - if err != nil { - return nil, err - } - st, err := gw.Status() - if err != nil { - return nil, fmt.Errorf("apigw_status step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "status": st, - "gateway": s.gateway, - }}, nil -} - -// ─── apigw_destroy ──────────────────────────────────────────────────────────── - -// ApigwDestroyStep calls Destroy() on a named platform.apigateway module. -type ApigwDestroyStep struct { - name string - gateway string - app modular.Application -} - -// NewApigwDestroyStepFactory returns a StepFactory for step.apigw_destroy. -func NewApigwDestroyStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - gateway, _ := cfg["gateway"].(string) - if gateway == "" { - return nil, fmt.Errorf("apigw_destroy step %q: 'gateway' is required", name) - } - return &ApigwDestroyStep{name: name, gateway: gateway, app: app}, nil - } -} - -func (s *ApigwDestroyStep) Name() string { return s.name } - -func (s *ApigwDestroyStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - gw, err := resolveAPIGatewayModule(s.app, s.gateway, s.name) - if err != nil { - return nil, err - } - if err := gw.Destroy(); err != nil { - return nil, fmt.Errorf("apigw_destroy step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "destroyed": true, - "gateway": s.gateway, - }}, nil -} - -// ─── helpers ───────────────────────────────────────────────────────────────── - -func resolveAPIGatewayModule(app modular.Application, gateway, stepName string) (*PlatformAPIGateway, error) { - if app == nil { - return nil, fmt.Errorf("step %q: no application context", stepName) - } - svc, ok := app.SvcRegistry()[gateway] - if !ok { - return nil, fmt.Errorf("step %q: gateway service %q not found in registry", stepName, gateway) - } - gw, ok := svc.(*PlatformAPIGateway) - if !ok { - return nil, fmt.Errorf("step %q: service %q is not a *PlatformAPIGateway (got %T)", stepName, gateway, svc) - } - return gw, nil -} diff --git a/module/pipeline_step_autoscaling.go b/module/pipeline_step_autoscaling.go deleted file mode 100644 index 4d748d20..00000000 --- a/module/pipeline_step_autoscaling.go +++ /dev/null @@ -1,177 +0,0 @@ -package module - -import ( - "context" - "fmt" - - "github.com/GoCodeAlone/modular" -) - -// ─── scaling_plan ───────────────────────────────────────────────────────────── - -// ScalingPlanStep calls Plan() on a named platform.autoscaling module. -type ScalingPlanStep struct { - name string - scaling string - app modular.Application -} - -// NewScalingPlanStepFactory returns a StepFactory for step.scaling_plan. -func NewScalingPlanStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - scaling, _ := cfg["scaling"].(string) - if scaling == "" { - return nil, fmt.Errorf("scaling_plan step %q: 'scaling' is required", name) - } - return &ScalingPlanStep{name: name, scaling: scaling, app: app}, nil - } -} - -func (s *ScalingPlanStep) Name() string { return s.name } - -func (s *ScalingPlanStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - as, err := resolveAutoscalingModule(s.app, s.scaling, s.name) - if err != nil { - return nil, err - } - plan, err := as.Plan() - if err != nil { - return nil, fmt.Errorf("scaling_plan step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "plan": plan, - "scaling": s.scaling, - "policies": plan.Policies, - "changes": plan.Changes, - }}, nil -} - -// ─── scaling_apply ──────────────────────────────────────────────────────────── - -// ScalingApplyStep calls Apply() on a named platform.autoscaling module. -type ScalingApplyStep struct { - name string - scaling string - app modular.Application -} - -// NewScalingApplyStepFactory returns a StepFactory for step.scaling_apply. -func NewScalingApplyStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - scaling, _ := cfg["scaling"].(string) - if scaling == "" { - return nil, fmt.Errorf("scaling_apply step %q: 'scaling' is required", name) - } - return &ScalingApplyStep{name: name, scaling: scaling, app: app}, nil - } -} - -func (s *ScalingApplyStep) Name() string { return s.name } - -func (s *ScalingApplyStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - as, err := resolveAutoscalingModule(s.app, s.scaling, s.name) - if err != nil { - return nil, err - } - state, err := as.Apply() - if err != nil { - return nil, fmt.Errorf("scaling_apply step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "state": state, - "scaling": s.scaling, - "id": state.ID, - "currentCapacity": state.CurrentCapacity, - "status": state.Status, - }}, nil -} - -// ─── scaling_status ─────────────────────────────────────────────────────────── - -// ScalingStatusStep calls Status() on a named platform.autoscaling module. -type ScalingStatusStep struct { - name string - scaling string - app modular.Application -} - -// NewScalingStatusStepFactory returns a StepFactory for step.scaling_status. -func NewScalingStatusStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - scaling, _ := cfg["scaling"].(string) - if scaling == "" { - return nil, fmt.Errorf("scaling_status step %q: 'scaling' is required", name) - } - return &ScalingStatusStep{name: name, scaling: scaling, app: app}, nil - } -} - -func (s *ScalingStatusStep) Name() string { return s.name } - -func (s *ScalingStatusStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - as, err := resolveAutoscalingModule(s.app, s.scaling, s.name) - if err != nil { - return nil, err - } - st, err := as.Status() - if err != nil { - return nil, fmt.Errorf("scaling_status step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "status": st, - "scaling": s.scaling, - }}, nil -} - -// ─── scaling_destroy ────────────────────────────────────────────────────────── - -// ScalingDestroyStep calls Destroy() on a named platform.autoscaling module. -type ScalingDestroyStep struct { - name string - scaling string - app modular.Application -} - -// NewScalingDestroyStepFactory returns a StepFactory for step.scaling_destroy. -func NewScalingDestroyStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - scaling, _ := cfg["scaling"].(string) - if scaling == "" { - return nil, fmt.Errorf("scaling_destroy step %q: 'scaling' is required", name) - } - return &ScalingDestroyStep{name: name, scaling: scaling, app: app}, nil - } -} - -func (s *ScalingDestroyStep) Name() string { return s.name } - -func (s *ScalingDestroyStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - as, err := resolveAutoscalingModule(s.app, s.scaling, s.name) - if err != nil { - return nil, err - } - if err := as.Destroy(); err != nil { - return nil, fmt.Errorf("scaling_destroy step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "destroyed": true, - "scaling": s.scaling, - }}, nil -} - -// ─── helpers ───────────────────────────────────────────────────────────────── - -func resolveAutoscalingModule(app modular.Application, scaling, stepName string) (*PlatformAutoscaling, error) { - if app == nil { - return nil, fmt.Errorf("step %q: no application context", stepName) - } - svc, ok := app.SvcRegistry()[scaling] - if !ok { - return nil, fmt.Errorf("step %q: scaling service %q not found in registry", stepName, scaling) - } - as, ok := svc.(*PlatformAutoscaling) - if !ok { - return nil, fmt.Errorf("step %q: service %q is not a *PlatformAutoscaling (got %T)", stepName, scaling, svc) - } - return as, nil -} diff --git a/module/pipeline_step_ecs.go b/module/pipeline_step_ecs.go deleted file mode 100644 index 8b30d7c9..00000000 --- a/module/pipeline_step_ecs.go +++ /dev/null @@ -1,177 +0,0 @@ -package module - -import ( - "context" - "fmt" - - "github.com/GoCodeAlone/modular" -) - -// ─── ecs_plan ───────────────────────────────────────────────────────────────── - -// ECSPlanStep calls Plan() on a named platform.ecs module. -type ECSPlanStep struct { - name string - service string - app modular.Application -} - -// NewECSPlanStepFactory returns a StepFactory for step.ecs_plan. -func NewECSPlanStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - service, _ := cfg["service"].(string) - if service == "" { - return nil, fmt.Errorf("ecs_plan step %q: 'service' is required", name) - } - return &ECSPlanStep{name: name, service: service, app: app}, nil - } -} - -func (s *ECSPlanStep) Name() string { return s.name } - -func (s *ECSPlanStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - e, err := resolveECSModule(s.app, s.service, s.name) - if err != nil { - return nil, err - } - plan, err := e.Plan() - if err != nil { - return nil, fmt.Errorf("ecs_plan step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "plan": plan, - "service": s.service, - "provider": plan.Provider, - "actions": plan.Actions, - }}, nil -} - -// ─── ecs_apply ──────────────────────────────────────────────────────────────── - -// ECSApplyStep calls Apply() on a named platform.ecs module. -type ECSApplyStep struct { - name string - service string - app modular.Application -} - -// NewECSApplyStepFactory returns a StepFactory for step.ecs_apply. -func NewECSApplyStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - service, _ := cfg["service"].(string) - if service == "" { - return nil, fmt.Errorf("ecs_apply step %q: 'service' is required", name) - } - return &ECSApplyStep{name: name, service: service, app: app}, nil - } -} - -func (s *ECSApplyStep) Name() string { return s.name } - -func (s *ECSApplyStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - e, err := resolveECSModule(s.app, s.service, s.name) - if err != nil { - return nil, err - } - result, err := e.Apply() - if err != nil { - return nil, fmt.Errorf("ecs_apply step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "result": result, - "service": s.service, - "success": result.Success, - "message": result.Message, - "state": result.State, - }}, nil -} - -// ─── ecs_status ─────────────────────────────────────────────────────────────── - -// ECSStatusStep calls Status() on a named platform.ecs module. -type ECSStatusStep struct { - name string - service string - app modular.Application -} - -// NewECSStatusStepFactory returns a StepFactory for step.ecs_status. -func NewECSStatusStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - service, _ := cfg["service"].(string) - if service == "" { - return nil, fmt.Errorf("ecs_status step %q: 'service' is required", name) - } - return &ECSStatusStep{name: name, service: service, app: app}, nil - } -} - -func (s *ECSStatusStep) Name() string { return s.name } - -func (s *ECSStatusStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - e, err := resolveECSModule(s.app, s.service, s.name) - if err != nil { - return nil, err - } - st, err := e.Status() - if err != nil { - return nil, fmt.Errorf("ecs_status step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "status": st, - "service": s.service, - }}, nil -} - -// ─── ecs_destroy ────────────────────────────────────────────────────────────── - -// ECSDestroyStep calls Destroy() on a named platform.ecs module. -type ECSDestroyStep struct { - name string - service string - app modular.Application -} - -// NewECSDestroyStepFactory returns a StepFactory for step.ecs_destroy. -func NewECSDestroyStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - service, _ := cfg["service"].(string) - if service == "" { - return nil, fmt.Errorf("ecs_destroy step %q: 'service' is required", name) - } - return &ECSDestroyStep{name: name, service: service, app: app}, nil - } -} - -func (s *ECSDestroyStep) Name() string { return s.name } - -func (s *ECSDestroyStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - e, err := resolveECSModule(s.app, s.service, s.name) - if err != nil { - return nil, err - } - if err := e.Destroy(); err != nil { - return nil, fmt.Errorf("ecs_destroy step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "destroyed": true, - "service": s.service, - }}, nil -} - -// ─── helpers ────────────────────────────────────────────────────────────────── - -func resolveECSModule(app modular.Application, service, stepName string) (*PlatformECS, error) { - if app == nil { - return nil, fmt.Errorf("step %q: no application context", stepName) - } - svc, ok := app.SvcRegistry()[service] - if !ok { - return nil, fmt.Errorf("step %q: service %q not found in registry", stepName, service) - } - e, ok := svc.(*PlatformECS) - if !ok { - return nil, fmt.Errorf("step %q: service %q is not a *PlatformECS (got %T)", stepName, service, svc) - } - return e, nil -} diff --git a/module/pipeline_step_networking.go b/module/pipeline_step_networking.go deleted file mode 100644 index 9e926aec..00000000 --- a/module/pipeline_step_networking.go +++ /dev/null @@ -1,140 +0,0 @@ -package module - -import ( - "context" - "fmt" - - "github.com/GoCodeAlone/modular" -) - -// ─── network_plan ───────────────────────────────────────────────────────────── - -// NetworkPlanStep calls Plan() on a named platform.networking module. -type NetworkPlanStep struct { - name string - network string - app modular.Application -} - -// NewNetworkPlanStepFactory returns a StepFactory for step.network_plan. -func NewNetworkPlanStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - network, _ := cfg["network"].(string) - if network == "" { - return nil, fmt.Errorf("network_plan step %q: 'network' is required", name) - } - return &NetworkPlanStep{name: name, network: network, app: app}, nil - } -} - -func (s *NetworkPlanStep) Name() string { return s.name } - -func (s *NetworkPlanStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - m, err := resolveNetworkModule(s.app, s.network, s.name) - if err != nil { - return nil, err - } - plan, err := m.Plan() - if err != nil { - return nil, fmt.Errorf("network_plan step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "plan": plan, - "network": s.network, - "changes": plan.Changes, - "vpc": plan.VPC, - }}, nil -} - -// ─── network_apply ──────────────────────────────────────────────────────────── - -// NetworkApplyStep calls Apply() on a named platform.networking module. -type NetworkApplyStep struct { - name string - network string - app modular.Application -} - -// NewNetworkApplyStepFactory returns a StepFactory for step.network_apply. -func NewNetworkApplyStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - network, _ := cfg["network"].(string) - if network == "" { - return nil, fmt.Errorf("network_apply step %q: 'network' is required", name) - } - return &NetworkApplyStep{name: name, network: network, app: app}, nil - } -} - -func (s *NetworkApplyStep) Name() string { return s.name } - -func (s *NetworkApplyStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - m, err := resolveNetworkModule(s.app, s.network, s.name) - if err != nil { - return nil, err - } - state, err := m.Apply() - if err != nil { - return nil, fmt.Errorf("network_apply step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "state": state, - "network": s.network, - "vpcId": state.VPCID, - "status": state.Status, - }}, nil -} - -// ─── network_status ─────────────────────────────────────────────────────────── - -// NetworkStatusStep calls Status() on a named platform.networking module. -type NetworkStatusStep struct { - name string - network string - app modular.Application -} - -// NewNetworkStatusStepFactory returns a StepFactory for step.network_status. -func NewNetworkStatusStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - network, _ := cfg["network"].(string) - if network == "" { - return nil, fmt.Errorf("network_status step %q: 'network' is required", name) - } - return &NetworkStatusStep{name: name, network: network, app: app}, nil - } -} - -func (s *NetworkStatusStep) Name() string { return s.name } - -func (s *NetworkStatusStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - m, err := resolveNetworkModule(s.app, s.network, s.name) - if err != nil { - return nil, err - } - st, err := m.Status() - if err != nil { - return nil, fmt.Errorf("network_status step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "status": st, - "network": s.network, - }}, nil -} - -// ─── helpers ────────────────────────────────────────────────────────────────── - -func resolveNetworkModule(app modular.Application, network, stepName string) (*PlatformNetworking, error) { - if app == nil { - return nil, fmt.Errorf("step %q: no application context", stepName) - } - svc, ok := app.SvcRegistry()[network] - if !ok { - return nil, fmt.Errorf("step %q: network service %q not found in registry", stepName, network) - } - m, ok := svc.(*PlatformNetworking) - if !ok { - return nil, fmt.Errorf("step %q: service %q is not a *PlatformNetworking (got %T)", stepName, network, svc) - } - return m, nil -} diff --git a/module/platform_apigateway.go b/module/platform_apigateway.go deleted file mode 100644 index e35bf689..00000000 --- a/module/platform_apigateway.go +++ /dev/null @@ -1,519 +0,0 @@ -package module - -import ( - "context" - "fmt" - "strings" - - "github.com/GoCodeAlone/modular" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/apigatewayv2" - apigwtypes "github.com/aws/aws-sdk-go-v2/service/apigatewayv2/types" -) - -// PlatformGatewayCORSConfig holds CORS settings for a provisioned API gateway. -type PlatformGatewayCORSConfig struct { - AllowOrigins []string `json:"allowOrigins"` - AllowMethods []string `json:"allowMethods"` - AllowHeaders []string `json:"allowHeaders"` -} - -// PlatformGatewayRoute describes a single route managed by the API gateway provisioner. -type PlatformGatewayRoute struct { - Path string `json:"path"` - Method string `json:"method"` - Target string `json:"target"` - RateLimit int `json:"rateLimit"` - AuthType string `json:"authType"` // none, api_key, jwt -} - -// PlatformGatewayPlan describes the changes needed to reach desired gateway state. -type PlatformGatewayPlan struct { - Name string `json:"name"` - Stage string `json:"stage"` - Routes []PlatformGatewayRoute `json:"routes"` - CORS *PlatformGatewayCORSConfig `json:"cors,omitempty"` - Changes []string `json:"changes"` -} - -// PlatformGatewayState represents the current state of a provisioned API gateway. -type PlatformGatewayState struct { - ID string `json:"id"` - Name string `json:"name"` - Endpoint string `json:"endpoint"` - Stage string `json:"stage"` - Routes []PlatformGatewayRoute `json:"routes"` - CORS *PlatformGatewayCORSConfig `json:"cors,omitempty"` - Status string `json:"status"` // pending, active, updating, deleted -} - -// apigatewayBackend is the internal interface for gateway provisioning backends. -type apigatewayBackend interface { - plan(m *PlatformAPIGateway) (*PlatformGatewayPlan, error) - apply(m *PlatformAPIGateway) (*PlatformGatewayState, error) - status(m *PlatformAPIGateway) (*PlatformGatewayState, error) - destroy(m *PlatformAPIGateway) error -} - -// APIGatewayBackendFactory creates an apigatewayBackend for a given provider config. -type APIGatewayBackendFactory func(cfg map[string]any) (apigatewayBackend, error) - -// apigatewayBackendRegistry maps provider name to its factory. -var apigatewayBackendRegistry = map[string]APIGatewayBackendFactory{} - -// RegisterAPIGatewayBackend registers an APIGatewayBackendFactory for the given provider name. -func RegisterAPIGatewayBackend(provider string, factory APIGatewayBackendFactory) { - apigatewayBackendRegistry[provider] = factory -} - -func init() { - RegisterAPIGatewayBackend("mock", func(_ map[string]any) (apigatewayBackend, error) { - return &mockAPIGatewayBackend{}, nil - }) - RegisterAPIGatewayBackend("aws", func(_ map[string]any) (apigatewayBackend, error) { - return &awsAPIGatewayBackend{}, nil - }) -} - -// PlatformAPIGateway manages API gateway provisioning via pluggable backends. -// Config: -// -// account: name of a cloud.account module (optional for mock) -// provider: mock | aws -// name: gateway name -// stage: deployment stage (dev, staging, prod) -// cors: CORS configuration -// routes: list of route definitions -type PlatformAPIGateway struct { - name string - config map[string]any - account string - provider CloudCredentialProvider - state *PlatformGatewayState - backend apigatewayBackend -} - -// NewPlatformAPIGateway creates a new PlatformAPIGateway module. -func NewPlatformAPIGateway(name string, cfg map[string]any) *PlatformAPIGateway { - return &PlatformAPIGateway{name: name, config: cfg} -} - -// Name returns the module name. -func (m *PlatformAPIGateway) Name() string { return m.name } - -// Init resolves the cloud.account service and initialises the backend. -func (m *PlatformAPIGateway) Init(app modular.Application) error { - m.account, _ = m.config["account"].(string) - if m.account != "" { - svc, ok := app.SvcRegistry()[m.account] - if !ok { - return fmt.Errorf("platform.apigateway %q: account service %q not found", m.name, m.account) - } - if prov, ok := svc.(CloudCredentialProvider); ok { - m.provider = prov - } - } - - provider, _ := m.config["provider"].(string) - if provider == "" { - provider = "mock" - } - - factory, ok := apigatewayBackendRegistry[provider] - if !ok { - return fmt.Errorf("platform.apigateway %q: unsupported provider %q", m.name, provider) - } - backend, err := factory(m.config) - if err != nil { - return fmt.Errorf("platform.apigateway %q: creating backend: %w", m.name, err) - } - m.backend = backend - - gwName, _ := m.config["name"].(string) - if gwName == "" { - gwName = m.name - } - stage, _ := m.config["stage"].(string) - if stage == "" { - stage = "dev" - } - - m.state = &PlatformGatewayState{ - Name: gwName, - Stage: stage, - Status: "pending", - } - - return app.RegisterService(m.name, m) -} - -// ProvidesServices declares the service this module provides. -func (m *PlatformAPIGateway) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "API Gateway: " + m.name, Instance: m}, - } -} - -// RequiresServices returns nil — cloud.account is resolved by name. -func (m *PlatformAPIGateway) RequiresServices() []modular.ServiceDependency { - return nil -} - -// Plan returns the proposed changes. -func (m *PlatformAPIGateway) Plan() (*PlatformGatewayPlan, error) { - return m.backend.plan(m) -} - -// Apply provisions or updates the gateway. -func (m *PlatformAPIGateway) Apply() (*PlatformGatewayState, error) { - return m.backend.apply(m) -} - -// Status returns the current gateway state. -func (m *PlatformAPIGateway) Status() (any, error) { - return m.backend.status(m) -} - -// Destroy tears down the gateway. -func (m *PlatformAPIGateway) Destroy() error { - return m.backend.destroy(m) -} - -// gatewayName returns the configured gateway name, falling back to the module name. -func (m *PlatformAPIGateway) gatewayName() string { - if n, ok := m.config["name"].(string); ok && n != "" { - return n - } - return m.name -} - -// platformRoutes parses routes from config. -func (m *PlatformAPIGateway) platformRoutes() []PlatformGatewayRoute { - raw, ok := m.config["routes"].([]any) - if !ok { - return nil - } - var routes []PlatformGatewayRoute - for _, item := range raw { - r, ok := item.(map[string]any) - if !ok { - continue - } - path, _ := r["path"].(string) - method, _ := r["method"].(string) - target, _ := r["target"].(string) - authType, _ := r["auth_type"].(string) - rateLimit, _ := intFromAny(r["rate_limit"]) - routes = append(routes, PlatformGatewayRoute{ - Path: path, - Method: method, - Target: target, - RateLimit: rateLimit, - AuthType: authType, - }) - } - return routes -} - -// platformCORS parses CORS config. -func (m *PlatformAPIGateway) platformCORS() *PlatformGatewayCORSConfig { - raw, ok := m.config["cors"].(map[string]any) - if !ok { - return nil - } - cfg := &PlatformGatewayCORSConfig{} - if origins, ok := raw["allow_origins"].([]any); ok { - for _, o := range origins { - if s, ok := o.(string); ok { - cfg.AllowOrigins = append(cfg.AllowOrigins, s) - } - } - } - if methods, ok := raw["allow_methods"].([]any); ok { - for _, me := range methods { - if s, ok := me.(string); ok { - cfg.AllowMethods = append(cfg.AllowMethods, s) - } - } - } - if headers, ok := raw["allow_headers"].([]any); ok { - for _, h := range headers { - if s, ok := h.(string); ok { - cfg.AllowHeaders = append(cfg.AllowHeaders, s) - } - } - } - return cfg -} - -// ─── Mock backend ───────────────────────────────────────────────────────────── - -// mockAPIGatewayBackend implements apigatewayBackend with in-memory state. -type mockAPIGatewayBackend struct{} - -func (b *mockAPIGatewayBackend) plan(m *PlatformAPIGateway) (*PlatformGatewayPlan, error) { - routes := m.platformRoutes() - plan := &PlatformGatewayPlan{ - Name: m.gatewayName(), - Stage: m.state.Stage, - Routes: routes, - CORS: m.platformCORS(), - } - - switch m.state.Status { - case "pending", "deleted": - plan.Changes = []string{ - fmt.Sprintf("create gateway %q in stage %q with %d route(s)", m.gatewayName(), m.state.Stage, len(routes)), - } - case "active": - plan.Changes = []string{"gateway already active, no changes"} - default: - plan.Changes = []string{fmt.Sprintf("gateway status=%s, no action", m.state.Status)} - } - - return plan, nil -} - -func (b *mockAPIGatewayBackend) apply(m *PlatformAPIGateway) (*PlatformGatewayState, error) { - if m.state.Status == "active" { - return m.state, nil - } - - routes := m.platformRoutes() - m.state.ID = fmt.Sprintf("mock-gw-%s", strings.ReplaceAll(m.gatewayName(), " ", "-")) - m.state.Routes = routes - m.state.CORS = m.platformCORS() - m.state.Endpoint = fmt.Sprintf("https://mock.execute-api.example.com/%s", m.state.Stage) - m.state.Status = "active" - - return m.state, nil -} - -func (b *mockAPIGatewayBackend) status(m *PlatformAPIGateway) (*PlatformGatewayState, error) { - return m.state, nil -} - -func (b *mockAPIGatewayBackend) destroy(m *PlatformAPIGateway) error { - if m.state.Status == "deleted" { - return nil - } - m.state.Status = "deleted" - m.state.Endpoint = "" - m.state.ID = "" - return nil -} - -// ─── AWS APIGateway v2 backend ──────────────────────────────────────────────── - -// awsAPIGatewayBackend manages AWS API Gateway v2 (HTTP APIs) using -// aws-sdk-go-v2/service/apigatewayv2. -type awsAPIGatewayBackend struct{} - -func (b *awsAPIGatewayBackend) plan(m *PlatformAPIGateway) (*PlatformGatewayPlan, error) { - routes := m.platformRoutes() - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return &PlatformGatewayPlan{ - Name: m.gatewayName(), - Stage: m.state.Stage, - Routes: routes, - Changes: []string{fmt.Sprintf("create API Gateway %q with %d route(s)", m.gatewayName(), len(routes))}, - }, nil - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return nil, fmt.Errorf("apigateway plan: AWS config: %w", err) - } - client := apigatewayv2.NewFromConfig(cfg) - - // Check if API already exists by name - listOut, err := client.GetApis(context.Background(), &apigatewayv2.GetApisInput{}) - if err != nil { - return nil, fmt.Errorf("apigateway plan: GetApis: %w", err) - } - - for i := range listOut.Items { - if listOut.Items[i].Name != nil && *listOut.Items[i].Name == m.gatewayName() { - return &PlatformGatewayPlan{ - Name: m.gatewayName(), - Stage: m.state.Stage, - Routes: routes, - Changes: []string{fmt.Sprintf("noop: API Gateway %q already exists", m.gatewayName())}, - }, nil - } - } - - return &PlatformGatewayPlan{ - Name: m.gatewayName(), - Stage: m.state.Stage, - Routes: routes, - CORS: m.platformCORS(), - Changes: []string{fmt.Sprintf("create API Gateway %q with %d route(s) in stage %q", m.gatewayName(), len(routes), m.state.Stage)}, - }, nil -} - -func (b *awsAPIGatewayBackend) apply(m *PlatformAPIGateway) (*PlatformGatewayState, error) { - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return nil, fmt.Errorf("apigateway apply: no AWS cloud account configured") - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return nil, fmt.Errorf("apigateway apply: AWS config: %w", err) - } - client := apigatewayv2.NewFromConfig(cfg) - - // Check if API already exists - apiID := m.state.ID - if apiID == "" { - listOut, _ := client.GetApis(context.Background(), &apigatewayv2.GetApisInput{}) - if listOut != nil { - for i := range listOut.Items { - if listOut.Items[i].Name != nil && *listOut.Items[i].Name == m.gatewayName() && listOut.Items[i].ApiId != nil { - apiID = *listOut.Items[i].ApiId - break - } - } - } - } - - if apiID == "" { - createInput := &apigatewayv2.CreateApiInput{ - Name: aws.String(m.gatewayName()), - ProtocolType: apigwtypes.ProtocolTypeHttp, - } - if cors := m.platformCORS(); cors != nil { - createInput.CorsConfiguration = &apigwtypes.Cors{ - AllowOrigins: cors.AllowOrigins, - AllowMethods: cors.AllowMethods, - AllowHeaders: cors.AllowHeaders, - } - } - apiOut, err := client.CreateApi(context.Background(), createInput) - if err != nil { - return nil, fmt.Errorf("apigateway apply: CreateApi: %w", err) - } - if apiOut.ApiId != nil { - apiID = *apiOut.ApiId - } - if apiOut.ApiEndpoint != nil { - m.state.Endpoint = *apiOut.ApiEndpoint - } - } - m.state.ID = apiID - - // Create stage - if _, stageErr := client.CreateStage(context.Background(), &apigatewayv2.CreateStageInput{ - ApiId: aws.String(apiID), - StageName: aws.String(m.state.Stage), - AutoDeploy: aws.Bool(true), - }); stageErr != nil { - return nil, fmt.Errorf("apigateway apply: CreateStage: %w", stageErr) - } - - // Create routes and integrations - routes := m.platformRoutes() - var routeErrs []string - for _, route := range routes { - // Create integration for the route target - integOut, err := client.CreateIntegration(context.Background(), &apigatewayv2.CreateIntegrationInput{ - ApiId: aws.String(apiID), - IntegrationType: apigwtypes.IntegrationTypeHttpProxy, - IntegrationUri: aws.String(route.Target), - IntegrationMethod: aws.String(route.Method), - PayloadFormatVersion: aws.String("1.0"), - }) - if err != nil { - routeErrs = append(routeErrs, fmt.Sprintf("CreateIntegration %s %s: %v", route.Method, route.Path, err)) - continue - } - - integID := "" - if integOut.IntegrationId != nil { - integID = *integOut.IntegrationId - } - - routeKey := fmt.Sprintf("%s %s", strings.ToUpper(route.Method), route.Path) - if _, err := client.CreateRoute(context.Background(), &apigatewayv2.CreateRouteInput{ - ApiId: aws.String(apiID), - RouteKey: aws.String(routeKey), - Target: optString(fmt.Sprintf("integrations/%s", integID)), - }); err != nil { - routeErrs = append(routeErrs, fmt.Sprintf("CreateRoute %s: %v", routeKey, err)) - } - } - if len(routeErrs) > 0 { - return nil, fmt.Errorf("apigateway apply: route errors: %s", strings.Join(routeErrs, "; ")) - } - - m.state.Routes = routes - m.state.CORS = m.platformCORS() - m.state.Status = "active" - if m.state.Endpoint == "" { - m.state.Endpoint = fmt.Sprintf("https://%s.execute-api.amazonaws.com/%s", apiID, m.state.Stage) - } - - return m.state, nil -} - -func (b *awsAPIGatewayBackend) status(m *PlatformAPIGateway) (*PlatformGatewayState, error) { - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return m.state, nil - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return m.state, fmt.Errorf("apigateway status: AWS config: %w", err) - } - client := apigatewayv2.NewFromConfig(cfg) - - if m.state.ID == "" { - m.state.Status = "not-found" - return m.state, nil - } - - out, getErr := client.GetApi(context.Background(), &apigatewayv2.GetApiInput{ - ApiId: aws.String(m.state.ID), - }) - if getErr == nil { - if out.ApiEndpoint != nil { - m.state.Endpoint = *out.ApiEndpoint - } - m.state.Status = "active" - } else { - m.state.Status = "not-found" - } - return m.state, nil -} - -func (b *awsAPIGatewayBackend) destroy(m *PlatformAPIGateway) error { - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return fmt.Errorf("apigateway destroy: no AWS cloud account configured") - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return fmt.Errorf("apigateway destroy: AWS config: %w", err) - } - client := apigatewayv2.NewFromConfig(cfg) - - if m.state.ID == "" { - return nil - } - - _, err = client.DeleteApi(context.Background(), &apigatewayv2.DeleteApiInput{ - ApiId: aws.String(m.state.ID), - }) - if err != nil { - return fmt.Errorf("apigateway destroy: DeleteApi: %w", err) - } - - m.state.Status = "deleted" - m.state.ID = "" - m.state.Endpoint = "" - return nil -} diff --git a/module/platform_apigateway_test.go b/module/platform_apigateway_test.go deleted file mode 100644 index 8c30782c..00000000 --- a/module/platform_apigateway_test.go +++ /dev/null @@ -1,335 +0,0 @@ -package module_test - -import ( - "context" - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -func newAPIGatewayConfig() map[string]any { - return map[string]any{ - "provider": "mock", - "name": "test-api", - "stage": "prod", - "cors": map[string]any{ - "allow_origins": []any{"*"}, - "allow_methods": []any{"GET", "POST"}, - }, - "routes": []any{ - map[string]any{ - "path": "/api/v1/users", - "method": "*", - "target": "http://users-svc:8080", - "rate_limit": 100, - }, - map[string]any{ - "path": "/api/v1/orders", - "method": "GET", - "target": "http://orders-svc:8080", - "auth_type": "jwt", - }, - }, - } -} - -// TestPlatformAPIGateway_MockLifecycle tests the full plan→apply→status→destroy lifecycle. -func TestPlatformAPIGateway_MockLifecycle(t *testing.T) { - gw := module.NewPlatformAPIGateway("my-gateway", newAPIGatewayConfig()) - app := module.NewMockApplication() - if err := gw.Init(app); err != nil { - t.Fatalf("Init failed: %v", err) - } - - // Plan — fresh gateway should propose create. - plan, err := gw.Plan() - if err != nil { - t.Fatalf("Plan failed: %v", err) - } - if len(plan.Changes) == 0 { - t.Fatal("expected at least one change in plan") - } - if plan.Name != "test-api" { - t.Errorf("expected plan.Name=test-api, got %q", plan.Name) - } - if plan.Stage != "prod" { - t.Errorf("expected plan.Stage=prod, got %q", plan.Stage) - } - - // Apply — should create the gateway in-memory. - state, err := gw.Apply() - if err != nil { - t.Fatalf("Apply failed: %v", err) - } - if state.Status != "active" { - t.Errorf("expected status=active, got %q", state.Status) - } - if state.Endpoint == "" { - t.Error("expected non-empty endpoint after apply") - } - if state.ID == "" { - t.Error("expected non-empty ID after apply") - } - - // Status — should show active. - st, err := gw.Status() - if err != nil { - t.Fatalf("Status failed: %v", err) - } - gwState, ok := st.(*module.PlatformGatewayState) - if !ok { - t.Fatalf("Status returned unexpected type %T", st) - } - if gwState.Status != "active" { - t.Errorf("expected status=active, got %q", gwState.Status) - } - - // Plan after apply — should show no changes needed. - plan2, err := gw.Plan() - if err != nil { - t.Fatalf("second Plan failed: %v", err) - } - if len(plan2.Changes) == 0 { - t.Fatal("expected at least one change entry after apply") - } - - // Destroy. - if err := gw.Destroy(); err != nil { - t.Fatalf("Destroy failed: %v", err) - } - st2, err := gw.Status() - if err != nil { - t.Fatalf("Status after destroy failed: %v", err) - } - gwState2 := st2.(*module.PlatformGatewayState) - if gwState2.Status != "deleted" { - t.Errorf("expected status=deleted after destroy, got %q", gwState2.Status) - } - if gwState2.Endpoint != "" { - t.Errorf("expected empty endpoint after destroy, got %q", gwState2.Endpoint) - } -} - -// TestPlatformAPIGateway_RoutesAndCORS verifies routes and CORS are reflected in the plan. -func TestPlatformAPIGateway_RoutesAndCORS(t *testing.T) { - gw := module.NewPlatformAPIGateway("cors-gw", newAPIGatewayConfig()) - app := module.NewMockApplication() - if err := gw.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - - plan, err := gw.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) - } - if len(plan.Routes) != 2 { - t.Errorf("expected 2 routes, got %d", len(plan.Routes)) - } - if plan.CORS == nil { - t.Fatal("expected non-nil CORS config in plan") - } - if len(plan.CORS.AllowOrigins) == 0 { - t.Error("expected at least one allow_origin") - } -} - -// TestPlatformAPIGateway_ApplyIdempotent verifies double-apply is safe. -func TestPlatformAPIGateway_ApplyIdempotent(t *testing.T) { - gw := module.NewPlatformAPIGateway("idem-gw", newAPIGatewayConfig()) - app := module.NewMockApplication() - if err := gw.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - if _, err := gw.Apply(); err != nil { - t.Fatalf("first Apply: %v", err) - } - state, err := gw.Apply() - if err != nil { - t.Fatalf("second Apply: %v", err) - } - if state.Status != "active" { - t.Errorf("expected active after second apply, got %q", state.Status) - } -} - -// TestPlatformAPIGateway_InvalidProvider verifies Init rejects unknown providers. -func TestPlatformAPIGateway_InvalidProvider(t *testing.T) { - gw := module.NewPlatformAPIGateway("bad-gw", map[string]any{"provider": "gcp"}) - app := module.NewMockApplication() - if err := gw.Init(app); err == nil { - t.Error("expected error for unsupported provider, got nil") - } -} - -// TestPlatformAPIGateway_InvalidAccount verifies Init fails when account is missing. -func TestPlatformAPIGateway_InvalidAccount(t *testing.T) { - gw := module.NewPlatformAPIGateway("no-acc-gw", map[string]any{ - "provider": "mock", - "account": "nonexistent-account", - }) - app := module.NewMockApplication() - if err := gw.Init(app); err == nil { - t.Error("expected error for nonexistent account, got nil") - } -} - -// TestPlatformAPIGateway_AWSStubPlan verifies the AWS stub returns a plan. -func TestPlatformAPIGateway_AWSStubPlan(t *testing.T) { - gw := module.NewPlatformAPIGateway("aws-gw", map[string]any{ - "provider": "aws", - "name": "aws-api", - "stage": "prod", - }) - app := module.NewMockApplication() - if err := gw.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - plan, err := gw.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) - } - if len(plan.Changes) == 0 { - t.Fatal("expected at least one change from AWS stub") - } -} - -// TestPlatformAPIGateway_AWSApplyNotImplemented verifies AWS Apply returns an error. -func TestPlatformAPIGateway_AWSApplyNotImplemented(t *testing.T) { - gw := module.NewPlatformAPIGateway("aws-gw2", map[string]any{"provider": "aws"}) - app := module.NewMockApplication() - if err := gw.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - _, err := gw.Apply() - if err == nil { - t.Error("expected error from AWS Apply stub, got nil") - } -} - -// TestPlatformAPIGateway_ServiceRegistration verifies the module registers itself. -func TestPlatformAPIGateway_ServiceRegistration(t *testing.T) { - gw := module.NewPlatformAPIGateway("reg-gw", map[string]any{"provider": "mock"}) - app := module.NewMockApplication() - if err := gw.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - svc, ok := app.Services["reg-gw"] - if !ok { - t.Fatal("expected reg-gw in service registry") - } - if _, ok := svc.(*module.PlatformAPIGateway); !ok { - t.Fatalf("expected *PlatformAPIGateway in registry, got %T", svc) - } -} - -// ─── pipeline step tests ────────────────────────────────────────────────────── - -func setupAPIGatewayApp(t *testing.T) (*module.MockApplication, *module.PlatformAPIGateway) { - t.Helper() - app := module.NewMockApplication() - gw := module.NewPlatformAPIGateway("my-gateway", newAPIGatewayConfig()) - if err := gw.Init(app); err != nil { - t.Fatalf("gateway Init: %v", err) - } - return app, gw -} - -func TestApigwPlanStep(t *testing.T) { - app, _ := setupAPIGatewayApp(t) - factory := module.NewApigwPlanStepFactory() - step, err := factory("plan", map[string]any{"gateway": "my-gateway"}, 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["gateway"] != "my-gateway" { - t.Errorf("expected gateway=my-gateway, got %v", result.Output["gateway"]) - } - if result.Output["changes"] == nil { - t.Error("expected changes in output") - } -} - -func TestApigwApplyStep(t *testing.T) { - app, _ := setupAPIGatewayApp(t) - factory := module.NewApigwApplyStepFactory() - step, err := factory("apply", map[string]any{"gateway": "my-gateway"}, 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["status"] != "active" { - t.Errorf("expected status=active, got %v", result.Output["status"]) - } - if result.Output["endpoint"] == "" { - t.Error("expected non-empty endpoint") - } -} - -func TestApigwStatusStep(t *testing.T) { - app, gw := setupAPIGatewayApp(t) - if _, err := gw.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - factory := module.NewApigwStatusStepFactory() - step, err := factory("status", map[string]any{"gateway": "my-gateway"}, 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["gateway"] != "my-gateway" { - t.Errorf("expected gateway=my-gateway, got %v", result.Output["gateway"]) - } - st := result.Output["status"].(*module.PlatformGatewayState) - if st.Status != "active" { - t.Errorf("expected status=active, got %q", st.Status) - } -} - -func TestApigwDestroyStep(t *testing.T) { - app, gw := setupAPIGatewayApp(t) - if _, err := gw.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - factory := module.NewApigwDestroyStepFactory() - step, err := factory("destroy", map[string]any{"gateway": "my-gateway"}, 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 TestApigwPlanStep_MissingGateway(t *testing.T) { - factory := module.NewApigwPlanStepFactory() - _, err := factory("plan", map[string]any{}, module.NewMockApplication()) - if err == nil { - t.Error("expected error for missing gateway, got nil") - } -} - -func TestApigwPlanStep_GatewayNotFound(t *testing.T) { - factory := module.NewApigwPlanStepFactory() - step, err := factory("plan", map[string]any{"gateway": "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 gateway service, got nil") - } -} diff --git a/module/platform_autoscaling.go b/module/platform_autoscaling.go deleted file mode 100644 index 424680b8..00000000 --- a/module/platform_autoscaling.go +++ /dev/null @@ -1,485 +0,0 @@ -package module - -import ( - "context" - "fmt" - "strings" - - "github.com/GoCodeAlone/modular" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/applicationautoscaling" - appscalingtypes "github.com/aws/aws-sdk-go-v2/service/applicationautoscaling/types" -) - -// ScalingPolicy describes a single autoscaling policy. -type ScalingPolicy struct { - Name string `json:"name"` - Type string `json:"type"` // target_tracking, step, scheduled - TargetResource string `json:"targetResource"` // ECS service, K8s deployment, etc. - MinCapacity int `json:"minCapacity"` - MaxCapacity int `json:"maxCapacity"` - MetricName string `json:"metricName,omitempty"` - TargetValue float64 `json:"targetValue,omitempty"` - Schedule string `json:"schedule,omitempty"` // cron expression - DesiredCapacity int `json:"desiredCapacity,omitempty"` -} - -// ScalingPlan describes the changes needed to reach desired autoscaling state. -type ScalingPlan struct { - Policies []ScalingPolicy `json:"policies"` - Changes []string `json:"changes"` -} - -// ScalingState represents the current state of the autoscaling configuration. -type ScalingState struct { - ID string `json:"id"` - Policies []ScalingPolicy `json:"policies"` - CurrentCapacity int `json:"currentCapacity"` - Status string `json:"status"` // pending, active, updating, deleted -} - -// autoscalingBackend is the internal interface for autoscaling backends. -type autoscalingBackend interface { - plan(m *PlatformAutoscaling) (*ScalingPlan, error) - apply(m *PlatformAutoscaling) (*ScalingState, error) - status(m *PlatformAutoscaling) (*ScalingState, error) - destroy(m *PlatformAutoscaling) error -} - -// AutoscalingBackendFactory creates an autoscalingBackend for a given provider config. -type AutoscalingBackendFactory func(cfg map[string]any) (autoscalingBackend, error) - -// autoscalingBackendRegistry maps provider name to its factory. -var autoscalingBackendRegistry = map[string]AutoscalingBackendFactory{} - -// RegisterAutoscalingBackend registers an AutoscalingBackendFactory for the given provider name. -func RegisterAutoscalingBackend(provider string, factory AutoscalingBackendFactory) { - autoscalingBackendRegistry[provider] = factory -} - -func init() { - RegisterAutoscalingBackend("mock", func(_ map[string]any) (autoscalingBackend, error) { - return &mockAutoscalingBackend{}, nil - }) - RegisterAutoscalingBackend("aws", func(_ map[string]any) (autoscalingBackend, error) { - return &awsAutoscalingBackend{}, nil - }) -} - -// PlatformAutoscaling manages autoscaling policies via pluggable backends. -// Config: -// -// account: name of a cloud.account module (optional for mock) -// provider: mock | aws -// policies: list of scaling policy definitions -type PlatformAutoscaling struct { - name string - config map[string]any - account string - provider CloudCredentialProvider - state *ScalingState - backend autoscalingBackend -} - -// NewPlatformAutoscaling creates a new PlatformAutoscaling module. -func NewPlatformAutoscaling(name string, cfg map[string]any) *PlatformAutoscaling { - return &PlatformAutoscaling{name: name, config: cfg} -} - -// Name returns the module name. -func (m *PlatformAutoscaling) Name() string { return m.name } - -// Init resolves the cloud.account service and initialises the backend. -func (m *PlatformAutoscaling) Init(app modular.Application) error { - m.account, _ = m.config["account"].(string) - if m.account != "" { - svc, ok := app.SvcRegistry()[m.account] - if !ok { - return fmt.Errorf("platform.autoscaling %q: account service %q not found", m.name, m.account) - } - if prov, ok := svc.(CloudCredentialProvider); ok { - m.provider = prov - } - } - - provider, _ := m.config["provider"].(string) - if provider == "" { - provider = "mock" - } - - factory, ok := autoscalingBackendRegistry[provider] - if !ok { - return fmt.Errorf("platform.autoscaling %q: unsupported provider %q", m.name, provider) - } - backend, err := factory(m.config) - if err != nil { - return fmt.Errorf("platform.autoscaling %q: creating backend: %w", m.name, err) - } - m.backend = backend - - m.state = &ScalingState{ - ID: "", - CurrentCapacity: 0, - Status: "pending", - } - - return app.RegisterService(m.name, m) -} - -// ProvidesServices declares the service this module provides. -func (m *PlatformAutoscaling) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "Autoscaling: " + m.name, Instance: m}, - } -} - -// RequiresServices returns nil — cloud.account is resolved by name. -func (m *PlatformAutoscaling) RequiresServices() []modular.ServiceDependency { - return nil -} - -// Plan returns the proposed autoscaling changes. -func (m *PlatformAutoscaling) Plan() (*ScalingPlan, error) { - return m.backend.plan(m) -} - -// Apply provisions or updates the autoscaling policies. -func (m *PlatformAutoscaling) Apply() (*ScalingState, error) { - return m.backend.apply(m) -} - -// Status returns the current autoscaling state. -func (m *PlatformAutoscaling) Status() (any, error) { - return m.backend.status(m) -} - -// Destroy removes all autoscaling policies. -func (m *PlatformAutoscaling) Destroy() error { - return m.backend.destroy(m) -} - -// policies parses policies from config. -func (m *PlatformAutoscaling) policies() []ScalingPolicy { - raw, ok := m.config["policies"].([]any) - if !ok { - return nil - } - var policies []ScalingPolicy - for _, item := range raw { - p, ok := item.(map[string]any) - if !ok { - continue - } - name, _ := p["name"].(string) - pType, _ := p["type"].(string) - targetResource, _ := p["target_resource"].(string) - minCap, _ := intFromAny(p["min_capacity"]) - maxCap, _ := intFromAny(p["max_capacity"]) - metricName, _ := p["metric_name"].(string) - schedule, _ := p["schedule"].(string) - desiredCap, _ := intFromAny(p["desired_capacity"]) - - var targetValue float64 - switch v := p["target_value"].(type) { - case float64: - targetValue = v - case int: - targetValue = float64(v) - } - - policies = append(policies, ScalingPolicy{ - Name: name, - Type: pType, - TargetResource: targetResource, - MinCapacity: minCap, - MaxCapacity: maxCap, - MetricName: metricName, - TargetValue: targetValue, - Schedule: schedule, - DesiredCapacity: desiredCap, - }) - } - return policies -} - -// ─── Mock backend ───────────────────────────────────────────────────────────── - -// mockAutoscalingBackend implements autoscalingBackend with in-memory state. -type mockAutoscalingBackend struct{} - -func (b *mockAutoscalingBackend) plan(m *PlatformAutoscaling) (*ScalingPlan, error) { - policies := m.policies() - plan := &ScalingPlan{ - Policies: policies, - } - - switch m.state.Status { - case "pending", "deleted": - plan.Changes = []string{ - fmt.Sprintf("create %d autoscaling policy(s)", len(policies)), - } - for _, p := range policies { - plan.Changes = append(plan.Changes, - fmt.Sprintf(" add %s policy %q on %q", p.Type, p.Name, p.TargetResource)) - } - case "active": - plan.Changes = []string{"autoscaling already active, no changes"} - default: - plan.Changes = []string{fmt.Sprintf("autoscaling status=%s, no action", m.state.Status)} - } - - return plan, nil -} - -func (b *mockAutoscalingBackend) apply(m *PlatformAutoscaling) (*ScalingState, error) { - if m.state.Status == "active" { - return m.state, nil - } - - policies := m.policies() - m.state.ID = fmt.Sprintf("mock-scaling-%s", strings.ReplaceAll(m.name, " ", "-")) - m.state.Policies = policies - m.state.CurrentCapacity = 1 - if len(policies) > 0 && policies[0].MinCapacity > 0 { - m.state.CurrentCapacity = policies[0].MinCapacity - } - m.state.Status = "active" - - return m.state, nil -} - -func (b *mockAutoscalingBackend) status(m *PlatformAutoscaling) (*ScalingState, error) { - return m.state, nil -} - -func (b *mockAutoscalingBackend) destroy(m *PlatformAutoscaling) error { - if m.state.Status == "deleted" { - return nil - } - m.state.Status = "deleted" - m.state.Policies = nil - m.state.ID = "" - return nil -} - -// ─── AWS Application Autoscaling backend ────────────────────────────────────── - -// awsAutoscalingBackend manages AWS Application Autoscaling policies using -// aws-sdk-go-v2/service/applicationautoscaling. -type awsAutoscalingBackend struct{} - -// ecsServiceDimension returns the Application Autoscaling resource ID for an ECS service. -func ecsServiceDimension(policy ScalingPolicy) string { - // Resource ID format: service// - parts := strings.SplitN(policy.TargetResource, "/", 2) - if len(parts) == 2 { - return fmt.Sprintf("service/%s/%s", parts[0], parts[1]) - } - return fmt.Sprintf("service/%s", policy.TargetResource) -} - -func (b *awsAutoscalingBackend) plan(m *PlatformAutoscaling) (*ScalingPlan, error) { - policies := m.policies() - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - plan := &ScalingPlan{Policies: policies} - plan.Changes = []string{fmt.Sprintf("register %d Application Autoscaling policy(s)", len(policies))} - return plan, nil - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return nil, fmt.Errorf("autoscaling plan: AWS config: %w", err) - } - client := applicationautoscaling.NewFromConfig(cfg) - - if len(policies) == 0 { - return &ScalingPlan{Policies: policies, Changes: []string{"no policies configured"}}, nil - } - - // Check if targets already registered - resourceIDs := make([]string, 0, len(policies)) - for _, p := range policies { - resourceIDs = append(resourceIDs, ecsServiceDimension(p)) - } - - out, err := client.DescribeScalableTargets(context.Background(), &applicationautoscaling.DescribeScalableTargetsInput{ - ServiceNamespace: appscalingtypes.ServiceNamespaceEcs, - ResourceIds: resourceIDs, - }) - if err != nil { - return nil, fmt.Errorf("autoscaling plan: DescribeScalableTargets: %w", err) - } - - registered := make(map[string]bool) - for _, t := range out.ScalableTargets { - if t.ResourceId != nil { - registered[*t.ResourceId] = true - } - } - - plan := &ScalingPlan{Policies: policies} - for _, p := range policies { - rid := ecsServiceDimension(p) - if registered[rid] { - plan.Changes = append(plan.Changes, fmt.Sprintf("noop: target %q already registered", rid)) - } else { - plan.Changes = append(plan.Changes, fmt.Sprintf("register scalable target %q (%s)", p.Name, rid)) - } - } - return plan, nil -} - -func (b *awsAutoscalingBackend) apply(m *PlatformAutoscaling) (*ScalingState, error) { - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return nil, fmt.Errorf("autoscaling apply: no AWS cloud account configured") - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return nil, fmt.Errorf("autoscaling apply: AWS config: %w", err) - } - client := applicationautoscaling.NewFromConfig(cfg) - - roleARN, _ := m.config["role_arn"].(string) - policies := m.policies() - - for _, policy := range policies { - resourceID := ecsServiceDimension(policy) - - // Register scalable target - minCap := safeIntToInt32(policy.MinCapacity) - maxCap := safeIntToInt32(policy.MaxCapacity) - _, err := client.RegisterScalableTarget(context.Background(), &applicationautoscaling.RegisterScalableTargetInput{ - ServiceNamespace: appscalingtypes.ServiceNamespaceEcs, - ScalableDimension: appscalingtypes.ScalableDimensionECSServiceDesiredCount, - ResourceId: aws.String(resourceID), - MinCapacity: aws.Int32(minCap), - MaxCapacity: aws.Int32(maxCap), - RoleARN: optString(roleARN), - }) - if err != nil { - return nil, fmt.Errorf("autoscaling apply: RegisterScalableTarget %q: %w", policy.Name, err) - } - - // Put scaling policy - switch policy.Type { - case "target_tracking": - _, err = client.PutScalingPolicy(context.Background(), &applicationautoscaling.PutScalingPolicyInput{ - PolicyName: aws.String(policy.Name), - ServiceNamespace: appscalingtypes.ServiceNamespaceEcs, - ScalableDimension: appscalingtypes.ScalableDimensionECSServiceDesiredCount, - ResourceId: aws.String(resourceID), - PolicyType: appscalingtypes.PolicyTypeTargetTrackingScaling, - TargetTrackingScalingPolicyConfiguration: &appscalingtypes.TargetTrackingScalingPolicyConfiguration{ - TargetValue: aws.Float64(policy.TargetValue), - PredefinedMetricSpecification: &appscalingtypes.PredefinedMetricSpecification{ - PredefinedMetricType: appscalingtypes.MetricTypeECSServiceAverageCPUUtilization, - }, - }, - }) - case "step": - _, err = client.PutScalingPolicy(context.Background(), &applicationautoscaling.PutScalingPolicyInput{ - PolicyName: aws.String(policy.Name), - ServiceNamespace: appscalingtypes.ServiceNamespaceEcs, - ScalableDimension: appscalingtypes.ScalableDimensionECSServiceDesiredCount, - ResourceId: aws.String(resourceID), - PolicyType: appscalingtypes.PolicyTypeStepScaling, - }) - } - if err != nil { - return nil, fmt.Errorf("autoscaling apply: PutScalingPolicy %q: %w", policy.Name, err) - } - } - - m.state.ID = fmt.Sprintf("aws-scaling-%s", strings.ReplaceAll(m.name, " ", "-")) - m.state.Policies = policies - if len(policies) > 0 { - m.state.CurrentCapacity = policies[0].MinCapacity - } - m.state.Status = "active" - return m.state, nil -} - -func (b *awsAutoscalingBackend) status(m *PlatformAutoscaling) (*ScalingState, error) { - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return m.state, nil - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return m.state, fmt.Errorf("autoscaling status: AWS config: %w", err) - } - client := applicationautoscaling.NewFromConfig(cfg) - - policies := m.policies() - if len(policies) == 0 { - return m.state, nil - } - - resourceIDs := make([]string, 0, len(policies)) - for _, p := range policies { - resourceIDs = append(resourceIDs, ecsServiceDimension(p)) - } - - out, err := client.DescribeScalableTargets(context.Background(), &applicationautoscaling.DescribeScalableTargetsInput{ - ServiceNamespace: appscalingtypes.ServiceNamespaceEcs, - ResourceIds: resourceIDs, - }) - if err != nil { - return m.state, fmt.Errorf("autoscaling status: DescribeScalableTargets: %w", err) - } - - if len(out.ScalableTargets) > 0 { - m.state.Status = "active" - if out.ScalableTargets[0].MinCapacity != nil { - m.state.CurrentCapacity = int(*out.ScalableTargets[0].MinCapacity) - } - } else { - m.state.Status = "not-registered" - } - - return m.state, nil -} - -func (b *awsAutoscalingBackend) destroy(m *PlatformAutoscaling) error { - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return fmt.Errorf("autoscaling destroy: no AWS cloud account configured") - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return fmt.Errorf("autoscaling destroy: AWS config: %w", err) - } - client := applicationautoscaling.NewFromConfig(cfg) - - for _, policy := range m.policies() { - resourceID := ecsServiceDimension(policy) - - // Delete scaling policy - _, _ = client.DeleteScalingPolicy(context.Background(), &applicationautoscaling.DeleteScalingPolicyInput{ - PolicyName: aws.String(policy.Name), - ServiceNamespace: appscalingtypes.ServiceNamespaceEcs, - ScalableDimension: appscalingtypes.ScalableDimensionECSServiceDesiredCount, - ResourceId: aws.String(resourceID), - }) - - // Deregister scalable target - _, err := client.DeregisterScalableTarget(context.Background(), &applicationautoscaling.DeregisterScalableTargetInput{ - ServiceNamespace: appscalingtypes.ServiceNamespaceEcs, - ScalableDimension: appscalingtypes.ScalableDimensionECSServiceDesiredCount, - ResourceId: aws.String(resourceID), - }) - if err != nil { - return fmt.Errorf("autoscaling destroy: DeregisterScalableTarget %q: %w", policy.Name, err) - } - } - - m.state.Status = "deleted" - m.state.Policies = nil - m.state.ID = "" - return nil -} diff --git a/module/platform_autoscaling_test.go b/module/platform_autoscaling_test.go deleted file mode 100644 index 73c4e38a..00000000 --- a/module/platform_autoscaling_test.go +++ /dev/null @@ -1,334 +0,0 @@ -package module_test - -import ( - "context" - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -func newAutoscalingConfig() map[string]any { - return map[string]any{ - "provider": "mock", - "policies": []any{ - map[string]any{ - "name": "cpu-target", - "type": "target_tracking", - "target_resource": "staging-ecs", - "min_capacity": 2, - "max_capacity": 20, - "metric_name": "CPUUtilization", - "target_value": 70.0, - }, - map[string]any{ - "name": "night-scale-down", - "type": "scheduled", - "target_resource": "staging-ecs", - "schedule": "cron(0 22 * * ? *)", - "desired_capacity": 1, - }, - }, - } -} - -// TestPlatformAutoscaling_MockLifecycle tests the full plan→apply→status→destroy lifecycle. -func TestPlatformAutoscaling_MockLifecycle(t *testing.T) { - as := module.NewPlatformAutoscaling("app-scaling", newAutoscalingConfig()) - app := module.NewMockApplication() - if err := as.Init(app); err != nil { - t.Fatalf("Init failed: %v", err) - } - - // Plan — fresh state should propose creation. - plan, err := as.Plan() - if err != nil { - t.Fatalf("Plan failed: %v", err) - } - if len(plan.Changes) == 0 { - t.Fatal("expected at least one change in plan") - } - if len(plan.Policies) != 2 { - t.Errorf("expected 2 policies in plan, got %d", len(plan.Policies)) - } - - // Apply — should create the scaling policies in-memory. - state, err := as.Apply() - if err != nil { - t.Fatalf("Apply failed: %v", err) - } - if state.Status != "active" { - t.Errorf("expected status=active, got %q", state.Status) - } - if state.ID == "" { - t.Error("expected non-empty ID after apply") - } - if state.CurrentCapacity == 0 { - t.Error("expected non-zero CurrentCapacity after apply") - } - - // Status — should show active. - st, err := as.Status() - if err != nil { - t.Fatalf("Status failed: %v", err) - } - scalingState, ok := st.(*module.ScalingState) - if !ok { - t.Fatalf("Status returned unexpected type %T", st) - } - if scalingState.Status != "active" { - t.Errorf("expected status=active, got %q", scalingState.Status) - } - - // Plan after apply — should show idempotent state. - plan2, err := as.Plan() - if err != nil { - t.Fatalf("second Plan failed: %v", err) - } - if len(plan2.Changes) == 0 { - t.Fatal("expected at least one change entry") - } - - // Destroy. - if err := as.Destroy(); err != nil { - t.Fatalf("Destroy failed: %v", err) - } - st2, err := as.Status() - if err != nil { - t.Fatalf("Status after destroy failed: %v", err) - } - scalingState2 := st2.(*module.ScalingState) - if scalingState2.Status != "deleted" { - t.Errorf("expected status=deleted after destroy, got %q", scalingState2.Status) - } - if scalingState2.ID != "" { - t.Errorf("expected empty ID after destroy, got %q", scalingState2.ID) - } -} - -// TestPlatformAutoscaling_PolicyTypes verifies all policy types are parsed. -func TestPlatformAutoscaling_PolicyTypes(t *testing.T) { - as := module.NewPlatformAutoscaling("typed-scaling", newAutoscalingConfig()) - app := module.NewMockApplication() - if err := as.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - plan, err := as.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) - } - types := map[string]bool{} - for _, p := range plan.Policies { - types[p.Type] = true - } - if !types["target_tracking"] { - t.Error("expected target_tracking policy type") - } - if !types["scheduled"] { - t.Error("expected scheduled policy type") - } -} - -// TestPlatformAutoscaling_ApplyIdempotent verifies double-apply is safe. -func TestPlatformAutoscaling_ApplyIdempotent(t *testing.T) { - as := module.NewPlatformAutoscaling("idem-scaling", newAutoscalingConfig()) - app := module.NewMockApplication() - if err := as.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - if _, err := as.Apply(); err != nil { - t.Fatalf("first Apply: %v", err) - } - state, err := as.Apply() - if err != nil { - t.Fatalf("second Apply: %v", err) - } - if state.Status != "active" { - t.Errorf("expected active after second apply, got %q", state.Status) - } -} - -// TestPlatformAutoscaling_InvalidProvider verifies Init rejects unknown providers. -func TestPlatformAutoscaling_InvalidProvider(t *testing.T) { - as := module.NewPlatformAutoscaling("bad-scaling", map[string]any{"provider": "azure"}) - app := module.NewMockApplication() - if err := as.Init(app); err == nil { - t.Error("expected error for unsupported provider, got nil") - } -} - -// TestPlatformAutoscaling_InvalidAccount verifies Init fails when account is missing. -func TestPlatformAutoscaling_InvalidAccount(t *testing.T) { - as := module.NewPlatformAutoscaling("no-acc-scaling", map[string]any{ - "provider": "mock", - "account": "nonexistent-account", - }) - app := module.NewMockApplication() - if err := as.Init(app); err == nil { - t.Error("expected error for nonexistent account, got nil") - } -} - -// TestPlatformAutoscaling_AWSStubPlan verifies the AWS stub returns a plan. -func TestPlatformAutoscaling_AWSStubPlan(t *testing.T) { - as := module.NewPlatformAutoscaling("aws-scaling", map[string]any{ - "provider": "aws", - "policies": []any{ - map[string]any{ - "name": "cpu", - "type": "target_tracking", - "target_resource": "my-ecs", - "min_capacity": 1, - "max_capacity": 10, - }, - }, - }) - app := module.NewMockApplication() - if err := as.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - plan, err := as.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) - } - if len(plan.Changes) == 0 { - t.Fatal("expected at least one change from AWS stub") - } -} - -// TestPlatformAutoscaling_AWSApplyNotImplemented verifies AWS Apply returns an error. -func TestPlatformAutoscaling_AWSApplyNotImplemented(t *testing.T) { - as := module.NewPlatformAutoscaling("aws-scaling2", map[string]any{"provider": "aws"}) - app := module.NewMockApplication() - if err := as.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - _, err := as.Apply() - if err == nil { - t.Error("expected error from AWS Apply stub, got nil") - } -} - -// TestPlatformAutoscaling_ServiceRegistration verifies the module registers itself. -func TestPlatformAutoscaling_ServiceRegistration(t *testing.T) { - as := module.NewPlatformAutoscaling("reg-scaling", map[string]any{"provider": "mock"}) - app := module.NewMockApplication() - if err := as.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - svc, ok := app.Services["reg-scaling"] - if !ok { - t.Fatal("expected reg-scaling in service registry") - } - if _, ok := svc.(*module.PlatformAutoscaling); !ok { - t.Fatalf("expected *PlatformAutoscaling in registry, got %T", svc) - } -} - -// ─── pipeline step tests ────────────────────────────────────────────────────── - -func setupAutoscalingApp(t *testing.T) (*module.MockApplication, *module.PlatformAutoscaling) { - t.Helper() - app := module.NewMockApplication() - as := module.NewPlatformAutoscaling("my-scaling", newAutoscalingConfig()) - if err := as.Init(app); err != nil { - t.Fatalf("autoscaling Init: %v", err) - } - return app, as -} - -func TestScalingPlanStep(t *testing.T) { - app, _ := setupAutoscalingApp(t) - factory := module.NewScalingPlanStepFactory() - step, err := factory("plan", map[string]any{"scaling": "my-scaling"}, 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["scaling"] != "my-scaling" { - t.Errorf("expected scaling=my-scaling, got %v", result.Output["scaling"]) - } - if result.Output["changes"] == nil { - t.Error("expected changes in output") - } -} - -func TestScalingApplyStep(t *testing.T) { - app, _ := setupAutoscalingApp(t) - factory := module.NewScalingApplyStepFactory() - step, err := factory("apply", map[string]any{"scaling": "my-scaling"}, 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["status"] != "active" { - t.Errorf("expected status=active, got %v", result.Output["status"]) - } -} - -func TestScalingStatusStep(t *testing.T) { - app, as := setupAutoscalingApp(t) - if _, err := as.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - factory := module.NewScalingStatusStepFactory() - step, err := factory("status", map[string]any{"scaling": "my-scaling"}, 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["scaling"] != "my-scaling" { - t.Errorf("expected scaling=my-scaling, got %v", result.Output["scaling"]) - } - st := result.Output["status"].(*module.ScalingState) - if st.Status != "active" { - t.Errorf("expected status=active, got %q", st.Status) - } -} - -func TestScalingDestroyStep(t *testing.T) { - app, as := setupAutoscalingApp(t) - if _, err := as.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - factory := module.NewScalingDestroyStepFactory() - step, err := factory("destroy", map[string]any{"scaling": "my-scaling"}, 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 TestScalingPlanStep_MissingScaling(t *testing.T) { - factory := module.NewScalingPlanStepFactory() - _, err := factory("plan", map[string]any{}, module.NewMockApplication()) - if err == nil { - t.Error("expected error for missing scaling, got nil") - } -} - -func TestScalingPlanStep_ScalingNotFound(t *testing.T) { - factory := module.NewScalingPlanStepFactory() - step, err := factory("plan", map[string]any{"scaling": "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 scaling service, got nil") - } -} diff --git a/module/platform_aws_integration_test.go b/module/platform_aws_integration_test.go deleted file mode 100644 index 71a33734..00000000 --- a/module/platform_aws_integration_test.go +++ /dev/null @@ -1,153 +0,0 @@ -//go:build integration - -package module_test - -import ( - "os" - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -// newAWSCloudAccount creates a cloud.account backed by environment credentials. -func newAWSCloudAccount(t *testing.T, name string) *module.CloudAccount { - t.Helper() - region := os.Getenv("AWS_REGION") - if region == "" { - region = "us-east-1" - } - acc := module.NewCloudAccount(name, map[string]any{ - "provider": "aws", - "region": region, - "credentials": map[string]any{ - "type": "env", - }, - }) - app := module.NewMockApplication() - if err := acc.Init(app); err != nil { - t.Fatalf("cloud account Init: %v", err) - } - return acc -} - -// TestEKS_Integration_Plan verifies that EKS plan() can call DescribeCluster -// against a real AWS account and return a valid plan. -func TestEKS_Integration_Plan(t *testing.T) { - acc := newAWSCloudAccount(t, "aws-account") - app := module.NewMockApplication() - - k := module.NewPlatformKubernetes("integration-cluster", map[string]any{ - "type": "eks", - "version": "1.29", - "account": "aws-account", - }) - if err := acc.Init(app); err != nil { - t.Fatalf("account Init: %v", err) - } - if err := k.Init(app); err != nil { - t.Fatalf("k8s Init: %v", err) - } - - plan, err := k.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) - } - if plan.Provider != "eks" { - t.Errorf("expected provider=eks, got %q", plan.Provider) - } - if len(plan.Actions) == 0 { - t.Error("expected at least one action") - } - t.Logf("EKS plan: %+v", plan.Actions) -} - -// TestECS_Integration_Plan verifies that ECS plan() can call DescribeServices -// against a real AWS account. -func TestECS_Integration_Plan(t *testing.T) { - clusterName := os.Getenv("ECS_CLUSTER_NAME") - if clusterName == "" { - clusterName = "default" - } - - acc := newAWSCloudAccount(t, "aws-account") - app := module.NewMockApplication() - if err := acc.Init(app); err != nil { - t.Fatalf("account Init: %v", err) - } - - e := module.NewPlatformECS("integration-svc", map[string]any{ - "cluster": clusterName, - "account": "aws-account", - }) - if err := e.Init(app); err != nil { - t.Fatalf("ECS Init: %v", err) - } - - plan, err := e.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) - } - if len(plan.Actions) == 0 { - t.Error("expected at least one action") - } - t.Logf("ECS plan: %+v", plan.Actions) -} - -// TestNetworking_Integration_Plan verifies aws network plan() calls DescribeVpcs. -func TestNetworking_Integration_Plan(t *testing.T) { - acc := newAWSCloudAccount(t, "aws-account") - app := module.NewMockApplication() - if err := acc.Init(app); err != nil { - t.Fatalf("account Init: %v", err) - } - - net := module.NewPlatformNetworking("integration-net", map[string]any{ - "provider": "aws", - "account": "aws-account", - "vpc": map[string]any{ - "cidr": "10.0.0.0/16", - "name": "integration-test-vpc", - }, - }) - if err := net.Init(app); err != nil { - t.Fatalf("network Init: %v", err) - } - - plan, err := net.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) - } - if len(plan.Changes) == 0 { - t.Error("expected at least one change") - } - t.Logf("Network plan: %+v", plan.Changes) -} - -// TestDNS_Integration_Plan verifies route53 plan() calls ListHostedZonesByName. -func TestDNS_Integration_Plan(t *testing.T) { - acc := newAWSCloudAccount(t, "aws-account") - app := module.NewMockApplication() - if err := acc.Init(app); err != nil { - t.Fatalf("account Init: %v", err) - } - - dns := module.NewPlatformDNS("integration-dns", map[string]any{ - "provider": "aws", - "account": "aws-account", - "zone": map[string]any{ - "name": "integration.example.com", - }, - }) - if err := dns.Init(app); err != nil { - t.Fatalf("DNS Init: %v", err) - } - - plan, err := dns.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) - } - if len(plan.Changes) == 0 { - t.Error("expected at least one change") - } - t.Logf("DNS plan: %+v", plan.Changes) -} diff --git a/module/platform_ecs.go b/module/platform_ecs.go deleted file mode 100644 index 114d48fa..00000000 --- a/module/platform_ecs.go +++ /dev/null @@ -1,571 +0,0 @@ -package module - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/GoCodeAlone/modular" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ecs" - ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" -) - -// ECSServiceState holds the current state of a managed ECS service. -type ECSServiceState struct { - Name string `json:"name"` - Cluster string `json:"cluster"` - Region string `json:"region"` - LaunchType string `json:"launchType"` - Status string `json:"status"` // pending, creating, running, deleting, deleted - DesiredCount int `json:"desiredCount"` - RunningCount int `json:"runningCount"` - TaskDefinition ECSTaskDefinition `json:"taskDefinition"` - LoadBalancer *ECSLoadBalancer `json:"loadBalancer,omitempty"` - CreatedAt time.Time `json:"createdAt"` -} - -// ECSTaskDefinition describes an ECS task definition. -type ECSTaskDefinition struct { - Family string `json:"family"` - Revision int `json:"revision"` - CPU string `json:"cpu"` - Memory string `json:"memory"` - Containers []ECSContainer `json:"containers"` -} - -// ECSContainer describes a container within a task definition. -type ECSContainer struct { - Name string `json:"name"` - Image string `json:"image"` - Port int `json:"port,omitempty"` -} - -// ECSLoadBalancer describes the ALB/NLB configuration for an ECS service. -type ECSLoadBalancer struct { - TargetGroupARN string `json:"targetGroupArn"` - ContainerName string `json:"containerName"` - ContainerPort int `json:"containerPort"` -} - -// PlatformECS manages AWS ECS/Fargate services via pluggable backends. -// Config: -// -// account: name of a cloud.account module (resolved from service registry) -// cluster: ECS cluster name -// region: AWS region (e.g. us-east-1) -// launch_type: FARGATE or EC2 (default: FARGATE) -// vpc_subnets: list of subnet IDs -// security_groups: list of security group IDs -type PlatformECS struct { - name string - config map[string]any - provider CloudCredentialProvider // resolved from service registry - state *ECSServiceState - backend ecsBackend -} - -// ecsBackend is the internal interface that ECS backends implement. -type ecsBackend interface { - plan(e *PlatformECS) (*PlatformPlan, error) - apply(e *PlatformECS) (*PlatformResult, error) - status(e *PlatformECS) (*ECSServiceState, error) - destroy(e *PlatformECS) error -} - -// ECSBackendFactory creates an ecsBackend for a given provider config. -type ECSBackendFactory func(cfg map[string]any) (ecsBackend, error) - -// ecsBackendRegistry maps provider name to its factory. -var ecsBackendRegistry = map[string]ECSBackendFactory{} - -// RegisterECSBackend registers an ECSBackendFactory for the given provider name. -func RegisterECSBackend(provider string, factory ECSBackendFactory) { - ecsBackendRegistry[provider] = factory -} - -func init() { - RegisterECSBackend("mock", func(_ map[string]any) (ecsBackend, error) { - return &ecsMockBackend{}, nil - }) - RegisterECSBackend("aws", func(_ map[string]any) (ecsBackend, error) { - return &awsECSBackend{}, nil - }) -} - -// NewPlatformECS creates a new PlatformECS module. -func NewPlatformECS(name string, cfg map[string]any) *PlatformECS { - return &PlatformECS{name: name, config: cfg} -} - -// Name returns the module name. -func (m *PlatformECS) Name() string { return m.name } - -// Init resolves the cloud.account service and initialises the backend. -func (m *PlatformECS) Init(app modular.Application) error { - cluster, _ := m.config["cluster"].(string) - if cluster == "" { - return fmt.Errorf("platform.ecs %q: 'cluster' is required", m.name) - } - - accountName, _ := m.config["account"].(string) - if accountName != "" { - svc, ok := app.SvcRegistry()[accountName] - if !ok { - return fmt.Errorf("platform.ecs %q: account service %q not found", m.name, accountName) - } - provider, ok := svc.(CloudCredentialProvider) - if !ok { - return fmt.Errorf("platform.ecs %q: service %q does not implement CloudCredentialProvider", m.name, accountName) - } - m.provider = provider - } - - launchType, _ := m.config["launch_type"].(string) - if launchType == "" { - launchType = "FARGATE" - } - - region, _ := m.config["region"].(string) - if region == "" { - region = "us-east-1" - } - - m.state = &ECSServiceState{ - Name: m.name, - Cluster: cluster, - Region: region, - LaunchType: launchType, - Status: "pending", - } - - // Determine provider type: use explicit "provider" config field if set, - // otherwise fall back to the cloud account's provider name (if available). - providerType, _ := m.config["provider"].(string) - if providerType == "" && m.provider != nil { - providerType = m.provider.Provider() - } - if providerType == "" { - providerType = "mock" - } - - factory, ok := ecsBackendRegistry[providerType] - if !ok { - // Fall back to mock for unknown provider types to preserve backward compatibility. - factory = ecsBackendRegistry["mock"] - } - backend, err := factory(m.config) - if err != nil { - return fmt.Errorf("platform.ecs %q: creating backend: %w", m.name, err) - } - m.backend = backend - - return app.RegisterService(m.name, m) -} - -// ProvidesServices declares the service this module provides. -func (m *PlatformECS) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "ECS service: " + m.name, Instance: m}, - } -} - -// RequiresServices returns nil — cloud.account is resolved by name, not declared. -func (m *PlatformECS) RequiresServices() []modular.ServiceDependency { - return nil -} - -// Plan returns the changes that would be made to bring the ECS service to desired state. -func (m *PlatformECS) Plan() (*PlatformPlan, error) { - return m.backend.plan(m) -} - -// Apply creates or updates the ECS task definition and service. -func (m *PlatformECS) Apply() (*PlatformResult, error) { - return m.backend.apply(m) -} - -// Status returns the current ECS service state. -func (m *PlatformECS) Status() (any, error) { - return m.backend.status(m) -} - -// Destroy deletes the ECS service and task definition. -func (m *PlatformECS) Destroy() error { - return m.backend.destroy(m) -} - -// serviceName returns the ECS service name (defaults to module name). -func (m *PlatformECS) serviceName() string { - if n, ok := m.config["service_name"].(string); ok && n != "" { - return n - } - return m.name -} - -// taskFamily returns the ECS task definition family name. -func (m *PlatformECS) taskFamily() string { - if f, ok := m.config["task_family"].(string); ok && f != "" { - return f - } - return m.name + "-task" -} - -// desiredCount returns the desired task count. -func (m *PlatformECS) desiredCount() int { - if n, ok := m.config["desired_count"]; ok { - if count, ok := intFromAny(n); ok && count > 0 { - return count - } - } - return 1 -} - -// ─── mock backend ───────────────────────────────────────────────────────────── - -// ecsMockBackend implements ecsBackend using in-memory state for local testing. -// Real implementation would use aws-sdk-go-v2/service/ecs to manage services. -type ecsMockBackend struct{} - -func (b *ecsMockBackend) plan(e *PlatformECS) (*PlatformPlan, error) { - plan := &PlatformPlan{ - Provider: "ecs", - Resource: e.serviceName(), - } - - switch e.state.Status { - case "pending", "deleted": - plan.Actions = []PlatformAction{ - { - Type: "create", - Resource: e.serviceName(), - Detail: fmt.Sprintf("create ECS service %q in cluster %q (%s)", e.serviceName(), e.state.Cluster, e.state.LaunchType), - }, - { - Type: "create", - Resource: e.taskFamily(), - Detail: fmt.Sprintf("register ECS task definition %q", e.taskFamily()), - }, - } - case "running": - plan.Actions = []PlatformAction{ - {Type: "noop", Resource: e.serviceName(), Detail: "ECS service already running"}, - } - default: - plan.Actions = []PlatformAction{ - {Type: "noop", Resource: e.serviceName(), Detail: fmt.Sprintf("ECS service status=%s, no action", e.state.Status)}, - } - } - - return plan, nil -} - -func (b *ecsMockBackend) apply(e *PlatformECS) (*PlatformResult, error) { - if e.state.Status == "running" { - return &PlatformResult{Success: true, Message: "ECS service already running", State: e.state}, nil - } - - e.state.Status = "creating" - e.state.CreatedAt = time.Now() - e.state.DesiredCount = e.desiredCount() - - // Simulate task definition registration. - e.state.TaskDefinition = ECSTaskDefinition{ - Family: e.taskFamily(), - Revision: 1, - CPU: "256", - Memory: "512", - Containers: []ECSContainer{ - {Name: "app", Image: "app:latest", Port: 8080}, - }, - } - - // Simulate ALB target group assignment. - e.state.LoadBalancer = &ECSLoadBalancer{ - TargetGroupARN: fmt.Sprintf("arn:aws:elasticloadbalancing:%s:123456789012:targetgroup/%s/mock", e.state.Region, e.serviceName()), - ContainerName: "app", - ContainerPort: 8080, - } - - // In-memory: immediately transition to running. - // Real implementation: call ecs.RegisterTaskDefinition + ecs.CreateService / ecs.UpdateService. - e.state.Status = "running" - e.state.RunningCount = e.state.DesiredCount - - return &PlatformResult{ - Success: true, - Message: fmt.Sprintf("ECS service %q created in cluster %q (in-memory mock)", e.serviceName(), e.state.Cluster), - State: e.state, - }, nil -} - -func (b *ecsMockBackend) status(e *PlatformECS) (*ECSServiceState, error) { - return e.state, nil -} - -func (b *ecsMockBackend) destroy(e *PlatformECS) error { - if e.state.Status == "deleted" { - return nil - } - e.state.Status = "deleting" - // In-memory: immediately mark deleted. - // Real implementation: call ecs.DeleteService + ecs.DeregisterTaskDefinition. - e.state.Status = "deleted" - e.state.RunningCount = 0 - e.state.DesiredCount = 0 - e.state.LoadBalancer = nil - return nil -} - -// ─── AWS ECS backend ────────────────────────────────────────────────────────── - -// awsECSBackend manages AWS ECS services using aws-sdk-go-v2/service/ecs. -type awsECSBackend struct{} - -func (b *awsECSBackend) plan(e *PlatformECS) (*PlatformPlan, error) { - awsProv, ok := awsProviderFrom(e.provider) - if !ok { - return &PlatformPlan{ - Provider: "ecs", - Resource: e.serviceName(), - Actions: []PlatformAction{{Type: "create", Resource: e.serviceName(), Detail: fmt.Sprintf("create ECS service %q (no AWS config)", e.serviceName())}}, - }, nil - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return nil, fmt.Errorf("ecs plan: AWS config: %w", err) - } - client := ecs.NewFromConfig(cfg) - - out, err := client.DescribeServices(context.Background(), &ecs.DescribeServicesInput{ - Cluster: aws.String(e.state.Cluster), - Services: []string{e.serviceName()}, - }) - if err != nil { - return nil, fmt.Errorf("ecs plan: DescribeServices: %w", err) - } - - for i := range out.Services { - if out.Services[i].ServiceName != nil && *out.Services[i].ServiceName == e.serviceName() && out.Services[i].Status != nil && *out.Services[i].Status != "INACTIVE" { - return &PlatformPlan{ - Provider: "ecs", - Resource: e.serviceName(), - Actions: []PlatformAction{{Type: "noop", Resource: e.serviceName(), Detail: fmt.Sprintf("ECS service %q exists (status: %s)", e.serviceName(), *out.Services[i].Status)}}, - }, nil - } - } - - return &PlatformPlan{ - Provider: "ecs", - Resource: e.serviceName(), - Actions: []PlatformAction{ - {Type: "create", Resource: e.taskFamily(), Detail: fmt.Sprintf("register ECS task definition %q", e.taskFamily())}, - {Type: "create", Resource: e.serviceName(), Detail: fmt.Sprintf("create ECS service %q in cluster %q (%s)", e.serviceName(), e.state.Cluster, e.state.LaunchType)}, - }, - }, nil -} - -func (b *awsECSBackend) apply(e *PlatformECS) (*PlatformResult, error) { - awsProv, ok := awsProviderFrom(e.provider) - if !ok { - return nil, fmt.Errorf("ecs apply: no AWS cloud account configured") - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return nil, fmt.Errorf("ecs apply: AWS config: %w", err) - } - client := ecs.NewFromConfig(cfg) - - // Build container definitions from config - containers := parseECSContainers(e.config) - if len(containers) == 0 { - containers = []ecstypes.ContainerDefinition{ - {Name: aws.String("app"), Image: aws.String("app:latest"), Essential: aws.Bool(true)}, - } - } - - cpu, _ := e.config["cpu"].(string) - memory, _ := e.config["memory"].(string) - execRoleARN, _ := e.config["execution_role_arn"].(string) - - tdOut, err := client.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{ - Family: aws.String(e.taskFamily()), - ContainerDefinitions: containers, - Cpu: optString(cpu), - Memory: optString(memory), - ExecutionRoleArn: optString(execRoleARN), - NetworkMode: ecstypes.NetworkModeAwsvpc, - RequiresCompatibilities: []ecstypes.Compatibility{ecstypes.CompatibilityFargate}, - }) - if err != nil { - return nil, fmt.Errorf("ecs apply: RegisterTaskDefinition: %w", err) - } - - taskDefARN := "" - revision := 1 - if tdOut.TaskDefinition != nil { - if tdOut.TaskDefinition.TaskDefinitionArn != nil { - taskDefARN = *tdOut.TaskDefinition.TaskDefinitionArn - } - revision = int(tdOut.TaskDefinition.Revision) - } - - subnets := parseStringSlice(e.config["vpc_subnets"]) - sgs := parseStringSlice(e.config["security_groups"]) - desiredCount := safeIntToInt32(e.desiredCount()) - - _, err = client.CreateService(context.Background(), &ecs.CreateServiceInput{ - ServiceName: aws.String(e.serviceName()), - Cluster: aws.String(e.state.Cluster), - TaskDefinition: aws.String(taskDefARN), - DesiredCount: aws.Int32(desiredCount), - LaunchType: ecstypes.LaunchTypeFargate, - NetworkConfiguration: &ecstypes.NetworkConfiguration{ - AwsvpcConfiguration: &ecstypes.AwsVpcConfiguration{ - Subnets: subnets, - SecurityGroups: sgs, - }, - }, - }) - if err != nil { - // If service already exists, update it instead - var alreadyExists *ecstypes.InvalidParameterException - if !errors.As(err, &alreadyExists) { - _, updateErr := client.UpdateService(context.Background(), &ecs.UpdateServiceInput{ - Service: aws.String(e.serviceName()), - Cluster: aws.String(e.state.Cluster), - TaskDefinition: aws.String(taskDefARN), - DesiredCount: aws.Int32(desiredCount), - }) - if updateErr != nil { - return nil, fmt.Errorf("ecs apply: CreateService failed (%v), UpdateService also failed: %w", err, updateErr) - } - } else { - return nil, fmt.Errorf("ecs apply: CreateService: %w", err) - } - } - - e.state.Status = "creating" - e.state.CreatedAt = time.Now() - e.state.DesiredCount = int(desiredCount) - e.state.TaskDefinition = ECSTaskDefinition{ - Family: e.taskFamily(), - Revision: revision, - CPU: cpu, - Memory: memory, - } - - return &PlatformResult{ - Success: true, - Message: fmt.Sprintf("ECS service %q created in cluster %q", e.serviceName(), e.state.Cluster), - State: e.state, - }, nil -} - -func (b *awsECSBackend) status(e *PlatformECS) (*ECSServiceState, error) { - awsProv, ok := awsProviderFrom(e.provider) - if !ok { - return e.state, nil - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return e.state, fmt.Errorf("ecs status: AWS config: %w", err) - } - client := ecs.NewFromConfig(cfg) - - out, err := client.DescribeServices(context.Background(), &ecs.DescribeServicesInput{ - Cluster: aws.String(e.state.Cluster), - Services: []string{e.serviceName()}, - }) - if err != nil { - return e.state, fmt.Errorf("ecs status: DescribeServices: %w", err) - } - - for i := range out.Services { - if out.Services[i].ServiceName != nil && *out.Services[i].ServiceName == e.serviceName() { - if out.Services[i].Status != nil { - e.state.Status = *out.Services[i].Status - } - e.state.RunningCount = int(out.Services[i].RunningCount) - e.state.DesiredCount = int(out.Services[i].DesiredCount) - } - } - - return e.state, nil -} - -func (b *awsECSBackend) destroy(e *PlatformECS) error { - awsProv, ok := awsProviderFrom(e.provider) - if !ok { - return fmt.Errorf("ecs destroy: no AWS cloud account configured") - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return fmt.Errorf("ecs destroy: AWS config: %w", err) - } - client := ecs.NewFromConfig(cfg) - - // Scale down to 0 before deleting - if _, err := client.UpdateService(context.Background(), &ecs.UpdateServiceInput{ - Service: aws.String(e.serviceName()), - Cluster: aws.String(e.state.Cluster), - DesiredCount: aws.Int32(0), - }); err != nil { - return fmt.Errorf("ecs destroy: UpdateService (scale down): %w", err) - } - - _, err = client.DeleteService(context.Background(), &ecs.DeleteServiceInput{ - Service: aws.String(e.serviceName()), - Cluster: aws.String(e.state.Cluster), - }) - if err != nil { - return fmt.Errorf("ecs destroy: DeleteService: %w", err) - } - - e.state.Status = "deleted" - e.state.RunningCount = 0 - e.state.DesiredCount = 0 - return nil -} - -// parseECSContainers parses ECS container definitions from module config. -func parseECSContainers(cfg map[string]any) []ecstypes.ContainerDefinition { - raw, ok := cfg["containers"].([]any) - if !ok { - return nil - } - var result []ecstypes.ContainerDefinition - for _, item := range raw { - c, ok := item.(map[string]any) - if !ok { - continue - } - name, _ := c["name"].(string) - image, _ := c["image"].(string) - def := ecstypes.ContainerDefinition{ - Name: aws.String(name), - Image: aws.String(image), - Essential: aws.Bool(true), - } - if port, ok := intFromAny(c["port"]); ok && port > 0 { - def.PortMappings = []ecstypes.PortMapping{ - {ContainerPort: aws.Int32(safeIntToInt32(port)), Protocol: ecstypes.TransportProtocolTcp}, - } - } - result = append(result, def) - } - return result -} - -// optString returns a *string pointer if the string is non-empty, otherwise nil. -func optString(s string) *string { - if s == "" { - return nil - } - return &s -} diff --git a/module/platform_ecs_test.go b/module/platform_ecs_test.go deleted file mode 100644 index d77aa986..00000000 --- a/module/platform_ecs_test.go +++ /dev/null @@ -1,368 +0,0 @@ -package module_test - -import ( - "context" - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -// ─── module lifecycle tests ─────────────────────────────────────────────────── - -// TestPlatformECS_MockLifecycle tests the full plan→apply→status→destroy -// lifecycle using the in-memory mock backend. -func TestPlatformECS_MockLifecycle(t *testing.T) { - e := module.NewPlatformECS("staging-svc", map[string]any{ - "cluster": "staging-cluster", - "region": "us-east-1", - "launch_type": "FARGATE", - "desired_count": 2, - }) - - app := module.NewMockApplication() - if err := e.Init(app); err != nil { - t.Fatalf("Init failed: %v", err) - } - - // Plan — should propose create actions on a fresh service. - plan, err := e.Plan() - if err != nil { - t.Fatalf("Plan failed: %v", err) - } - if len(plan.Actions) == 0 { - t.Fatal("expected at least one plan action") - } - if plan.Actions[0].Type != "create" { - t.Errorf("expected action=create, got %q", plan.Actions[0].Type) - } - if plan.Provider != "ecs" { - t.Errorf("expected provider=ecs, got %q", plan.Provider) - } - - // Apply — should create the service in-memory. - result, err := e.Apply() - if err != nil { - t.Fatalf("Apply failed: %v", err) - } - if !result.Success { - t.Errorf("expected Apply success=true") - } - if result.Message == "" { - t.Error("expected non-empty message from Apply") - } - - // Status — service should now be running. - st, err := e.Status() - if err != nil { - t.Fatalf("Status failed: %v", err) - } - state, ok := st.(*module.ECSServiceState) - if !ok { - t.Fatalf("Status returned unexpected type %T", st) - } - if state.Status != "running" { - t.Errorf("expected status=running, got %q", state.Status) - } - if state.RunningCount != 2 { - t.Errorf("expected RunningCount=2, got %d", state.RunningCount) - } - if state.DesiredCount != 2 { - t.Errorf("expected DesiredCount=2, got %d", state.DesiredCount) - } - - // Plan after apply — should produce a noop. - plan2, err := e.Plan() - if err != nil { - t.Fatalf("second Plan failed: %v", err) - } - if len(plan2.Actions) == 0 || plan2.Actions[0].Type != "noop" { - t.Errorf("expected noop after apply, got %+v", plan2.Actions) - } - - // Destroy — service should be deleted. - if err := e.Destroy(); err != nil { - t.Fatalf("Destroy failed: %v", err) - } - st2, err := e.Status() - if err != nil { - t.Fatalf("Status after destroy failed: %v", err) - } - state2 := st2.(*module.ECSServiceState) - if state2.Status != "deleted" { - t.Errorf("expected status=deleted after destroy, got %q", state2.Status) - } - if state2.RunningCount != 0 { - t.Errorf("expected RunningCount=0 after destroy, got %d", state2.RunningCount) - } -} - -// TestPlatformECS_PlatformProviderInterface verifies PlatformECS satisfies -// the PlatformProvider interface. -func TestPlatformECS_PlatformProviderInterface(t *testing.T) { - e := module.NewPlatformECS("iface-svc", map[string]any{"cluster": "test-cluster"}) - app := module.NewMockApplication() - if err := e.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - var _ module.PlatformProvider = e -} - -// TestPlatformECS_CloudAccountResolution verifies the module resolves a -// cloud.account from the service registry during Init. -func TestPlatformECS_CloudAccountResolution(t *testing.T) { - acc := module.NewCloudAccount("aws-staging", map[string]any{ - "provider": "mock", - "region": "us-east-1", - }) - app := module.NewMockApplication() - if err := acc.Init(app); err != nil { - t.Fatalf("cloud account Init: %v", err) - } - - e := module.NewPlatformECS("my-svc", map[string]any{ - "cluster": "staging-cluster", - "account": "aws-staging", - }) - if err := e.Init(app); err != nil { - t.Fatalf("ECS Init: %v", err) - } - - svc, ok := app.Services["my-svc"] - if !ok { - t.Fatal("expected my-svc in service registry") - } - if _, ok := svc.(*module.PlatformECS); !ok { - t.Fatalf("registry entry is %T, want *PlatformECS", svc) - } -} - -// TestPlatformECS_InvalidAccount verifies Init fails when the referenced -// cloud.account does not exist. -func TestPlatformECS_InvalidAccount(t *testing.T) { - e := module.NewPlatformECS("fail-svc", map[string]any{ - "cluster": "staging-cluster", - "account": "nonexistent-account", - }) - app := module.NewMockApplication() - if err := e.Init(app); err == nil { - t.Error("expected error for nonexistent account, got nil") - } -} - -// TestPlatformECS_MissingCluster verifies Init fails when 'cluster' is not set. -func TestPlatformECS_MissingCluster(t *testing.T) { - e := module.NewPlatformECS("no-cluster-svc", map[string]any{}) - app := module.NewMockApplication() - if err := e.Init(app); err == nil { - t.Error("expected error for missing cluster, got nil") - } -} - -// TestPlatformECS_TaskDefinitionPopulated verifies Apply populates the task definition. -func TestPlatformECS_TaskDefinitionPopulated(t *testing.T) { - e := module.NewPlatformECS("td-svc", map[string]any{"cluster": "test-cluster"}) - app := module.NewMockApplication() - if err := e.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - - result, err := e.Apply() - if err != nil { - t.Fatalf("Apply: %v", err) - } - - state, ok := result.State.(*module.ECSServiceState) - if !ok { - t.Fatalf("unexpected state type %T", result.State) - } - if state.TaskDefinition.Family == "" { - t.Error("expected TaskDefinition.Family to be set") - } - if state.TaskDefinition.Revision != 1 { - t.Errorf("expected Revision=1, got %d", state.TaskDefinition.Revision) - } - if len(state.TaskDefinition.Containers) == 0 { - t.Error("expected at least one container in task definition") - } -} - -// TestPlatformECS_LoadBalancerConfigured verifies Apply sets up load balancer config. -func TestPlatformECS_LoadBalancerConfigured(t *testing.T) { - e := module.NewPlatformECS("lb-svc", map[string]any{ - "cluster": "prod-cluster", - "region": "us-west-2", - }) - app := module.NewMockApplication() - if err := e.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - - result, err := e.Apply() - if err != nil { - t.Fatalf("Apply: %v", err) - } - - state := result.State.(*module.ECSServiceState) - if state.LoadBalancer == nil { - t.Fatal("expected LoadBalancer to be set after Apply") - } - if state.LoadBalancer.TargetGroupARN == "" { - t.Error("expected non-empty TargetGroupARN") - } - if state.LoadBalancer.ContainerPort == 0 { - t.Error("expected non-zero ContainerPort") - } -} - -// TestPlatformECS_DefaultDesiredCount verifies the default desired count is 1. -func TestPlatformECS_DefaultDesiredCount(t *testing.T) { - e := module.NewPlatformECS("default-svc", map[string]any{"cluster": "cluster"}) - app := module.NewMockApplication() - if err := e.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - - result, err := e.Apply() - if err != nil { - t.Fatalf("Apply: %v", err) - } - state := result.State.(*module.ECSServiceState) - if state.DesiredCount != 1 { - t.Errorf("expected default DesiredCount=1, got %d", state.DesiredCount) - } -} - -// TestPlatformECS_DestroyIdempotent verifies calling Destroy twice does not error. -func TestPlatformECS_DestroyIdempotent(t *testing.T) { - e := module.NewPlatformECS("idempotent-svc", map[string]any{"cluster": "cluster"}) - app := module.NewMockApplication() - if err := e.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - if _, err := e.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := e.Destroy(); err != nil { - t.Fatalf("first Destroy: %v", err) - } - if err := e.Destroy(); err != nil { - t.Errorf("second Destroy should be idempotent, got: %v", err) - } -} - -// ─── pipeline step tests ────────────────────────────────────────────────────── - -func setupECSApp(t *testing.T) (*module.MockApplication, *module.PlatformECS) { - t.Helper() - app := module.NewMockApplication() - e := module.NewPlatformECS("my-ecs-svc", map[string]any{ - "cluster": "test-cluster", - "region": "us-east-1", - "launch_type": "FARGATE", - }) - if err := e.Init(app); err != nil { - t.Fatalf("ECS Init: %v", err) - } - return app, e -} - -func TestECSPlanStep(t *testing.T) { - app, _ := setupECSApp(t) - factory := module.NewECSPlanStepFactory() - step, err := factory("plan", map[string]any{"service": "my-ecs-svc"}, 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["service"] != "my-ecs-svc" { - t.Errorf("expected service=my-ecs-svc, got %v", result.Output["service"]) - } - if result.Output["actions"] == nil { - t.Error("expected actions in output") - } -} - -func TestECSApplyStep(t *testing.T) { - app, _ := setupECSApp(t) - factory := module.NewECSApplyStepFactory() - step, err := factory("apply", map[string]any{"service": "my-ecs-svc"}, 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["success"] != true { - t.Errorf("expected success=true, got %v", result.Output["success"]) - } -} - -func TestECSStatusStep(t *testing.T) { - app, e := setupECSApp(t) - - if _, err := e.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - - factory := module.NewECSStatusStepFactory() - step, err := factory("status", map[string]any{"service": "my-ecs-svc"}, 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["service"] != "my-ecs-svc" { - t.Errorf("expected service=my-ecs-svc, got %v", result.Output["service"]) - } - st := result.Output["status"].(*module.ECSServiceState) - if st.Status != "running" { - t.Errorf("expected status=running, got %q", st.Status) - } -} - -func TestECSDestroyStep(t *testing.T) { - app, e := setupECSApp(t) - - if _, err := e.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - - factory := module.NewECSDestroyStepFactory() - step, err := factory("destroy", map[string]any{"service": "my-ecs-svc"}, 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 TestECSPlanStep_MissingService(t *testing.T) { - factory := module.NewECSPlanStepFactory() - _, err := factory("plan", map[string]any{}, module.NewMockApplication()) - if err == nil { - t.Error("expected error for missing service, got nil") - } -} - -func TestECSPlanStep_ServiceNotFound(t *testing.T) { - factory := module.NewECSPlanStepFactory() - step, err := factory("plan", map[string]any{"service": "ghost-svc"}, 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 service in registry, got nil") - } -} diff --git a/module/platform_networking.go b/module/platform_networking.go deleted file mode 100644 index bd4a3e25..00000000 --- a/module/platform_networking.go +++ /dev/null @@ -1,638 +0,0 @@ -package module - -import ( - "context" - "fmt" - "strings" - - "github.com/GoCodeAlone/modular" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ec2" - ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" -) - -// NetworkState holds the current state of a managed VPC network. -type NetworkState struct { - VPCID string `json:"vpcId"` - SubnetIDs map[string]string `json:"subnetIds"` // name → id - SecurityGroupIDs map[string]string `json:"securityGroupIds"` // name → id - NATGatewayID string `json:"natGatewayId"` - Status string `json:"status"` // planned, active, destroying, destroyed -} - -// VPCConfig describes the desired VPC configuration. -type VPCConfig struct { - CIDR string `json:"cidr"` - Name string `json:"name"` -} - -// SubnetConfig describes a single subnet. -type SubnetConfig struct { - Name string `json:"name"` - CIDR string `json:"cidr"` - AZ string `json:"az"` - Public bool `json:"public"` -} - -// SecurityGroupRule describes a single inbound/outbound rule. -type SecurityGroupRule struct { - Protocol string `json:"protocol"` - Port int `json:"port"` - Source string `json:"source"` -} - -// SecurityGroupConfig describes a security group with its rules. -type SecurityGroupConfig struct { - Name string `json:"name"` - Rules []SecurityGroupRule `json:"rules"` -} - -// NetworkPlan describes the changes a networking module intends to make. -type NetworkPlan struct { - VPC VPCConfig `json:"vpc"` - Subnets []SubnetConfig `json:"subnets"` - NATGateway bool `json:"natGateway"` - SecurityGroups []SecurityGroupConfig `json:"securityGroups"` - Changes []string `json:"changes"` -} - -// networkBackend is the internal interface that provider backends implement. -type networkBackend interface { - plan(m *PlatformNetworking) (*NetworkPlan, error) - apply(m *PlatformNetworking) (*NetworkState, error) - status(m *PlatformNetworking) (*NetworkState, error) - destroy(m *PlatformNetworking) error -} - -// NetworkingBackendFactory creates a networkBackend for a given provider config. -type NetworkingBackendFactory func(cfg map[string]any) (networkBackend, error) - -// networkingBackendRegistry maps provider name to its factory. -var networkingBackendRegistry = map[string]NetworkingBackendFactory{} - -// RegisterNetworkingBackend registers a NetworkingBackendFactory for the given provider name. -func RegisterNetworkingBackend(provider string, factory NetworkingBackendFactory) { - networkingBackendRegistry[provider] = factory -} - -func init() { - RegisterNetworkingBackend("mock", func(_ map[string]any) (networkBackend, error) { - return &mockNetworkBackend{}, nil - }) - RegisterNetworkingBackend("aws", func(_ map[string]any) (networkBackend, error) { - return &awsNetworkBackend{}, nil - }) -} - -// PlatformNetworking manages VPC/subnet/security-group resources via pluggable backends. -// Config: -// -// account: name of a cloud.account module (optional for mock) -// provider: mock | aws | gcp | azure -// vpc: VPC config (cidr, name) -// subnets: list of subnet definitions -// nat_gateway: bool — provision a NAT gateway -// security_groups: list of security group definitions -type PlatformNetworking struct { - name string - config map[string]any - provider CloudCredentialProvider - state *NetworkState - backend networkBackend -} - -// NewPlatformNetworking creates a new PlatformNetworking module. -func NewPlatformNetworking(name string, cfg map[string]any) *PlatformNetworking { - return &PlatformNetworking{name: name, config: cfg} -} - -// Name returns the module name. -func (m *PlatformNetworking) Name() string { return m.name } - -// Init resolves the cloud.account service and initialises the backend. -func (m *PlatformNetworking) Init(app modular.Application) error { - accountName, _ := m.config["account"].(string) - if accountName != "" { - svc, ok := app.SvcRegistry()[accountName] - if !ok { - return fmt.Errorf("platform.networking %q: account service %q not found", m.name, accountName) - } - provider, ok := svc.(CloudCredentialProvider) - if !ok { - return fmt.Errorf("platform.networking %q: service %q does not implement CloudCredentialProvider", m.name, accountName) - } - m.provider = provider - } - - // Validate VPC config - vpc := m.vpcConfig() - if vpc.CIDR == "" { - return fmt.Errorf("platform.networking %q: vpc.cidr is required", m.name) - } - - providerType, _ := m.config["provider"].(string) - if providerType == "" { - providerType = "mock" - } - - factory, ok := networkingBackendRegistry[providerType] - if !ok { - return fmt.Errorf("platform.networking %q: unsupported provider %q", m.name, providerType) - } - backend, err := factory(m.config) - if err != nil { - return fmt.Errorf("platform.networking %q: creating backend: %w", m.name, err) - } - m.backend = backend - - m.state = &NetworkState{ - SubnetIDs: make(map[string]string), - SecurityGroupIDs: make(map[string]string), - Status: "planned", - } - - return app.RegisterService(m.name, m) -} - -// ProvidesServices declares the service this module provides. -func (m *PlatformNetworking) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "Networking: " + m.name, Instance: m}, - } -} - -// RequiresServices returns nil — cloud.account is resolved by name, not declared. -func (m *PlatformNetworking) RequiresServices() []modular.ServiceDependency { - return nil -} - -// Plan returns the changes that would be made to bring the network to desired state. -func (m *PlatformNetworking) Plan() (*NetworkPlan, error) { - return m.backend.plan(m) -} - -// Apply provisions the VPC/subnets/security groups. -func (m *PlatformNetworking) Apply() (*NetworkState, error) { - return m.backend.apply(m) -} - -// Status returns the current network state. -func (m *PlatformNetworking) Status() (any, error) { - return m.backend.status(m) -} - -// Destroy tears down the VPC and all associated resources. -func (m *PlatformNetworking) Destroy() error { - return m.backend.destroy(m) -} - -// vpcConfig parses the vpc config block. -func (m *PlatformNetworking) vpcConfig() VPCConfig { - raw, ok := m.config["vpc"].(map[string]any) - if !ok { - return VPCConfig{} - } - cidr, _ := raw["cidr"].(string) - name, _ := raw["name"].(string) - if name == "" { - name = m.name + "-vpc" - } - return VPCConfig{CIDR: cidr, Name: name} -} - -// subnets parses the subnets config list. -func (m *PlatformNetworking) subnets() []SubnetConfig { - raw, ok := m.config["subnets"].([]any) - if !ok { - return nil - } - var result []SubnetConfig - for _, item := range raw { - s, ok := item.(map[string]any) - if !ok { - continue - } - name, _ := s["name"].(string) - cidr, _ := s["cidr"].(string) - az, _ := s["az"].(string) - public, _ := s["public"].(bool) - result = append(result, SubnetConfig{Name: name, CIDR: cidr, AZ: az, Public: public}) - } - return result -} - -// natGateway returns whether a NAT gateway should be provisioned. -func (m *PlatformNetworking) natGateway() bool { - v, _ := m.config["nat_gateway"].(bool) - return v -} - -// securityGroups parses the security_groups config list. -func (m *PlatformNetworking) securityGroups() []SecurityGroupConfig { - raw, ok := m.config["security_groups"].([]any) - if !ok { - return nil - } - var result []SecurityGroupConfig - for _, item := range raw { - sg, ok := item.(map[string]any) - if !ok { - continue - } - name, _ := sg["name"].(string) - var rules []SecurityGroupRule - if rawRules, ok := sg["rules"].([]any); ok { - for _, r := range rawRules { - rule, ok := r.(map[string]any) - if !ok { - continue - } - proto, _ := rule["protocol"].(string) - source, _ := rule["source"].(string) - port, _ := intFromAny(rule["port"]) - rules = append(rules, SecurityGroupRule{Protocol: proto, Port: port, Source: source}) - } - } - result = append(result, SecurityGroupConfig{Name: name, Rules: rules}) - } - return result -} - -// ─── mock backend ───────────────────────────────────────────────────────────── - -// mockNetworkBackend implements networkBackend using in-memory state. -type mockNetworkBackend struct{} - -func (b *mockNetworkBackend) plan(m *PlatformNetworking) (*NetworkPlan, error) { - vpc := m.vpcConfig() - subnets := m.subnets() - sgs := m.securityGroups() - nat := m.natGateway() - - plan := &NetworkPlan{ - VPC: vpc, - Subnets: subnets, - NATGateway: nat, - SecurityGroups: sgs, - } - - switch m.state.Status { - case "active": - plan.Changes = []string{"noop: network already active"} - default: - plan.Changes = []string{ - fmt.Sprintf("create VPC %q (%s)", vpc.Name, vpc.CIDR), - } - for _, sn := range subnets { - visibility := "private" - if sn.Public { - visibility = "public" - } - plan.Changes = append(plan.Changes, fmt.Sprintf("create %s subnet %q (%s) in %s", visibility, sn.Name, sn.CIDR, sn.AZ)) - } - if nat { - plan.Changes = append(plan.Changes, "create NAT gateway") - } - for _, sg := range sgs { - plan.Changes = append(plan.Changes, fmt.Sprintf("create security group %q (%d rules)", sg.Name, len(sg.Rules))) - } - } - - return plan, nil -} - -func (b *mockNetworkBackend) apply(m *PlatformNetworking) (*NetworkState, error) { - if m.state.Status == "active" { - return m.state, nil - } - - vpc := m.vpcConfig() - subnets := m.subnets() - sgs := m.securityGroups() - - m.state.VPCID = fmt.Sprintf("vpc-mock-%s", vpc.Name) - m.state.SubnetIDs = make(map[string]string) - for _, sn := range subnets { - m.state.SubnetIDs[sn.Name] = fmt.Sprintf("subnet-mock-%s", sn.Name) - } - m.state.SecurityGroupIDs = make(map[string]string) - for _, sg := range sgs { - m.state.SecurityGroupIDs[sg.Name] = fmt.Sprintf("sg-mock-%s", sg.Name) - } - if m.natGateway() { - m.state.NATGatewayID = fmt.Sprintf("nat-mock-%s", m.name) - } - m.state.Status = "active" - - return m.state, nil -} - -func (b *mockNetworkBackend) status(m *PlatformNetworking) (*NetworkState, error) { - return m.state, nil -} - -func (b *mockNetworkBackend) destroy(m *PlatformNetworking) error { - if m.state.Status == "destroyed" { - return nil - } - m.state.Status = "destroying" - m.state.VPCID = "" - m.state.SubnetIDs = make(map[string]string) - m.state.SecurityGroupIDs = make(map[string]string) - m.state.NATGatewayID = "" - m.state.Status = "destroyed" - return nil -} - -// ─── AWS EC2 backend ────────────────────────────────────────────────────────── - -// awsNetworkBackend manages AWS VPC networking using aws-sdk-go-v2/service/ec2. -type awsNetworkBackend struct{} - -func (b *awsNetworkBackend) plan(m *PlatformNetworking) (*NetworkPlan, error) { - awsProv, ok := awsProviderFrom(m.provider) - vpc := m.vpcConfig() - if !ok { - return &NetworkPlan{ - VPC: vpc, - Subnets: m.subnets(), - NATGateway: m.natGateway(), - SecurityGroups: m.securityGroups(), - Changes: []string{fmt.Sprintf("create VPC %q (%s)", vpc.Name, vpc.CIDR)}, - }, nil - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return nil, fmt.Errorf("aws network plan: AWS config: %w", err) - } - client := ec2.NewFromConfig(cfg) - - // Check if VPC already exists by Name tag - descOut, err := client.DescribeVpcs(context.Background(), &ec2.DescribeVpcsInput{ - Filters: []ec2types.Filter{ - {Name: aws.String("tag:Name"), Values: []string{vpc.Name}}, - }, - }) - if err != nil { - return nil, fmt.Errorf("aws network plan: DescribeVpcs: %w", err) - } - - plan := &NetworkPlan{ - VPC: vpc, - Subnets: m.subnets(), - NATGateway: m.natGateway(), - SecurityGroups: m.securityGroups(), - } - - if len(descOut.Vpcs) > 0 { - plan.Changes = []string{fmt.Sprintf("noop: VPC %q already exists", vpc.Name)} - } else { - plan.Changes = []string{fmt.Sprintf("create VPC %q (%s)", vpc.Name, vpc.CIDR)} - for _, sn := range m.subnets() { - plan.Changes = append(plan.Changes, fmt.Sprintf("create subnet %q (%s)", sn.Name, sn.CIDR)) - } - for _, sg := range m.securityGroups() { - plan.Changes = append(plan.Changes, fmt.Sprintf("create security group %q", sg.Name)) - } - if m.natGateway() { - plan.Changes = append(plan.Changes, "create NAT gateway") - } - } - return plan, nil -} - -func (b *awsNetworkBackend) apply(m *PlatformNetworking) (*NetworkState, error) { - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return nil, fmt.Errorf("aws network apply: no AWS cloud account configured") - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return nil, fmt.Errorf("aws network apply: AWS config: %w", err) - } - client := ec2.NewFromConfig(cfg) - vpc := m.vpcConfig() - - // Create VPC - vpcOut, err := client.CreateVpc(context.Background(), &ec2.CreateVpcInput{ - CidrBlock: aws.String(vpc.CIDR), - TagSpecifications: []ec2types.TagSpecification{ - { - ResourceType: ec2types.ResourceTypeVpc, - Tags: []ec2types.Tag{{Key: aws.String("Name"), Value: aws.String(vpc.Name)}}, - }, - }, - }) - if err != nil { - return nil, fmt.Errorf("aws network apply: CreateVpc: %w", err) - } - - vpcID := "" - if vpcOut.Vpc != nil && vpcOut.Vpc.VpcId != nil { - vpcID = *vpcOut.Vpc.VpcId - } - m.state.VPCID = vpcID - - // Create Internet Gateway and attach - igwOut, err := client.CreateInternetGateway(context.Background(), &ec2.CreateInternetGatewayInput{}) - if err != nil { - return nil, fmt.Errorf("aws network apply: CreateInternetGateway: %w", err) - } - if igwOut.InternetGateway != nil && igwOut.InternetGateway.InternetGatewayId != nil { - _, _ = client.AttachInternetGateway(context.Background(), &ec2.AttachInternetGatewayInput{ - InternetGatewayId: igwOut.InternetGateway.InternetGatewayId, - VpcId: aws.String(vpcID), - }) - } - - // Create subnets - m.state.SubnetIDs = make(map[string]string) - var firstPublicSubnetID string - for _, sn := range m.subnets() { - snOut, err := client.CreateSubnet(context.Background(), &ec2.CreateSubnetInput{ - VpcId: aws.String(vpcID), - CidrBlock: aws.String(sn.CIDR), - AvailabilityZone: optString(sn.AZ), - TagSpecifications: []ec2types.TagSpecification{ - { - ResourceType: ec2types.ResourceTypeSubnet, - Tags: []ec2types.Tag{{Key: aws.String("Name"), Value: aws.String(sn.Name)}}, - }, - }, - }) - if err != nil { - return nil, fmt.Errorf("aws network apply: CreateSubnet %q: %w", sn.Name, err) - } - if snOut.Subnet != nil && snOut.Subnet.SubnetId != nil { - m.state.SubnetIDs[sn.Name] = *snOut.Subnet.SubnetId - if sn.Public && firstPublicSubnetID == "" { - firstPublicSubnetID = *snOut.Subnet.SubnetId - } - } - } - - // Create NAT gateway if requested - if m.natGateway() && firstPublicSubnetID != "" { - // Allocate EIP for NAT gateway - eipOut, err := client.AllocateAddress(context.Background(), &ec2.AllocateAddressInput{ - Domain: ec2types.DomainTypeVpc, - }) - if err == nil && eipOut.AllocationId != nil { - natOut, err := client.CreateNatGateway(context.Background(), &ec2.CreateNatGatewayInput{ - SubnetId: aws.String(firstPublicSubnetID), - AllocationId: eipOut.AllocationId, - }) - if err != nil { - return nil, fmt.Errorf("aws network apply: CreateNatGateway: %w", err) - } - if natOut.NatGateway != nil && natOut.NatGateway.NatGatewayId != nil { - m.state.NATGatewayID = *natOut.NatGateway.NatGatewayId - } - } - } - - // Create security groups - m.state.SecurityGroupIDs = make(map[string]string) - for _, sg := range m.securityGroups() { - sgOut, err := client.CreateSecurityGroup(context.Background(), &ec2.CreateSecurityGroupInput{ - GroupName: aws.String(sg.Name), - Description: aws.String(fmt.Sprintf("Security group: %s", sg.Name)), - VpcId: aws.String(vpcID), - }) - if err != nil { - return nil, fmt.Errorf("aws network apply: CreateSecurityGroup %q: %w", sg.Name, err) - } - if sgOut.GroupId != nil { - m.state.SecurityGroupIDs[sg.Name] = *sgOut.GroupId - - // Authorize ingress rules - var ipPerms []ec2types.IpPermission - for _, rule := range sg.Rules { - rulePort := safeIntToInt32(rule.Port) - ipPerms = append(ipPerms, ec2types.IpPermission{ - IpProtocol: aws.String(rule.Protocol), - FromPort: aws.Int32(rulePort), - ToPort: aws.Int32(rulePort), - IpRanges: []ec2types.IpRange{{CidrIp: aws.String(rule.Source)}}, - }) - } - if len(ipPerms) > 0 { - if _, err := client.AuthorizeSecurityGroupIngress(context.Background(), &ec2.AuthorizeSecurityGroupIngressInput{ - GroupId: sgOut.GroupId, - IpPermissions: ipPerms, - }); err != nil { - return nil, fmt.Errorf("aws network apply: AuthorizeSecurityGroupIngress %q: %w", sg.Name, err) - } - } - } - } - - m.state.Status = "active" - return m.state, nil -} - -func (b *awsNetworkBackend) status(m *PlatformNetworking) (*NetworkState, error) { - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return m.state, nil - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return m.state, fmt.Errorf("aws network status: AWS config: %w", err) - } - client := ec2.NewFromConfig(cfg) - - if m.state.VPCID == "" { - vpc := m.vpcConfig() - descOut, err := client.DescribeVpcs(context.Background(), &ec2.DescribeVpcsInput{ - Filters: []ec2types.Filter{ - {Name: aws.String("tag:Name"), Values: []string{vpc.Name}}, - }, - }) - if err == nil && len(descOut.Vpcs) > 0 && descOut.Vpcs[0].VpcId != nil { - m.state.VPCID = *descOut.Vpcs[0].VpcId - m.state.Status = "active" - } else { - m.state.Status = "not-found" - } - return m.state, nil - } - - descOut, err := client.DescribeVpcs(context.Background(), &ec2.DescribeVpcsInput{ - VpcIds: []string{m.state.VPCID}, - }) - if err != nil { - return m.state, fmt.Errorf("aws network status: DescribeVpcs: %w", err) - } - if len(descOut.Vpcs) > 0 { - m.state.Status = "active" - } else { - m.state.Status = "not-found" - } - return m.state, nil -} - -func (b *awsNetworkBackend) destroy(m *PlatformNetworking) error { - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return fmt.Errorf("aws network destroy: no AWS cloud account configured") - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return fmt.Errorf("aws network destroy: AWS config: %w", err) - } - client := ec2.NewFromConfig(cfg) - - var destroyErrs []string - - // Delete security groups - for name, sgID := range m.state.SecurityGroupIDs { - if _, err := client.DeleteSecurityGroup(context.Background(), &ec2.DeleteSecurityGroupInput{ - GroupId: aws.String(sgID), - }); err != nil { - destroyErrs = append(destroyErrs, fmt.Sprintf("DeleteSecurityGroup %q: %v", name, err)) - } - } - - // Delete subnets - for name, snID := range m.state.SubnetIDs { - if _, err := client.DeleteSubnet(context.Background(), &ec2.DeleteSubnetInput{ - SubnetId: aws.String(snID), - }); err != nil { - destroyErrs = append(destroyErrs, fmt.Sprintf("DeleteSubnet %q: %v", name, err)) - } - } - - // Delete NAT gateway - if m.state.NATGatewayID != "" { - if _, err := client.DeleteNatGateway(context.Background(), &ec2.DeleteNatGatewayInput{ - NatGatewayId: aws.String(m.state.NATGatewayID), - }); err != nil { - destroyErrs = append(destroyErrs, fmt.Sprintf("DeleteNatGateway: %v", err)) - } - } - - // Delete VPC - if m.state.VPCID != "" { - if _, err := client.DeleteVpc(context.Background(), &ec2.DeleteVpcInput{ - VpcId: aws.String(m.state.VPCID), - }); err != nil { - destroyErrs = append(destroyErrs, fmt.Sprintf("DeleteVpc: %v", err)) - } - } - - if len(destroyErrs) > 0 { - return fmt.Errorf("aws network destroy: %s", strings.Join(destroyErrs, "; ")) - } - - m.state.Status = "destroyed" - m.state.VPCID = "" - m.state.SubnetIDs = make(map[string]string) - m.state.SecurityGroupIDs = make(map[string]string) - m.state.NATGatewayID = "" - return nil -} diff --git a/module/platform_networking_test.go b/module/platform_networking_test.go deleted file mode 100644 index e982cdf8..00000000 --- a/module/platform_networking_test.go +++ /dev/null @@ -1,408 +0,0 @@ -package module_test - -import ( - "context" - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -// ─── helpers ───────────────────────────────────────────────────────────────── - -func newTestNetworkConfig() map[string]any { - return map[string]any{ - "provider": "mock", - "vpc": map[string]any{ - "cidr": "10.0.0.0/16", - "name": "test-vpc", - }, - "subnets": []any{ - map[string]any{ - "name": "public-a", - "cidr": "10.0.1.0/24", - "az": "us-east-1a", - "public": true, - }, - map[string]any{ - "name": "private-a", - "cidr": "10.0.10.0/24", - "az": "us-east-1a", - "public": false, - }, - }, - "nat_gateway": true, - "security_groups": []any{ - map[string]any{ - "name": "web", - "rules": []any{ - map[string]any{ - "protocol": "tcp", - "port": float64(443), - "source": "0.0.0.0/0", - }, - map[string]any{ - "protocol": "tcp", - "port": float64(80), - "source": "0.0.0.0/0", - }, - }, - }, - }, - } -} - -func setupNetworkApp(t *testing.T) (*module.MockApplication, *module.PlatformNetworking) { - t.Helper() - app := module.NewMockApplication() - m := module.NewPlatformNetworking("prod-network", newTestNetworkConfig()) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - return app, m -} - -// ─── module tests ───────────────────────────────────────────────────────────── - -func TestPlatformNetworking_Init(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformNetworking("prod-network", newTestNetworkConfig()) - if err := m.Init(app); err != nil { - t.Fatalf("Init failed: %v", err) - } - if m.Name() != "prod-network" { - t.Errorf("expected name=prod-network, got %q", m.Name()) - } - // Module should register itself in the service registry - if _, ok := app.Services["prod-network"]; !ok { - t.Error("expected prod-network in service registry") - } -} - -func TestPlatformNetworking_Plan(t *testing.T) { - _, m := setupNetworkApp(t) - - plan, err := m.Plan() - if err != nil { - t.Fatalf("Plan failed: %v", err) - } - if plan.VPC.CIDR != "10.0.0.0/16" { - t.Errorf("expected vpc cidr=10.0.0.0/16, got %q", plan.VPC.CIDR) - } - if plan.VPC.Name != "test-vpc" { - t.Errorf("expected vpc name=test-vpc, got %q", plan.VPC.Name) - } - if len(plan.Subnets) != 2 { - t.Errorf("expected 2 subnets, got %d", len(plan.Subnets)) - } - if !plan.NATGateway { - t.Error("expected nat_gateway=true") - } - if len(plan.SecurityGroups) != 1 { - t.Errorf("expected 1 security group, got %d", len(plan.SecurityGroups)) - } - if len(plan.SecurityGroups[0].Rules) != 2 { - t.Errorf("expected 2 security group rules, got %d", len(plan.SecurityGroups[0].Rules)) - } - if len(plan.Changes) == 0 { - t.Error("expected non-empty changes list") - } -} - -func TestPlatformNetworking_Apply(t *testing.T) { - _, m := setupNetworkApp(t) - - state, err := m.Apply() - if err != nil { - t.Fatalf("Apply failed: %v", err) - } - if state.Status != "active" { - t.Errorf("expected status=active, got %q", state.Status) - } - if state.VPCID == "" { - t.Error("expected non-empty VPCID after apply") - } - if len(state.SubnetIDs) != 2 { - t.Errorf("expected 2 subnet IDs, got %d", len(state.SubnetIDs)) - } - if state.SubnetIDs["public-a"] == "" { - t.Error("expected public-a subnet ID to be set") - } - if state.SubnetIDs["private-a"] == "" { - t.Error("expected private-a subnet ID to be set") - } - if state.NATGatewayID == "" { - t.Error("expected non-empty NAT gateway ID after apply") - } - if state.SecurityGroupIDs["web"] == "" { - t.Error("expected web security group ID to be set") - } -} - -func TestPlatformNetworking_Status(t *testing.T) { - _, m := setupNetworkApp(t) - - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - - st, err := m.Status() - if err != nil { - t.Fatalf("Status failed: %v", err) - } - state, ok := st.(*module.NetworkState) - if !ok { - t.Fatalf("Status returned unexpected type %T", st) - } - if state.Status != "active" { - t.Errorf("expected status=active, got %q", state.Status) - } -} - -func TestPlatformNetworking_PlanAfterApply_NoChanges(t *testing.T) { - _, m := setupNetworkApp(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) - } - // After apply the network is active; plan should show noop - if len(plan.Changes) == 0 { - t.Error("expected at least one change entry") - } - if plan.Changes[0] != "noop: network already active" { - t.Errorf("expected noop change, got %q", plan.Changes[0]) - } -} - -func TestPlatformNetworking_Destroy(t *testing.T) { - _, m := setupNetworkApp(t) - - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - - if err := m.Destroy(); err != nil { - t.Fatalf("Destroy failed: %v", err) - } - - st, err := m.Status() - if err != nil { - t.Fatalf("Status after destroy: %v", err) - } - state := st.(*module.NetworkState) - if state.Status != "destroyed" { - t.Errorf("expected status=destroyed, got %q", state.Status) - } - if state.VPCID != "" { - t.Error("expected VPCID to be empty after destroy") - } -} - -func TestPlatformNetworking_ApplyIdempotent(t *testing.T) { - _, m := setupNetworkApp(t) - - state1, err := m.Apply() - if err != nil { - t.Fatalf("first Apply: %v", err) - } - state2, err := m.Apply() - if err != nil { - t.Fatalf("second Apply: %v", err) - } - // Second apply should return the same VPCID - if state1.VPCID != state2.VPCID { - t.Errorf("expected same VPCID on second apply, got %q vs %q", state1.VPCID, state2.VPCID) - } -} - -func TestPlatformNetworking_InvalidConfig_MissingVPCCIDR(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformNetworking("bad-net", map[string]any{ - "provider": "mock", - "vpc": map[string]any{ - "name": "no-cidr", - }, - }) - if err := m.Init(app); err == nil { - t.Error("expected error for missing vpc.cidr, got nil") - } -} - -func TestPlatformNetworking_InvalidProvider(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformNetworking("bad-net", map[string]any{ - "provider": "digitalocean", - "vpc": map[string]any{"cidr": "10.0.0.0/16"}, - }) - if err := m.Init(app); err == nil { - t.Error("expected error for unsupported provider, got nil") - } -} - -func TestPlatformNetworking_CloudAccountResolution(t *testing.T) { - app := module.NewMockApplication() - acc := module.NewCloudAccount("aws-prod", map[string]any{ - "provider": "mock", - "region": "us-east-1", - }) - if err := acc.Init(app); err != nil { - t.Fatalf("cloud account Init: %v", err) - } - - cfg := newTestNetworkConfig() - cfg["account"] = "aws-prod" - m := module.NewPlatformNetworking("net-with-account", cfg) - if err := m.Init(app); err != nil { - t.Fatalf("networking Init: %v", err) - } -} - -func TestPlatformNetworking_InvalidAccount(t *testing.T) { - app := module.NewMockApplication() - cfg := newTestNetworkConfig() - cfg["account"] = "nonexistent" - m := module.NewPlatformNetworking("bad-net", cfg) - if err := m.Init(app); err == nil { - t.Error("expected error for nonexistent account, got nil") - } -} - -func TestPlatformNetworking_AWSStubPlan(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformNetworking("aws-net", map[string]any{ - "provider": "aws", - "vpc": map[string]any{"cidr": "10.0.0.0/16", "name": "aws-vpc"}, - }) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - plan, err := m.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) - } - if len(plan.Changes) == 0 { - t.Fatal("expected at least one change") - } -} - -func TestPlatformNetworking_AWSApplyNotImplemented(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformNetworking("aws-net", map[string]any{ - "provider": "aws", - "vpc": map[string]any{"cidr": "10.0.0.0/16"}, - }) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - if _, err := m.Apply(); err == nil { - t.Error("expected error from AWS Apply stub, got nil") - } -} - -// ─── pipeline step tests ────────────────────────────────────────────────────── - -func TestNetworkPlanStep(t *testing.T) { - app, _ := setupNetworkApp(t) - factory := module.NewNetworkPlanStepFactory() - step, err := factory("plan", map[string]any{"network": "prod-network"}, 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["network"] != "prod-network" { - t.Errorf("expected network=prod-network, got %v", result.Output["network"]) - } - if result.Output["changes"] == nil { - t.Error("expected changes in output") - } - if result.Output["vpc"] == nil { - t.Error("expected vpc in output") - } -} - -func TestNetworkApplyStep(t *testing.T) { - app, _ := setupNetworkApp(t) - factory := module.NewNetworkApplyStepFactory() - step, err := factory("apply", map[string]any{"network": "prod-network"}, 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["status"] != "active" { - t.Errorf("expected status=active, got %v", result.Output["status"]) - } - if result.Output["vpcId"] == "" { - t.Error("expected non-empty vpcId in output") - } -} - -func TestNetworkStatusStep(t *testing.T) { - app, m := setupNetworkApp(t) - - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - - factory := module.NewNetworkStatusStepFactory() - step, err := factory("status", map[string]any{"network": "prod-network"}, 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["network"] != "prod-network" { - t.Errorf("expected network=prod-network, got %v", result.Output["network"]) - } - st := result.Output["status"].(*module.NetworkState) - if st.Status != "active" { - t.Errorf("expected status=active, got %q", st.Status) - } -} - -func TestNetworkPlanStep_MissingNetwork(t *testing.T) { - factory := module.NewNetworkPlanStepFactory() - _, err := factory("plan", map[string]any{}, module.NewMockApplication()) - if err == nil { - t.Error("expected error for missing network, got nil") - } -} - -func TestNetworkPlanStep_NetworkNotFound(t *testing.T) { - factory := module.NewNetworkPlanStepFactory() - step, err := factory("plan", map[string]any{"network": "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 network service, got nil") - } -} - -func TestNetworkApplyStep_MissingNetwork(t *testing.T) { - factory := module.NewNetworkApplyStepFactory() - _, err := factory("apply", map[string]any{}, module.NewMockApplication()) - if err == nil { - t.Error("expected error for missing network, got nil") - } -} - -func TestNetworkStatusStep_MissingNetwork(t *testing.T) { - factory := module.NewNetworkStatusStepFactory() - _, err := factory("status", map[string]any{}, module.NewMockApplication()) - if err == nil { - t.Error("expected error for missing network, got nil") - } -} From 87f4dd5c1d6879c9b01f3fdf93ec3e975bb17b45 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 08:10:52 -0400 Subject: [PATCH 11/18] =?UTF-8?q?feat(#653):=20T2=20=E2=80=94=20replace=20?= =?UTF-8?q?Route53=20backend=20with=20migration=20error=20stub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/platform_dns.go | 2 +- module/platform_dns_backends.go | 285 +++----------------------------- module/platform_dns_test.go | 38 ++--- 3 files changed, 38 insertions(+), 287 deletions(-) diff --git a/module/platform_dns.go b/module/platform_dns.go index 66d34719..4b05ef1f 100644 --- a/module/platform_dns.go +++ b/module/platform_dns.go @@ -68,7 +68,7 @@ func init() { return &mockDNSBackend{}, nil }) RegisterDNSBackend("aws", func(_ map[string]any) (dnsBackend, error) { - return &route53Backend{}, nil + return &awsRoute53ErrorBackend{}, nil }) } diff --git a/module/platform_dns_backends.go b/module/platform_dns_backends.go index 106b84a3..93f857bf 100644 --- a/module/platform_dns_backends.go +++ b/module/platform_dns_backends.go @@ -1,13 +1,7 @@ package module import ( - "context" "fmt" - "strings" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/route53" - r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" ) // ─── Mock backend ───────────────────────────────────────────────────────────── @@ -88,271 +82,36 @@ func (b *mockDNSBackend) destroyDNS(m *PlatformDNS) error { return nil } -// ─── Route53 backend ────────────────────────────────────────────────────────── - -// route53Backend manages Amazon Route 53 hosted zones and records -// using aws-sdk-go-v2/service/route53. -type route53Backend struct{} - -func (b *route53Backend) planDNS(m *PlatformDNS) (*DNSPlan, error) { - zone := m.zoneConfig() - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return &DNSPlan{ - Zone: zone, - Records: m.recordConfigs(), - Changes: []string{fmt.Sprintf("create Route53 hosted zone %q", zone.Name)}, - }, nil - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return nil, fmt.Errorf("route53 plan: AWS config: %w", err) - } - client := route53.NewFromConfig(cfg) - - // Check if zone already exists - listOut, err := client.ListHostedZonesByName(context.Background(), &route53.ListHostedZonesByNameInput{ - DNSName: aws.String(zone.Name), - }) - if err != nil { - return nil, fmt.Errorf("route53 plan: ListHostedZonesByName: %w", err) - } +// ─── AWS Route53 migration error backend ────────────────────────────────────── - plan := &DNSPlan{Zone: zone, Records: m.recordConfigs()} - for _, hz := range listOut.HostedZones { - if hz.Name != nil && strings.TrimSuffix(*hz.Name, ".") == strings.TrimSuffix(zone.Name, ".") { - plan.Changes = []string{fmt.Sprintf("noop: Route53 zone %q already exists", zone.Name)} - return plan, nil - } - } +// awsRoute53ErrorBackend is registered under provider "aws" after the Route53 +// backend was removed from workflow core in v0.53.0 (issue #653). +// All methods return the actionable migration error directing the operator to +// infra.dns + workflow-plugin-aws. +type awsRoute53ErrorBackend struct{} - plan.Changes = []string{fmt.Sprintf("create Route53 hosted zone %q", zone.Name)} - for _, r := range m.recordConfigs() { - plan.Changes = append(plan.Changes, fmt.Sprintf("create %s record %q -> %q", r.Type, r.Name, r.Value)) - } - return plan, nil +func (b *awsRoute53ErrorBackend) planDNS(m *PlatformDNS) (*DNSPlan, error) { + return nil, b.err(m) } -func (b *route53Backend) applyDNS(m *PlatformDNS) (*DNSState, error) { - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return nil, fmt.Errorf("route53 apply: no AWS cloud account configured") - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return nil, fmt.Errorf("route53 apply: AWS config: %w", err) - } - client := route53.NewFromConfig(cfg) - zone := m.zoneConfig() - - // Find or create hosted zone - zoneID := m.state.ZoneID - if zoneID == "" { - listOut, err := client.ListHostedZonesByName(context.Background(), &route53.ListHostedZonesByNameInput{ - DNSName: aws.String(zone.Name), - }) - if err != nil { - return nil, fmt.Errorf("route53 apply: ListHostedZonesByName: %w", err) - } - for _, hz := range listOut.HostedZones { - if hz.Name != nil && strings.TrimSuffix(*hz.Name, ".") == strings.TrimSuffix(zone.Name, ".") { - if hz.Id != nil { - zoneID = strings.TrimPrefix(*hz.Id, "/hostedzone/") - } - break - } - } - } - - if zoneID == "" { - createOut, err := client.CreateHostedZone(context.Background(), &route53.CreateHostedZoneInput{ - Name: aws.String(zone.Name), - CallerReference: aws.String(fmt.Sprintf("workflow-%s", zone.Name)), - HostedZoneConfig: &r53types.HostedZoneConfig{ - Comment: aws.String(zone.Comment), - PrivateZone: zone.Private, - }, - }) - if err != nil { - return nil, fmt.Errorf("route53 apply: CreateHostedZone: %w", err) - } - if createOut.HostedZone != nil && createOut.HostedZone.Id != nil { - zoneID = strings.TrimPrefix(*createOut.HostedZone.Id, "/hostedzone/") - } - } - - // Upsert DNS records - records := m.recordConfigs() - if len(records) > 0 { - var changes []r53types.Change - for _, rec := range records { - rrType, err := r53RecordType(rec.Type) - if err != nil { - continue - } - changes = append(changes, r53types.Change{ - Action: r53types.ChangeActionUpsert, - ResourceRecordSet: &r53types.ResourceRecordSet{ - Name: aws.String(rec.Name), - Type: rrType, - TTL: aws.Int64(int64(rec.TTL)), - ResourceRecords: []r53types.ResourceRecord{ - {Value: aws.String(rec.Value)}, - }, - }, - }) - } - if len(changes) > 0 { - _, err = client.ChangeResourceRecordSets(context.Background(), &route53.ChangeResourceRecordSetsInput{ - HostedZoneId: aws.String(zoneID), - ChangeBatch: &r53types.ChangeBatch{Changes: changes}, - }) - if err != nil { - return nil, fmt.Errorf("route53 apply: ChangeResourceRecordSets: %w", err) - } - } - } - - m.state.ZoneID = zoneID - m.state.ZoneName = zone.Name - m.state.Records = records - m.state.Status = "active" - return m.state, nil +func (b *awsRoute53ErrorBackend) applyDNS(m *PlatformDNS) (*DNSState, error) { + return nil, b.err(m) } -func (b *route53Backend) statusDNS(m *PlatformDNS) (*DNSState, error) { - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return m.state, nil - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return m.state, fmt.Errorf("route53 status: AWS config: %w", err) - } - client := route53.NewFromConfig(cfg) - - if m.state.ZoneID == "" { - m.state.Status = "not-found" - return m.state, nil - } - - _, getErr := client.GetHostedZone(context.Background(), &route53.GetHostedZoneInput{ - Id: aws.String(m.state.ZoneID), - }) - if getErr == nil { - // Zone found — list records - listOut, listErr := client.ListResourceRecordSets(context.Background(), &route53.ListResourceRecordSetsInput{ - HostedZoneId: aws.String(m.state.ZoneID), - }) - if listErr == nil { - var records []DNSRecordConfig - for i := range listOut.ResourceRecordSets { - if listOut.ResourceRecordSets[i].Name == nil { - continue - } - for _, rr := range listOut.ResourceRecordSets[i].ResourceRecords { - if rr.Value == nil { - continue - } - ttl := 300 - if listOut.ResourceRecordSets[i].TTL != nil { - ttl = int(*listOut.ResourceRecordSets[i].TTL) - } - records = append(records, DNSRecordConfig{ - Name: *listOut.ResourceRecordSets[i].Name, - Type: string(listOut.ResourceRecordSets[i].Type), - Value: *rr.Value, - TTL: ttl, - }) - } - } - m.state.Records = records - } - m.state.Status = "active" - } else { - m.state.Status = "not-found" - } - return m.state, nil +func (b *awsRoute53ErrorBackend) statusDNS(m *PlatformDNS) (*DNSState, error) { + return nil, b.err(m) } -func (b *route53Backend) destroyDNS(m *PlatformDNS) error { - awsProv, ok := awsProviderFrom(m.provider) - if !ok { - return fmt.Errorf("route53 destroy: no AWS cloud account configured") - } - - cfg, err := awsProv.AWSConfig(context.Background()) - if err != nil { - return fmt.Errorf("route53 destroy: AWS config: %w", err) - } - client := route53.NewFromConfig(cfg) - - if m.state.ZoneID == "" { - return nil - } - - // Delete all non-NS/SOA records before deleting the zone - listOut, listErr := client.ListResourceRecordSets(context.Background(), &route53.ListResourceRecordSetsInput{ - HostedZoneId: aws.String(m.state.ZoneID), - }) - if listErr != nil { - return fmt.Errorf("route53 destroy: ListResourceRecordSets: %w", listErr) - } - var changes []r53types.Change - for i := range listOut.ResourceRecordSets { - if listOut.ResourceRecordSets[i].Type == r53types.RRTypeNs || listOut.ResourceRecordSets[i].Type == r53types.RRTypeSoa { - continue - } - changes = append(changes, r53types.Change{ - Action: r53types.ChangeActionDelete, - ResourceRecordSet: &listOut.ResourceRecordSets[i], - }) - } - if len(changes) > 0 { - if _, err := client.ChangeResourceRecordSets(context.Background(), &route53.ChangeResourceRecordSetsInput{ - HostedZoneId: aws.String(m.state.ZoneID), - ChangeBatch: &r53types.ChangeBatch{Changes: changes}, - }); err != nil { - return fmt.Errorf("route53 destroy: ChangeResourceRecordSets: %w", err) - } - } - - _, err = client.DeleteHostedZone(context.Background(), &route53.DeleteHostedZoneInput{ - Id: aws.String(m.state.ZoneID), - }) - if err != nil { - return fmt.Errorf("route53 destroy: DeleteHostedZone: %w", err) - } - - m.state.Status = "deleted" - m.state.ZoneID = "" - m.state.Records = nil - return nil +func (b *awsRoute53ErrorBackend) destroyDNS(m *PlatformDNS) error { + return b.err(m) } -// r53RecordType maps a DNS record type string to the Route53 RRType. -func r53RecordType(t string) (r53types.RRType, error) { - switch strings.ToUpper(t) { - case "A": - return r53types.RRTypeA, nil - case "AAAA": - return r53types.RRTypeAaaa, nil - case "CNAME": - return r53types.RRTypeCname, nil - case "TXT": - return r53types.RRTypeTxt, nil - case "MX": - return r53types.RRTypeMx, nil - case "NS": - return r53types.RRTypeNs, nil - case "SRV": - return r53types.RRTypeSrv, nil - case "PTR": - return r53types.RRTypePtr, nil - default: - return "", fmt.Errorf("unsupported Route53 record type: %q", t) - } +func (b *awsRoute53ErrorBackend) err(m *PlatformDNS) error { + return fmt.Errorf( + "platform.dns %q: AWS Route53 backend removed from workflow core in v0.53.0 (issue #653).\n"+ + "Migrate to: infra.dns (provider: aws) with workflow-plugin-aws v0.2.0+.\n"+ + "Install: https://github.com/GoCodeAlone/workflow-plugin-aws\n"+ + "See docs/migrations/v0.53.0-aws-iac-removal.md", + m.name, + ) } diff --git a/module/platform_dns_test.go b/module/platform_dns_test.go index 79a15ed0..2c297021 100644 --- a/module/platform_dns_test.go +++ b/module/platform_dns_test.go @@ -2,6 +2,7 @@ package module_test import ( "context" + "strings" "testing" "github.com/GoCodeAlone/workflow/module" @@ -235,37 +236,28 @@ func TestPlatformDNS_ValidRecordTypes(t *testing.T) { } } -// ─── Route53 stub ───────────────────────────────────────────────────────────── +// ─── AWS Route53 migration error (issue #653) ───────────────────────────────── -func TestPlatformDNS_Route53_PlanReturnsStub(t *testing.T) { +func TestPlatformDNS_AWSBackendMigrationError(t *testing.T) { app := module.NewMockApplication() - m := module.NewPlatformDNS("r53-dns", map[string]any{ + m := module.NewPlatformDNS("test-dns", map[string]any{ "provider": "aws", - "zone": map[string]any{"name": "aws.example.com"}, + "zone": map[string]any{"name": "example.com"}, }) if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - plan, err := m.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) + t.Fatalf("Init should succeed (backend registered): %v", err) } - if len(plan.Changes) == 0 { - t.Fatal("expected at least one change from route53 stub") + // Migration error fires at operation time, not Init time. + _, err := m.Plan() + if err == nil { + t.Fatal("expected migration error from Plan() for provider: aws, got nil") } -} - -func TestPlatformDNS_Route53_ApplyNotImplemented(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDNS("r53-dns", map[string]any{ - "provider": "aws", - "zone": map[string]any{"name": "aws.example.com"}, - }) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) + errStr := err.Error() + if !strings.Contains(errStr, "infra.dns") { + t.Errorf("error should mention infra.dns, got: %s", errStr) } - if _, err := m.Apply(); err == nil { - t.Error("expected error from route53 Apply stub, got nil") + if !strings.Contains(errStr, "workflow-plugin-aws") { + t.Errorf("error should mention workflow-plugin-aws, got: %s", errStr) } } From 456e402cc338918c60908a3c195118418e10fd12 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 08:23:39 -0400 Subject: [PATCH 12/18] feat(#653): strip registration sites + add infra.autoscaling_group (T3) Remove platform.ecs, platform.networking, platform.apigateway, platform.autoscaling module types and their 15 step types (ecs/apigw/scaling/network) from all registration sites: plugins/platform, schema/schema.go, schema/module_schema.go, schema/step_schema_builtins.go, cmd/wfctl/type_registry.go. Add infra.autoscaling_group to plugins/infra and type_registry.go. Update DOCUMENTATION.md with AWS IaC removal notice + infra.autoscaling_group row. Fix multi_region.go error string to reference infra.container_service. Regenerate schema/testdata/editor-schemas.golden.json. Co-Authored-By: Claude Sonnet 4.6 --- DOCUMENTATION.md | 27 +- cmd/wfctl/type_registry.go | 30 +- module/multi_region.go | 2 +- plugins/infra/plugin.go | 1 + plugins/platform/plugin.go | 121 +------- plugins/platform/plugin_test.go | 19 -- schema/module_schema.go | 91 +----- schema/schema.go | 21 +- schema/step_schema_builtins.go | 223 --------------- schema/testdata/editor-schemas.golden.json | 309 +-------------------- 10 files changed, 28 insertions(+), 816 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b4294526..daa17a00 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -272,10 +272,6 @@ flowchart TD | `step.k8s_apply` | Applies a Kubernetes manifest or deployment config | platform | | `step.k8s_status` | Retrieves the status of a Kubernetes workload | platform | | `step.k8s_destroy` | Tears down a Kubernetes workload | platform | -| `step.ecs_plan` | Generates an ECS task/service deployment plan | platform | -| `step.ecs_apply` | Deploys a task or service to AWS ECS | platform | -| `step.ecs_status` | Retrieves the status of an ECS service | platform | -| `step.ecs_destroy` | Removes an ECS task or service | platform | | `step.iac_plan` | Plans IaC changes (Terraform plan, Pulumi preview, etc.) | platform | | `step.iac_apply` | Applies IaC changes | platform | | `step.iac_status` | Retrieves the current state of an IaC stack | platform | @@ -297,17 +293,6 @@ flowchart TD | `step.dns_plan` | Plans DNS record changes | platform | | `step.dns_apply` | Applies DNS record changes | platform | | `step.dns_status` | Retrieves the current DNS records for a domain | platform | -| `step.network_plan` | Plans networking resource changes (VPC, subnets, etc.) | platform | -| `step.network_apply` | Applies networking resource changes | platform | -| `step.network_status` | Retrieves the status of networking resources | platform | -| `step.apigw_plan` | Plans API gateway configuration changes | platform | -| `step.apigw_apply` | Applies API gateway configuration changes | platform | -| `step.apigw_status` | Retrieves API gateway deployment status | platform | -| `step.apigw_destroy` | Removes an API gateway configuration | platform | -| `step.scaling_plan` | Plans auto-scaling policy changes | platform | -| `step.scaling_apply` | Applies auto-scaling policies | platform | -| `step.scaling_status` | Retrieves current auto-scaling state | platform | -| `step.scaling_destroy` | Removes auto-scaling policies | platform | | `step.app_deploy` | Deploys a containerized application | platform | | `step.app_status` | Retrieves deployment status of an application | platform | | `step.app_rollback` | Rolls back an application to a previous deployment | platform | @@ -503,11 +488,7 @@ Strict mode applies to **both** direct dot-access (`{{ .steps.auth.field }}`) an | `platform.resource` | Infrastructure resource managed by a platform provider | platform | | `platform.context` | Execution context for platform operations (org, environment, tier) | platform | | `platform.kubernetes` | Kubernetes cluster deployment target | platform | -| `platform.ecs` | AWS ECS cluster deployment target | platform | -| `platform.dns` | DNS provider for managing records (Route53, CloudFlare, etc.) | platform | -| `platform.networking` | VPC and networking resource management | platform | -| `platform.apigateway` | API gateway resource management (AWS API GW, etc.) | platform | -| `platform.autoscaling` | Auto-scaling policy and target management | platform | +| `platform.dns` | DNS provider for managing records (mock; AWS Route53 backend removed in v0.53.0) | platform | | `platform.region` | Multi-region deployment configuration | platform | | `platform.region_router` | Routes traffic across regions by weight, latency, or failover | platform | @@ -516,6 +497,11 @@ Strict mode applies to **both** direct dot-access (`{{ .steps.auth.field }}`) an 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). + +**AWS IaC modules** (`platform.ecs`, `platform.networking`, `platform.apigateway`, `platform.autoscaling`) were removed from workflow core in v0.53.0 and are provided by the +[workflow-plugin-aws](https://github.com/GoCodeAlone/workflow-plugin-aws) v0.2.0+ plugin. +Use the generic `infra.*` module types with `provider: aws` and `step.iac_*` pipeline steps. +See [v0.53.0 migration guide](docs/migrations/v0.53.0-aws-iac-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 | @@ -531,6 +517,7 @@ steps. See [v0.52.0 migration guide](docs/migrations/v0.52.0-godo-removal.md). | `infra.iam_role` | IAM role and policy creation and management | platform | | `infra.storage` | Object storage provisioning and access configuration | platform | | `infra.certificate` | SSL/TLS certificate provisioning and renewal | platform | +| `infra.autoscaling_group` | Auto-scaling group provisioning and policy management | platform | | `app.container` | Containerised application deployment descriptor | platform | | `argo.workflows` | Argo Workflows integration for Kubernetes-native workflow orchestration | platform | | `aws.codebuild` | AWS CodeBuild project and build management | cicd | diff --git a/cmd/wfctl/type_registry.go b/cmd/wfctl/type_registry.go index 8945ceed..18d53985 100644 --- a/cmd/wfctl/type_registry.go +++ b/cmd/wfctl/type_registry.go @@ -519,36 +519,12 @@ func KnownModuleTypes() map[string]ModuleTypeInfo { Stateful: false, ConfigKeys: []string{"account", "cluster", "namespace", "kubeconfig"}, }, - "platform.ecs": { - Type: "platform.ecs", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "cluster", "region"}, - }, "platform.dns": { Type: "platform.dns", Plugin: "platform", Stateful: false, ConfigKeys: []string{"account", "provider", "domain"}, }, - "platform.networking": { - Type: "platform.networking", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "provider", "vpc"}, - }, - "platform.apigateway": { - Type: "platform.apigateway", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "provider", "name", "region"}, - }, - "platform.autoscaling": { - Type: "platform.autoscaling", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "provider", "resource"}, - }, "platform.region": { Type: "platform.region", Plugin: "platform", @@ -653,6 +629,12 @@ func KnownModuleTypes() map[string]ModuleTypeInfo { Stateful: false, ConfigKeys: []string{"provider", "size", "resources"}, }, + "infra.autoscaling_group": { + Type: "infra.autoscaling_group", + Plugin: "infra", + Stateful: false, + ConfigKeys: []string{"provider", "size", "resources"}, + }, // actors plugin "actor.system": { diff --git a/module/multi_region.go b/module/multi_region.go index e29082ca..b67f576c 100644 --- a/module/multi_region.go +++ b/module/multi_region.go @@ -114,7 +114,7 @@ func (m *MultiRegionModule) Init(app modular.Application) error { case "mock": m.backend = &mockMultiRegionBackend{} case "aws": - return fmt.Errorf("platform.region %q: provider %q is not yet supported; use AWS Route53/ALB directly via platform.kubernetes or platform.ecs modules", m.name, providerType) + return fmt.Errorf("platform.region %q: provider %q is not yet supported; use AWS Route53/ALB directly via platform.kubernetes or infra.container_service (workflow-plugin-aws) modules", m.name, providerType) case "gcp": return fmt.Errorf("platform.region %q: provider %q is not yet supported; use GKE modules with Cloud Load Balancing for multi-region routing", m.name, providerType) case "azure": diff --git a/plugins/infra/plugin.go b/plugins/infra/plugin.go index c9e1e616..96670bc5 100644 --- a/plugins/infra/plugin.go +++ b/plugins/infra/plugin.go @@ -26,6 +26,7 @@ var infraTypes = []string{ "infra.iam_role", "infra.storage", "infra.certificate", + "infra.autoscaling_group", } // Plugin registers all infra.* abstract module types. diff --git a/plugins/platform/plugin.go b/plugins/platform/plugin.go index c416a25a..816e71c4 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", "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"}, + ModuleTypes: []string{"platform.provider", "platform.resource", "platform.context", "platform.kubernetes", "platform.dns", "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.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.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"}, }, @@ -46,21 +46,9 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { "platform.kubernetes": func(name string, cfg map[string]any) modular.Module { return module.NewPlatformKubernetes(name, cfg) }, - "platform.ecs": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformECS(name, cfg) - }, "platform.dns": func(name string, cfg map[string]any) modular.Module { return module.NewPlatformDNS(name, cfg) }, - "platform.networking": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformNetworking(name, cfg) - }, - "platform.apigateway": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformAPIGateway(name, cfg) - }, - "platform.autoscaling": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformAutoscaling(name, cfg) - }, "iac.state": func(name string, cfg map[string]any) modular.Module { return module.NewIaCModule(name, cfg) }, @@ -118,18 +106,6 @@ func (p *Plugin) StepFactories() map[string]plugin.StepFactory { "step.k8s_destroy": func(name string, cfg map[string]any, app modular.Application) (any, error) { return module.NewK8sDestroyStepFactory()(name, cfg, app) }, - "step.ecs_plan": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewECSPlanStepFactory()(name, cfg, app) - }, - "step.ecs_apply": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewECSApplyStepFactory()(name, cfg, app) - }, - "step.ecs_status": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewECSStatusStepFactory()(name, cfg, app) - }, - "step.ecs_destroy": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewECSDestroyStepFactory()(name, cfg, app) - }, "step.iac_plan": func(name string, cfg map[string]any, app modular.Application) (any, error) { return module.NewIaCPlanStepFactory()(name, cfg, app) }, @@ -154,39 +130,6 @@ func (p *Plugin) StepFactories() map[string]plugin.StepFactory { "step.dns_status": func(name string, cfg map[string]any, app modular.Application) (any, error) { return module.NewDNSStatusStepFactory()(name, cfg, app) }, - "step.network_plan": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewNetworkPlanStepFactory()(name, cfg, app) - }, - "step.network_apply": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewNetworkApplyStepFactory()(name, cfg, app) - }, - "step.network_status": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewNetworkStatusStepFactory()(name, cfg, app) - }, - "step.apigw_plan": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewApigwPlanStepFactory()(name, cfg, app) - }, - "step.apigw_apply": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewApigwApplyStepFactory()(name, cfg, app) - }, - "step.apigw_status": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewApigwStatusStepFactory()(name, cfg, app) - }, - "step.apigw_destroy": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewApigwDestroyStepFactory()(name, cfg, app) - }, - "step.scaling_plan": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewScalingPlanStepFactory()(name, cfg, app) - }, - "step.scaling_apply": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewScalingApplyStepFactory()(name, cfg, app) - }, - "step.scaling_status": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewScalingStatusStepFactory()(name, cfg, app) - }, - "step.scaling_destroy": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewScalingDestroyStepFactory()(name, cfg, app) - }, "step.app_deploy": func(name string, cfg map[string]any, app modular.Application) (any, error) { return module.NewAppDeployStepFactory()(name, cfg, app) }, @@ -275,21 +218,6 @@ func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { {Key: "nodeGroups", Label: "Node Groups", Type: schema.FieldTypeArray, Description: "Node group definitions"}, }, }, - { - Type: "platform.ecs", - Label: "ECS Fargate Service", - Category: "infrastructure", - Description: "AWS ECS/Fargate service with task definitions and ALB target group config", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (optional for mock)"}, - {Key: "cluster", Label: "ECS Cluster", Type: schema.FieldTypeString, Required: true, Description: "ECS cluster name"}, - {Key: "region", Label: "AWS Region", Type: schema.FieldTypeString, Description: "AWS region (e.g. us-east-1)"}, - {Key: "launch_type", Label: "Launch Type", Type: schema.FieldTypeString, Description: "FARGATE or EC2 (default: FARGATE)"}, - {Key: "desired_count", Label: "Desired Count", Type: schema.FieldTypeString, Description: "Number of tasks to run (default: 1)"}, - {Key: "vpc_subnets", Label: "VPC Subnets", Type: schema.FieldTypeArray, ArrayItemType: "string", Description: "List of subnet IDs"}, - {Key: "security_groups", Label: "Security Groups", Type: schema.FieldTypeArray, ArrayItemType: "string", Description: "List of security group IDs"}, - }, - }, { Type: "platform.dns", Label: "DNS Zone Manager", @@ -297,50 +225,11 @@ func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { Description: "Manages DNS zones and records (mock or Route53/aws backend)", ConfigFields: []schema.ConfigFieldDef{ {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (optional for mock)"}, - {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | aws (Route53)"}, + {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | aws (aws Route53 backend removed in v0.53.0; use infra.dns + workflow-plugin-aws)"}, {Key: "zone", Label: "Zone Config", Type: schema.FieldTypeMap, Required: true, Description: "Zone configuration (name, comment, private, vpcId)"}, {Key: "records", Label: "DNS Records", Type: schema.FieldTypeArray, Description: "List of DNS record definitions"}, }, }, - { - Type: "platform.networking", - Label: "VPC Networking", - Category: "infrastructure", - Description: "Manages VPC, subnets, NAT gateway, and security groups (mock or AWS backend)", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (optional for mock)"}, - {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | aws"}, - {Key: "vpc", Label: "VPC Config", Type: schema.FieldTypeMap, Required: true, Description: "VPC configuration (cidr, name)"}, - {Key: "subnets", Label: "Subnets", Type: schema.FieldTypeArray, Description: "List of subnet definitions"}, - {Key: "nat_gateway", Label: "NAT Gateway", Type: schema.FieldTypeBool, Description: "Provision a NAT gateway"}, - {Key: "security_groups", Label: "Security Groups", Type: schema.FieldTypeArray, Description: "List of security group definitions"}, - }, - }, - { - Type: "platform.apigateway", - Label: "API Gateway", - Category: "infrastructure", - Description: "Manages API gateway provisioning with routes, stages, and rate limiting (mock or AWS API Gateway v2)", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (optional for mock)"}, - {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | aws"}, - {Key: "name", Label: "Gateway Name", Type: schema.FieldTypeString, Required: true, Description: "API gateway name"}, - {Key: "stage", Label: "Stage", Type: schema.FieldTypeString, Description: "Deployment stage (dev, staging, prod)"}, - {Key: "cors", Label: "CORS Config", Type: schema.FieldTypeMap, Description: "CORS configuration (allow_origins, allow_methods, allow_headers)"}, - {Key: "routes", Label: "Routes", Type: schema.FieldTypeArray, Description: "Route definitions (path, method, target, rate_limit, auth_type)"}, - }, - }, - { - Type: "platform.autoscaling", - Label: "Autoscaling Policies", - Category: "infrastructure", - Description: "Manages autoscaling policies (target tracking, step, scheduled) for AWS or mock resources", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (optional for mock)"}, - {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | aws"}, - {Key: "policies", Label: "Policies", Type: schema.FieldTypeArray, Required: true, Description: "Scaling policy definitions (name, type, target_resource, min_capacity, max_capacity, ...)"}, - }, - }, { Type: "platform.provider", Label: "Platform Provider", @@ -372,9 +261,9 @@ func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { Type: "app.container", Label: "App Container", Category: "application", - Description: "Application deployment abstraction that translates high-level config into platform-specific resources (Kubernetes Deployment+Service or ECS task definition)", + Description: "Application deployment abstraction that translates high-level config into platform-specific resources (Kubernetes Deployment+Service)", ConfigFields: []schema.ConfigFieldDef{ - {Key: "environment", Label: "Environment", Type: schema.FieldTypeString, Required: true, Description: "Name of the platform.kubernetes or platform.ecs module to deploy to"}, + {Key: "environment", Label: "Environment", Type: schema.FieldTypeString, Required: true, Description: "Name of the platform.kubernetes module to deploy to"}, {Key: "image", Label: "Container Image", Type: schema.FieldTypeString, Required: true, Description: "Container image reference (e.g. registry.example.com/my-api:v1.0.0)"}, {Key: "replicas", Label: "Replicas", Type: schema.FieldTypeNumber, Description: "Desired replica count (default: 1)"}, {Key: "ports", Label: "Ports", Type: schema.FieldTypeArray, Description: "List of container port numbers"}, diff --git a/plugins/platform/plugin_test.go b/plugins/platform/plugin_test.go index 42b83253..eaee1efe 100644 --- a/plugins/platform/plugin_test.go +++ b/plugins/platform/plugin_test.go @@ -36,10 +36,6 @@ func TestStepFactories(t *testing.T) { "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", @@ -48,17 +44,6 @@ func TestStepFactories(t *testing.T) { "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", @@ -92,11 +77,7 @@ func TestModuleFactories(t *testing.T) { expectedModules := []string{ "platform.kubernetes", - "platform.ecs", "platform.dns", - "platform.networking", - "platform.apigateway", - "platform.autoscaling", "iac.state", "platform.provider", "platform.resource", diff --git a/schema/module_schema.go b/schema/module_schema.go index 1015d47c..52ac0708 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -2423,10 +2423,10 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { Type: "app.container", Label: "App Container", Category: "infrastructure", - Description: "Application deployment abstraction that translates high-level config into platform-specific resources (Kubernetes Deployment+Service or ECS task definition)", + Description: "Application deployment abstraction that translates high-level config into platform-specific resources (Kubernetes Deployment+Service)", Outputs: []ServiceIODef{{Name: "container", Type: "JSON", Description: "Deployment output with service endpoint and status"}}, ConfigFields: []ConfigFieldDef{ - {Key: "environment", Label: "Environment", Type: FieldTypeString, Required: true, Description: "Name of the platform.kubernetes or platform.ecs module to deploy to"}, + {Key: "environment", Label: "Environment", Type: FieldTypeString, Required: true, Description: "Name of the platform.kubernetes module to deploy to"}, {Key: "image", Label: "Container Image", Type: FieldTypeString, Required: true, Description: "Container image reference (e.g. registry.example.com/my-api:v1.0.0)"}, {Key: "replicas", Label: "Replicas", Type: FieldTypeNumber, Description: "Desired replica count"}, {Key: "ports", Label: "Ports", Type: FieldTypeArray, Description: "List of container port numbers"}, @@ -2636,39 +2636,6 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { }, }) - // ---- Platform API Gateway ---- - - r.Register(&ModuleSchema{ - Type: "platform.apigateway", - Label: "API Gateway", - Category: "infrastructure", - Description: "Manages API gateway provisioning with routes, stages, and rate limiting (mock or AWS API Gateway v2)", - Outputs: []ServiceIODef{{Name: "gateway", Type: "JSON", Description: "Provisioned API gateway endpoint and 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 | aws"}, - {Key: "name", Label: "Gateway Name", Type: FieldTypeString, Required: true, Description: "API gateway name"}, - {Key: "stage", Label: "Stage", Type: FieldTypeString, Description: "Deployment stage (dev, staging, prod)"}, - {Key: "cors", Label: "CORS Config", Type: FieldTypeMap, Description: "CORS configuration (allowedOrigins, allowedMethods, allowedHeaders)"}, - {Key: "routes", Label: "Routes", Type: FieldTypeArray, Description: "Route definitions"}, - }, - }) - - // ---- Platform Autoscaling ---- - - r.Register(&ModuleSchema{ - Type: "platform.autoscaling", - Label: "Autoscaling Policies", - Category: "infrastructure", - Description: "Manages autoscaling policies (target tracking, step, scheduled) for AWS or mock resources", - Outputs: []ServiceIODef{{Name: "policies", Type: "JSON", Description: "Configured autoscaling policies"}}, - ConfigFields: []ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "provider", Label: "Provider", Type: FieldTypeString, Description: "mock | aws"}, - {Key: "policies", Label: "Policies", Type: FieldTypeArray, Required: true, Description: "Scaling policy definitions"}, - }, - }) - // ---- Platform DNS ---- r.Register(&ModuleSchema{ @@ -2679,31 +2646,12 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { Outputs: []ServiceIODef{{Name: "zone", Type: "JSON", Description: "Provisioned DNS zone and record set"}}, ConfigFields: []ConfigFieldDef{ {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "provider", Label: "Provider", Type: FieldTypeString, Description: "mock | aws"}, + {Key: "provider", Label: "Provider", Type: FieldTypeString, Description: "mock | aws (aws Route53 backend removed in v0.53.0; use infra.dns + workflow-plugin-aws)"}, {Key: "zone", Label: "Zone Config", Type: FieldTypeMap, Required: true, Description: "Zone configuration (name, comment, private, vpcId)"}, {Key: "records", Label: "DNS Records", Type: FieldTypeArray, Description: "List of DNS record definitions"}, }, }) - // ---- Platform ECS ---- - - r.Register(&ModuleSchema{ - Type: "platform.ecs", - Label: "ECS Fargate Service", - Category: "infrastructure", - Description: "AWS ECS/Fargate service with task definitions and ALB target group config", - Outputs: []ServiceIODef{{Name: "service", Type: "JSON", Description: "Provisioned ECS Fargate service ARN and endpoint"}}, - ConfigFields: []ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "cluster", Label: "ECS Cluster", Type: FieldTypeString, Required: true, Description: "ECS cluster name"}, - {Key: "region", Label: "AWS Region", Type: FieldTypeString, Description: "AWS region (e.g. us-east-1)"}, - {Key: "launch_type", Label: "Launch Type", Type: FieldTypeString, Description: "FARGATE or EC2"}, - {Key: "desired_count", Label: "Desired Count", Type: FieldTypeString, Description: "Number of tasks to run"}, - {Key: "vpc_subnets", Label: "VPC Subnets", Type: FieldTypeArray, ArrayItemType: "string", Description: "List of subnet IDs"}, - {Key: "security_groups", Label: "Security Groups", Type: FieldTypeArray, ArrayItemType: "string", Description: "List of security group IDs"}, - }, - }) - // ---- Platform Kubernetes ---- r.Register(&ModuleSchema{ @@ -2720,24 +2668,6 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { }, }) - // ---- Platform Networking ---- - - r.Register(&ModuleSchema{ - Type: "platform.networking", - Label: "VPC Networking", - Category: "infrastructure", - Description: "Manages VPC, subnets, NAT gateway, and security groups (mock or AWS backend)", - Outputs: []ServiceIODef{{Name: "vpc", Type: "JSON", Description: "Provisioned VPC with subnets and security groups"}}, - ConfigFields: []ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "provider", Label: "Provider", Type: FieldTypeString, Description: "mock | aws"}, - {Key: "vpc", Label: "VPC Config", Type: FieldTypeMap, Required: true, Description: "VPC configuration (cidr, name)"}, - {Key: "subnets", Label: "Subnets", Type: FieldTypeArray, Description: "List of subnet definitions"}, - {Key: "nat_gateway", Label: "NAT Gateway", Type: FieldTypeBool, Description: "Provision a NAT gateway"}, - {Key: "security_groups", Label: "Security Groups", Type: FieldTypeArray, Description: "List of security group definitions"}, - }, - }) - // ---- Platform Region ---- r.Register(&ModuleSchema{ @@ -2885,10 +2815,6 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { }{ {"step.actor_send", "Actor Send", "Send a message to an actor without waiting for a response"}, {"step.actor_ask", "Actor Ask", "Send a message to an actor and wait for a response"}, - {"step.apigw_apply", "API Gateway Apply", "Applies API gateway configuration"}, - {"step.apigw_destroy", "API Gateway Destroy", "Destroys a provisioned API gateway"}, - {"step.apigw_plan", "API Gateway Plan", "Plans API gateway changes without applying them"}, - {"step.apigw_status", "API Gateway Status", "Gets the current status of an API gateway"}, {"step.app_deploy", "App Deploy", "Deploys an application container"}, {"step.app_rollback", "App Rollback", "Rolls back an application to a previous version"}, {"step.app_status", "App Status", "Gets the deployment status of an application"}, @@ -2913,10 +2839,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.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"}, - {"step.ecs_status", "ECS Status", "Gets the status of an ECS Fargate service"}, {"step.git_checkout", "Git Checkout", "Checks out a Git branch, tag, or commit"}, {"step.git_clone", "Git Clone", "Clones a Git repository"}, {"step.git_commit", "Git Commit", "Creates a Git commit"}, @@ -2942,9 +2864,6 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { {"step.marketplace_search", "Marketplace Search", "Searches the plugin marketplace"}, {"step.marketplace_uninstall", "Marketplace Uninstall", "Uninstalls a marketplace plugin"}, {"step.marketplace_update", "Marketplace Update", "Updates an installed marketplace plugin"}, - {"step.network_apply", "Network Apply", "Applies VPC networking changes"}, - {"step.network_plan", "Network Plan", "Plans VPC networking changes"}, - {"step.network_status", "Network Status", "Gets VPC networking status"}, {"step.nosql_delete", "NoSQL Delete", "Deletes an item from a NoSQL store"}, {"step.policy_evaluate", "Policy Evaluate", "Evaluates input against a policy"}, {"step.policy_list", "Policy List", "Lists loaded policies"}, @@ -2956,10 +2875,6 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { {"step.region_status", "Region Status", "Gets multi-region health status"}, {"step.region_sync", "Region Sync", "Syncs state across regions"}, {"step.region_weight", "Region Weight", "Sets traffic weight for a region"}, - {"step.scaling_apply", "Scaling Apply", "Applies autoscaling policies"}, - {"step.scaling_destroy", "Scaling Destroy", "Removes autoscaling policies"}, - {"step.scaling_plan", "Scaling Plan", "Plans autoscaling changes"}, - {"step.scaling_status", "Scaling Status", "Gets autoscaling status"}, {"step.secret_rotate", "Secret Rotate", "Rotates a secret"}, {"step.trace_annotate", "Trace Annotate", "Adds attributes to the current trace span"}, {"step.trace_extract", "Trace Extract", "Extracts trace context from incoming headers"}, diff --git a/schema/schema.go b/schema/schema.go index fe6f3029..007214ee 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -204,13 +204,9 @@ var coreModuleTypes = []string{ "openapi.consumer", "openapi.generator", "persistence.store", - "platform.apigateway", - "platform.autoscaling", "platform.context", "platform.dns", - "platform.ecs", "platform.kubernetes", - "platform.networking", "platform.provider", "platform.region", "platform.region_router", @@ -232,10 +228,6 @@ var coreModuleTypes = []string{ "step.ai_classify", "step.ai_complete", "step.ai_extract", - "step.apigw_apply", - "step.apigw_destroy", - "step.apigw_plan", - "step.apigw_status", "step.app_deploy", "step.app_rollback", "step.app_status", @@ -295,10 +287,6 @@ var coreModuleTypes = []string{ "step.docker_push", "step.docker_run", "step.drift_check", - "step.ecs_apply", - "step.ecs_destroy", - "step.ecs_plan", - "step.ecs_status", "step.event_decrypt", "step.event_publish", "step.feature_flag", @@ -340,9 +328,7 @@ var coreModuleTypes = []string{ "step.marketplace_search", "step.marketplace_uninstall", "step.marketplace_update", - "step.network_apply", - "step.network_plan", - "step.network_status", + "step.nosql_delete", "step.nosql_get", "step.nosql_put", @@ -374,10 +360,7 @@ var coreModuleTypes = []string{ "step.retry_with_backoff", "step.s3_upload", "step.sandbox_exec", - "step.scaling_apply", - "step.scaling_destroy", - "step.scaling_plan", - "step.scaling_status", + "step.scan_container", "step.scan_deps", "step.scan_sast", diff --git a/schema/step_schema_builtins.go b/schema/step_schema_builtins.go index 1195ef92..d4a1a39a 100644 --- a/schema/step_schema_builtins.go +++ b/schema/step_schema_builtins.go @@ -1455,65 +1455,6 @@ func (r *StepSchemaRegistry) registerBuiltins() { }, }) - // ---- API Gateway Apply ---- - - r.Register(&StepSchema{ - Type: "step.apigw_apply", - Plugin: "platform", - Description: "Applies (provisions or updates) an API gateway configuration.", - ConfigFields: []ConfigFieldDef{ - {Key: "gateway", Type: FieldTypeString, Description: "Name of the platform.apigateway module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "id", Type: "string", Description: "API gateway ID"}, - {Key: "endpoint", Type: "string", Description: "Gateway endpoint URL"}, - }, - }) - - // ---- API Gateway Destroy ---- - - r.Register(&StepSchema{ - Type: "step.apigw_destroy", - Plugin: "platform", - Description: "Destroys a provisioned API gateway.", - ConfigFields: []ConfigFieldDef{ - {Key: "gateway", Type: FieldTypeString, Description: "Name of the platform.apigateway module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "destroyed", Type: "boolean", Description: "Whether the gateway was destroyed"}, - }, - }) - - // ---- API Gateway Plan ---- - - r.Register(&StepSchema{ - Type: "step.apigw_plan", - Plugin: "platform", - Description: "Plans API gateway changes without applying them.", - ConfigFields: []ConfigFieldDef{ - {Key: "gateway", Type: FieldTypeString, Description: "Name of the platform.apigateway module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "plan", Type: "string", Description: "Human-readable plan output"}, - {Key: "changes", Type: "number", Description: "Number of changes planned"}, - }, - }) - - // ---- API Gateway Status ---- - - r.Register(&StepSchema{ - Type: "step.apigw_status", - Plugin: "platform", - Description: "Gets the current status of an API gateway.", - ConfigFields: []ConfigFieldDef{ - {Key: "gateway", Type: FieldTypeString, Description: "Name of the platform.apigateway module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "status", Type: "string", Description: "Current gateway status"}, - {Key: "endpoint", Type: "string", Description: "Gateway endpoint URL"}, - }, - }) - // ---- App Deploy ---- r.Register(&StepSchema{ @@ -1896,67 +1837,6 @@ func (r *StepSchemaRegistry) registerBuiltins() { }, }) - // ---- ECS Apply ---- - - r.Register(&StepSchema{ - Type: "step.ecs_apply", - Plugin: "platform", - Description: "Applies (deploys) an ECS Fargate service.", - ConfigFields: []ConfigFieldDef{ - {Key: "service", Type: FieldTypeString, Description: "Name of the platform.ecs module", Required: true}, - {Key: "image", Type: FieldTypeString, Description: "Container image to deploy"}, - }, - Outputs: []StepOutputDef{ - {Key: "service_arn", Type: "string", Description: "ECS service ARN"}, - {Key: "status", Type: "string", Description: "Service status"}, - }, - }) - - // ---- ECS Destroy ---- - - r.Register(&StepSchema{ - Type: "step.ecs_destroy", - Plugin: "platform", - Description: "Destroys an ECS Fargate service.", - ConfigFields: []ConfigFieldDef{ - {Key: "service", Type: FieldTypeString, Description: "Name of the platform.ecs module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "destroyed", Type: "boolean", Description: "Whether the service was destroyed"}, - }, - }) - - // ---- ECS Plan ---- - - r.Register(&StepSchema{ - Type: "step.ecs_plan", - Plugin: "platform", - Description: "Plans ECS service deployment changes without applying them.", - ConfigFields: []ConfigFieldDef{ - {Key: "service", Type: FieldTypeString, Description: "Name of the platform.ecs module", Required: true}, - {Key: "image", Type: FieldTypeString, Description: "Container image to plan for"}, - }, - Outputs: []StepOutputDef{ - {Key: "plan", Type: "string", Description: "Human-readable plan output"}, - {Key: "changes", Type: "number", Description: "Number of changes planned"}, - }, - }) - - // ---- ECS Status ---- - - r.Register(&StepSchema{ - Type: "step.ecs_status", - Plugin: "platform", - Description: "Gets the status of an ECS Fargate service.", - ConfigFields: []ConfigFieldDef{ - {Key: "service", Type: FieldTypeString, Description: "Name of the platform.ecs module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "status", Type: "string", Description: "Service status"}, - {Key: "running_count", Type: "number", Description: "Number of running tasks"}, - }, - }) - // ---- Git Checkout ---- r.Register(&StepSchema{ @@ -2377,51 +2257,6 @@ func (r *StepSchemaRegistry) registerBuiltins() { }, }) - // ---- Network Apply ---- - - r.Register(&StepSchema{ - Type: "step.network_apply", - Plugin: "platform", - Description: "Applies VPC networking changes.", - ConfigFields: []ConfigFieldDef{ - {Key: "network", Type: FieldTypeString, Description: "Name of the platform.networking module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "vpc_id", Type: "string", Description: "VPC ID"}, - {Key: "subnet_ids", Type: "[]string", Description: "Created subnet IDs"}, - }, - }) - - // ---- Network Plan ---- - - r.Register(&StepSchema{ - Type: "step.network_plan", - Plugin: "platform", - Description: "Plans VPC networking changes without applying them.", - ConfigFields: []ConfigFieldDef{ - {Key: "network", Type: FieldTypeString, Description: "Name of the platform.networking module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "plan", Type: "string", Description: "Human-readable plan output"}, - {Key: "changes", Type: "number", Description: "Number of changes planned"}, - }, - }) - - // ---- Network Status ---- - - r.Register(&StepSchema{ - Type: "step.network_status", - Plugin: "platform", - Description: "Gets the status of VPC networking resources.", - ConfigFields: []ConfigFieldDef{ - {Key: "network", Type: FieldTypeString, Description: "Name of the platform.networking module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "status", Type: "string", Description: "Network status"}, - {Key: "vpc_id", Type: "string", Description: "VPC ID"}, - }, - }) - // ---- NoSQL Delete ---- r.Register(&StepSchema{ @@ -2595,64 +2430,6 @@ func (r *StepSchemaRegistry) registerBuiltins() { }, }) - // ---- Scaling Apply ---- - - r.Register(&StepSchema{ - Type: "step.scaling_apply", - Plugin: "platform", - Description: "Applies autoscaling policy changes.", - ConfigFields: []ConfigFieldDef{ - {Key: "scaling", Type: FieldTypeString, Description: "Name of the platform.autoscaling module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "applied", Type: "boolean", Description: "Whether scaling policies were applied"}, - }, - }) - - // ---- Scaling Destroy ---- - - r.Register(&StepSchema{ - Type: "step.scaling_destroy", - Plugin: "platform", - Description: "Removes autoscaling policies.", - ConfigFields: []ConfigFieldDef{ - {Key: "scaling", Type: FieldTypeString, Description: "Name of the platform.autoscaling module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "destroyed", Type: "boolean", Description: "Whether policies were removed"}, - }, - }) - - // ---- Scaling Plan ---- - - r.Register(&StepSchema{ - Type: "step.scaling_plan", - Plugin: "platform", - Description: "Plans autoscaling policy changes without applying them.", - ConfigFields: []ConfigFieldDef{ - {Key: "scaling", Type: FieldTypeString, Description: "Name of the platform.autoscaling module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "plan", Type: "string", Description: "Human-readable plan output"}, - {Key: "changes", Type: "number", Description: "Number of changes planned"}, - }, - }) - - // ---- Scaling Status ---- - - r.Register(&StepSchema{ - Type: "step.scaling_status", - Plugin: "platform", - Description: "Gets the status of autoscaling policies.", - ConfigFields: []ConfigFieldDef{ - {Key: "scaling", Type: FieldTypeString, Description: "Name of the platform.autoscaling module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "status", Type: "string", Description: "Scaling status"}, - {Key: "policies", Type: "[]any", Description: "Active scaling policy details"}, - }, - }) - // ---- Secret Rotate ---- r.Register(&StepSchema{ diff --git a/schema/testdata/editor-schemas.golden.json b/schema/testdata/editor-schemas.golden.json index bd69e44a..62280233 100644 --- a/schema/testdata/editor-schemas.golden.json +++ b/schema/testdata/editor-schemas.golden.json @@ -331,7 +331,7 @@ "type": "app.container", "label": "App Container", "category": "infrastructure", - "description": "Application deployment abstraction that translates high-level config into platform-specific resources (Kubernetes Deployment+Service or ECS task definition)", + "description": "Application deployment abstraction that translates high-level config into platform-specific resources (Kubernetes Deployment+Service)", "outputs": [ { "name": "container", @@ -344,7 +344,7 @@ "key": "environment", "label": "Environment", "type": "string", - "description": "Name of the platform.kubernetes or platform.ecs module to deploy to", + "description": "Name of the platform.kubernetes module to deploy to", "required": true }, { @@ -2686,92 +2686,6 @@ "database": "database" } }, - "platform.apigateway": { - "type": "platform.apigateway", - "label": "API Gateway", - "category": "infrastructure", - "description": "Manages API gateway provisioning with routes, stages, and rate limiting (mock or AWS API Gateway v2)", - "outputs": [ - { - "name": "gateway", - "type": "JSON", - "description": "Provisioned API gateway endpoint and configuration" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "provider", - "label": "Provider", - "type": "string", - "description": "mock | aws" - }, - { - "key": "name", - "label": "Gateway Name", - "type": "string", - "description": "API gateway name", - "required": true - }, - { - "key": "stage", - "label": "Stage", - "type": "string", - "description": "Deployment stage (dev, staging, prod)" - }, - { - "key": "cors", - "label": "CORS Config", - "type": "map", - "description": "CORS configuration (allowedOrigins, allowedMethods, allowedHeaders)" - }, - { - "key": "routes", - "label": "Routes", - "type": "array", - "description": "Route definitions" - } - ] - }, - "platform.autoscaling": { - "type": "platform.autoscaling", - "label": "Autoscaling Policies", - "category": "infrastructure", - "description": "Manages autoscaling policies (target tracking, step, scheduled) for AWS or mock resources", - "outputs": [ - { - "name": "policies", - "type": "JSON", - "description": "Configured autoscaling policies" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "provider", - "label": "Provider", - "type": "string", - "description": "mock | aws" - }, - { - "key": "policies", - "label": "Policies", - "type": "array", - "description": "Scaling policy definitions", - "required": true - } - ] - }, "platform.context": { "type": "platform.context", "label": "Platform Context", @@ -2842,7 +2756,7 @@ "key": "provider", "label": "Provider", "type": "string", - "description": "mock | aws" + "description": "mock | aws (aws Route53 backend removed in v0.53.0; use infra.dns + workflow-plugin-aws)" }, { "key": "zone", @@ -2859,66 +2773,6 @@ } ] }, - "platform.ecs": { - "type": "platform.ecs", - "label": "ECS Fargate Service", - "category": "infrastructure", - "description": "AWS ECS/Fargate service with task definitions and ALB target group config", - "outputs": [ - { - "name": "service", - "type": "JSON", - "description": "Provisioned ECS Fargate service ARN and endpoint" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "cluster", - "label": "ECS Cluster", - "type": "string", - "description": "ECS cluster name", - "required": true - }, - { - "key": "region", - "label": "AWS Region", - "type": "string", - "description": "AWS region (e.g. us-east-1)" - }, - { - "key": "launch_type", - "label": "Launch Type", - "type": "string", - "description": "FARGATE or EC2" - }, - { - "key": "desired_count", - "label": "Desired Count", - "type": "string", - "description": "Number of tasks to run" - }, - { - "key": "vpc_subnets", - "label": "VPC Subnets", - "type": "array", - "description": "List of subnet IDs", - "arrayItemType": "string" - }, - { - "key": "security_groups", - "label": "Security Groups", - "type": "array", - "description": "List of security group IDs", - "arrayItemType": "string" - } - ] - }, "platform.kubernetes": { "type": "platform.kubernetes", "label": "Kubernetes Cluster", @@ -2959,58 +2813,6 @@ } ] }, - "platform.networking": { - "type": "platform.networking", - "label": "VPC Networking", - "category": "infrastructure", - "description": "Manages VPC, subnets, NAT gateway, and security groups (mock or AWS backend)", - "outputs": [ - { - "name": "vpc", - "type": "JSON", - "description": "Provisioned VPC with subnets and security groups" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "provider", - "label": "Provider", - "type": "string", - "description": "mock | aws" - }, - { - "key": "vpc", - "label": "VPC Config", - "type": "map", - "description": "VPC configuration (cidr, name)", - "required": true - }, - { - "key": "subnets", - "label": "Subnets", - "type": "array", - "description": "List of subnet definitions" - }, - { - "key": "nat_gateway", - "label": "NAT Gateway", - "type": "boolean", - "description": "Provision a NAT gateway" - }, - { - "key": "security_groups", - "label": "Security Groups", - "type": "array", - "description": "List of security group definitions" - } - ] - }, "platform.provider": { "type": "platform.provider", "label": "Platform Provider", @@ -3875,34 +3677,6 @@ "temperature": 0.3 } }, - "step.apigw_apply": { - "type": "step.apigw_apply", - "label": "API Gateway Apply", - "category": "pipeline", - "description": "Applies API gateway configuration", - "configFields": [] - }, - "step.apigw_destroy": { - "type": "step.apigw_destroy", - "label": "API Gateway Destroy", - "category": "pipeline", - "description": "Destroys a provisioned API gateway", - "configFields": [] - }, - "step.apigw_plan": { - "type": "step.apigw_plan", - "label": "API Gateway Plan", - "category": "pipeline", - "description": "Plans API gateway changes without applying them", - "configFields": [] - }, - "step.apigw_status": { - "type": "step.apigw_status", - "label": "API Gateway Status", - "category": "pipeline", - "description": "Gets the current status of an API gateway", - "configFields": [] - }, "step.app_deploy": { "type": "step.app_deploy", "label": "App Deploy", @@ -5608,34 +5382,6 @@ } ] }, - "step.ecs_apply": { - "type": "step.ecs_apply", - "label": "ECS Apply", - "category": "pipeline", - "description": "Applies ECS Fargate service deployment", - "configFields": [] - }, - "step.ecs_destroy": { - "type": "step.ecs_destroy", - "label": "ECS Destroy", - "category": "pipeline", - "description": "Destroys an ECS Fargate service", - "configFields": [] - }, - "step.ecs_plan": { - "type": "step.ecs_plan", - "label": "ECS Plan", - "category": "pipeline", - "description": "Plans ECS service deployment changes", - "configFields": [] - }, - "step.ecs_status": { - "type": "step.ecs_status", - "label": "ECS Status", - "category": "pipeline", - "description": "Gets the status of an ECS Fargate service", - "configFields": [] - }, "step.event_decrypt": { "type": "step.event_decrypt", "label": "Event Decrypt", @@ -6542,27 +6288,6 @@ "description": "Updates an installed marketplace plugin", "configFields": [] }, - "step.network_apply": { - "type": "step.network_apply", - "label": "Network Apply", - "category": "pipeline", - "description": "Applies VPC networking changes", - "configFields": [] - }, - "step.network_plan": { - "type": "step.network_plan", - "label": "Network Plan", - "category": "pipeline", - "description": "Plans VPC networking changes", - "configFields": [] - }, - "step.network_status": { - "type": "step.network_status", - "label": "Network Status", - "category": "pipeline", - "description": "Gets VPC networking status", - "configFields": [] - }, "step.nosql_delete": { "type": "step.nosql_delete", "label": "NoSQL Delete", @@ -7324,34 +7049,6 @@ } ] }, - "step.scaling_apply": { - "type": "step.scaling_apply", - "label": "Scaling Apply", - "category": "pipeline", - "description": "Applies autoscaling policies", - "configFields": [] - }, - "step.scaling_destroy": { - "type": "step.scaling_destroy", - "label": "Scaling Destroy", - "category": "pipeline", - "description": "Removes autoscaling policies", - "configFields": [] - }, - "step.scaling_plan": { - "type": "step.scaling_plan", - "label": "Scaling Plan", - "category": "pipeline", - "description": "Plans autoscaling changes", - "configFields": [] - }, - "step.scaling_status": { - "type": "step.scaling_status", - "label": "Scaling Status", - "category": "pipeline", - "description": "Gets autoscaling status", - "configFields": [] - }, "step.scan_container": { "type": "step.scan_container", "label": "Container Scan", From 4297f427f22d2e5e1d67f09d4565db7199814f3d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 08:31:09 -0400 Subject: [PATCH 13/18] feat(#653): add legacyaws package + wire migration errors (T4) Create internal/legacyaws/types.go mirroring internal/legacydo/types.go. Maps 4 removed module types (platform.ecs/networking/apigateway/autoscaling) and 15 removed step types to their infra.*/step.iac_* successors. RemovedInVersion = v0.53.0. Wire into engine.go, cmd/wfctl/validate.go, cmd/wfctl/ci_validate.go: - extra schema module types list includes legacyaws.ModuleTypes - post-validate sweep checks legacyaws.IsModuleType / IsStepType - actionable FormatModuleError / FormatStepError returned on match Remove 15 legacy step entries from cmd/wfctl/type_registry.go KnownStepTypes(). Tests: engine_legacyaws_test.go (plugin not loaded + plugin loaded branches) + cmd/wfctl/legacy_aws_types_removed_test.go (registry absent + validate/ci_validate paths). Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/ci_validate.go | 14 ++- cmd/wfctl/legacy_aws_types_removed_test.go | 89 ++++++++++++++++++ cmd/wfctl/type_registry.go | 83 ----------------- cmd/wfctl/validate.go | 17 +++- engine.go | 11 ++- engine_legacyaws_test.go | 58 ++++++++++++ internal/legacyaws/types.go | 100 +++++++++++++++++++++ 7 files changed, 284 insertions(+), 88 deletions(-) create mode 100644 cmd/wfctl/legacy_aws_types_removed_test.go create mode 100644 engine_legacyaws_test.go create mode 100644 internal/legacyaws/types.go diff --git a/cmd/wfctl/ci_validate.go b/cmd/wfctl/ci_validate.go index e79583e2..e7f3e0b9 100644 --- a/cmd/wfctl/ci_validate.go +++ b/cmd/wfctl/ci_validate.go @@ -10,6 +10,7 @@ import ( "time" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/internal/legacyaws" "github.com/GoCodeAlone/workflow/internal/legacydo" "github.com/GoCodeAlone/workflow/schema" "github.com/GoCodeAlone/workflow/validation" @@ -138,15 +139,23 @@ func ciValidateFile(cfgPath string, strict, immutableConfig bool, immutableSecti for t := range legacydo.ModuleTypes { opts = append(opts, schema.WithExtraModuleTypes(t)) } + // Same for legacy AWS module types removed in issue #653. + for t := range legacyaws.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). + // Post-validate sweep: accumulate legacy DO and AWS module/step errors + // (issues #617, #653). for _, m := range cfg.Modules { if legacydo.IsModuleType(m.Type) { errs = append(errs, legacydo.FormatModuleError(m.Type, m.Name, false)) } + if legacyaws.IsModuleType(m.Type) { + errs = append(errs, legacyaws.FormatModuleError(m.Type, m.Name, false)) + } } for _, rawPipeline := range cfg.Pipelines { yamlBytes, err := yaml.Marshal(rawPipeline) @@ -161,6 +170,9 @@ func ciValidateFile(cfgPath string, strict, immutableConfig bool, immutableSecti if legacydo.IsStepType(s.Type) { errs = append(errs, legacydo.FormatStepError(s.Type, false)) } + if legacyaws.IsStepType(s.Type) { + errs = append(errs, legacyaws.FormatStepError(s.Type, false)) + } } } diff --git a/cmd/wfctl/legacy_aws_types_removed_test.go b/cmd/wfctl/legacy_aws_types_removed_test.go new file mode 100644 index 00000000..f7761fb6 --- /dev/null +++ b/cmd/wfctl/legacy_aws_types_removed_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestLegacyAWSTypesAbsent_FromTypeRegistry locks the post-cutover state of +// cmd/wfctl/type_registry.go for issue #653. If any legacy AWS type leaks back +// in, this test fires and the CI gate fires. +func TestLegacyAWSTypesAbsent_FromTypeRegistry(t *testing.T) { + modules := KnownModuleTypes() + steps := KnownStepTypes() + legacyModules := []string{ + "platform.ecs", "platform.networking", + "platform.apigateway", "platform.autoscaling", + } + legacySteps := []string{ + "step.ecs_plan", "step.ecs_apply", "step.ecs_status", "step.ecs_destroy", + "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", + } + for _, tname := range legacyModules { + if _, ok := modules[tname]; ok { + t.Errorf("module type registry still contains legacy AWS type %q (issue #653)", tname) + } + } + for _, tname := range legacySteps { + if _, ok := steps[tname]; ok { + t.Errorf("step type registry still contains legacy AWS type %q (issue #653)", tname) + } + } +} + +// TestValidateFile_LegacyAWSModule_ReturnsActionableError verifies that +// wfctl validate emits the actionable migration error when the config +// references a removed legacy AWS module type (issue #653). Covers the +// validate path (the engine path is covered by +// TestLegacyAWSModuleError_PluginNotLoaded in the workflow package). +func TestValidateFile_LegacyAWSModule_ReturnsActionableError(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "legacy.yaml") + yamlContent := []byte("modules:\n - name: svc\n type: platform.ecs\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 AWS module type") + } + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-aws", + "infra.container_service", + } { + if !strings.Contains(msg, want) { + t.Errorf("error missing %q; got: %s", want, msg) + } + } +} + +// TestCIValidateFile_LegacyAWSStep_ReturnsActionableError covers ciValidateFile's +// accumulating return for legacy AWS step types. +func TestCIValidateFile_LegacyAWSStep_ReturnsActionableError(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "legacy.yaml") + yamlContent := []byte("pipelines:\n deploy:\n steps:\n - type: step.ecs_apply\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 AWS 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 18d53985..f4118446 100644 --- a/cmd/wfctl/type_registry.go +++ b/cmd/wfctl/type_registry.go @@ -1408,28 +1408,6 @@ func KnownStepTypes() map[string]StepTypeInfo { ConfigKeys: []string{"cluster"}, }, - // platform plugin steps (scaling) - "step.scaling_plan": { - Type: "step.scaling_plan", - Plugin: "platform", - ConfigKeys: []string{"scaling"}, - }, - "step.scaling_apply": { - Type: "step.scaling_apply", - Plugin: "platform", - ConfigKeys: []string{"scaling"}, - }, - "step.scaling_status": { - Type: "step.scaling_status", - Plugin: "platform", - ConfigKeys: []string{"scaling"}, - }, - "step.scaling_destroy": { - Type: "step.scaling_destroy", - Plugin: "platform", - ConfigKeys: []string{"scaling"}, - }, - // platform plugin steps (iac) "step.iac_plan": { Type: "step.iac_plan", @@ -1474,67 +1452,6 @@ func KnownStepTypes() map[string]StepTypeInfo { ConfigKeys: []string{"zone"}, }, - // platform plugin steps (networking) - "step.network_plan": { - Type: "step.network_plan", - Plugin: "platform", - ConfigKeys: []string{"network"}, - }, - "step.network_apply": { - Type: "step.network_apply", - Plugin: "platform", - ConfigKeys: []string{"network"}, - }, - "step.network_status": { - Type: "step.network_status", - Plugin: "platform", - ConfigKeys: []string{"network"}, - }, - - // platform plugin steps (api gateway) - "step.apigw_plan": { - Type: "step.apigw_plan", - Plugin: "platform", - ConfigKeys: []string{"gateway"}, - }, - "step.apigw_apply": { - Type: "step.apigw_apply", - Plugin: "platform", - ConfigKeys: []string{"gateway"}, - }, - "step.apigw_status": { - Type: "step.apigw_status", - Plugin: "platform", - ConfigKeys: []string{"gateway"}, - }, - "step.apigw_destroy": { - Type: "step.apigw_destroy", - Plugin: "platform", - ConfigKeys: []string{"gateway"}, - }, - - // platform plugin steps (ecs) - "step.ecs_plan": { - Type: "step.ecs_plan", - Plugin: "platform", - ConfigKeys: []string{"service"}, - }, - "step.ecs_apply": { - Type: "step.ecs_apply", - Plugin: "platform", - ConfigKeys: []string{"service"}, - }, - "step.ecs_status": { - Type: "step.ecs_status", - Plugin: "platform", - ConfigKeys: []string{"service"}, - }, - "step.ecs_destroy": { - Type: "step.ecs_destroy", - Plugin: "platform", - ConfigKeys: []string{"service"}, - }, - // platform plugin steps (app container) "step.app_deploy": { Type: "step.app_deploy", diff --git a/cmd/wfctl/validate.go b/cmd/wfctl/validate.go index 4e6eaa04..a7cdfb46 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/legacyaws" "github.com/GoCodeAlone/workflow/internal/legacydo" "github.com/GoCodeAlone/workflow/schema" "gopkg.in/yaml.v3" @@ -148,18 +149,25 @@ func validateFile(cfgPath string, strict, skipUnknownTypes, allowNoEntryPoints b for t := range legacydo.ModuleTypes { opts = append(opts, schema.WithExtraModuleTypes(t)) } + // Same for legacy AWS module types removed in issue #653. + for t := range legacyaws.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. + // Post-validate sweep: reject legacy DO and AWS module/step types with + // actionable migration errors (issues #617, #653). 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) } + if legacyaws.IsModuleType(m.Type) { + return legacyaws.FormatModuleError(m.Type, m.Name, false) + } } for _, rawPipeline := range cfg.Pipelines { yamlBytes, err := yaml.Marshal(rawPipeline) @@ -174,6 +182,9 @@ func validateFile(cfgPath string, strict, skipUnknownTypes, allowNoEntryPoints b if legacydo.IsStepType(s.Type) { return legacydo.FormatStepError(s.Type, false) } + if legacyaws.IsStepType(s.Type) { + return legacyaws.FormatStepError(s.Type, false) + } } } diff --git a/engine.go b/engine.go index e96565c2..db503c61 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/legacyaws" "github.com/GoCodeAlone/workflow/internal/legacydo" "github.com/GoCodeAlone/workflow/module" "github.com/GoCodeAlone/workflow/plugin" @@ -391,7 +392,7 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { schema.WithSkipWorkflowTypeCheck(), schema.WithSkipTriggerTypeCheck(), } - extra := make([]string, 0, len(e.moduleFactories)+len(legacydo.ModuleTypes)) + extra := make([]string, 0, len(e.moduleFactories)+len(legacydo.ModuleTypes)+len(legacyaws.ModuleTypes)) for t := range e.moduleFactories { extra = append(extra, t) } @@ -402,6 +403,10 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { for t := range legacydo.ModuleTypes { extra = append(extra, t) } + // Same pattern for legacy AWS module types removed in issue #653. + for t := range legacyaws.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) @@ -515,6 +520,10 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { _, iacLoaded := e.moduleFactories["iac.provider"] return legacydo.FormatModuleError(modCfg.Type, modCfg.Name, iacLoaded) } + if legacyaws.IsModuleType(modCfg.Type) { + _, iacLoaded := e.moduleFactories["iac.provider"] + return legacyaws.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) diff --git a/engine_legacyaws_test.go b/engine_legacyaws_test.go new file mode 100644 index 00000000..e803bc66 --- /dev/null +++ b/engine_legacyaws_test.go @@ -0,0 +1,58 @@ +package workflow + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/config" +) + +func TestLegacyAWSModuleError_PluginNotLoaded(t *testing.T) { + cases := []struct{ legacyType, hint string }{ + {"platform.ecs", "infra.container_service"}, + {"platform.networking", "infra.vpc"}, + {"platform.apigateway", "infra.api_gateway"}, + {"platform.autoscaling", "infra.autoscaling_group"}, + } + 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-aws", + "Install workflow-plugin-aws", + tc.hint, + } { + if !strings.Contains(msg, want) { + t.Errorf("error for %q missing %q; got: %s", tc.legacyType, want, msg) + } + } + }) + } +} + +func TestLegacyAWSModuleError_PluginLoaded(t *testing.T) { + e := newIsolatedEngine(t) + // Register a stub iac.provider factory to simulate workflow-plugin-aws being loaded. + 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.ecs", 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-aws") { + t.Errorf("plugin-loaded branch must NOT instruct install; got: %s", msg) + } +} diff --git a/internal/legacyaws/types.go b/internal/legacyaws/types.go new file mode 100644 index 00000000..557da68f --- /dev/null +++ b/internal/legacyaws/types.go @@ -0,0 +1,100 @@ +// Package legacyaws holds the read-only data and message formatters for the +// legacy AWS module + step types removed in issue #653. 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 legacyaws + +import ( + "fmt" + "sort" + "strings" +) + +// RemovedInVersion is the workflow tag that ships issue #653's force-cutover. +// Used in every legacy-AWS migration error and in the wfctl modernize rule. +const RemovedInVersion = "v0.53.0" + +// ModuleTypes maps each removed legacy AWS module type to its infra.* +// IaC successor (issue #653). +var ModuleTypes = map[string]string{ + "platform.ecs": "infra.container_service", + "platform.networking": "infra.vpc + infra.firewall", + "platform.apigateway": "infra.api_gateway", + "platform.autoscaling": "infra.autoscaling_group", +} + +// StepTypes maps each removed legacy AWS step type to its successor. +var StepTypes = map[string]string{ + "step.ecs_plan": "step.iac_plan (against an infra.container_service module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.ecs_apply": "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.ecs_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.ecs_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.network_plan": "step.iac_plan (against an infra.vpc module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.network_apply": "step.iac_apply (against an infra.vpc module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.network_status": "step.iac_status (against an infra.vpc module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.apigw_plan": "step.iac_plan (against an infra.api_gateway module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.apigw_apply": "step.iac_apply (against an infra.api_gateway module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.apigw_status": "step.iac_status (against an infra.api_gateway module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.apigw_destroy": "step.iac_destroy (against an infra.api_gateway module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.scaling_plan": "step.iac_plan (against an infra.autoscaling_group module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.scaling_apply": "step.iac_apply (against an infra.autoscaling_group module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.scaling_status": "step.iac_status (against an infra.autoscaling_group module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.scaling_destroy": "step.iac_destroy (against an infra.autoscaling_group module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", +} + +// IsModuleType reports whether t is a removed legacy AWS module type. +func IsModuleType(t string) bool { _, ok := ModuleTypes[t]; return ok } + +// IsStepType reports whether t is a removed legacy AWS step type. +func IsStepType(t string) bool { _, ok := StepTypes[t]; return ok } + +// FormatModuleError builds the actionable migration error for a legacy +// AWS 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-aws v0.2.0+: https://github.com/GoCodeAlone/workflow-plugin-aws" + if iacProviderLoaded { + pluginLine = "workflow-plugin-aws 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 — AWS IaC moved to workflow-plugin-aws.\n\n", legacyType, moduleName, RemovedInVersion) + b.WriteString(pluginLine) + b.WriteString("\n\nMigrate this module to: ") + b.WriteString(successor) + b.WriteString(" (provider: aws)\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.53.0-aws-iac-removal.md") + return fmt.Errorf("%s", b.String()) +} + +// FormatStepError builds the actionable migration error for a legacy +// AWS step type. +func FormatStepError(legacyType string, iacProviderLoaded bool) error { + successor, ok := StepTypes[legacyType] + if !ok { + return nil + } + pluginLine := "Install workflow-plugin-aws v0.2.0+: https://github.com/GoCodeAlone/workflow-plugin-aws" + if iacProviderLoaded { + pluginLine = "workflow-plugin-aws 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 — AWS IaC moved to workflow-plugin-aws.\n\n", legacyType, RemovedInVersion) + b.WriteString(pluginLine) + b.WriteString("\n\nMigrate this step to: ") + b.WriteString(successor) + b.WriteString("\n\nSee docs/migrations/v0.53.0-aws-iac-removal.md") + return fmt.Errorf("%s", b.String()) +} From c2bc54cec543d3a6087c027cfb6867a9c5ac7221 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 08:34:29 -0400 Subject: [PATCH 14/18] feat(#653): add legacy-aws-types modernize rule + migration guide (T5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add modernize/legacy_aws_rule.go mirroring legacy_do_rule.go: - ID: legacy-aws-types, Severity: error - Auto-fixes: platform.ecs→infra.container_service, platform.apigateway→infra.api_gateway, platform.autoscaling→infra.autoscaling_group - Flags but does not auto-fix: platform.networking (1→2 split) + all 15 step types (config key shape mismatch: platform + state_store vs legacy service/gateway/scaling keys) - Uses walkTypeNodes helper from legacy_do_rule.go (same package) Register legacyAWSRule() in modernize/modernize.go AllRules() list. Add docs/migrations/v0.53.0-aws-iac-removal.md with full migration recipe (install plugin, modernize, add provider, manual rewrites for networking + steps). Tests: TestLegacyAWSRule_Rewrites (3 auto-fixable types) + TestLegacyAWSRule_GapTypesFlaggedNotRewritten (networking + 6 step types). Co-Authored-By: Claude Sonnet 4.6 --- docs/migrations/v0.53.0-aws-iac-removal.md | 187 +++++++++++++++++++++ modernize/legacy_aws_rule.go | 105 ++++++++++++ modernize/legacy_aws_rule_test.go | 102 +++++++++++ modernize/modernize.go | 1 + 4 files changed, 395 insertions(+) create mode 100644 docs/migrations/v0.53.0-aws-iac-removal.md create mode 100644 modernize/legacy_aws_rule.go create mode 100644 modernize/legacy_aws_rule_test.go diff --git a/docs/migrations/v0.53.0-aws-iac-removal.md b/docs/migrations/v0.53.0-aws-iac-removal.md new file mode 100644 index 00000000..73d6a3ea --- /dev/null +++ b/docs/migrations/v0.53.0-aws-iac-removal.md @@ -0,0 +1,187 @@ +# v0.53.0 — Removing AWS IaC modules from workflow core (issue #653) + +## What changed + +Four legacy AWS IaC module types, their 15 companion pipeline step types, and +the AWS API Gateway helper (`AWSAPIGateway`) were removed from workflow core. +The three AWS SDK service packages they required (`service/apigatewayv2`, +`service/applicationautoscaling`, `service/route53`) are no longer pulled by +the workflow module. + +AWS IaC functionality is provided by +[`workflow-plugin-aws`](https://github.com/GoCodeAlone/workflow-plugin-aws) +v0.2.0+, which exposes the same resources through the generic `infra.*` IaC +type system with `provider: aws`. + +The `platform.dns` module type remains in core (the AWS Route53 backend was +removed; the mock backend and generic DNS zone management stay). Users of +`platform.dns` with `provider: aws` must migrate to `infra.dns` via the AWS +plugin. + +## Why + +Workflow core should own IaC interfaces and orchestration, not provider SDKs. +Dependabot bumps to aws-sdk-go-v2 now target the AWS plugin repo, not core. +Mirrors the pattern established by issue #617 (godo removal). See the +design plan at `docs/plans/2026-05-13-issue-653-phase1-aws-cutover-design.md`. + +## Removed types + +### Module types + +| Removed | Successor | Notes | +|---------|-----------|-------| +| `platform.ecs` | `infra.container_service` (provider: aws) | Auto-fixable via `wfctl modernize` | +| `platform.apigateway` | `infra.api_gateway` (provider: aws) | Auto-fixable via `wfctl modernize` | +| `platform.autoscaling` | `infra.autoscaling_group` (provider: aws) | Auto-fixable via `wfctl modernize` | +| `platform.networking` | `infra.vpc` + `infra.firewall` (provider: aws) | Manual rewrite (1→2 split) | + +### Step types (all require manual config rewrite) + +| Removed | Successor | +|---------|-----------| +| `step.ecs_plan` | `step.iac_plan` | +| `step.ecs_apply` | `step.iac_apply` | +| `step.ecs_status` | `step.iac_status` | +| `step.ecs_destroy` | `step.iac_destroy` | +| `step.network_plan` | `step.iac_plan` | +| `step.network_apply` | `step.iac_apply` | +| `step.network_status` | `step.iac_status` | +| `step.apigw_plan` | `step.iac_plan` | +| `step.apigw_apply` | `step.iac_apply` | +| `step.apigw_status` | `step.iac_status` | +| `step.apigw_destroy` | `step.iac_destroy` | +| `step.scaling_plan` | `step.iac_plan` | +| `step.scaling_apply` | `step.iac_apply` | +| `step.scaling_status` | `step.iac_status` | +| `step.scaling_destroy` | `step.iac_destroy` | + +Step types are **not** auto-rewritten by `wfctl modernize` because `step.iac_*` +requires different config keys (`platform` + `state_store`) compared to the +legacy per-step keys (`service:`, `gateway:`, `scaling:`, `network:`). Rewriting +the type alone would produce an invalid config. + +## Migration recipe + +1. Install the AWS plugin (v0.2.0+): + ```sh + wfctl plugin install workflow-plugin-aws@0.2.0 + ``` + Or declare it in your workflow config under `plugins.external`: + ```yaml + plugins: + external: + - name: workflow-plugin-aws + version: ">=0.2.0" + autoFetch: true + ``` + + To declare the dependency without auto-fetch: + ```yaml + requires: + plugins: + - workflow-plugin-aws + ``` + +2. Run the modernizer over each affected YAML config: + ```sh + wfctl modernize --apply ./config/*.yaml + ``` + This **renames the type field** for 3 module types automatically + (`platform.ecs`, `platform.apigateway`, `platform.autoscaling`). + `platform.networking` and all 15 step types are flagged but **not** + auto-rewritten. + +3. **Add `provider: aws` to each rewritten module's `config:` block.** The + modernize rule does NOT auto-inject this key. Example: + + Before: + ```yaml + modules: + - name: my_service + type: platform.ecs + config: + cluster: my-cluster + region: us-east-1 + ``` + + After (modernize renames + you add provider): + ```yaml + modules: + - name: my_service + type: infra.container_service + config: + provider: aws # add this + cluster: my-cluster + region: us-east-1 + ``` + +4. **Manually rewrite `platform.networking` configs** (splits into two modules): + ```yaml + # Before + modules: + - name: net + type: platform.networking + config: + vpc_cidr: 10.0.0.0/16 + + # After + modules: + - name: net_vpc + type: infra.vpc + config: + provider: aws + vpc_cidr: 10.0.0.0/16 + - name: net_fw + type: infra.firewall + config: + provider: aws + vpc: net_vpc + ``` + +5. **Manually rewrite step types.** Replace config keys as follows: + + Before: + ```yaml + steps: + - type: step.ecs_apply + config: + service: my_service + image: my-image:latest + ``` + + After: + ```yaml + steps: + - type: step.iac_apply + config: + platform: aws_provider # name of your iac.provider module + state_store: tfstate # name of your iac.state module + resource_id: my_service + ``` + +6. **For `platform.dns` with `provider: aws`**, migrate to `infra.dns`: + ```yaml + # Before + modules: + - name: my_dns + type: platform.dns + config: + provider: aws + zone: + name: example.com + + # After + modules: + - name: my_dns + type: infra.dns + config: + provider: aws + ``` + Then use the AWS plugin's Route53 driver via `step.iac_plan` / `step.iac_apply`. + +## Rollback + +If you need to roll back to a pre-v0.53.0 workflow core, pin to `v0.52.x` in +your `go.mod` and revert config changes. The `platform.ecs/networking/apigateway/autoscaling` +module types are fully supported in v0.52.x. diff --git a/modernize/legacy_aws_rule.go b/modernize/legacy_aws_rule.go new file mode 100644 index 00000000..4bafaca2 --- /dev/null +++ b/modernize/legacy_aws_rule.go @@ -0,0 +1,105 @@ +package modernize + +import ( + "fmt" + + "github.com/GoCodeAlone/workflow/internal/legacyaws" + "gopkg.in/yaml.v3" +) + +// legacyAWSRule flags legacy AWS module + step types and rewrites +// module types to their infra.* IaC successors (issue #653). +// +// Auto-fixable: 3 of 4 modules (platform.ecs/apigateway/autoscaling). +// Not auto-fixable: platform.networking (1→2 split: infra.vpc + infra.firewall). +// Step types: flagged but NOT auto-rewritten — step.iac_apply/status/destroy +// require different config keys (platform + state_store) vs the legacy module/ +// service keys. Operator must rewrite step config manually per the migration +// guide (docs/migrations/v0.53.0-aws-iac-removal.md). +func legacyAWSRule() Rule { + moduleMap := map[string]string{ + "platform.ecs": "infra.container_service", + "platform.apigateway": "infra.api_gateway", + "platform.autoscaling": "infra.autoscaling_group", + // platform.networking is intentionally NOT auto-fixed: it splits + // 1→2 (infra.vpc + infra.firewall), which requires a structural + // rewrite the operator must review. + } + // stepMap: successor name shown in migration messages only — NOT auto-fixed. + stepMap := map[string]string{ + "step.ecs_plan": "step.iac_plan", + "step.ecs_apply": "step.iac_apply", + "step.ecs_status": "step.iac_status", + "step.ecs_destroy": "step.iac_destroy", + "step.network_plan": "step.iac_plan", + "step.network_apply": "step.iac_apply", + "step.network_status": "step.iac_status", + "step.apigw_plan": "step.iac_plan", + "step.apigw_apply": "step.iac_apply", + "step.apigw_status": "step.iac_status", + "step.apigw_destroy": "step.iac_destroy", + "step.scaling_plan": "step.iac_plan", + "step.scaling_apply": "step.iac_apply", + "step.scaling_status": "step.iac_status", + "step.scaling_destroy": "step.iac_destroy", + } + gapTypes := map[string]string{ + "platform.networking": "splits into infra.vpc + infra.firewall — manual rewrite required", + } + + return Rule{ + ID: "legacy-aws-types", + Description: "Rewrite legacy AWS module/step types to infra.* IaC successors (issue #653).", + 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-aws-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in %s; rewrite to %s (provider: aws) — requires workflow-plugin-aws v0.2.0+", typeVal.Value, legacyaws.RemovedInVersion, successor), + Fixable: true, + }) + } + if successor, ok := stepMap[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-aws-types", + Line: typeVal.Line, + // Not auto-fixable: step.iac_apply/status/destroy require + // different config keys (platform + state_store) vs legacy + // service/gateway/scaling keys. Auto-rewriting the type + // alone would produce an invalid config. + Message: fmt.Sprintf("%s removed in %s; manually rewrite to %s with config keys platform + state_store (see docs/migrations/v0.53.0-aws-iac-removal.md) — requires workflow-plugin-aws v0.2.0+", typeVal.Value, legacyaws.RemovedInVersion, successor), + Fixable: false, + }) + } + if reason, ok := gapTypes[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-aws-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in %s — %s", typeVal.Value, legacyaws.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-aws-types", + Line: typeVal.Line, + Description: fmt.Sprintf("rewrote %s → %s", old, successor), + }) + } + // stepMap and gapTypes are intentionally NOT rewritten. + }) + return out + }, + } +} diff --git a/modernize/legacy_aws_rule_test.go b/modernize/legacy_aws_rule_test.go new file mode 100644 index 00000000..3dd97df7 --- /dev/null +++ b/modernize/legacy_aws_rule_test.go @@ -0,0 +1,102 @@ +package modernize + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestLegacyAWSRule_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.ecs → infra.container_service (provider NOT auto-injected)", + yamlIn: "modules:\n - name: svc\n type: platform.ecs\n config:\n cluster: my-cluster\n", + wantNew: "infra.container_service", + wantDrop: "platform.ecs", + }, + { + name: "platform.apigateway → infra.api_gateway", + yamlIn: "modules:\n - name: gw\n type: platform.apigateway\n config: {}\n", + wantNew: "infra.api_gateway", + wantDrop: "platform.apigateway", + }, + { + name: "platform.autoscaling → infra.autoscaling_group", + yamlIn: "modules:\n - name: asg\n type: platform.autoscaling\n config: {}\n", + wantNew: "infra.autoscaling_group", + wantDrop: "platform.autoscaling", + }, + } + rule := legacyAWSRule() + 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 TestLegacyAWSRule_GapTypesFlaggedNotRewritten(t *testing.T) { + // Non-fixable types: the rule must flag them as findings (Fixable: false) + // and must NOT modify the YAML after Fix() runs. + cases := []struct { + name string + legacy string + yamlIn string + }{ + // platform.networking: splits 1→2 (infra.vpc + infra.firewall) + {"platform.networking", "platform.networking", "modules:\n - name: net\n type: platform.networking\n config: {}\n"}, + // Step types: config key shape mismatch — not auto-fixable + {"step.ecs_apply", "step.ecs_apply", "pipelines:\n - steps:\n - type: step.ecs_apply\n config:\n service: svc\n"}, + {"step.ecs_plan", "step.ecs_plan", "pipelines:\n - steps:\n - type: step.ecs_plan\n config:\n service: svc\n"}, + {"step.ecs_status", "step.ecs_status", "pipelines:\n - steps:\n - type: step.ecs_status\n config:\n service: svc\n"}, + {"step.ecs_destroy", "step.ecs_destroy", "pipelines:\n - steps:\n - type: step.ecs_destroy\n config:\n service: svc\n"}, + {"step.apigw_apply", "step.apigw_apply", "pipelines:\n - steps:\n - type: step.apigw_apply\n config:\n gateway: gw\n"}, + {"step.scaling_apply", "step.scaling_apply", "pipelines:\n - steps:\n - type: step.scaling_apply\n config:\n scaling: asg\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 := legacyAWSRule() + 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 bba531f4..0edf0b24 100644 --- a/modernize/modernize.go +++ b/modernize/modernize.go @@ -44,6 +44,7 @@ func AllRules() []Rule { camelCaseConfigRule(), requestParseConfigRule(), legacyDORule(), + legacyAWSRule(), } } From 6df53711b066581440f4916adb21eacf1616bc92 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 08:36:44 -0400 Subject: [PATCH 15/18] chore(#653): go mod tidy + add aws-sdk-banned CI gate (T6) go mod tidy drops 3 freed AWS SDK packages: - service/apigatewayv2 (was platform.apigateway) - service/applicationautoscaling (was platform.autoscaling) - service/route53 (was platform.dns Route53 backend) Also tidy example/go.mod. Add aws-sdk-banned CI job to .github/workflows/ci.yml mirroring the godo-banned job: grep-gates both *.go imports and go.mod entries for the three freed service paths, excluding aws_absent_test.go. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ example/go.mod | 5 ----- example/go.sum | 10 ---------- go.mod | 3 --- go.sum | 6 ------ 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b23cfa5..bd306a16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -393,3 +393,27 @@ jobs: - name: Grep gate — go.mod files must not list godo run: | ! grep -qH "digitalocean/godo" go.mod example/go.mod + + aws-sdk-banned: + name: Verify removed AWS SDK packages are not imported (issue #653) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Grep gate — *.go files must not import removed AWS service packages + run: | + ! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + --exclude="aws_absent_test.go" \ + -e "aws-sdk-go-v2/service/apigatewayv2" \ + -e "aws-sdk-go-v2/service/applicationautoscaling" \ + -e "aws-sdk-go-v2/service/route53" \ + . + - name: Grep gate — go.mod files must not list removed AWS SDK packages + run: | + ! grep -qH \ + -e "aws-sdk-go-v2/service/apigatewayv2" \ + -e "aws-sdk-go-v2/service/applicationautoscaling" \ + -e "aws-sdk-go-v2/service/route53" \ + go.mod example/go.mod diff --git a/example/go.mod b/example/go.mod index a8a8052f..132d8b43 100644 --- a/example/go.mod +++ b/example/go.mod @@ -46,18 +46,13 @@ require ( github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.34.1 // indirect - github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.14 // indirect github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.13 // indirect - github.com/aws/aws-sdk-go-v2/service/ec2 v1.297.0 // indirect - github.com/aws/aws-sdk-go-v2/service/ecs v1.78.0 // indirect github.com/aws/aws-sdk-go-v2/service/eks v1.82.0 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5 // indirect - github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect diff --git a/example/go.sum b/example/go.sum index 4170ba52..d0638806 100644 --- a/example/go.sum +++ b/example/go.sum @@ -102,16 +102,8 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj1 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= -github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.34.1 h1:KdeMmKNbHOcFsiFe8BhLYE2A8crvEWnetrs8GkF2od8= -github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.34.1/go.mod h1:QdVi7bF8U8HE8FWQ77pa4wcAMFnxI/UPx4mmQ4azRQs= -github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.14 h1:0aYQ2UaSB1ccXZXUQ4a5XanrHEykKNzMLFgLEDhf8PU= -github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.14/go.mod h1:nbvfbwTfbJ6tTw6OGrSCgoMqmuDRBqqOIq83FdQKpaY= github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.13 h1:EEdmtkVROLA9VniV5STKv/EfEgV+n9NFBpOYU1jN9As= github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.13/go.mod h1:+pwMMAvpmRuI7oHsTT2F5Lrp4ZQV2RF7b6tiaBj3Ugk= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.297.0 h1:A+7NViqbMUCoTQFWjbSXdbzE4K5Ziu2zWJtZzAusm+A= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.297.0/go.mod h1:R+2BNtUfTfhPY0RH18oL02q116bakeBWjanrbnVBqkM= -github.com/aws/aws-sdk-go-v2/service/ecs v1.78.0 h1:P8s4jrrYr9CUPhoYXS0dI4Zi5oKXa6DWHUkeJ9m/gDQ= -github.com/aws/aws-sdk-go-v2/service/ecs v1.78.0/go.mod h1:QkWmubOYmjj3cHn7A4CoUU7BKJhVeo39Gp6NH7IyhZw= github.com/aws/aws-sdk-go-v2/service/eks v1.82.0 h1:AvBDUgHffBd4AErnQY6sB9u5vY/9Z0Ll5VmzzMraxW0= github.com/aws/aws-sdk-go-v2/service/eks v1.82.0/go.mod h1:xdUh6tdF9A8hc+PE84kmHbF/zsVPNiKnc6oLgulq1Eo= github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 h1:n9YLiWtX3+6pTLZWvRJmtq5JIB9NA/KFelyCg5fOlTU= @@ -126,8 +118,6 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWUR github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5 h1:LxgRVyuY+5DEPSX7kmin/V7toE8MWZ9U8n2dqRtX+RE= github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5/go.mod h1:eUebEBEqVfOwEyDDDbGauH4PNqDCuepRvTaNbJeWr5w= -github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 h1:Z+/OLsb85Kpq7TVLCspskqePaf68Tdv6GfmJP4kH6i0= -github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5/go.mod h1:TmxGowuBYwjmHFOsEDxaZdsQE62JJzOmtiWafTi/czg= github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk= github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= diff --git a/go.mod b/go.mod index 7426df87..3ac817e9 100644 --- a/go.mod +++ b/go.mod @@ -21,8 +21,6 @@ require ( github.com/aws/aws-sdk-go-v2 v1.41.6 github.com/aws/aws-sdk-go-v2/config v1.32.16 github.com/aws/aws-sdk-go-v2/credentials v1.19.15 - github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.33.8 - github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.13 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2 github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.12 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.0 @@ -32,7 +30,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.6 github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 github.com/aws/aws-sdk-go-v2/service/rds v1.115.0 - github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 github.com/aws/aws-sdk-go-v2/service/sqs v1.42.21 github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 diff --git a/go.sum b/go.sum index 7f99e311..e111b7e0 100644 --- a/go.sum +++ b/go.sum @@ -108,10 +108,6 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj1 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= -github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.33.8 h1:I0AMtyv5tqQ/VNDDalbbujALCWl64TP3F61bBw4U8Qs= -github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.33.8/go.mod h1:qnrKR+Jzg9NbZqy+YusE7frSZUaYQ7EPJvki4+SwS3U= -github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.13 h1:juPaAcploym78WhVwleVHNLPmgURO6gkObC442Hal1s= -github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.13/go.mod h1:HjgDVqI6lGR0azGz1GKmZTzGHkXuzhKzRUfG/p5Ug8s= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2 h1:mleWBVIxwceEzyItUVoqMFiv6TmOP6ECPoN6WB/VWXc= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.55.2/go.mod h1:cMApt548kNgu87UsBTNWVv+fpzjbUTFRSFjD1688SBs= github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.12 h1:lQTVEv/YAk8Rw1Yf4XZS/jNNxF9klCN10WcSR3xlMtU= @@ -142,8 +138,6 @@ github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4 h1:3m9iJtMtLq75jKRAfw0kapoH github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4/go.mod h1:O2L6vGm4xacEuN2otHFMgn7yXXlgzFKzxrba0fy/yk8= github.com/aws/aws-sdk-go-v2/service/rds v1.115.0 h1:oNl6YghOtxu3MiFk1tQ86QlrYMIEJazGUDbBCg9nxLA= github.com/aws/aws-sdk-go-v2/service/rds v1.115.0/go.mod h1:JBRYWpz5oXQtHgQC+X8LX9lh0FBCwRHJlWEIT+TTLaE= -github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 h1:Z+/OLsb85Kpq7TVLCspskqePaf68Tdv6GfmJP4kH6i0= -github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5/go.mod h1:TmxGowuBYwjmHFOsEDxaZdsQE62JJzOmtiWafTi/czg= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 h1:MRNiP6nqa20aEl8fQ6PJpEq11b2d40b16sm4WD7QgMU= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2/go.mod h1:FrNA56srbsr3WShiaelyWYEo70x80mXnVZ17ZZfbeqg= github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= From 7b8d67bacfb01a76f17bb2cd71784421d66f7fef Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 08:40:19 -0400 Subject: [PATCH 16/18] fix(#653): update wfctl test fixtures for removed AWS step types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestModernizeAllRulesRegistered: expected 9 rules → 10 (add legacy-aws-types). TestKnownStepTypesPopulated: remove 15 legacy AWS step entries from expected list (step.ecs_*/step.network_*/step.apigw_*/step.scaling_* all removed from KnownStepTypes). Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/modernize_test.go | 1 + cmd/wfctl/type_registry_test.go | 15 --------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/cmd/wfctl/modernize_test.go b/cmd/wfctl/modernize_test.go index 8bed2692..acaac848 100644 --- a/cmd/wfctl/modernize_test.go +++ b/cmd/wfctl/modernize_test.go @@ -553,6 +553,7 @@ func TestModernizeAllRulesRegistered(t *testing.T) { "camelcase-config", "request-parse-config", "legacy-do-types", + "legacy-aws-types", } if len(rules) != len(expectedIDs) { t.Errorf("expected %d rules, got %d", len(expectedIDs), len(rules)) diff --git a/cmd/wfctl/type_registry_test.go b/cmd/wfctl/type_registry_test.go index b430fa1b..ce475347 100644 --- a/cmd/wfctl/type_registry_test.go +++ b/cmd/wfctl/type_registry_test.go @@ -112,10 +112,6 @@ func TestKnownStepTypesPopulated(t *testing.T) { "step.secret_rotate", // platform plugin "step.platform_template", - "step.scaling_plan", - "step.scaling_apply", - "step.scaling_status", - "step.scaling_destroy", "step.iac_plan", "step.iac_apply", "step.iac_status", @@ -124,17 +120,6 @@ func TestKnownStepTypesPopulated(t *testing.T) { "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.ecs_plan", - "step.ecs_apply", - "step.ecs_status", - "step.ecs_destroy", "step.app_deploy", "step.app_status", "step.app_rollback", From 93c2436d4437057f7b81a0396b393fb1bd0a62f9 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 08:51:42 -0400 Subject: [PATCH 17/18] fix(#653): fix nilerr lint in aws_absent_test.go --- module/aws_absent_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/module/aws_absent_test.go b/module/aws_absent_test.go index d954a3fc..bb4d48a9 100644 --- a/module/aws_absent_test.go +++ b/module/aws_absent_test.go @@ -30,8 +30,8 @@ func TestAWSServicePackagesAbsent(t *testing.T) { if strings.HasSuffix(path, "aws_absent_test.go") { return nil // skip self } - f, parseErr := parser.ParseFile(fset, path, nil, parser.ImportsOnly) - if parseErr != nil { + f, _ := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if f == nil { return nil // skip unparseable files } for _, imp := range f.Imports { From 2e9cf4430b76492c80ea60b09bdf4ae586cc65b7 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 13 May 2026 09:03:39 -0400 Subject: [PATCH 18/18] fix(#653): correct platform.dns ConfigKeys: zone+records not domain --- cmd/wfctl/type_registry.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/wfctl/type_registry.go b/cmd/wfctl/type_registry.go index f4118446..48723842 100644 --- a/cmd/wfctl/type_registry.go +++ b/cmd/wfctl/type_registry.go @@ -523,7 +523,7 @@ func KnownModuleTypes() map[string]ModuleTypeInfo { Type: "platform.dns", Plugin: "platform", Stateful: false, - ConfigKeys: []string{"account", "provider", "domain"}, + ConfigKeys: []string{"account", "provider", "zone", "records"}, }, "platform.region": { Type: "platform.region",