diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 935fb1d6..9b23cfa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -376,3 +376,20 @@ jobs: echo "" echo "Results: $total configs, $passed passed, $warned warnings" + + godo-banned: + name: Verify godo is not imported (issue #617) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Grep gate — *.go files must not import godo + run: | + ! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + --exclude="godo_absent_test.go" \ + "digitalocean/godo" . + - name: Grep gate — go.mod files must not list godo + run: | + ! grep -qH "digitalocean/godo" go.mod example/go.mod diff --git a/CHANGELOG.md b/CHANGELOG.md index 83510c28..385e4e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## v0.52.0 (2026-05-13) — BREAKING + +### Removed (issue #617) + +- All legacy DigitalOcean IaC modules (`platform.do_app`, `platform.do_database`, `platform.do_dns`, `platform.do_networking`, `platform.doks`) and the DO credential resolver `cloud_account_do.go`. +- All legacy DigitalOcean pipeline steps (`step.do_deploy`, `step.do_status`, `step.do_logs`, `step.do_scale`, `step.do_destroy`). +- The `github.com/digitalocean/godo` dependency from `go.mod` (root and `example/`). + +### Migration + +DigitalOcean IaC moved to [`workflow-plugin-digitalocean`](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) v0.12.0+. After loading the plugin, replace legacy module types with `infra.*` types and `provider: digitalocean`. Run `wfctl modernize --apply ` to auto-rewrite supported types — **then manually add `provider: digitalocean` to each rewritten module's `config:` block** (the modernize rule does not inject the provider key; see the [migration guide](docs/migrations/v0.52.0-godo-removal.md) for the exact recipe). Two step types (`step.do_logs`, `step.do_scale`) have no 1:1 pipeline successor — workarounds documented in the migration guide. + +Configs that still reference the legacy types now fail to load with an actionable error pointing to the plugin and the relevant `infra.*` successor. + +--- + ## [Unreleased] ### Added diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b67613f0..b4294526 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -322,11 +322,11 @@ flowchart TD | `step.argo_logs` | Retrieves logs from an Argo Workflow | platform | | `step.argo_delete` | Deletes an Argo Workflow | platform | | `step.argo_list` | Lists Argo Workflows for a namespace | platform | -| `step.do_deploy` | Deploys to DigitalOcean App Platform | platform | -| `step.do_status` | Retrieves DigitalOcean App Platform deployment status | platform | -| `step.do_logs` | Fetches DigitalOcean App Platform runtime logs | platform | -| `step.do_scale` | Scales a DigitalOcean App Platform component | platform | -| `step.do_destroy` | Destroys a DigitalOcean App Platform deployment | platform | + +**DigitalOcean IaC steps** were removed from workflow core in v0.52.0 and moved to the +[workflow-plugin-digitalocean](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) +external plugin. After loading the plugin, use the generic `step.iac_*` pipeline steps. +See [v0.52.0 migration guide](docs/migrations/v0.52.0-godo-removal.md). ### Expression Syntax @@ -510,11 +510,12 @@ Strict mode applies to **both** direct dot-access (`{{ .steps.auth.field }}`) an | `platform.autoscaling` | Auto-scaling policy and target management | platform | | `platform.region` | Multi-region deployment configuration | platform | | `platform.region_router` | Routes traffic across regions by weight, latency, or failover | platform | -| `platform.doks` | DigitalOcean Kubernetes Service (DOKS) deployment | platform | -| `platform.do_app` | DigitalOcean App Platform deployment (deploy, scale, logs, destroy) | platform | -| `platform.do_networking` | DigitalOcean VPC and firewall management | platform | -| `platform.do_dns` | DigitalOcean domain and DNS record management | platform | -| `platform.do_database` | DigitalOcean Managed Database (PostgreSQL, MySQL, Redis) | platform | + +**DigitalOcean IaC modules** were removed from workflow core in v0.52.0 and moved to the +[workflow-plugin-digitalocean](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) +external plugin. After loading the plugin, use the generic `infra.*` module +types with `provider: digitalocean` and the generic `step.iac_*` pipeline +steps. See [v0.52.0 migration guide](docs/migrations/v0.52.0-godo-removal.md). | `iac.provider` | Cloud provider configuration (aws, gcp, azure, digitalocean) for IaC operations | platform | | `iac.state` | IaC state persistence (memory, filesystem, spaces, gcs, azure_blob, postgres) | platform | | `infra.vpc` | Virtual Private Cloud and subnet management | platform | diff --git a/cmd/wfctl/ci_run_dryrun.go b/cmd/wfctl/ci_run_dryrun.go index ab007f35..556b255f 100644 --- a/cmd/wfctl/ci_run_dryrun.go +++ b/cmd/wfctl/ci_run_dryrun.go @@ -177,7 +177,6 @@ func resolveDeployInfoFromConfig(wfCfg *config.WorkflowConfig, envName, provider // references the provider. deployTargetTypes := []string{ "infra.container_service", - "platform.do_app", "platform.app_platform", "infra.k8s_cluster", } diff --git a/cmd/wfctl/ci_validate.go b/cmd/wfctl/ci_validate.go index 4dac981f..e79583e2 100644 --- a/cmd/wfctl/ci_validate.go +++ b/cmd/wfctl/ci_validate.go @@ -10,8 +10,10 @@ import ( "time" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/internal/legacydo" "github.com/GoCodeAlone/workflow/schema" "github.com/GoCodeAlone/workflow/validation" + "gopkg.in/yaml.v3" ) // ciFileResult holds the outcome of validating a single config file. @@ -131,10 +133,37 @@ func ciValidateFile(cfgPath string, strict, immutableConfig bool, immutableSecti opts = append(opts, schema.WithAllowEmptyModules()) } opts = append(opts, schema.WithSkipWorkflowTypeCheck(), schema.WithSkipTriggerTypeCheck()) + // Pass legacy DO module types through schema so the migration error fires + // below instead of a generic "unknown module type" (issue #617). + for t := range legacydo.ModuleTypes { + opts = append(opts, schema.WithExtraModuleTypes(t)) + } if err := schema.ValidateConfig(cfg, opts...); err != nil { errs = append(errs, fmt.Errorf("schema: %w", err)) } + // Post-validate sweep: accumulate legacy DO module/step errors (issue #617). + for _, m := range cfg.Modules { + if legacydo.IsModuleType(m.Type) { + errs = append(errs, legacydo.FormatModuleError(m.Type, m.Name, false)) + } + } + for _, rawPipeline := range cfg.Pipelines { + yamlBytes, err := yaml.Marshal(rawPipeline) + if err != nil { + continue + } + var pipeCfg config.PipelineConfig + if err := yaml.Unmarshal(yamlBytes, &pipeCfg); err != nil { + continue + } + for _, s := range pipeCfg.Steps { + if legacydo.IsStepType(s.Type) { + errs = append(errs, legacydo.FormatStepError(s.Type, false)) + } + } + } + // CI config check. if immutableConfig { if cfg.CI == nil { diff --git a/cmd/wfctl/deploy.go b/cmd/wfctl/deploy.go index e88de93f..20fdf38f 100644 --- a/cmd/wfctl/deploy.go +++ b/cmd/wfctl/deploy.go @@ -778,7 +778,7 @@ func runDeployCloud(args []string) error { fmt.Fprintf(fs.Output(), `Usage: wfctl deploy cloud [options] Deploy infrastructure defined in a workflow config to a cloud environment. -Discovers cloud.account and platform.* modules, validates credentials, +Discovers cloud.account, platform.*, and infra.* modules, validates credentials, shows a deployment plan, and applies changes. Options: @@ -829,15 +829,15 @@ Options: return fmt.Errorf("parse config %s: %w", cfg, yamlErr) } - // Discover cloud accounts and platform modules + // Discover cloud accounts and deploy-target modules (platform.* or infra.*) var cloudAccounts []moduleEntry - var platformModules []moduleEntry + var deployTargetModules []moduleEntry for _, m := range parsed.Modules { if m.Type == "cloud.account" { cloudAccounts = append(cloudAccounts, m) } - if strings.HasPrefix(m.Type, "platform.") { - platformModules = append(platformModules, m) + if strings.HasPrefix(m.Type, "platform.") || strings.HasPrefix(m.Type, "infra.") { + deployTargetModules = append(deployTargetModules, m) } } @@ -896,13 +896,13 @@ Options: fmt.Println() } - // Report platform modules (deployment plan) - if len(platformModules) == 0 { - return fmt.Errorf("no platform.* modules found in config — nothing to deploy") + // Report deploy-target modules (deployment plan) + if len(deployTargetModules) == 0 { + return fmt.Errorf("no platform.* or infra.* modules found in config — nothing to deploy") } - fmt.Printf("Infrastructure Modules (%d):\n", len(platformModules)) - for _, pm := range platformModules { + fmt.Printf("Infrastructure Modules (%d):\n", len(deployTargetModules)) + for _, pm := range deployTargetModules { account, _ := pm.Config["account"].(string) detail := pm.Type if account != "" { diff --git a/cmd/wfctl/deploy_providers.go b/cmd/wfctl/deploy_providers.go index 395c96a9..0177d108 100644 --- a/cmd/wfctl/deploy_providers.go +++ b/cmd/wfctl/deploy_providers.go @@ -86,8 +86,8 @@ var resolveIaCProvider = discoverAndLoadIaCProvider // double parse — and either may be empty without affecting the // other. type iacPluginManifest struct { - Name string `json:"name"` - Version string `json:"version"` + Name string `json:"name"` + Version string `json:"version"` Capabilities struct { IaCProvider struct { Name string `json:"name"` @@ -418,7 +418,6 @@ func newPluginDeployProvider(providerName string, wfCfg *config.WorkflowConfig, // behaviour is predictable rather than silently wrong. deployTargetTypes := []string{ "infra.container_service", - "platform.do_app", "platform.app_platform", "infra.k8s_cluster", } diff --git a/cmd/wfctl/deploy_test.go b/cmd/wfctl/deploy_test.go index e30c1518..b6a38b45 100644 --- a/cmd/wfctl/deploy_test.go +++ b/cmd/wfctl/deploy_test.go @@ -92,7 +92,7 @@ func TestRunDeployCloudValidTarget(t *testing.T) { if err == nil { t.Fatal("expected error when no platform modules found") } - if !strings.Contains(err.Error(), "no platform.* modules found") { + if !strings.Contains(err.Error(), "no platform.* or infra.* modules found") { t.Errorf("expected no platform modules error, got: %v", err) } } diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 644ebc40..6d51634f 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -457,7 +457,7 @@ func secretGenKeys(cfg *config.WorkflowConfig) []string { } // parseInfraResourceSpecs reads an infra config (resolving imports:) and -// returns ResourceSpecs for all infra.* and platform.* modules. +// returns ResourceSpecs for all infra.* and platform.* (e.g., platform.kubernetes, platform.ecs) modules. func parseInfraResourceSpecs(cfgFile string) ([]interfaces.ResourceSpec, error) { cfg, err := config.LoadFromFile(cfgFile) if err != nil { @@ -574,7 +574,7 @@ func planResourcesForEnv(path, envName string) ([]*config.ResolvedModule, error) } func isContainerType(t string) bool { - return t == "infra.container_service" || t == "platform.do_app" + return t == "infra.container_service" } // loadCurrentState loads ResourceStates from the configured iac.state backend. diff --git a/cmd/wfctl/infra_apply.go b/cmd/wfctl/infra_apply.go index 8cff45e6..e1243c0c 100644 --- a/cmd/wfctl/infra_apply.go +++ b/cmd/wfctl/infra_apply.go @@ -127,8 +127,8 @@ func hasInfraModules(cfgFile string) bool { return false } -// hasPlatformModules reports whether cfgFile contains any modules with the legacy -// platform.* type prefix. +// hasPlatformModules reports whether cfgFile contains any modules with the +// platform.* type prefix (e.g., platform.kubernetes, platform.ecs). func hasPlatformModules(cfgFile string) bool { cfg, err := config.LoadFromFile(cfgFile) if err != nil { diff --git a/cmd/wfctl/infra_apply_test.go b/cmd/wfctl/infra_apply_test.go index 944248d7..c8e29b69 100644 --- a/cmd/wfctl/infra_apply_test.go +++ b/cmd/wfctl/infra_apply_test.go @@ -1987,7 +1987,7 @@ modules: if err := os.WriteFile(legacyOnly, []byte(` modules: - name: app - type: platform.do_app + type: example.legacy_unknown config: {} `), 0o600); err != nil { t.Fatal(err) diff --git a/cmd/wfctl/legacy_do_types_removed_test.go b/cmd/wfctl/legacy_do_types_removed_test.go new file mode 100644 index 00000000..b109eb7e --- /dev/null +++ b/cmd/wfctl/legacy_do_types_removed_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestLegacyDOTypesAbsent_FromTypeRegistry locks the post-cutover state of +// cmd/wfctl/type_registry.go for issue #617. If any legacy type leaks back in, +// this test fires and the CI gate fires. +func TestLegacyDOTypesAbsent_FromTypeRegistry(t *testing.T) { + modules := KnownModuleTypes() + steps := KnownStepTypes() + legacyModules := []string{ + "platform.do_app", "platform.do_database", "platform.do_dns", + "platform.do_networking", "platform.doks", + } + legacySteps := []string{ + "step.do_deploy", "step.do_status", "step.do_logs", + "step.do_scale", "step.do_destroy", + } + for _, tname := range legacyModules { + if _, ok := modules[tname]; ok { + t.Errorf("module type registry still contains legacy DO type %q (issue #617)", tname) + } + } + for _, tname := range legacySteps { + if _, ok := steps[tname]; ok { + t.Errorf("step type registry still contains legacy DO type %q (issue #617)", tname) + } + } +} + +// TestValidateFile_LegacyDOModule_ReturnsActionableError verifies that +// wfctl validate emits the actionable migration error when the config +// references a removed legacy DO module type (issue #617). Covers AC3 +// on the validate path (the engine path is covered by +// TestLegacyDOModuleError_PluginNotLoaded in the workflow package). +func TestValidateFile_LegacyDOModule_ReturnsActionableError(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "legacy.yaml") + yamlContent := []byte("modules:\n - name: api\n type: platform.do_app\n config: {}\n") + if err := os.WriteFile(cfgPath, yamlContent, 0o600); err != nil { + t.Fatal(err) + } + err := validateFile(cfgPath, false, false, false) + if err == nil { + t.Fatal("expected error for legacy DO module type") + } + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-digitalocean", + "infra.container_service", + } { + if !strings.Contains(msg, want) { + t.Errorf("error missing %q; got: %s", want, msg) + } + } +} + +// TestCIValidateFile_LegacyDOStep_ReturnsActionableError covers ciValidateFile's +// accumulating return for legacy DO step types. +func TestCIValidateFile_LegacyDOStep_ReturnsActionableError(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "legacy.yaml") + yamlContent := []byte("pipelines:\n deploy:\n steps:\n - type: step.do_deploy\n") + if err := os.WriteFile(cfgPath, yamlContent, 0o600); err != nil { + t.Fatal(err) + } + errs := ciValidateFile(cfgPath, false, false, "") + if len(errs) == 0 { + t.Fatal("expected error for legacy DO step type") + } + found := false + for _, e := range errs { + if strings.Contains(e.Error(), "step.iac_apply") && strings.Contains(e.Error(), "removed from workflow core") { + found = true + break + } + } + if !found { + t.Errorf("expected actionable migration error in errs; got: %v", errs) + } +} diff --git a/cmd/wfctl/modernize_test.go b/cmd/wfctl/modernize_test.go index dbe2f482..8bed2692 100644 --- a/cmd/wfctl/modernize_test.go +++ b/cmd/wfctl/modernize_test.go @@ -552,6 +552,7 @@ func TestModernizeAllRulesRegistered(t *testing.T) { "empty-routes", "camelcase-config", "request-parse-config", + "legacy-do-types", } if len(rules) != len(expectedIDs) { t.Errorf("expected %d rules, got %d", len(expectedIDs), len(rules)) diff --git a/cmd/wfctl/type_registry.go b/cmd/wfctl/type_registry.go index 6f7b9280..8945ceed 100644 --- a/cmd/wfctl/type_registry.go +++ b/cmd/wfctl/type_registry.go @@ -506,43 +506,13 @@ func KnownModuleTypes() map[string]ModuleTypeInfo { ConfigKeys: []string{"format"}, }, - // platform plugin (region router + DigitalOcean) + // platform plugin (region router) "platform.region_router": { Type: "platform.region_router", Plugin: "platform", Stateful: false, ConfigKeys: []string{"module", "mode"}, }, - "platform.doks": { - Type: "platform.doks", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "cluster_name", "region", "version", "node_pool"}, - }, - "platform.do_networking": { - Type: "platform.do_networking", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "provider", "vpc", "firewalls"}, - }, - "platform.do_dns": { - Type: "platform.do_dns", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "provider", "domain", "records"}, - }, - "platform.do_app": { - Type: "platform.do_app", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "provider", "name", "region", "image", "instances", "http_port", "envs"}, - }, - "platform.do_database": { - Type: "platform.do_database", - Plugin: "platform", - Stateful: false, - ConfigKeys: []string{"account", "provider", "engine", "size", "region", "nodes"}, - }, "platform.kubernetes": { Type: "platform.kubernetes", Plugin: "platform", @@ -1427,33 +1397,6 @@ func KnownStepTypes() map[string]StepTypeInfo { ConfigKeys: []string{"service", "label_selector"}, }, - // platform plugin steps (DigitalOcean) - "step.do_deploy": { - Type: "step.do_deploy", - Plugin: "platform", - ConfigKeys: []string{"app", "image"}, - }, - "step.do_status": { - Type: "step.do_status", - Plugin: "platform", - ConfigKeys: []string{"app"}, - }, - "step.do_logs": { - Type: "step.do_logs", - Plugin: "platform", - ConfigKeys: []string{"app"}, - }, - "step.do_scale": { - Type: "step.do_scale", - Plugin: "platform", - ConfigKeys: []string{"app", "instances"}, - }, - "step.do_destroy": { - Type: "step.do_destroy", - Plugin: "platform", - ConfigKeys: []string{"app"}, - }, - // platform plugin steps (platform template) "step.platform_template": { Type: "step.platform_template", diff --git a/cmd/wfctl/validate.go b/cmd/wfctl/validate.go index 450725b7..4e6eaa04 100644 --- a/cmd/wfctl/validate.go +++ b/cmd/wfctl/validate.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/internal/legacydo" "github.com/GoCodeAlone/workflow/schema" "gopkg.in/yaml.v3" ) @@ -142,10 +143,40 @@ func validateFile(cfgPath string, strict, skipUnknownTypes, allowNoEntryPoints b opts = append(opts, schema.WithAllowNoEntryPoints()) } + // Pass legacy DO module types through schema validation so the actionable + // migration error fires below instead of a generic "unknown module type". + for t := range legacydo.ModuleTypes { + opts = append(opts, schema.WithExtraModuleTypes(t)) + } + if err := schema.ValidateConfig(cfg, opts...); err != nil { return err } + // Post-validate sweep: reject legacy DO module/step types with actionable + // migration errors (issue #617). wfctl validate has no engine, so the + // iacProviderLoaded flag is always false here. + for _, m := range cfg.Modules { + if legacydo.IsModuleType(m.Type) { + return legacydo.FormatModuleError(m.Type, m.Name, false) + } + } + for _, rawPipeline := range cfg.Pipelines { + yamlBytes, err := yaml.Marshal(rawPipeline) + if err != nil { + continue + } + var pipeCfg config.PipelineConfig + if err := yaml.Unmarshal(yamlBytes, &pipeCfg); err != nil { + continue + } + for _, s := range pipeCfg.Steps { + if legacydo.IsStepType(s.Type) { + return legacydo.FormatStepError(s.Type, false) + } + } + } + // Validate ci:, environments:, and secrets: sections when present. if cfg.CI != nil { if err := cfg.CI.Validate(); err != nil { diff --git a/docs/migrations/v0.52.0-godo-removal.md b/docs/migrations/v0.52.0-godo-removal.md new file mode 100644 index 00000000..b79d4740 --- /dev/null +++ b/docs/migrations/v0.52.0-godo-removal.md @@ -0,0 +1,176 @@ +# v0.52.0 — Removing godo from workflow core (issue #617) + +## What changed + +The five legacy `platform.do_*` modules, the `cloud.account` DO credential +resolver, and the five legacy `step.do_*` pipeline steps were removed from +workflow core. The `github.com/digitalocean/godo` dependency is no longer +pulled by the workflow module. + +DigitalOcean IaC functionality moved entirely to +[`workflow-plugin-digitalocean`](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) +v0.12.0+, which exposes the same resources through the generic `infra.*` IaC +type system with `provider: digitalocean`. + +## Why + +Workflow core should own IaC interfaces and orchestration, not provider SDKs. +Dependabot bumps to godo now target the DO plugin repo, not core. See the +design plan at `docs/plans/2026-05-13-issue-617-godo-removal.md`. + +## Migration recipe + +1. Install the DO plugin (v0.12.0+): + ```sh + wfctl plugin install workflow-plugin-digitalocean@0.12.0 + ``` + Or declare it in your workflow config under `plugins.external` so the engine + auto-fetches it from the registry when it isn't already in the local plugin + directory: + ```yaml + plugins: + external: + - name: workflow-plugin-digitalocean + version: ">=0.12.0" + autoFetch: true + ``` + + To declare the dependency without auto-fetch, list the plugin name under + `requires.plugins`: + ```yaml + requires: + plugins: + - workflow-plugin-digitalocean + ``` + +2. Run the modernizer over each affected YAML config: + ```sh + wfctl modernize --apply ./config/*.yaml + ``` + This **renames the type field** for 4 module types automatically. + All 5 step types and one module type (`platform.do_networking`) are flagged + but **not** auto-rewritten — see below. Step types require a manual + config rewrite because `step.iac_apply/status/destroy` use different config + keys (`platform` + `state_store`) than the legacy `app:` key. + +3. **Add `provider: digitalocean` to each rewritten module's `config:` + block.** The modernize rule does NOT auto-inject this key, because the + `config:` block typically contains operator-authored settings that + shouldn't be silently modified. Example: + + ```yaml + # After modernize (type renamed, provider absent): + modules: + - name: api + type: infra.container_service + config: + region: nyc # <-- modernize left this alone + + # Operator adds provider key manually: + modules: + - name: api + type: infra.container_service + config: + provider: digitalocean # <-- ADD THIS + region: nyc + ``` + + Forgetting this produces a load-time error: + `infra module "api" (infra.container_service): 'provider' config is required`. + +4. Manually address the GAP types listed below. + +5. Re-run `wfctl validate` and `wfctl infra plan` to confirm the rewritten + config loads and produces the same plan. + +## Module type mapping + +| Legacy type | Successor | Auto-fix | +|--------------------------|-----------------------------------|----------| +| `platform.do_app` | `infra.container_service` | Yes | +| `platform.do_database` | `infra.database` | Yes | +| `platform.do_dns` | `infra.dns` | Yes | +| `platform.do_networking` | `infra.vpc` + `infra.firewall` | **No** — splits 1→2, manual review required | +| `platform.doks` | `infra.k8s_cluster` | Yes | + +All successors require `config.provider: digitalocean`. + +## Step type mapping + +| Legacy type | Successor | Auto-fix | +|--------------------|--------------------------------------------------------------------|----------| +| `step.do_deploy` | `step.iac_apply` (against an `infra.container_service` module); requires `platform` + `state_store` config keys | **No** — config shape change; manual rewrite required | +| `step.do_status` | `step.iac_status` (against an `infra.container_service` module); requires `platform` + `state_store` config keys | **No** — config shape change; manual rewrite required | +| `step.do_destroy` | `step.iac_destroy` (against an `infra.container_service` module); requires `platform` + `state_store` config keys | **No** — config shape change; manual rewrite required | +| `step.do_logs` | **GAP** — no pipeline step successor; use `wfctl infra logs` ad-hoc, or rely on the DO plugin's Troubleshoot hook on `step.iac_apply` failure. Tracked: [workflow-plugin-digitalocean#107](https://github.com/GoCodeAlone/workflow-plugin-digitalocean/issues/107) | **No** | +| `step.do_scale` | **GAP** — no pipeline step successor; update `instance_count` in the `infra.container_service` module config and re-run `step.iac_apply`. Tracked: [workflow-plugin-digitalocean#108](https://github.com/GoCodeAlone/workflow-plugin-digitalocean/issues/108) | **No** | + +## Before / after examples + +### App Platform + +Before: +```yaml +modules: + - name: api + type: platform.do_app + config: + region: nyc + services: + - name: web + image: registry.digitalocean.com/myorg/api:latest +``` + +After: +```yaml +modules: + - name: api + type: infra.container_service + config: + provider: digitalocean + region: nyc + services: + - name: web + image: registry.digitalocean.com/myorg/api:latest +``` + +### Pipeline step + +Before: +```yaml +pipelines: + - id: deploy + steps: + - type: step.do_deploy + config: { app: api } +``` + +After: +```yaml +pipelines: + - id: deploy + steps: + - type: step.iac_apply + config: + platform: digitalocean # name of the iac.provider service in the registry + state_store: mystore # name of an iac.state backend module +``` + +> **Note:** `step.iac_apply/status/destroy` require `platform` (the name of the +> `iac.provider` service registered by workflow-plugin-digitalocean) and +> `state_store` (the name of an IaC state backend module). The legacy `app:` +> config key is not used. The `wfctl modernize` tool flags these steps but does +> **not** auto-rewrite them because the config shape change cannot be done +> automatically — you must supply the correct `platform` and `state_store` values +> for your deployment. + +## Errors you may see + +* `unsupported legacy module type "platform.do_app" (module "api"): this type was removed from workflow core in v0.52.0 — DigitalOcean IaC moved to workflow-plugin-digitalocean.` — fix the config per the table above; install the plugin if not already loaded. +* `unsupported legacy step type "step.do_logs": ...` — see GAP entry above; remove the step and use `wfctl infra logs` ad-hoc, or wait for `step.iac_logs` ([workflow-plugin-digitalocean#107](https://github.com/GoCodeAlone/workflow-plugin-digitalocean/issues/107)). + +## Rollback + +If your environment cannot upgrade in this cycle, pin to the previous workflow +core tag (`go get github.com/GoCodeAlone/workflow@v0.51.3`). The legacy modules +remain available there. diff --git a/docs/plans/2026-05-13-issue-617-godo-removal-design.md b/docs/plans/2026-05-13-issue-617-godo-removal-design.md new file mode 100644 index 00000000..d5bd3cf0 --- /dev/null +++ b/docs/plans/2026-05-13-issue-617-godo-removal-design.md @@ -0,0 +1,303 @@ +# Issue #617 — Remove godo (DigitalOcean SDK) from Workflow Core + +**Status:** Draft for adversarial review +**Owner:** autonomous pipeline (intel352) +**Issue:** [GoCodeAlone/workflow#617](https://github.com/GoCodeAlone/workflow/issues/617) +**Date:** 2026-05-13 + +## Summary + +Workflow core directly imports `github.com/digitalocean/godo` to back six legacy IaC modules (`platform.do_app`, `platform.doks`, `platform.do_dns`, `platform.do_database`, `platform.do_networking`, `cloud.account` DO resolver) and five legacy pipeline steps (`step.do_deploy/status/logs/scale/destroy`). The same surface is already implemented in `workflow-plugin-digitalocean` v0.12.0 as a proper IaC provider plugin (`iac.provider` module type, gRPC, computePlanVersion v2). Dependabot bumps to godo therefore drift core, the wrong owner. + +This design proposes a **single-PR force-cutover** that deletes the legacy DO surface from workflow core, removes `godo` from `go.mod`, and emits an actionable migration error when a user config still references the legacy module types. This mirrors the precedent established by the strict-contracts force-cutover (memory: `feedback_force_strict_contracts_no_compat.md`). + +AWS SDK usage is **explicitly out of scope** for this issue — `iam/`, `plugin/rbac/aws.go`, `artifact/s3.go`, and IaC drivers under `platform/providers/aws/` are audited as a separate follow-up issue created at the end of this work. + +## Goals (acceptance criteria from #617) + +1. Workflow core no longer imports `github.com/digitalocean/godo` for IaC/App Platform behavior. +2. Existing DO App Platform behavior remains available through `workflow-plugin-digitalocean`. +3. `wfctl` errors remain actionable when a provider plugin is missing or a legacy DO module type is referenced. +4. Dependabot provider SDK bumps target the provider repo, not workflow core. + +## Non-goals + +- Removing AWS SDK from core (separate issue created at end of work). +- Replacing the DO plugin's existing functionality. +- Backwards-compatible shim modules — force-cutover, no compat layer. +- Touching `module/iac_state_spaces.go` (S3-compat backend uses `aws-sdk-go-v2`, not godo). +- Migration tooling beyond an actionable load-time error pointing at the migration guide. + +## Current state — surface to remove + +### Module files (godo importers) + +| File | Lines | Purpose | +|------|------|---------| +| `module/platform_do_app.go` | 430 | App Platform module | +| `module/platform_do_app_test.go` | 399 | tests | +| `module/platform_do_database.go` | 263 | Managed Database module | +| `module/platform_do_database_test.go` | 66 | tests | +| `module/platform_do_dns.go` | 357 | DNS module | +| `module/platform_do_dns_test.go` | 270 | tests | +| `module/platform_do_networking.go` | 370 | VPC + firewall module | +| `module/platform_do_networking_test.go` | 264 | tests | +| `module/platform_doks.go` | 329 | DOKS Kubernetes module | +| `module/platform_doks_test.go` | 164 | tests | +| `module/cloud_account_do.go` | 74 | DO credential resolvers + `doClient()` | +| `module/pipeline_step_do.go` | 220 | 5 DO App Platform pipeline steps | +| **Total (12 files)** | **~3206** | | + +### Registration / schema sites + +| File | Edit | +|------|------| +| `plugins/platform/plugin.go` | Drop `platform.do_*` + `platform.doks` from `ModuleTypes`; drop 5 module factories; drop `step.do_*` from `StepTypes`; drop 5 step factories. | +| `plugins/platform/plugin_test.go` | Drop the 5 `step.do_*` + 5 `platform.do_*` assertions. | +| `schema/schema.go` | Drop 5 module-type entries + 5 step-type entries. | +| `schema/module_schema.go` | Drop 5 module schemas + 5 step descriptions. | +| `schema/step_schema_builtins.go` | Drop 5 step schema `Register` calls. | +| `cmd/wfctl/type_registry.go` | Drop 5 module + 5 step type-registry entries. | +| `cmd/wfctl/infra.go:577` | `return t == "infra.container_service"` (drop `|| t == "platform.do_app"`). | +| `cmd/wfctl/deploy_providers.go:419-424` | Drop `"platform.do_app"` from `deployTargetTypes`. | +| `cmd/wfctl/ci_run_dryrun.go:178-183` | Drop `"platform.do_app"` from `deployTargetTypes`. | +| `module/multi_region.go:123` | Rewrite error message to point at `workflow-plugin-digitalocean` + `infra.*` types. | +| `DOCUMENTATION.md` | Replace the 5 module rows + 5 step rows with a paragraph pointing at the DO plugin. | +| `go.mod` / `go.sum` | `go mod tidy` after deletion drops `github.com/digitalocean/godo` + transitive deps. | + +### Migration error — modules + steps (both paths covered) + +Two guards, one per registry. Both fire in the unknown-type fallback path so they are unreachable when the type is registered by a loaded plugin. + +**Module guard** — in `engine.go BuildFromConfig` (unknown-module-type branch): + +``` +unsupported legacy module type %q: this type was removed from workflow core in v. + +DigitalOcean IaC moved to workflow-plugin-digitalocean. +%s + +Migrate this module to the equivalent infra.* IaC type: + platform.do_app → infra.container_service (provider: digitalocean) + platform.do_database → infra.database (provider: digitalocean) + platform.do_dns → infra.dns (provider: digitalocean) + platform.do_networking → infra.vpc + infra.firewall (provider: digitalocean) + platform.doks → infra.k8s_cluster (provider: digitalocean) + +See docs/migrations/v-godo-removal.md. +``` + +The middle `%s` line branches on plugin-loaded detection (closes adversarial finding I-2): + +- If the `iac.provider` factory is registered in the engine's module factory map (`_, iacLoaded := e.moduleFactories["iac.provider"]`) → emit `"workflow-plugin-digitalocean is already loaded; your config still references the legacy module name."` +- Otherwise → emit `"Install workflow-plugin-digitalocean: https://github.com/GoCodeAlone/workflow-plugin-digitalocean"` + +(The single-factory-map check is sufficient because the DO plugin is the only known publisher of `iac.provider` in the GoCodeAlone ecosystem. An ANDed check on `provider: digitalocean` infra.* bindings would have no clean implementation path at the engine layer and would not add discriminating signal — see cycle-3 review m-2.) + +**Step guard** — in `module/pipeline_step_registry.go` (or `engine.go buildPipelineSteps`'s unknown-step-type branch), for the five legacy step types. Per-step messages because the mapping is NOT one-to-one (closes finding I-1): + +``` +step.do_deploy → step.iac_apply (against an infra.container_service module) +step.do_destroy → step.iac_destroy (against an infra.container_service module) +step.do_status → step.iac_status (against an infra.container_service module) +step.do_logs → no direct equivalent. The DO plugin attaches deploy logs + internally via its Troubleshoot hook on step.iac_apply + failure. For ad-hoc log fetch, use `wfctl infra logs`. + A pipeline-step replacement is tracked in + workflow-plugin-digitalocean issue . +step.do_scale → no direct equivalent. Update instance_count in the + infra.container_service module config and re-run + step.iac_apply. A first-class step.iac_scale is tracked + in workflow-plugin-digitalocean issue . +``` + +The same plugin-loaded detection branches the step-guard prefix (`Install ... / already loaded ...`). + +This satisfies acceptance criterion #3 for both modules and pipeline steps. + +## Considered approaches + +### Option A — Single-PR force-cutover (RECOMMENDED) + +Delete in one PR: 11 module files, all registration sites, godo from go.mod, plus migration error + migration doc. Tag a new minor; CHANGELOG calls out breaking change. + +**Pros:** Mirrors strict-contracts precedent; no duplication window; clean git history; Dependabot stops touching core immediately. +**Cons:** Any consumer YAML using `platform.do_*` breaks on engine upgrade. Mitigated by actionable error message + migration guide. + +### Option B — Phased deprecation (REJECTED) + +Mark legacy modules deprecated, gate behind a `LEGACY_DO_MODULES=1` env var, remove godo in a later release. + +**Pros:** Soft landing. +**Cons:** Fights force-cutover precedent; perpetuates duplication; Dependabot still nags core during the window; doubles the work (two PRs, deprecation warnings, retest matrix); a "later release" reliably becomes "never." + +### Option B′ — Go build tag fence (REJECTED) + +Add `//go:build !workflow_strict` (or similar) to the six godo-importing files so the production binary excludes them while tests stay. + +**Pros:** No deletion; tests keep running; "reversible." +**Cons:** Fails goal #1 — `godo` remains in `go.mod` because the build-tagged code still parses. Fails goal #4 — Dependabot still nags. Perpetuates ambiguity about "supported." Adds a build matrix for zero net benefit over Option A. + +### Option C — Move-then-delete (REJECTED for DO; matches the AWS audit follow-up) + +Audit DO plugin parity, file gap issues against `workflow-plugin-digitalocean`, fix gaps, then delete from core. + +**Pros:** Surfaces gaps before consumer surprise. +**Cons:** Premature here — plugin v0.12.0 has been the de facto IaC provider in BMW deploys since v0.51.2 (memory: `project_strict_contracts_cutover_complete.md`); the legacy modules predate the IaC abstraction and produce a different (non-conformant) state shape. There is no parity to verify — the new path supersedes the old path with a different config schema. Migration is a config rewrite, not a code port. + +The "move-then-delete" model fits the AWS audit better because parts of AWS legitimately stay (RBAC, secrets, artifact). For DO, every godo importer is replaced. + +## Recommendation + +**Option A**, one PR `feat: remove godo from core (issue #617)`. + +### Companion: `wfctl modernize` rule (in scope of T5) + +The engine already has a `modernize` command (`mcp__workflow__modernize` tool + wfctl subcommand) that auto-rewrites legacy YAML anti-patterns. Add five rewrite rules so user YAML migrates with one `wfctl modernize --write` invocation: + +- `module/type: platform.do_app` → `module/type: infra.container_service` + inject `config.provider: digitalocean` +- `module/type: platform.do_database` → `module/type: infra.database` + provider +- `module/type: platform.do_dns` → `module/type: infra.dns` + provider +- `module/type: platform.do_networking` → split into `infra.vpc` + `infra.firewall` modules (lossy — emit a comment-prefixed warning when source has both `vpc` and `firewalls` keys with non-overlapping shapes) +- `module/type: platform.doks` → `module/type: infra.k8s_cluster` + provider +- `step/type: step.do_deploy/status/destroy` → `step.iac_apply/status/destroy` with `module` field re-bound to the migrated module name +- `step/type: step.do_logs/scale` → emit a `wfctl: cannot rewrite — see migration guide` annotation; do not delete the step (operator must address manually) + +This reduces migration friction from manual-rewrite to one `wfctl modernize --apply ` invocation + manual review of the two annotated step types. (Flag is `--apply`, verified against `cmd/wfctl/modernize.go`.) Folds into T5. + +## Assumptions (load-bearing) + +1. **Plugin parity assumption:** `workflow-plugin-digitalocean` v0.12.0 covers every resource served by the deleted core modules. *Test:* the parity matrix below maps each legacy module to its plugin replacement; the matrix MUST be re-validated before merge. + +2. **No internal consumers downstream:** No downstream repo's YAML still relies on `platform.do_*` / `step.do_*` types post-IaC migration. *Test:* the implementer greps `buymywishlist`, `core-dump`, `workflow-cloud`, `workflow-scenarios`, `ratchet`, `ratchet-cli` config trees for the legacy names before opening the PR. Any hit becomes either a migration PR in that repo (Option A still ships) or a blocker (revisit). **Sequencing constraint:** any `workflow-scenarios` migration PRs must merge before — or in the same batch as — the engine cutover tag is consumed by scenario-CI, otherwise scenario CI will fail with the (correctly) actionable migration errors. + +3. **Schema allow-lists are advisory, not authoritative:** Removing entries from `schema/schema.go` does not silently re-allow them elsewhere — the registry is the only enforcement point and we're removing them there too. *Test:* `go test ./schema/...` after deletion. + +4. **`go mod tidy` is sufficient to drop godo:** No other core file imports godo besides the listed eleven. *Test:* `grep -rn "digitalocean/godo" --include="*.go"` returns no results post-deletion (excluding worktree dirs). + +5. **Engine v0.NEXT bump is acceptable:** This is a breaking change; CHANGELOG + a minor-version bump suffices. The user has authorized the autonomous pipeline to ship breaking changes (memory: `feedback_force_strict_contracts_no_compat.md`). + +6. **DO plugin minEngineVersion `0.51.2` remains valid:** The plugin does not depend on any core symbol we're removing — it imports godo itself and only consumes the `iac.provider` interface from core. *Test:* the implementer runs `go build ./...` against the DO plugin with the post-cutover workflow module pinned via `replace`. + +## Parity matrix — legacy core module → plugin replacement + +| Legacy core type | Plugin replacement (`workflow-plugin-digitalocean` v0.12.0) | Notes | +|------------------|-------------------------------------------------------------|-------| +| `platform.do_app` | `infra.container_service` + provider `digitalocean` → driver `internal/drivers/app_platform.go` | App Platform spec maps, region routing, build spec, migration repair, image presence — all present in plugin. | +| `platform.do_database` | `infra.database` + provider `digitalocean` → driver `internal/drivers/database.go` | Managed PG / MySQL / Redis. | +| `platform.do_dns` | `infra.dns` + provider `digitalocean` → `internal/drivers/dns.go` | DNS zone + records. | +| `platform.do_networking` | `infra.vpc` + `infra.firewall` + provider `digitalocean` → `internal/drivers/vpc.go`, `internal/drivers/firewall.go` (per plugin manifest) | VPC + firewall split per IaC model. | +| `platform.doks` | `infra.k8s_cluster` + provider `digitalocean` (plugin manifest) | DOKS cluster + node pool. | +| `cloud.account` (DO resolver) | DO plugin manages its own DO API token via `iac.provider` credential broker | Plugin doesn't need the legacy resolver chain. | +| `step.do_deploy` | `step.iac_apply` against `infra.container_service` | 1:1 mapping; provider drives apply. | +| `step.do_status` | `step.iac_status` against `infra.container_service` | 1:1 mapping. | +| `step.do_destroy` | `step.iac_destroy` against `infra.container_service` | 1:1 mapping. | +| `step.do_logs` | **GAP** — no pipeline-step equivalent | DO plugin attaches logs via `Troubleshoot` hook internally on apply failure; ad-hoc fetch via `wfctl infra logs`. Tracked in plugin issue (filed pre-merge). Documented in migration guide. | +| `step.do_scale` | **GAP** — config-driven re-apply only | Update `instance_count` in `infra.container_service` config + re-run `step.iac_apply`. First-class `step.iac_scale` tracked in plugin issue (filed pre-merge). Documented in migration guide. | + +If any cell of this matrix is wrong, the implementer files an issue against `workflow-plugin-digitalocean` BEFORE submitting the cutover PR, and the cutover PR blocks on that issue's fix. + +## Self-challenge round + +1. **Lazier solution?** A single `replace github.com/digitalocean/godo => ../shim` is lazier but doesn't satisfy goal #1 (godo still in go.mod). A `// nolint:godox` is laziest but ignores the problem. No lazier path satisfies all four goals. + +2. **Most fragile assumption?** Assumption #2 — no downstream consumer still uses `platform.do_*`. Mitigation: the implementer's pre-PR grep step covers it; the load-time migration error covers the field; the CHANGELOG covers expectations. Cost of a missed hit = one follow-up migration PR in the affected repo. + +3. **YAGNI sweep — what does this design solve that wasn't asked?** Two items examined: + - Migration error message (kept — directly satisfies goal #3 "wfctl errors remain actionable"). + - Generic "legacy provider type" framework (dropped — only DO needs this today; AWS legacy types stay; adding a framework is premature abstraction). + +4. **Partial failure surface:** `go mod tidy` fails on a transitive godo dep we missed → CI catches before merge. Plugin doesn't satisfy a parity cell → caught by the matrix re-validation step pre-merge; if discovered post-merge, fixed in plugin and core is unaffected because the symbol is gone from core. Config loads with a removed type → migration error fires; no silent skip. + +5. **Fights existing pattern?** No. Force-cutover precedent: `feedback_force_strict_contracts_no_compat.md`. The IaC migration design (`docs/plans/2026-04-17-deploy-pipeline-multi-env-design.md` lines 126-130) explicitly maps the legacy DO types to `infra.*` — this design completes that migration. + +**Top 3 doubts surfaced for adversarial review:** + +1. Are there cached external YAML configs (BMW prod, core-dump prod) still using `platform.do_*` that will fail on next deploy? Migration error catches it but operators may not be reading the changelog. +2. Does the DO plugin's `iac.provider` IaC state shape converge with the legacy modules' state shape, or is there an unmigrated state-file flag day? (State-shape mismatch already known to exist — memory `project_strict_contracts_cutover_complete.md`.) +3. Does removing `cloud_account_do.go` break the credential resolver registry for any test or external module that registers a DO-flavored resolver? `RegisterCredentialResolver` is a global registry; removing one register call should be safe but the order-dependence is worth checking. + +## Implementation plan (preview — full plan written by writing-plans skill) + +Single PR, ~5 tasks: + +1. **T1 — Delete legacy module + step files (12 files).** Pure deletion (all 12 rows in the "Module files" table above, including `module/platform_doks_test.go`); a new test asserts removal of registry entries. +2. **T2 — Strip registration sites (10 files).** Edits to `plugins/platform/plugin.go`, `schema/schema.go`, `schema/module_schema.go`, `schema/step_schema_builtins.go`, `cmd/wfctl/type_registry.go`, `cmd/wfctl/infra.go`, `cmd/wfctl/deploy_providers.go`, `cmd/wfctl/ci_run_dryrun.go`, `plugins/platform/plugin_test.go`, `module/multi_region.go`. Implementer also reviews `cmd/wfctl/infra_apply_test.go` line 1990 (negative-test fixture using `type: platform.do_app`) — replace with a synthetic non-existent type or remove if the negative case is redundant. +3. **T3 — Add load-time migration error + tests.** Engine fails-closed on legacy DO types with actionable message; new test fixtures cover all 5 legacy types. +4. **T4 — `go mod tidy` + grep gate.** Confirm zero `digitalocean/godo` imports remain in code AND in module files; update go.sum; add CI gate. + + Tidy steps: + ```sh + go mod tidy # root module + (cd example && go mod tidy) # standalone example/ sub-module also pins godo as indirect + ``` + + Grep gate (fail-on-match, exact invocation — both gates use `!` to invert grep's exit code so a match becomes a failing CI step): + ```sh + # *.go gate: + ! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + "digitalocean/godo" . + + # go.mod gate (root + example/): + ! grep -qH "digitalocean/godo" go.mod example/go.mod + ``` + Gate lives in `.github/workflows/ci.yml` (or wherever `golangci-lint` already runs) as a fail-on-match step. Same grep also runs as a pre-commit step locally documented in CONTRIBUTING (no install required, repo-relative). +5. **T5 — Docs + CHANGELOG + migration guide + modernize rules.** Update `DOCUMENTATION.md`, prepend CHANGELOG breaking-change entry, add `docs/migrations/v-godo-removal.md` (5 module + 5 step mappings, plus explicit GAP callout for `step.do_logs` / `step.do_scale` with workaround YAML examples), implement the seven `wfctl modernize` rewrite rules above + test fixtures, file the two follow-up issues in `workflow-plugin-digitalocean` (`step.iac_logs`, `step.iac_scale`) and wire their issue numbers back into the migration error messages. + +Post-merge: file follow-up issue **"#NEXT — Audit AWS SDK usage in workflow core (RBAC/secrets/artifact stay; IaC drivers reviewed for plugin move)"** so the AWS half of the issue's audit-points note is tracked. + +## Rollback + +This change affects build, package version, and runtime config loading. Rollback path: + +- **Pre-merge:** revert the branch; no consumer impact. +- **Post-merge, pre-tag:** revert the PR; force a new minor without the change. No consumer impact. +- **Post-tag:** consumers pin the previous tag (`go get github.com/GoCodeAlone/workflow@v`). Migration error reverts. Provided we have not advanced any consumer's pin in the same window, this is a clean fallback. CHANGELOG must call out the pinned-pre-cutover version explicitly. +- **State files written by the new path:** unaffected (state lives in `iac.state` backends, not in deleted code). + +## Open questions (none blocking — autonomous pipeline proceeds) + +- Should the migration error be a hard error or a warning + skip? **Decision (autonomous):** hard error. A silently-skipped module is worse than a failed load; goal #3 mandates actionable errors. Re-open if adversarial review pushes back. +- Should `cloud_account_do.go` deletion include removing the registered resolver names (`digitalocean/static`, `digitalocean/env`, `digitalocean/api_token`) from any global registry to prevent dead config keys? **Decision (autonomous):** the registry is purely additive via `init()`; deleting the file removes the `init()` call, which is itself the evidence that no DO resolver is registered. No separate test is added — `credentialResolvers` is unexported (`module/cloud_credential_resolver.go:14`) and adding an exported accessor solely for a one-shot self-evidencing assertion is API-surface-for-test-only. Verified instead by the build (no godo importer remains) and by the migration error path (which fires when a `cloud.account` with `provider: digitalocean` is loaded but no DO resolver is registered). + +## Adversarial review history + +### Cycle 3 (PASS — 0 Critical / 0 Important; 3 Minor incorporated) — 2026-05-13 + +- **m-1** Grep gates lacked `!`-prefix; `|| true` silently suppressed exit code → **fixed**: both gates now use `!` prefix to fail CI on match. +- **m-2** Plugin-loaded detection over-specified (AND of factory map + provider binding) → **fixed**: simplified to `_, iacLoaded := e.moduleFactories["iac.provider"]` with rationale. +- **m-3** Missing sequencing constraint for `workflow-scenarios` migration → **fixed**: explicit constraint added to Assumption #2. +- **t-1** T2 file count was 9; actual list contained 10 → **fixed**. +- **Cycle-1 and Cycle-2 fixes verified to hold.** + +Verdict: PASS. Pipeline advances to writing-plans. + +### Cycle 2 (FAIL) — 2026-05-13 + +- **I-1** (new) `module/platform_doks_test.go` (164 LOC) missing from deletion inventory → **fixed**: row added; total bumped to 12 files / ~3206 LOC; T1 scope updated. +- **m-1** (new) wfctl flag was `--write`, actual flag is `--apply` → **fixed**. +- **m-2** (new) `example/go.mod` carries `godo` as indirect dependency; T4 grep only covered `*.go` → **fixed**: `(cd example && go mod tidy)` added; second grep over `go.mod` files added. +- **Cycle-1 fixes verified to hold** — no regressions introduced by cycle-1 changes. + +### Cycle 1 (FAIL) — 2026-05-13 + +- **C-1** Migration error covered modules but not `step.do_*` steps → **fixed**: added per-step guard + per-step migration message (modules/steps both branched on plugin-loaded detection). +- **I-1** Parity matrix collapsed 5 step types into 4 generic ones, hiding `step.do_logs` + `step.do_scale` capability gap → **fixed**: parity matrix now lists each step row separately, GAPs called out, two follow-up issues to be filed pre-merge in `workflow-plugin-digitalocean`. +- **I-2** Migration error misleads users who already have plugin loaded → **fixed**: migration error branches on plugin-loaded detection (different prefix for "install" vs "already loaded — config issue"). +- **m-1** Grep gate worktree exclusion underspecified → **fixed**: exact `grep -rn ... --exclude-dir=...` invocation in T4. +- **m-2** `dns_*.go` → `dns.go` typo → **fixed**. +- **m-3** `cmd/wfctl/infra_apply_test.go:1990` fixture missing from T2 → **fixed**: explicitly called out for review. +- **Option B′ (build tag fence)** added to Considered approaches per reviewer suggestion (rejected with stated reason). +- **`wfctl modernize` companion** added per reviewer suggestion (in scope of T5). + +## References + +- Issue #617 +- PR #421 (godo dependabot bump — the trigger signal) +- Memory: `feedback_force_strict_contracts_no_compat.md` — force-cutover precedent +- Memory: `project_strict_contracts_cutover_complete.md` — typed-gRPC cutover; DO plugin v1.0.1 strict-contracts release +- Memory: `project_do_plugin_typed_iac_gap.md` — DO plugin IaC service registration history +- `docs/plans/2026-04-17-deploy-pipeline-multi-env-design.md` lines 126-130 — legacy → `infra.*` mapping (decided long before this issue) diff --git a/docs/plans/2026-05-13-issue-617-godo-removal.md b/docs/plans/2026-05-13-issue-617-godo-removal.md new file mode 100644 index 00000000..1386f623 --- /dev/null +++ b/docs/plans/2026-05-13-issue-617-godo-removal.md @@ -0,0 +1,1582 @@ +# Remove godo from workflow core (issue #617) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Force-cutover delete the six godo-importing legacy DigitalOcean IaC modules + five legacy pipeline steps from workflow core, remove `github.com/digitalocean/godo` from `go.mod` (root + `example/`), and add actionable load-time migration errors so user configs that still reference the legacy types get a clear pointer to `workflow-plugin-digitalocean` v0.12.0+ and the `infra.*` IaC type system. + +**Architecture:** Single PR. Pure deletion of 12 files + edits to 10 registration sites + new migration-error guards in `engine.go` (module path) and `module/pipeline_step_registry.go` (step path). Plugin-loaded detection via a single `_, ok := e.moduleFactories["iac.provider"]` lookup. CI gate: `!`-prefixed grep over `*.go` and `go.mod` files (root + `example/`) to prevent regression. `wfctl modernize` rules auto-rewrite legacy YAML. + +**Tech Stack:** Go 1.26, `github.com/GoCodeAlone/workflow` engine, `cmd/wfctl`, `modernize/` package, GitHub Actions CI. + +**Base branch:** `main` + +--- + +## Scope Manifest + +**PR Count:** 1 +**Tasks:** 5 +**Estimated Lines of Change:** ~3206 deleted + ~400 added + ~80 edited = net ~−2700 (informational; not enforced) + +**Out of scope:** +- AWS SDK audit (separate follow-up issue filed at end of T5, addresses `iam/`, `plugin/rbac/aws.go`, `artifact/s3.go`, `platform/providers/aws/` IaC drivers). +- New plugin-side step types (`step.iac_logs`, `step.iac_scale`) — tracked as follow-up issues in `workflow-plugin-digitalocean` (filed in T5; out of scope for this plan). +- Changes to `module/iac_state_spaces.go` — it uses `aws-sdk-go-v2` (not godo) for S3-compat blob access. +- Downstream consumer migration PRs (`buymywishlist-phase3`, `workflow-scenarios` scenarios 42/51) — tracked as follow-ups; the engine cutover PR ships independently with migration errors as the user-facing path. +- Compatibility shim, build tag fence, deprecation period — explicitly rejected in design. + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | feat: remove godo from core (issue #617) | Task 1, Task 2, Task 3, Task 4, Task 5 | `feat/issue-617-godo-removal` | + +**Status:** Locked 2026-05-13T00:00:00Z + +--- + +### Task 1: Delete legacy DO module + step files + +**Files:** +- Delete: `module/platform_do_app.go` +- Delete: `module/platform_do_app_test.go` +- Delete: `module/platform_do_database.go` +- Delete: `module/platform_do_database_test.go` +- Delete: `module/platform_do_dns.go` +- Delete: `module/platform_do_dns_test.go` +- Delete: `module/platform_do_networking.go` +- Delete: `module/platform_do_networking_test.go` +- Delete: `module/platform_doks.go` +- Delete: `module/platform_doks_test.go` +- Delete: `module/cloud_account_do.go` +- Delete: `module/pipeline_step_do.go` +- Test: `module/godo_absent_test.go` (new — asserts the godo import is gone from the package) + +**Step 1: Write the failing test** + +Create `module/godo_absent_test.go`: + +```go +package module_test + +import ( + "go/parser" + "go/token" + "path/filepath" + "strings" + "testing" +) + +// TestGodoNotImported_InModulePackage asserts no file under module/ imports +// github.com/digitalocean/godo. This is the regression gate for issue #617. +func TestGodoNotImported_InModulePackage(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatalf("glob: %v", err) + } + fset := token.NewFileSet() + for _, f := range files { + af, err := parser.ParseFile(fset, f, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", f, err) + } + for _, imp := range af.Imports { + if strings.Trim(imp.Path.Value, `"`) == "github.com/digitalocean/godo" { + t.Errorf("%s imports github.com/digitalocean/godo (issue #617 — moved to workflow-plugin-digitalocean)", f) + } + } + } +} +``` + +**Step 2: Run test to verify it fails (godo files still present)** + +Run: `go test ./module -run TestGodoNotImported_InModulePackage -v` +Expected: FAIL with 6 lines naming each godo-importing file. + +**Step 3: Delete the 12 legacy files** + +```bash +rm module/platform_do_app.go module/platform_do_app_test.go \ + module/platform_do_database.go module/platform_do_database_test.go \ + module/platform_do_dns.go module/platform_do_dns_test.go \ + module/platform_do_networking.go module/platform_do_networking_test.go \ + module/platform_doks.go module/platform_doks_test.go \ + module/cloud_account_do.go module/pipeline_step_do.go +``` + +**Step 4: Verify package still parses (will fail at link time due to registrations — that is T2's problem)** + +Run: `go vet ./module/...` +Expected: clean (or fails only on undefined symbols *outside* the `module` package, e.g., in `plugins/platform/`). If anything inside `module/` fails to compile, the deletion missed a sibling file — investigate. + +**Step 5: Run T1 regression test** + +Run: `go test ./module -run TestGodoNotImported_InModulePackage -v` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add module/godo_absent_test.go module/ +git commit -m "$(cat <<'EOF' +feat(#617): delete legacy DO modules (godo importers) + +Removes 12 files / ~3206 LOC. Registration sites cleaned in T2. + +* platform_do_app.go + test +* platform_do_database.go + test +* platform_do_dns.go + test +* platform_do_networking.go + test +* platform_doks.go + test +* cloud_account_do.go (DO credential resolvers + doClient()) +* pipeline_step_do.go (5 DO App Platform step types) + +Adds godo_absent_test.go as a regression gate inside module/. +EOF +)" +``` + +**Rollback:** `git revert ` restores the 12 files; combined with T2/T3 revert restores all registrations and migration errors. + +--- + +### Task 2: Strip registration sites and remap detection hooks + +**Files:** +- Modify: `plugins/platform/plugin.go` (drop 5 module factories + 5 step factories + 10 strings from `ModuleTypes` / `StepTypes` slices) +- Modify: `plugins/platform/plugin_test.go` (drop 10 string-presence assertions) +- Modify: `schema/schema.go` (drop 5 module-type entries + 5 step-type entries from the registry slices) +- Modify: `schema/module_schema.go` (drop 5 module schemas + 5 step descriptions) +- Modify: `schema/step_schema_builtins.go` (drop 5 `Register(&StepSchema{Type: "step.do_*"})` calls) +- Modify: `cmd/wfctl/type_registry.go` (drop 5 module entries + 5 step entries from the type-registry map) +- Modify: `cmd/wfctl/infra.go:577` — change `return t == "infra.container_service" || t == "platform.do_app"` to `return t == "infra.container_service"`. +- Modify: `cmd/wfctl/deploy_providers.go:419-424` — drop the `"platform.do_app"` line from the `deployTargetTypes` slice. +- Modify: `cmd/wfctl/ci_run_dryrun.go:178-183` — drop the `"platform.do_app"` line from the `deployTargetTypes` slice. +- Modify: `cmd/wfctl/deploy.go:839,901` — the `wfctl deploy cloud` subcommand collects modules via `strings.HasPrefix(m.Type, "platform.")` and errors with `"no platform.* modules found"` when none match. Post-cutover the user's modern config uses `infra.*` types; both call sites must include `infra.*` as well. Replace the prefix check with `strings.HasPrefix(m.Type, "platform.") || strings.HasPrefix(m.Type, "infra.")` and update the error message to `"no platform.* or infra.* modules found in config — nothing to deploy"`. Header comment on line 781 updated to reference both prefixes. **Rename the local slice variable `platformModules` to `deployTargetModules`** in the same edit so the name reflects what it now contains. +- Modify: `cmd/wfctl/validate.go:145` — inject `legacydo.ModuleTypes` keys into the local `opts` slice via `schema.WithExtraModuleTypes(...)` before calling `schema.ValidateConfig`, then add a post-`ValidateConfig` legacy-type sweep that emits `legacydo.FormatModuleError` / `legacydo.FormatStepError` if any module / step type is legacy. Without this, schema rejects legacy types with the generic `"unknown module type"` message before the migration error can fire (cycle-6 C-1). +- Modify: `cmd/wfctl/ci_validate.go:134` — same edit pattern as `validate.go`. +- Modify: `module/multi_region.go:123` — replace the error message text (see Step 3). +- Modify: `cmd/wfctl/infra_apply_test.go:1990` — the negative-test YAML fixture uses `type: platform.do_app`. Replace with `type: example.legacy_unknown` (a synthetic type that will never be registered) so the test's intent (negative coverage for unknown types) is preserved without referencing a removed type. +- Test: `cmd/wfctl/legacy_do_types_removed_test.go` (new — asserts the type registry no longer contains the legacy keys) + +**Step 1: Write the failing test** + +Create `cmd/wfctl/legacy_do_types_removed_test.go`: + +```go +package main + +import "testing" + +// TestLegacyDOTypesAbsent_FromTypeRegistry locks the post-cutover state of +// cmd/wfctl/type_registry.go for issue #617. If any legacy type leaks back in, +// this test fires and the CI gate fires. +func TestLegacyDOTypesAbsent_FromTypeRegistry(t *testing.T) { + modules := KnownModuleTypes() + steps := KnownStepTypes() + legacyModules := []string{ + "platform.do_app", "platform.do_database", "platform.do_dns", + "platform.do_networking", "platform.doks", + } + legacySteps := []string{ + "step.do_deploy", "step.do_status", "step.do_logs", + "step.do_scale", "step.do_destroy", + } + for _, tname := range legacyModules { + if _, ok := modules[tname]; ok { + t.Errorf("module type registry still contains legacy DO type %q (issue #617)", tname) + } + } + for _, tname := range legacySteps { + if _, ok := steps[tname]; ok { + t.Errorf("step type registry still contains legacy DO type %q (issue #617)", tname) + } + } +} +``` + +(API confirmed against `cmd/wfctl/type_registry.go:25` `KnownModuleTypes()` and `:727` `KnownStepTypes()`.) + +**Step 2: Run test to verify it fails** + +Run: `go test ./cmd/wfctl -run TestLegacyDOTypesAbsent_FromTypeRegistry -v` +Expected: FAIL with 10 lines naming each legacy type still in the registry. + +**Step 3: Apply the registration deletions and detection-hook remappings** + +For each file in the Files list, perform the listed deletion. The `module/multi_region.go:123` rewrite: + +```go +// Before: +return fmt.Errorf("platform.region %q: provider %q is not yet supported; use platform.doks modules per region for DigitalOcean multi-region deployments", m.name, providerType) + +// After: +return fmt.Errorf("platform.region %q: provider %q is not yet supported; for DigitalOcean multi-region, use infra.k8s_cluster modules per region with provider: digitalocean (requires workflow-plugin-digitalocean)", m.name, providerType) +``` + +**Step 4: Run tests** + +Run: `go test ./cmd/wfctl -run TestLegacyDOTypesAbsent_FromTypeRegistry -v` +Expected: PASS. + +Run: `go build ./...` +Expected: clean. + +Run: `go test ./plugins/platform/... ./schema/... ./module/... ./cmd/wfctl/...` +Expected: PASS — any test that asserted the presence of a legacy `platform.do_*` or `step.do_*` was updated in this task to assert its absence. + +**Step 5: Commit** + +```bash +git add plugins/ schema/ cmd/wfctl/ module/multi_region.go cmd/wfctl/legacy_do_types_removed_test.go +git commit -m "$(cat <<'EOF' +feat(#617): strip DO registration sites + remap wfctl detection hooks + +* plugins/platform: drop 5 module + 5 step factories. +* schema/*: drop 10 entries from registries and schema descriptions. +* cmd/wfctl/type_registry.go: drop 10 type entries. +* cmd/wfctl/{infra.go,deploy_providers.go,ci_run_dryrun.go}: remap + isContainerType and deployTargetTypes to infra.container_service only. +* module/multi_region.go: rewrite DOKS multi-region hint to point at + infra.k8s_cluster + workflow-plugin-digitalocean. +* cmd/wfctl/infra_apply_test.go: replace platform.do_app negative-test + fixture with example.legacy_unknown synthetic type. + +Adds legacy_do_types_removed_test.go as a registry-absence regression gate. +EOF +)" +``` + +**Rollback:** `git revert ` restores all 10 registration sites; the package will fail to compile until T1 is also reverted (the factories reference deleted symbols). + +--- + +### Task 3: Add load-time migration error guards (module + step) + +**Files:** +- Modify: `engine.go:508` — replace the single `unknown module type` error with a legacy-DO-aware branch (see Step 3). +- Modify: `module/pipeline_step_registry.go:35` — replace the single `unknown step type` error with the same legacy-DO-aware branch for step types. +- Create: `internal/legacydo/types.go` — **leaf package** containing the legacy-type lookup tables, the `RemovedInVersion` constant, and the message-formatter helpers. Lives in `internal/` so neither `module/` nor `modernize/` transitively imports it via any indirect path. Both packages import it directly: `module/` for the engine + step guard wiring; `modernize/` for the rewrite rule. **Architectural reason:** `module` transitively imports `modernize` via `plugin` (`go list -deps github.com/GoCodeAlone/workflow/module | grep modernize` returns a match — `plugin/manifest.go` and `plugin/engine_plugin.go` both import `modernize`). Therefore `modernize` cannot import `module` directly; a shared leaf package is the only cycle-free way to share the constants. +- Test: `engine_legacy_do_migration_test.go` (new — covers all 5 module types × {plugin loaded, plugin not loaded}) +- Test: `module/pipeline_step_legacy_do_migration_test.go` (new — covers all 5 step types × {plugin loaded, plugin not loaded}) + +**Step 1: Write the failing tests** + +Create `engine_legacy_do_migration_test.go` at repo root (in-package — same package convention as `engine_test.go`): + +```go +package workflow + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/config" +) + +// newTestEngine builds an isolated StdEngine with no plugins loaded — required +// so that the iac.provider factory-map lookup is deterministically false in +// the "plugin not loaded" test, and so that the manual AddModuleType stub in +// the "plugin loaded" test is the only factory in the map. This intentionally +// differs from setupEngineTest (engine_test.go), which calls loadAllPlugins. +// Reuses the `mockLogger` type already defined in engine_test.go — both files +// are in package workflow so the type is visible at compile time. DO NOT +// redeclare it here. +func newTestEngine(t *testing.T) *StdEngine { + t.Helper() + logger := &mockLogger{} + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + if err := app.Init(); err != nil { + t.Fatalf("app.Init: %v", err) + } + return NewStdEngine(app, logger) +} + +func TestLegacyDOModuleError_PluginNotLoaded(t *testing.T) { + cases := []struct{ legacyType, hint string }{ + {"platform.do_app", "infra.container_service"}, + {"platform.do_database", "infra.database"}, + {"platform.do_dns", "infra.dns"}, + {"platform.do_networking", "infra.vpc"}, + {"platform.doks", "infra.k8s_cluster"}, + } + for _, tc := range cases { + t.Run(tc.legacyType, func(t *testing.T) { + e := newTestEngine(t) + cfg := &config.WorkflowConfig{Modules: []config.ModuleConfig{{Name: "x", Type: tc.legacyType, Config: map[string]any{}}}} + err := e.BuildFromConfig(cfg) + if err == nil { + t.Fatalf("expected error for legacy type %q", tc.legacyType) + } + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-digitalocean", + "Install workflow-plugin-digitalocean", + tc.hint, + } { + if !strings.Contains(msg, want) { + t.Errorf("error for %q missing %q; got: %s", tc.legacyType, want, msg) + } + } + }) + } +} + +func TestLegacyDOModuleError_PluginLoaded(t *testing.T) { + e := newTestEngine(t) + // Register a stub iac.provider factory to simulate workflow-plugin-digitalocean + // being loaded. ModuleFactory signature: func(name string, config map[string]any) modular.Module. + e.AddModuleType("iac.provider", func(name string, cfg map[string]any) modular.Module { return nil }) + + cfg := &config.WorkflowConfig{Modules: []config.ModuleConfig{{Name: "x", Type: "platform.do_app", Config: map[string]any{}}}} + err := e.BuildFromConfig(cfg) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "already loaded") { + t.Errorf("plugin-loaded branch must say 'already loaded'; got: %s", msg) + } + if strings.Contains(msg, "Install workflow-plugin-digitalocean") { + t.Errorf("plugin-loaded branch must NOT instruct install; got: %s", msg) + } +} +``` + +(APIs verified: `NewStdEngine(app modular.Application, logger modular.Logger)` at `engine.go:146`; `AddModuleType(moduleType string, factory ModuleFactory)` at `engine.go:210`; `ModuleFactory` is `func(name string, config map[string]any) modular.Module`. Test package convention matches `engine_test.go:1` — `package workflow`.) + +Create `module/pipeline_step_legacy_do_migration_test.go`: + +```go +package module_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/module" +) + +func TestLegacyDOStepError_PluginNotLoaded(t *testing.T) { + // step.do_logs / step.do_scale have GAP messages; the others map 1:1 to step.iac_*. + cases := []struct{ step, mustContain string }{ + {"step.do_deploy", "step.iac_apply"}, + {"step.do_status", "step.iac_status"}, + {"step.do_destroy", "step.iac_destroy"}, + {"step.do_logs", "wfctl infra logs"}, + {"step.do_scale", "instance_count"}, + } + for _, tc := range cases { + t.Run(tc.step, func(t *testing.T) { + r := module.NewStepRegistry() // fresh registry — iacProviderLoaded defaults to false + _, err := r.Create(tc.step, "x", map[string]any{}, nil) + if err == nil { + t.Fatalf("expected error for %q", tc.step) + } + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-digitalocean", + "Install workflow-plugin-digitalocean", + tc.mustContain, + } { + if !strings.Contains(msg, want) { + t.Errorf("error for %q missing %q; got: %s", tc.step, want, msg) + } + } + }) + } +} + +func TestLegacyDOStepError_PluginLoaded(t *testing.T) { + // Symmetric to TestLegacyDOModuleError_PluginLoaded — flips the per-registry + // flag and confirms the step guard's "already loaded" branch fires. + r := module.NewStepRegistry() + r.SetIaCProviderLoaded(true) + _, err := r.Create("step.do_deploy", "x", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "already loaded") { + t.Errorf("plugin-loaded branch must say 'already loaded'; got: %s", msg) + } + if strings.Contains(msg, "Install workflow-plugin-digitalocean") { + t.Errorf("plugin-loaded branch must NOT instruct install; got: %s", msg) + } +} +``` + +(API verified: `module.NewStepRegistry()` at `module/pipeline_step_registry.go:18`; `(*StepRegistry).Create(stepType, name string, config map[string]any, app any)` at `:32`. Empty registry exercises the unknown-type fallback path where the legacy guard fires.) + +**Step 2: Run tests to verify they fail** + +Run: `go test ./... -run 'TestLegacyDO(Module|Step)Error' -v` +Expected: FAIL — the engine currently emits the generic `"unknown module type %q for module %q"` and `"unknown step type: %s"` messages; neither mentions godo / workflow-plugin-digitalocean / infra.*. + +**Step 3: Implement the migration helper and wire it into both error paths** + +Create `internal/legacydo/types.go` (leaf package — imports only stdlib, so both `module/` and `modernize/` can import it without cycles): + +```go +// Package legacydo holds the read-only data and message formatters for the +// legacy DigitalOcean module + step types removed in issue #617. Lives in +// internal/ so that both module/ and modernize/ can import it without a +// cycle (module transitively imports modernize via plugin, so modernize +// cannot import module). +package legacydo + +import ( + "fmt" + "sort" + "strings" +) + +// RemovedInVersion is the workflow tag that ships issue #617's force-cutover. +// Used in every legacy-DO migration error and in the wfctl modernize rule. +// Update both this constant and the docs/migrations/v-godo-removal.md +// filename when the release tag is finalised. +const RemovedInVersion = "v0.52.0" + +// ModuleTypes maps each removed legacy DigitalOcean module type to its +// infra.* IaC successor (issue #617). +var ModuleTypes = map[string]string{ + "platform.do_app": "infra.container_service", + "platform.do_database": "infra.database", + "platform.do_dns": "infra.dns", + "platform.do_networking": "infra.vpc + infra.firewall", + "platform.doks": "infra.k8s_cluster", +} + +// StepTypes maps each removed legacy DigitalOcean step type to its +// successor or to a workaround when no 1:1 successor exists. +var StepTypes = map[string]string{ + "step.do_deploy": "step.iac_apply (against an infra.container_service module)", + "step.do_status": "step.iac_status (against an infra.container_service module)", + "step.do_destroy": "step.iac_destroy (against an infra.container_service module)", + "step.do_logs": "no direct pipeline-step equivalent; use `wfctl infra logs` ad-hoc, or rely on the DO plugin's Troubleshoot hook on step.iac_apply failure", + "step.do_scale": "no direct pipeline-step equivalent; update instance_count in the infra.container_service module config and re-run step.iac_apply", +} + +// IsModuleType reports whether t is a removed legacy DO module type. +func IsModuleType(t string) bool { _, ok := ModuleTypes[t]; return ok } + +// IsStepType reports whether t is a removed legacy DO step type. +func IsStepType(t string) bool { _, ok := StepTypes[t]; return ok } + +// FormatModuleError builds the actionable migration error for a legacy +// DO module type. iacProviderLoaded indicates whether the iac.provider factory +// is registered in the engine — used to branch between the "install plugin" +// and "config-only issue" messages. +func FormatModuleError(legacyType, moduleName string, iacProviderLoaded bool) error { + successor, ok := ModuleTypes[legacyType] + if !ok { + return nil + } + pluginLine := "Install workflow-plugin-digitalocean: https://github.com/GoCodeAlone/workflow-plugin-digitalocean" + if iacProviderLoaded { + pluginLine = "workflow-plugin-digitalocean is already loaded; your config still references the legacy module name." + } + var b strings.Builder + fmt.Fprintf(&b, "unsupported legacy module type %q (module %q): this type was removed from workflow core in %s — DigitalOcean IaC moved to workflow-plugin-digitalocean.\n\n", legacyType, moduleName, RemovedInVersion) + b.WriteString(pluginLine) + b.WriteString("\n\nMigrate this module to: ") + b.WriteString(successor) + b.WriteString(" (provider: digitalocean)\n\nFull mapping:\n") + keys := make([]string, 0, len(ModuleTypes)) + for k := range ModuleTypes { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&b, " %s → %s\n", k, ModuleTypes[k]) + } + b.WriteString("\nSee docs/migrations/v0.52.0-godo-removal.md") + return fmt.Errorf("%s", b.String()) +} + +// FormatStepError builds the actionable migration error for a legacy +// DO step type. +func FormatStepError(legacyType string, iacProviderLoaded bool) error { + successor, ok := StepTypes[legacyType] + if !ok { + return nil + } + pluginLine := "Install workflow-plugin-digitalocean: https://github.com/GoCodeAlone/workflow-plugin-digitalocean" + if iacProviderLoaded { + pluginLine = "workflow-plugin-digitalocean is already loaded; your config still references the legacy step name." + } + var b strings.Builder + fmt.Fprintf(&b, "unsupported legacy step type %q: this step was removed from workflow core in %s — DigitalOcean IaC moved to workflow-plugin-digitalocean.\n\n", legacyType, RemovedInVersion) + b.WriteString(pluginLine) + b.WriteString("\n\nMigrate this step to: ") + b.WriteString(successor) + b.WriteString("\n\nSee docs/migrations/v0.52.0-godo-removal.md") + return fmt.Errorf("%s", b.String()) +} +``` + +Modify `engine.go:508`: + +```go +// Before: +factory, exists := e.moduleFactories[modCfg.Type] +if !exists { + return fmt.Errorf("unknown module type %q for module %q — ensure the required plugin is loaded", modCfg.Type, modCfg.Name) +} + +// After: +factory, exists := e.moduleFactories[modCfg.Type] +if !exists { + if legacydo.IsModuleType(modCfg.Type) { + _, iacLoaded := e.moduleFactories["iac.provider"] + return legacydo.FormatModuleError(modCfg.Type, modCfg.Name, iacLoaded) + } + return fmt.Errorf("unknown module type %q for module %q — ensure the required plugin is loaded", modCfg.Type, modCfg.Name) +} +``` + +**Schema-validation ordering caveat (critical):** `schema.ValidateConfig(cfg, valOpts...)` at `engine.go:400` runs BEFORE the factory loop at `:506`. After T2 removes the five legacy DO types from `schema/schema.go`'s allow-list, schema validation will reject the config with the generic `"unknown module type"` schema error before the factory guard ever runs — making `legacydo.FormatModuleError` unreachable for module types. To fix, **add the five legacy DO module types to the `WithExtraModuleTypes` call** so schema validation passes them through and the factory-lookup guard becomes the rejection point. Restructure the existing guarded block (`engine.go:393-398`) into unconditional code (eliminates a `staticcheck SA4010` always-true-condition lint failure): + +```go +// Replace engine.go:393-398 with: +extra := make([]string, 0, len(e.moduleFactories)+len(legacydo.ModuleTypes)) +for t := range e.moduleFactories { + extra = append(extra, t) +} +// Pass legacy DO module types through schema so the factory-loop guard +// (which emits legacydo.FormatModuleError) is the rejection point — +// schema rejection produces a generic error and would mask the +// actionable migration message (issue #617). +for t := range legacydo.ModuleTypes { + extra = append(extra, t) +} +valOpts = append(valOpts, schema.WithExtraModuleTypes(extra...)) +``` + +**Step types do NOT need a schema-level injection:** `schema.ValidateConfig` does not validate `pipelines[*].steps[*].type` (no `WithExtraStepTypes` function exists; verified). The step migration guard at the `StepRegistry.Create` rejection point is therefore the only gate for legacy step types, which is exactly what we want. + +(Add `"github.com/GoCodeAlone/workflow/internal/legacydo"` to engine.go imports.) + +**`wfctl validate` and `wfctl ci validate` (acceptance criterion #3):** these two commands call `schema.ValidateConfig` directly (`cmd/wfctl/validate.go:145`, `cmd/wfctl/ci_validate.go:134`) WITHOUT going through `engine.BuildFromConfig`. Without injecting `legacydo.ModuleTypes` into their local `opts` slices, they would emit the generic schema error instead of routing to the migration message. To satisfy AC3 on the validate paths, mirror the same injection in both wfctl commands. Add to **T2** (since these are wfctl-side registration / validation hooks alongside the other T2 wfctl edits): + +```go +// In cmd/wfctl/validate.go validateFile() — before line 145 schema.ValidateConfig call, +// after the existing opts slice is assembled: +for t := range legacydo.ModuleTypes { + opts = append(opts, schema.WithExtraModuleTypes(t)) +} +// Same edit in cmd/wfctl/ci_validate.go ciValidateFile() before line 134. +``` + +After these edits, `wfctl validate` will skip schema rejection for legacy DO module types — but it does not call `BuildFromConfig`, so the factory-loop migration error won't fire either. The validate command needs to ALSO emit the migration error directly. Pattern: + +```go +// After schema.ValidateConfig succeeds, add a post-pass that explicitly +// rejects legacy DO module types with the actionable message — wfctl +// validate's contract is "config is valid", and a legacy DO module type +// is NOT valid post-cutover even though we let schema pass it through. +// +// For validate.go (return error directly): +for _, m := range cfg.Modules { + if legacydo.IsModuleType(m.Type) { + // wfctl validate has no engine, so the plugin-loaded flag is always + // false (validate doesn't know what plugins will be loaded at runtime). + return legacydo.FormatModuleError(m.Type, m.Name, false) + } +} + +// cfg.Pipelines is map[string]any (verified at config/config.go:149) — NOT a +// typed slice. Mirror the engine's existing pattern (engine.go configurePipelines): +// marshal each entry to YAML then unmarshal into config.PipelineConfig before +// accessing .Steps. The naive `p.Steps` access does not compile. +for _, rawPipeline := range cfg.Pipelines { + yamlBytes, err := yaml.Marshal(rawPipeline) + if err != nil { + continue + } + var pipeCfg config.PipelineConfig + if err := yaml.Unmarshal(yamlBytes, &pipeCfg); err != nil { + continue + } + for _, s := range pipeCfg.Steps { + if legacydo.IsStepType(s.Type) { + return legacydo.FormatStepError(s.Type, false) + } + } +} +``` + +For `ciValidateFile` (which returns `[]error`, accumulating), use `errs = append(errs, ...)` instead of `return`: + +```go +// In ci_validate.go ciValidateFile() — same post-pass, but accumulate: +for _, m := range cfg.Modules { + if legacydo.IsModuleType(m.Type) { + errs = append(errs, legacydo.FormatModuleError(m.Type, m.Name, false)) + } +} +for _, rawPipeline := range cfg.Pipelines { + yamlBytes, err := yaml.Marshal(rawPipeline) + if err != nil { + continue + } + var pipeCfg config.PipelineConfig + if err := yaml.Unmarshal(yamlBytes, &pipeCfg); err != nil { + continue + } + for _, s := range pipeCfg.Steps { + if legacydo.IsStepType(s.Type) { + errs = append(errs, legacydo.FormatStepError(s.Type, false)) + } + } +} +``` + +Add `cmd/wfctl/validate.go` and `cmd/wfctl/ci_validate.go` to T2's Files list (already listed above). + +**Automated test for the validate-path migration error** (T2): + +Add to `cmd/wfctl/legacy_do_types_removed_test.go`: + +```go +// TestValidateFile_LegacyDOModule_ReturnsActionableError verifies that +// wfctl validate emits the actionable migration error when the config +// references a removed legacy DO module type (issue #617). Covers AC3 +// on the validate path (the engine path is covered by +// TestLegacyDOModuleError_PluginNotLoaded in the workflow package). +func TestValidateFile_LegacyDOModule_ReturnsActionableError(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "legacy.yaml") + yaml := []byte("modules:\n - name: api\n type: platform.do_app\n config: {}\n") + if err := os.WriteFile(cfgPath, yaml, 0o600); err != nil { + t.Fatal(err) + } + err := validateFile(cfgPath) // direct call into the validate.go entry point + if err == nil { + t.Fatal("expected error for legacy DO module type") + } + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-digitalocean", + "infra.container_service", + } { + if !strings.Contains(msg, want) { + t.Errorf("error missing %q; got: %s", want, msg) + } + } +} + +// Step variant covering ciValidateFile's accumulating return. +func TestCIValidateFile_LegacyDOStep_ReturnsActionableError(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "legacy.yaml") + yaml := []byte("pipelines:\n deploy:\n steps:\n - type: step.do_deploy\n") + if err := os.WriteFile(cfgPath, yaml, 0o600); err != nil { + t.Fatal(err) + } + errs := ciValidateFile(cfgPath) + if len(errs) == 0 { + t.Fatal("expected error for legacy DO step type") + } + found := false + for _, e := range errs { + if strings.Contains(e.Error(), "step.iac_apply") && strings.Contains(e.Error(), "removed from workflow core") { + found = true + break + } + } + if !found { + t.Errorf("expected actionable migration error in errs; got: %v", errs) + } +} +``` + +(Confirm `validateFile` and `ciValidateFile` function signatures match — adapt argument list if the actual signatures take `*FileSystem` / context / different shape; the test bodies should compile against whatever the real signatures are.) + +For the step path, **avoid the package-level global** that cycle 4 reviewer flagged as a logic-race risk: instead, attach the `iacProviderLoaded` boolean to the `StepRegistry` as a field set by the engine before pipeline construction. Modify `module/pipeline_step_registry.go`: + +```go +// Add to StepRegistry struct (around line 13): +type StepRegistry struct { + factories map[string]StepFactory + iacProviderLoaded bool // set by SetIaCProviderLoaded; consumed by Create +} + +// New method on StepRegistry: +// SetIaCProviderLoaded is called by the engine after module factory registration +// is complete and before pipeline construction. Per-registry state — no global — +// so parallel test runs that build independent StepRegistry instances do not +// share or race the flag. +func (r *StepRegistry) SetIaCProviderLoaded(loaded bool) { + r.iacProviderLoaded = loaded +} + +// Modify (r *StepRegistry).Create at line 32: +func (r *StepRegistry) Create(stepType, name string, config map[string]any, app any) (PipelineStep, error) { + factory, ok := r.factories[stepType] + if !ok { + if legacydo.IsStepType(stepType) { + return nil, legacydo.FormatStepError(stepType, r.iacProviderLoaded) + } + return nil, fmt.Errorf("unknown step type: %s", stepType) + } + return factory(name, config, app) +} +``` + +Wire it in `engine.go` `BuildFromConfig` just before step construction. The engine field is `stepRegistry interfaces.StepRegistrar` at `engine.go:73`; `SetIaCProviderLoaded` is a method on `*module.StepRegistry`, NOT on the `StepRegistrar` interface. Use the same type-assertion pattern already used elsewhere in `engine.go:163,216`: + +```go +_, iacLoaded := e.moduleFactories["iac.provider"] +if r, ok := e.stepRegistry.(*module.StepRegistry); ok { + r.SetIaCProviderLoaded(iacLoaded) +} +``` + +(Do NOT extend the `StepRegistrar` interface — the method is private wiring between engine and the concrete registry; widening the interface adds a method burden to every alternate `StepRegistrar` implementor downstream for no benefit. The type-assertion pattern matches the precedent.) + +**No package-level global, no atomic.Bool.** + +(Add `"github.com/GoCodeAlone/workflow/internal/legacydo"` to pipeline_step_registry.go imports.) + +(The Create-method patch above replaces the previous `return nil, fmt.Errorf("unknown step type: %s", stepType)` at line 35.) + +**Step 4: Run tests to verify they pass** + +Run: `go test ./... -run 'TestLegacyDO(Module|Step)Error' -v` +Expected: PASS (all 12 sub-cases — 5 modules × 1 not-loaded + 1 module loaded; 5 steps × 1 not-loaded). + +Run: `go test ./...` +Expected: PASS overall (the existing tests untouched by T1/T2/T3 should still pass). + +**Step 5: Commit** + +```bash +git add internal/legacydo/ \ + engine.go module/pipeline_step_registry.go \ + engine_legacy_do_migration_test.go module/pipeline_step_legacy_do_migration_test.go +git commit -m "$(cat <<'EOF' +feat(#617): actionable migration errors for legacy DO types + +Adds module.LegacyDOModuleTypes + LegacyDOStepTypes lookup tables and two +formatters (FormatLegacyDOModuleError, FormatLegacyDOStepError). Both branch +on whether iac.provider is registered in the engine's factory map: + - not loaded → "Install workflow-plugin-digitalocean: " + - loaded → "already loaded; your config still references the legacy name" + +Wired into engine.go:508 (module path) and pipeline_step_registry.go:35 +(step path). SetIaCProviderLoaded bridges the boolean from engine to module +package. + +Each step type gets a per-step message; step.do_logs and step.do_scale have +GAP messages with workarounds because no 1:1 pipeline-step successor exists +yet (tracked as follow-up issues in T5). +EOF +)" +``` + +**Rollback:** `git revert ` restores generic unknown-type errors. Combined with T1/T2 revert, repository returns to pre-cutover state. + +--- + +### Task 4: `go mod tidy` (root + example) + CI grep gate + +**Files:** +- Modify: `go.mod` (drop `github.com/digitalocean/godo` direct require + transitive bumps via `go mod tidy`) +- Modify: `go.sum` (regenerated) +- Modify: `example/go.mod` (drop indirect godo) +- Modify: `example/go.sum` (regenerated) +- Modify: `.github/workflows/ci.yml` — add a `godo-banned` job that runs the `!`-prefixed greps. +- Test: this task's verification IS the CI gate itself; no new unit test. + +**Step 1: Run the tidies and verify godo is gone** + +```bash +go mod tidy +(cd example && go mod tidy) +``` + +**Step 2: Verify** + +Run: +```bash +! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + "digitalocean/godo" . +! grep -qH "digitalocean/godo" go.mod example/go.mod +``` +Expected: BOTH commands exit 0 (no match → grep exits 1 → `!` inverts to 0 → success). + +If either fails (i.e., grep finds godo), inspect: a transitive dependency may still pull it. Identify with `go mod why github.com/digitalocean/godo` and investigate. + +**Step 3: Add the CI gate** + +Modify `.github/workflows/ci.yml` to add a job (placed near `golangci-lint`): + +```yaml + godo-banned: + name: Verify godo is not imported (issue #617) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Grep gate — *.go files must not import godo + run: | + ! grep -rn --include="*.go" \ + --exclude-dir=_worktrees \ + --exclude-dir=.worktrees \ + --exclude-dir=.claude \ + "digitalocean/godo" . + - name: Grep gate — go.mod files must not list godo + run: | + ! grep -qH "digitalocean/godo" go.mod example/go.mod +``` + +(If `.github/workflows/ci.yml` does not exist or has a different name, locate the existing Go-build workflow file via `ls .github/workflows/` and add the job there. Adapt the runner/checkout action versions to match the rest of the file.) + +**Step 4: Verify locally one more time, including the gate's exact commands** + +```bash +bash -c '! grep -rn --include="*.go" --exclude-dir=_worktrees --exclude-dir=.worktrees --exclude-dir=.claude "digitalocean/godo" .' +echo "exit: $?" +# Expected: exit: 0 +bash -c '! grep -qH "digitalocean/godo" go.mod example/go.mod' +echo "exit: $?" +# Expected: exit: 0 +``` + +**Step 5: Commit** + +```bash +git add go.mod go.sum example/go.mod example/go.sum .github/workflows/ +git commit -m "$(cat <<'EOF' +feat(#617): drop godo from go.mod + add CI grep gate + +* go mod tidy on root and example/ drops github.com/digitalocean/godo + (direct from root, indirect from example/). +* New CI job 'godo-banned' fails the build on any *.go import of godo OR + any mention of godo in go.mod files. Excludes _worktrees, .worktrees, + and .claude (local agent state, not committed source). + +This satisfies acceptance criterion #4 (dependabot bumps target the +provider repo, not workflow core). +EOF +)" +``` + +**Rollback:** `git revert ` restores godo to go.mod and removes the CI gate. Combined with T1/T2/T3 revert returns to pre-cutover state. + +--- + +### Task 5: Docs, CHANGELOG, migration guide, `wfctl modernize` rules + file follow-up issues + +**Files:** +- Modify: `DOCUMENTATION.md` (replace the 5 module rows + 5 step rows in the platform tables with a single paragraph pointing at the DO plugin) +- Modify: `CHANGELOG.md` (prepend a `## v0.52.0` section with the breaking-change entry) +- Create: `docs/migrations/v0.52.0-godo-removal.md` (full migration guide — 5 module mappings + 5 step mappings + GAP callouts + before/after YAML examples + step-by-step migration recipe + ADR-style "why this was done") +- Create: `modernize/legacy_do_rule.go` (new modernize rules — see Step 3) +- Modify: `modernize/modernize.go` `AllRules()` to append the new rule +- Test: `modernize/legacy_do_rule_test.go` (new — covers Check + Fix for each of the 5 module + 5 step rewrites + 3 gap types) +- Create: `modernize/testdata/legacy-do-config.yaml` (committed smoke-test fixture exercising every legacy type) +- Create: `modernize/testdata/legacy-do-config.expected.yaml` (the post-`modernize --apply` output, used as a golden file for the smoke test in step 9 below) +- Modify: `cmd/wfctl/infra_apply.go:130-131` + `cmd/wfctl/infra.go:460` — comment hygiene: drop the "legacy DigitalOcean" phrasing in `hasPlatformModules` / `isInfraType` rationale comments. Both functions remain correct for the surviving `platform.*` types (e.g., `platform.kubernetes`, `platform.ecs`); only the DO-specific framing is stale. + +**Step 1: Write the failing test** + +Create `modernize/legacy_do_rule_test.go`: + +```go +package modernize + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestLegacyDORule_Rewrites(t *testing.T) { + cases := []struct { + name string + yamlIn string + wantNew string // must appear in fixed YAML + wantDrop string // must NOT appear in fixed YAML (the legacy type) + }{ + { + name: "platform.do_app → infra.container_service (provider NOT auto-injected)", + yamlIn: "modules:\n - name: api\n type: platform.do_app\n config:\n region: nyc\n", + wantNew: "infra.container_service", + wantDrop: "platform.do_app", + }, + { + name: "platform.do_database → infra.database", + yamlIn: "modules:\n - name: db\n type: platform.do_database\n config: {}\n", + wantNew: "infra.database", + wantDrop: "platform.do_database", + }, + { + name: "platform.do_dns → infra.dns", + yamlIn: "modules:\n - name: dns\n type: platform.do_dns\n config: {}\n", + wantNew: "infra.dns", + wantDrop: "platform.do_dns", + }, + { + name: "platform.doks → infra.k8s_cluster", + yamlIn: "modules:\n - name: k8s\n type: platform.doks\n config: {}\n", + wantNew: "infra.k8s_cluster", + wantDrop: "platform.doks", + }, + { + name: "step.do_deploy → step.iac_apply", + yamlIn: "pipelines:\n - steps:\n - type: step.do_deploy\n", + wantNew: "step.iac_apply", + wantDrop: "step.do_deploy", + }, + } + rule := legacyDORule() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var root yaml.Node + if err := yaml.Unmarshal([]byte(tc.yamlIn), &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } + findings := rule.Check(&root, []byte(tc.yamlIn)) + if len(findings) == 0 { + t.Fatalf("expected a finding, got 0") + } + rule.Fix(&root) + out, err := yaml.Marshal(&root) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(out) + if !strings.Contains(s, tc.wantNew) { + t.Errorf("fixed YAML missing %q; got:\n%s", tc.wantNew, s) + } + if strings.Contains(s, tc.wantDrop) { + t.Errorf("fixed YAML still contains legacy %q; got:\n%s", tc.wantDrop, s) + } + }) + } +} + +func TestLegacyDORule_GapTypesFlaggedNotRewritten(t *testing.T) { + // step.do_logs, step.do_scale, and platform.do_networking have NO 1:1 + // auto-fixable successor. Rule must: + // - flag them as findings, + // - NOT modify the YAML (no silent loss). + cases := []struct { + name string + legacy string + yamlIn string + }{ + {"step.do_logs", "step.do_logs", "pipelines:\n - steps:\n - type: step.do_logs\n"}, + {"step.do_scale", "step.do_scale", "pipelines:\n - steps:\n - type: step.do_scale\n"}, + {"platform.do_networking", "platform.do_networking", "modules:\n - name: net\n type: platform.do_networking\n config: {}\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var root yaml.Node + if err := yaml.Unmarshal([]byte(tc.yamlIn), &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } + rule := legacyDORule() + findings := rule.Check(&root, []byte(tc.yamlIn)) + if len(findings) == 0 { + t.Fatalf("expected a finding for %q", tc.legacy) + } + if findings[0].Fixable { + t.Errorf("%q must be marked Fixable: false (no auto-rewrite); got Fixable: true", tc.legacy) + } + rule.Fix(&root) + out, _ := yaml.Marshal(&root) + if !strings.Contains(string(out), tc.legacy) { + t.Errorf("Fix MUST NOT remove legacy %q; got:\n%s", tc.legacy, out) + } + }) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./modernize/... -run TestLegacyDORule -v` +Expected: FAIL with "undefined: legacyDORule". + +**Step 3: Implement the rule** + +Create `modernize/legacy_do_rule.go`: + +```go +package modernize + +import ( + "fmt" + + "github.com/GoCodeAlone/workflow/internal/legacydo" + "gopkg.in/yaml.v3" +) + +// Import note: `modernize` MUST NOT import `module` directly. `module` +// transitively imports `modernize` via `plugin` (plugin/manifest.go + +// plugin/engine_plugin.go), so `modernize → module` creates an import cycle. +// Shared constants live in `internal/legacydo`, a leaf package that imports +// only stdlib and is safe for both `module` and `modernize` to consume. + +// LegacyDORule rewrites legacy DigitalOcean module + step types to their +// infra.* IaC successors (issue #617). +// +// IMPORTANT: The Fix function ONLY renames the `type:` key — it does NOT +// inject the required `config.provider: digitalocean` setting, because that +// requires modifying a sibling mapping that may already contain unrelated +// keys the operator must review. The rule's Check Message and the migration +// guide both instruct the operator to add the provider key manually after +// running modernize. The committed `testdata/legacy-do-config.expected.yaml` +// fixture asserts the post-modernize shape: types renamed, provider NOT +// auto-added. Adding provider injection in a future iteration is tracked as +// a follow-up (see migration guide). +// +// Auto-fixable for 4 of 5 modules (platform.do_app/database/dns/doks) and +// 3 of 5 steps (step.do_deploy/status/destroy). The GAP types (do_networking +// splits 1→2; step.do_logs/scale have no pipeline-step successor) are flagged +// but not modified. +func legacyDORule() Rule { + moduleMap := map[string]string{ + "platform.do_app": "infra.container_service", + "platform.do_database": "infra.database", + "platform.do_dns": "infra.dns", + "platform.doks": "infra.k8s_cluster", + // platform.do_networking is intentionally NOT auto-fixed: it splits + // 1→2 (infra.vpc + infra.firewall), which requires structural + // rewrite the operator must review. + } + stepMap := map[string]string{ + "step.do_deploy": "step.iac_apply", + "step.do_status": "step.iac_status", + "step.do_destroy": "step.iac_destroy", + } + gapTypes := map[string]string{ + "platform.do_networking": "splits into infra.vpc + infra.firewall — manual rewrite required", + "step.do_logs": "no pipeline-step successor; use `wfctl infra logs` or rely on DO plugin Troubleshoot", + "step.do_scale": "no pipeline-step successor; edit instance_count and re-run step.iac_apply", + } + + return Rule{ + ID: "legacy-do-types", + Description: "Rewrite legacy DigitalOcean module/step types to infra.* IaC successors (issue #617).", + Severity: "error", + Check: func(root *yaml.Node, raw []byte) []Finding { + var out []Finding + walkTypeNodes(root, func(typeVal *yaml.Node) { + if successor, ok := moduleMap[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in %s; rewrite to %s (provider: digitalocean) — requires workflow-plugin-digitalocean", typeVal.Value, legacydo.RemovedInVersion, successor), + Fixable: true, + }) + } + if successor, ok := stepMap[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in %s; rewrite to %s — requires workflow-plugin-digitalocean", typeVal.Value, legacydo.RemovedInVersion, successor), + Fixable: true, + }) + } + if reason, ok := gapTypes[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in %s — %s", typeVal.Value, legacydo.RemovedInVersion, reason), + Fixable: false, + }) + } + }) + return out + }, + Fix: func(root *yaml.Node) []Change { + var out []Change + walkTypeNodes(root, func(typeVal *yaml.Node) { + if successor, ok := moduleMap[typeVal.Value]; ok { + old := typeVal.Value + typeVal.Value = successor + out = append(out, Change{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Description: fmt.Sprintf("rewrote %s → %s", old, successor), + }) + } + if successor, ok := stepMap[typeVal.Value]; ok { + old := typeVal.Value + typeVal.Value = successor + out = append(out, Change{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Description: fmt.Sprintf("rewrote %s → %s", old, successor), + }) + } + // gapTypes are intentionally not modified. + }) + return out + }, + } +} + +// walkTypeNodes traverses a YAML AST and invokes visit on every value node +// whose parent mapping key is "type". This differs from the package's existing +// walkNodes helper which visits every node — extracted as a separate helper +// because the type-key constraint produces tighter visitor code at call sites. +// If a future refactor unifies the two, prefer adding a key-filter parameter +// to walkNodes over keeping the duplication. +func walkTypeNodes(n *yaml.Node, visit func(*yaml.Node)) { + if n == nil { + return + } + if n.Kind == yaml.MappingNode { + for i := 0; i+1 < len(n.Content); i += 2 { + k, v := n.Content[i], n.Content[i+1] + if k.Value == "type" && v.Kind == yaml.ScalarNode { + visit(v) + } + walkTypeNodes(v, visit) + } + return + } + for _, c := range n.Content { + walkTypeNodes(c, visit) + } +} +``` + +Append to `modernize/modernize.go` `AllRules()`: + +```go +return []Rule{ + hyphenStepsRule(), + conditionalFieldRule(), + dbQueryModeRule(), + dbQueryIndexRule(), + absoluteDbPathRule(), + emptyRoutesRule(), + camelCaseConfigRule(), + requestParseConfigRule(), + legacyDORule(), // <-- ADD +} +``` + +**Step 4: Run rule tests to verify they pass** + +Run: `go test ./modernize/... -run TestLegacyDORule -v` +Expected: PASS. + +Run: `go test ./modernize/...` +Expected: PASS overall. + +**Step 5: Write the docs + migration guide + CHANGELOG** + +Modify `DOCUMENTATION.md`: locate the "Platform Modules" table containing the 5 `platform.do_*` rows and the "Platform Steps" table containing the 5 `step.do_*` rows. Replace each row block with: + +```markdown +**DigitalOcean IaC modules and steps** were removed from workflow core in +v0.52.0 and moved to the +[workflow-plugin-digitalocean](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) +external plugin. After loading the plugin, use the generic `infra.*` module +types with `provider: digitalocean` and the generic `step.iac_*` pipeline +steps. See [v0.52.0 migration guide](docs/migrations/v0.52.0-godo-removal.md). +``` + +Prepend to `CHANGELOG.md`: + +```markdown +## v0.52.0 (2026-05-13) — BREAKING + +### Removed (issue #617) + +- All legacy DigitalOcean IaC modules (`platform.do_app`, `platform.do_database`, `platform.do_dns`, `platform.do_networking`, `platform.doks`) and the DO credential resolver `cloud_account_do.go`. +- All legacy DigitalOcean pipeline steps (`step.do_deploy`, `step.do_status`, `step.do_logs`, `step.do_scale`, `step.do_destroy`). +- The `github.com/digitalocean/godo` dependency from `go.mod` (root and `example/`). + +### Migration + +DigitalOcean IaC moved to [`workflow-plugin-digitalocean`](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) v0.12.0+. After loading the plugin, replace legacy module types with `infra.*` types and `provider: digitalocean`. Run `wfctl modernize --apply ` to auto-rewrite supported types — **then manually add `provider: digitalocean` to each rewritten module's `config:` block** (the modernize rule does not inject the provider key; see the [migration guide](docs/migrations/v0.52.0-godo-removal.md) for the exact recipe). Two step types (`step.do_logs`, `step.do_scale`) have no 1:1 pipeline successor — workarounds documented in the migration guide. + +Configs that still reference the legacy types now fail to load with an actionable error pointing to the plugin and the relevant `infra.*` successor. +``` + +Create `docs/migrations/v0.52.0-godo-removal.md`: + +```markdown +# v0.52.0 — Removing godo from workflow core (issue #617) + +## What changed + +The five legacy `platform.do_*` modules, the `cloud.account` DO credential +resolver, and the five legacy `step.do_*` pipeline steps were removed from +workflow core. The `github.com/digitalocean/godo` dependency is no longer +pulled by the workflow module. + +DigitalOcean IaC functionality moved entirely to +[`workflow-plugin-digitalocean`](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) +v0.12.0+, which exposes the same resources through the generic `infra.*` IaC +type system with `provider: digitalocean`. + +## Why + +Workflow core should own IaC interfaces and orchestration, not provider SDKs. +Dependabot bumps to godo now target the DO plugin repo, not core. See ADR or +the design doc at `docs/plans/2026-05-13-issue-617-godo-removal-design.md`. + +## Migration recipe + +1. Install the DO plugin (v0.12.0+): + ```yaml + plugins: + - name: digitalocean + source: github.com/GoCodeAlone/workflow-plugin-digitalocean + version: ">=0.12.0" + ``` + +2. Run the modernizer over each affected YAML config: + ```sh + wfctl modernize --apply ./config/*.yaml + ``` + This **renames the type field** for 4 module types and 3 step types + automatically. Two step types (`step.do_logs`, `step.do_scale`) and one + module type (`platform.do_networking`) are flagged but not auto-rewritten + — see below. + +3. **Add `provider: digitalocean` to each rewritten module's `config:` + block.** The modernize rule does NOT auto-inject this key, because the + `config:` block typically contains operator-authored settings that + shouldn't be silently modified. Example: + + ```yaml + # After modernize (type renamed, provider absent): + modules: + - name: api + type: infra.container_service + config: + region: nyc # <-- modernize left this alone + + # Operator adds provider key manually: + modules: + - name: api + type: infra.container_service + config: + provider: digitalocean # <-- ADD THIS + region: nyc + ``` + + Forgetting this produces a load-time error: + `infra module "api" (infra.container_service): 'provider' config is required`. + +4. Manually address the GAP types listed below. + +5. Re-run `wfctl validate` and `wfctl infra plan` to confirm the rewritten + config loads and produces the same plan. + +## Module type mapping + +| Legacy type | Successor | Auto-fix | +|--------------------------|-----------------------------------|----------| +| `platform.do_app` | `infra.container_service` | Yes | +| `platform.do_database` | `infra.database` | Yes | +| `platform.do_dns` | `infra.dns` | Yes | +| `platform.do_networking` | `infra.vpc` + `infra.firewall` | **No** — splits 1→2, manual review required | +| `platform.doks` | `infra.k8s_cluster` | Yes | + +All successors require `config.provider: digitalocean`. + +## Step type mapping + +| Legacy type | Successor | Auto-fix | +|--------------------|--------------------------------------------------------------------|----------| +| `step.do_deploy` | `step.iac_apply` (against an `infra.container_service` module) | Yes | +| `step.do_status` | `step.iac_status` (against an `infra.container_service` module) | Yes | +| `step.do_destroy` | `step.iac_destroy` (against an `infra.container_service` module) | Yes | +| `step.do_logs` | **GAP** — no pipeline step successor; use `wfctl infra logs` ad-hoc, or rely on the DO plugin's Troubleshoot hook on `step.iac_apply` failure. Tracked: workflow-plugin-digitalocean issue | **No** | +| `step.do_scale` | **GAP** — no pipeline step successor; update `instance_count` in the `infra.container_service` module config and re-run `step.iac_apply`. Tracked: workflow-plugin-digitalocean issue | **No** | + +## Before / after examples + +### App Platform + +Before: +```yaml +modules: + - name: api + type: platform.do_app + config: + region: nyc + services: + - name: web + image: registry.digitalocean.com/myorg/api:latest +``` + +After: +```yaml +modules: + - name: api + type: infra.container_service + config: + provider: digitalocean + region: nyc + services: + - name: web + image: registry.digitalocean.com/myorg/api:latest +``` + +### Pipeline step + +Before: +```yaml +pipelines: + - id: deploy + steps: + - type: step.do_deploy + config: { app: api } +``` + +After: +```yaml +pipelines: + - id: deploy + steps: + - type: step.iac_apply + config: { module: api } +``` + +## Errors you may see + +* `unsupported legacy module type "platform.do_app" (module "api"): this type was removed from workflow core in v0.52.0 — DigitalOcean IaC moved to workflow-plugin-digitalocean.` — fix the config per the table above; install the plugin if not already loaded. +* `unsupported legacy step type "step.do_logs": ...` — see GAP entry above; remove the step and use `wfctl infra logs` ad-hoc, or wait for `step.iac_logs` (tracked). + +## Rollback + +If your environment cannot upgrade in this cycle, pin to the previous workflow +core tag (`go get github.com/GoCodeAlone/workflow@v0.51.3`). The legacy modules +remain available there. +``` + +**Step 6: File two follow-up issues in `workflow-plugin-digitalocean` and wire their numbers into the migration error** + +Using `gh`: + +```bash +LOGS_ISSUE_BODY=$(cat <<'EOF' +Legacy step.do_logs in workflow core was removed in workflow v0.52.0 (issue +GoCodeAlone/workflow#617). There is no 1:1 pipeline-step successor in the +generic step.iac_* family yet. Current workaround for users: `wfctl infra logs` +ad-hoc, or rely on the DO plugin's Troubleshoot hook on step.iac_apply +failure. This issue tracks adding a first-class step.iac_logs (in core) or +step.app_logs (in the DO plugin's exposed step set). +EOF +) +SCALE_ISSUE_BODY=$(cat <<'EOF' +Legacy step.do_scale in workflow core was removed in workflow v0.52.0 (issue +GoCodeAlone/workflow#617). Current workaround: update instance_count in the +infra.container_service module config and re-run step.iac_apply. This issue +tracks adding a first-class step.iac_scale (config-less runtime scale). +EOF +) +gh issue create --repo GoCodeAlone/workflow-plugin-digitalocean \ + --title "Add step.iac_logs (or step.app_logs) — closes step.do_logs migration GAP from workflow#617" \ + --body "$LOGS_ISSUE_BODY" +gh issue create --repo GoCodeAlone/workflow-plugin-digitalocean \ + --title "Add step.iac_scale — closes step.do_scale migration GAP from workflow#617" \ + --body "$SCALE_ISSUE_BODY" +``` + +Capture the two issue URLs / numbers and patch the migration guide's two ` / ` placeholders. (The error text in `internal/legacydo/types.go` does not contain URL placeholders — only the migration guide does — so this step is doc-only.) + +**Step 7: Verify the docs build / render** + +Run: `find docs -name "*.md" -exec grep -l "TODO\|/dev/null +# RBAC + non-IaC stays: +ls iam/aws*.go plugin/rbac/aws*.go artifact/s3*.go module/iac_state_spaces.go provider/aws/deploy.go 2>/dev/null +``` + +Then write the issue body. Template (replace the `<...>` placeholders with the actual grep output): + +```bash +AWS_BODY=$(cat <<'EOF' +Continuation of #617. The DO half of the SDK audit shipped in v0.52.0 (godo +gone from core). This issue tracks the AWS half. + +In scope (move to workflow-plugin-aws via the same Option A force-cutover +pattern used for #617): + + +Out of scope (justified non-IaC core surfaces; STAY in core): +- `iam/aws.go` — RBAC integration +- `plugin/rbac/aws.go` — RBAC plugin glue +- `artifact/s3.go` — generic S3-compat artifact storage +- `provider/aws/deploy.go` — IaC adapter (revisit if thin wrapper) +- `module/iac_state_spaces.go` — S3-compat state backend (also used by DO Spaces) + +Goal: same as #617 — Dependabot bumps for AWS SDKs target the provider +plugin repo, not core, except for the surfaces above. +EOF +) +gh issue create --repo GoCodeAlone/workflow \ + --title "Audit AWS SDK usage in workflow core (RBAC/secrets/artifact stay; IaC drivers reviewed for plugin move)" \ + --body "$AWS_BODY" +``` + +**Step 10: Final commit (issue URLs back into the migration guide)** + +After the two plugin issues are filed in Step 6, patch their numbers/URLs into `docs/migrations/v0.52.0-godo-removal.md`. The AWS follow-up issue from Step 9 does not need to be referenced in this PR — it lives independently. Commit the patched URLs: + +```bash +git add docs/migrations/v0.52.0-godo-removal.md +git commit -m "docs(#617): wire workflow-plugin-digitalocean follow-up issue URLs into migration guide" +``` + +**Rollback:** `git revert ` removes the modernize rule, migration guide, CHANGELOG entry. Combined with T1/T2/T3/T4 revert returns to pre-cutover state. Plugin follow-up issues remain filed (they describe genuine gaps regardless of whether this PR ships). + +--- + +## Verification per change class (summary) + +| Task | Class | Verification | +|------|-------|--------------| +| T1 | Internal-logic refactor (pure deletion + import test) | `go test ./module -run TestGodoNotImported_InModulePackage` PASS | +| T2 | Internal-logic refactor (registry edits) | `go test ./cmd/wfctl -run TestLegacyDOTypesAbsent_FromTypeRegistry` PASS + `go build ./...` clean | +| T3 | Internal-logic refactor (new error path + helper) | `go test -run 'TestLegacyDO(Module|Step)Error'` PASS | +| T4 | **Version pin update** | `go mod tidy` clean + CI `godo-banned` job PASS + `! grep ...` locally exits 0. **Rollback:** revert T4 commit; godo returns to go.mod (no runtime effect because no code uses it after T1-T3 either). | +| T5 | Documentation + new CLI rule | `go test ./modernize -run TestLegacyDORule` PASS + `grep -n "platform.do_app" DOCUMENTATION.md` returns nothing + two plugin issues filed + AWS audit issue filed | + +T4 is the only task with the runtime-launch-validation trigger (version-pin update), and the rollback note is included. + +--- + +## End-of-PR checklist (run before opening PR) + +1. `go test ./...` — all green. +1a. `go test -race ./...` — all green (the `module` package has parallel tests; while T3's per-registry instance field eliminates the global, `-race` is still mandatory to catch any future regression and to verify the engine→stepRegistry hook is goroutine-safe). +2. `! grep -rn --include="*.go" --exclude-dir=_worktrees --exclude-dir=.worktrees --exclude-dir=.claude "digitalocean/godo" .` exits 0. +3. `! grep -qH "digitalocean/godo" go.mod example/go.mod` exits 0. +4. `wfctl modernize --apply modernize/testdata/legacy-do-config.yaml` (fixture committed in T5) rewrites legacy types — verify against `modernize/testdata/legacy-do-config.expected.yaml`. +5. `go build ./cmd/wfctl && ./wfctl validate modernize/testdata/legacy-do-config.yaml` produces the actionable migration error and exits non-zero. +6. `go build ./cmd/server && ./server -config modernize/testdata/legacy-do-config.yaml` produces the same error and exits non-zero. +7. CHANGELOG.md has the v0.52.0 BREAKING entry at the top. +8. Two follow-up issues filed in `workflow-plugin-digitalocean`; URLs wired into the migration guide. +9. One follow-up issue filed in `workflow` for the AWS audit (no URL wiring needed — independent stream). +10. PR description references issue #617 and lists the breaking-change impact. + +--- + +## Adversarial review history (plan phase) + +### Cycle 7 (FAIL) — 2026-05-13 + +- **C-1** validate/ci_validate post-pass step sweep used naive `for _, p := range cfg.Pipelines { for _, s := range p.Steps {` but `cfg.Pipelines` is `map[string]any` (verified at `config/config.go:149`), not `[]PipelineConfig` — won't compile → **fixed**: T2 now uses yaml.Marshal/Unmarshal pattern matching `engine.go configurePipelines`. Also split out the `ciValidateFile` accumulating variant (`errs = append`) from the `validateFile` early-return variant. +- **I-1** No automated test for the validate-path migration error (only checklist item 5 covered it manually) → **fixed**: added `TestValidateFile_LegacyDOModule_ReturnsActionableError` and `TestCIValidateFile_LegacyDOStep_ReturnsActionableError` to T2. +- **Cycle 1-6 plan-phase fixes verified to hold.** + +### Cycle 6 (FAIL) — 2026-05-13 + +- **C-1** Plan referenced a phantom `schema.WithExtraStepTypes` (no such function exists; `schema.ValidateConfig` only validates module types, not step types) → **fixed**: step-types schema injection removed; step migration guard at the `StepRegistry.Create` rejection point is the sole gate for legacy step types, which is correct because schema never validated them. +- **C-1 second part** `wfctl validate` (`cmd/wfctl/validate.go:145`) and `wfctl ci validate` (`cmd/wfctl/ci_validate.go:134`) call `schema.ValidateConfig` directly without going through `engine.BuildFromConfig`, so the migration error path is unreachable from validate → **fixed**: added both files to T2; pattern is (a) inject `legacydo.ModuleTypes` into local opts so schema passes legacy types through, (b) post-`ValidateConfig` sweep emits `legacydo.FormatModuleError` / `FormatStepError` for any legacy type found in modules / pipeline steps. AC3 now satisfied on the validate path. +- **I-1** `if len(e.moduleFactories) > 0 || true { ... }` triggers `staticcheck SA4010` always-true-condition → CI lint fails → **fixed**: replaced with unconditional code. +- **m-1** Cycle-5 history checklist line mentioned `WithExtraStepTypes` (which doesn't exist) → **fixed implicitly** by deleting the step-types schema injection from T3. +- **Cycle 1-5 plan-phase fixes verified to hold.** + +### Cycle 5 (FAIL) — 2026-05-13 + +- **C-1** `schema.ValidateConfig` at `engine.go:400` fires BEFORE the factory loop at `:506` — removing the 5 legacy module types from `schema/schema.go`'s allow-list (T2) would cause the generic schema error to be returned ahead of the actionable `legacydo.FormatModuleError`, making the migration message unreachable → **fixed**: T3 wiring now adds the 5 legacy DO module types (and 5 step types) to `schema.WithExtraModuleTypes` / `WithExtraStepTypes` so schema passes them through to the factory guard, which is the real rejection point. +- **I-1** Plan wrote `e.stepRegistry.SetIaCProviderLoaded(iacLoaded)` but `e.stepRegistry` is `interfaces.StepRegistrar` (no such method on the interface) → would not compile → **fixed**: type-assertion pattern from `engine.go:163,216`: `if r, ok := e.stepRegistry.(*module.StepRegistry); ok { r.SetIaCProviderLoaded(iacLoaded) }`. Interface deliberately NOT widened — that would add a method burden to every downstream `StepRegistrar` for zero benefit. +- **I-2** End-of-PR checklist 1a still cited "T3 introduces a package-level atomic" — stale from cycle-3's pre-instance-field design → **fixed**. +- **m-1** `LegacyDORule()` was exported but all peer rule constructors (`hyphenStepsRule`, `dbQueryModeRule`, …) are unexported, and existing tests use `package modernize` (not `_test`) → **fixed**: renamed to `legacyDORule`; test file now uses internal `package modernize`; external `modernize` import dropped from the test. +- **Cycle 1/2/3/4 plan-phase fixes verified to hold.** + +### Cycle 4 (FAIL) — 2026-05-13 + +- **C-1** Cycle 3's "no import cycle" claim was wrong — `module` transitively imports `modernize` via `plugin` (`go list -deps github.com/GoCodeAlone/workflow/module | grep modernize` returns `modernize` because `plugin/manifest.go` and `plugin/engine_plugin.go` import it). Therefore `modernize → module` IS a cycle → **fixed**: shared constants/formatters moved to a new leaf package `internal/legacydo/types.go` that imports only stdlib. Both `module/` (via the engine guard) and `modernize/` (via the rewrite rule) import `internal/legacydo` cycle-free. +- **I-1** Package-level `atomic.Bool iacProviderLoaded` is a logic-race surface (atomic.Bool blocks data-race detector but not test-order non-determinism when multiple tests mutate the flag) → **fixed**: replaced the global with a `StepRegistry.iacProviderLoaded` instance field; `r.SetIaCProviderLoaded(loaded)` sets it, `r.Create` reads it. Per-registry state; parallel tests can each own a fresh `NewStepRegistry()`. +- **I-2** Design doc proposed an "assert credential registry has zero `digitalocean/*` entries" test, but `credentialResolvers` is unexported and there is no accessor → **fixed (option a)**: design doc rewritten to remove the proposed test; rationale "registry is additive via init(); deleting file removes init()" is the evidence; no API-for-test-only added. +- **m-1** `platformModules` local variable in `deploy.go` would carry `infra.*` items after T2 — misleading name → **fixed**: T2 spec now includes the rename to `deployTargetModules`. +- **m-2** `newTestEngine` couples to `package workflow` for `mockLogger` visibility → **acknowledged as informational** in cycle 3; no plan change. Current package structure makes the coupling correct. +- **Cycle 1/2/3 plan-phase fixes verified to hold.** + +### Cycle 3 (FAIL) — 2026-05-13 + +- **C-1** Plan declared `type mockLogger struct{}` in `engine_legacy_do_migration_test.go` (same package as `engine_test.go:482` which already declares it) → compile error → **fixed**: redeclaration removed; helper reuses the existing in-package type. +- **C-2** `legacyDORemovedInVersion` duplicate was justified by a falsely claimed import cycle (`go list -f '{{ join .Imports "\n" }}'` confirmed no cycle) → **fixed**: dropped the duplicate; modernize/legacy_do_rule.go now imports `module` and references `legacydo.RemovedInVersion` directly. Single source of truth. +- **I-1** Step "already loaded" branch had no test → **fixed**: added `TestLegacyDOStepError_PluginLoaded` symmetric to the engine equivalent. +- **m-1** CI snippet pinned `actions/checkout@v5` which doesn't exist (repo uses `@v4` everywhere) → **fixed**. +- **Cycle 1 and Cycle 2 plan-phase fixes verified to hold.** + +### Cycle 2 (FAIL) — 2026-05-13 + +- **C-1** `wfctl modernize` Fix renamed `type:` but did not inject `config.provider: digitalocean` → produced YAML that fails to load → **fixed by scope-limit (Option 2)**: rule explicitly does not inject the provider key; the migration guide adds a manual step with example YAML and the error string the user will hit; rule docstring + test names + expected fixture all assert the scope-limited behaviour. +- **C-2** `cmd/wfctl/deploy.go:839,901` had its own `strings.HasPrefix(m.Type, "platform.")` collector + "no platform.* modules found" error — missed in T2 scope → **fixed**: file added to T2's edit list; both call sites updated to include `infra.*` prefix. +- **I-1** `newTestEngine` differs from `setupEngineTest` (no `loadAllPlugins`) — intentional but not documented → **fixed**: comment added explaining the intentional divergence. +- **I-2** `hasPlatformModules` + `isInfraType` rationale comments still cite DigitalOcean → **fixed**: comment-hygiene edit added to T5. +- **m-1** `newTestEngine` passed `nil` logger to `NewStdApplication`; deviated from existing test pattern → **fixed**: use `mockLogger{}` matching the canonical shape in engine_test.go. +- **m-2** `RemovedInVersion` declared in `module/` but hardcoded again in `modernize/` (import cycle prevents reuse) → **fixed**: explicit duplicate `legacydo.RemovedInVersion` in modernize with a documented "keep in sync" comment. +- **m-3** AWS audit issue body invented speculative file names → **fixed**: T5 Step 9 now runs the grep BEFORE writing the body and uses the grep output to populate the in-scope list. +- **Cycle 1 fixes verified to hold** — no regressions introduced. + +### Cycle 1 (FAIL) — 2026-05-13 + +- **C-1** T3 engine test invented `workflow.NewEngine()` + `e.RegisterModuleFactory()` → **fixed**: use `NewStdEngine(app, app.Logger())` and `AddModuleType()` per `engine.go:146,210`; `package workflow`. +- **C-2** T3 step test invented `module.CreateStep()` → **fixed**: use `module.NewStepRegistry().Create()` per `module/pipeline_step_registry.go:18,32`. +- **I-1** T2 test invented `buildTypeRegistry()` → **fixed**: call `KnownModuleTypes()` + `KnownStepTypes()` directly. +- **I-2** Global `iacProviderLoaded` raced with parallel tests → **fixed**: `sync/atomic.Bool` + `IsIaCProviderLoaded()` accessor. +- **I-3** Missing test for `platform.do_networking` gap behaviour → **fixed**: gap-type test renamed and broadened to all three gap types. +- **m-1** New `walkTypeNodes` duplicates existing `walkNodes` → **acknowledged**: note added recommending unification in a future refactor; kept separate for now to preserve tight call-site code. +- **m-2** Version `v0.52.0` hardcoded in 7+ places → **fixed**: `legacydo.RemovedInVersion` constant. +- **m-3** Smoke fixture not committed → **fixed**: `modernize/testdata/legacy-do-config.yaml` + `.expected.yaml` added to T5. +- End-of-PR checklist: added `go test -race ./...` and pointed checklist items 4-6 at the committed fixture. + +## References + +- Design doc: `docs/plans/2026-05-13-issue-617-godo-removal-design.md` +- Issue: [GoCodeAlone/workflow#617](https://github.com/GoCodeAlone/workflow/issues/617) +- Trigger PR (Dependabot bump): [PR #421](https://github.com/GoCodeAlone/workflow/pull/421) +- Plugin: [`workflow-plugin-digitalocean`](https://github.com/GoCodeAlone/workflow-plugin-digitalocean) v0.12.0+ +- Precedent: `feedback_force_strict_contracts_no_compat.md` (force-cutover pattern); `project_strict_contracts_cutover_complete.md` (typed-gRPC cutover, DO plugin v1.0.1) diff --git a/docs/plans/2026-05-13-issue-617-godo-removal.md.scope-lock b/docs/plans/2026-05-13-issue-617-godo-removal.md.scope-lock new file mode 100644 index 00000000..f4496d70 --- /dev/null +++ b/docs/plans/2026-05-13-issue-617-godo-removal.md.scope-lock @@ -0,0 +1 @@ +7fcc5df5daafc2911a0df9c0a165057ac59b3cfb467ff98b71b1b2fa9c74f6a2 diff --git a/engine.go b/engine.go index 055baac8..e96565c2 100644 --- a/engine.go +++ b/engine.go @@ -15,6 +15,7 @@ import ( "github.com/GoCodeAlone/workflow/dynamic" "github.com/GoCodeAlone/workflow/infra" "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/internal/legacydo" "github.com/GoCodeAlone/workflow/module" "github.com/GoCodeAlone/workflow/plugin" "github.com/GoCodeAlone/workflow/schema" @@ -390,13 +391,18 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { schema.WithSkipWorkflowTypeCheck(), schema.WithSkipTriggerTypeCheck(), } - if len(e.moduleFactories) > 0 { - extra := make([]string, 0, len(e.moduleFactories)) - for t := range e.moduleFactories { - extra = append(extra, t) - } - valOpts = append(valOpts, schema.WithExtraModuleTypes(extra...)) + extra := make([]string, 0, len(e.moduleFactories)+len(legacydo.ModuleTypes)) + for t := range e.moduleFactories { + extra = append(extra, t) + } + // Pass legacy DO module types through schema so the factory-loop guard + // (which emits legacydo.FormatModuleError) is the rejection point — + // schema rejection produces a generic error and would mask the + // actionable migration message (issue #617). + for t := range legacydo.ModuleTypes { + extra = append(extra, t) } + valOpts = append(valOpts, schema.WithExtraModuleTypes(extra...)) if err := schema.ValidateConfig(cfg, valOpts...); err != nil { return fmt.Errorf("config validation failed: %w", err) } @@ -505,6 +511,10 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { // Look up the module factory from the registry (populated by LoadPlugin) factory, exists := e.moduleFactories[modCfg.Type] if !exists { + if legacydo.IsModuleType(modCfg.Type) { + _, iacLoaded := e.moduleFactories["iac.provider"] + return legacydo.FormatModuleError(modCfg.Type, modCfg.Name, iacLoaded) + } return fmt.Errorf("unknown module type %q for module %q — ensure the required plugin is loaded", modCfg.Type, modCfg.Name) } e.logger.Debug("Using factory for module type: " + modCfg.Type) @@ -562,6 +572,14 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { return fmt.Errorf("failed to configure triggers: %w", err) } + // Inform the step registry whether the iac.provider module factory is loaded. + // This lets StepRegistry.Create emit an actionable migration error for legacy + // DO step types instead of the generic "unknown step type" message (issue #617). + _, iacLoaded := e.moduleFactories["iac.provider"] + if r, ok := e.stepRegistry.(*module.StepRegistry); ok { + r.SetIaCProviderLoaded(iacLoaded) + } + // Configure pipelines (composable step-based workflows) if len(cfg.Pipelines) > 0 { if err := e.configurePipelines(cfg.Pipelines); err != nil { diff --git a/engine_legacy_do_migration_test.go b/engine_legacy_do_migration_test.go new file mode 100644 index 00000000..deeec128 --- /dev/null +++ b/engine_legacy_do_migration_test.go @@ -0,0 +1,79 @@ +package workflow + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/config" +) + +// newIsolatedEngine builds a plugin-free StdEngine — required so that the +// iac.provider factory-map lookup is deterministically absent in the +// "plugin not loaded" tests, and so that the manual AddModuleType stub in +// the "plugin loaded" test is the only factory registered. This differs from +// setupEngineTest (engine_test.go) which calls loadAllPlugins, and from +// newTestEngine (engine_multi_config_test.go) which loads pipelinesteps. +// Reuses the `mockLogger` type already defined in engine_test.go — both files +// are in package workflow so the type is visible at compile time. DO NOT +// redeclare it here. +func newIsolatedEngine(t *testing.T) *StdEngine { + t.Helper() + logger := &mockLogger{} + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + if err := app.Init(); err != nil { + t.Fatalf("app.Init: %v", err) + } + return NewStdEngine(app, logger) +} + +func TestLegacyDOModuleError_PluginNotLoaded(t *testing.T) { + cases := []struct{ legacyType, hint string }{ + {"platform.do_app", "infra.container_service"}, + {"platform.do_database", "infra.database"}, + {"platform.do_dns", "infra.dns"}, + {"platform.do_networking", "infra.vpc"}, + {"platform.doks", "infra.k8s_cluster"}, + } + for _, tc := range cases { + t.Run(tc.legacyType, func(t *testing.T) { + e := newIsolatedEngine(t) + cfg := &config.WorkflowConfig{Modules: []config.ModuleConfig{{Name: "x", Type: tc.legacyType, Config: map[string]any{}}}} + err := e.BuildFromConfig(cfg) + if err == nil { + t.Fatalf("expected error for legacy type %q", tc.legacyType) + } + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-digitalocean", + "Install workflow-plugin-digitalocean", + tc.hint, + } { + if !strings.Contains(msg, want) { + t.Errorf("error for %q missing %q; got: %s", tc.legacyType, want, msg) + } + } + }) + } +} + +func TestLegacyDOModuleError_PluginLoaded(t *testing.T) { + e := newIsolatedEngine(t) + // Register a stub iac.provider factory to simulate workflow-plugin-digitalocean + // being loaded. ModuleFactory signature: func(name string, config map[string]any) modular.Module. + e.AddModuleType("iac.provider", func(name string, cfg map[string]any) modular.Module { return nil }) + + cfg := &config.WorkflowConfig{Modules: []config.ModuleConfig{{Name: "x", Type: "platform.do_app", Config: map[string]any{}}}} + err := e.BuildFromConfig(cfg) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "already loaded") { + t.Errorf("plugin-loaded branch must say 'already loaded'; got: %s", msg) + } + if strings.Contains(msg, "Install workflow-plugin-digitalocean") { + t.Errorf("plugin-loaded branch must NOT instruct install; got: %s", msg) + } +} diff --git a/example/go.mod b/example/go.mod index 99dc0e51..a8a8052f 100644 --- a/example/go.mod +++ b/example/go.mod @@ -77,7 +77,6 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deckarep/golang-set/v2 v2.9.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/digitalocean/godo v1.184.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/go-connections v0.7.0 // indirect @@ -116,7 +115,6 @@ require ( github.com/golobby/cast v1.3.3 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.1 // indirect - github.com/google/go-querystring v1.2.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect diff --git a/example/go.sum b/example/go.sum index a9205a20..4170ba52 100644 --- a/example/go.sum +++ b/example/go.sum @@ -195,8 +195,6 @@ github.com/deckarep/golang-set/v2 v2.9.0 h1:prva4eP9UysWagLyKrtn074ughi0NnkIf0A4 github.com/deckarep/golang-set/v2 v2.9.0/go.mod h1:EWknQXbs0mcFpat2QOoXV0Ee57cD+w6ZEN76BR2JVrM= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/digitalocean/godo v1.184.0 h1:2B2CQhxftlf3xa24Nrzn5CBQlaQjyaWqi3XbbnJlG3w= -github.com/digitalocean/godo v1.184.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= @@ -330,11 +328,8 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= -github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/go.mod b/go.mod index ce0d364b..7426df87 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sqs v1.42.21 github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 github.com/cucumber/godog v0.15.1 - github.com/digitalocean/godo v1.178.0 github.com/docker/docker v28.5.2+incompatible github.com/expr-lang/expr v1.17.8 github.com/fsnotify/fsnotify v1.9.0 @@ -183,7 +182,6 @@ require ( github.com/golobby/cast v1.3.3 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.1 // indirect - github.com/google/go-querystring v1.2.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect diff --git a/go.sum b/go.sum index 4d7b7175..7f99e311 100644 --- a/go.sum +++ b/go.sum @@ -240,8 +240,6 @@ github.com/deckarep/golang-set/v2 v2.9.0 h1:prva4eP9UysWagLyKrtn074ughi0NnkIf0A4 github.com/deckarep/golang-set/v2 v2.9.0/go.mod h1:EWknQXbs0mcFpat2QOoXV0Ee57cD+w6ZEN76BR2JVrM= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/digitalocean/godo v1.178.0 h1:+B4xGOaoFwwwpM7TKhoyGHdmFg5eF9zDB1YfOLvNJ2E= -github.com/digitalocean/godo v1.178.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= @@ -382,11 +380,8 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= -github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/iac/conformance/scenario_delete_action.go b/iac/conformance/scenario_delete_action.go index de4decdc..ab86e12a 100644 --- a/iac/conformance/scenario_delete_action.go +++ b/iac/conformance/scenario_delete_action.go @@ -77,6 +77,7 @@ func scenarioDeleteActionInApplyInvokesDriverDelete(t *testing.T, cfg Config) { } if result == nil { t.Fatal("ApplyPlan returned nil result") + return } if len(result.Errors) != 0 { // Most likely failure mode: provider's dispatch lacks a diff --git a/iac/conformance/scenario_grpc_roundtrip.go b/iac/conformance/scenario_grpc_roundtrip.go index ca338551..6e7e2414 100644 --- a/iac/conformance/scenario_grpc_roundtrip.go +++ b/iac/conformance/scenario_grpc_roundtrip.go @@ -102,6 +102,7 @@ func scenarioDiffSurvivesGRPCRoundTrip(t *testing.T, cfg Config) { } if res == nil { t.Fatal("roundtripDriver.Diff returned nil DiffResult after structpb roundtrip; the response decode must yield a non-nil value") + return } // Each Change that survived the response-side roundtrip must diff --git a/iac/conformance/scenario_replace_cascade_preserves_dependents.go b/iac/conformance/scenario_replace_cascade_preserves_dependents.go index bb4e3c54..f750137a 100644 --- a/iac/conformance/scenario_replace_cascade_preserves_dependents.go +++ b/iac/conformance/scenario_replace_cascade_preserves_dependents.go @@ -95,6 +95,7 @@ func scenarioReplaceCascadePreservesDependents(t *testing.T, cfg Config) { } if result == nil { t.Fatal("ApplyPlan returned nil result") + return } if len(result.Errors) != 0 { t.Errorf("expected no per-action errors (cascade JIT must resolve "+ diff --git a/iac/conformance/scenario_upsert_on_already_exists.go b/iac/conformance/scenario_upsert_on_already_exists.go index cf36115e..d55caf3b 100644 --- a/iac/conformance/scenario_upsert_on_already_exists.go +++ b/iac/conformance/scenario_upsert_on_already_exists.go @@ -91,6 +91,7 @@ func scenarioUpsertOnAlreadyExists(t *testing.T, cfg Config) { } if result == nil { t.Fatal("ApplyPlan returned nil result") + return } if len(result.Errors) != 0 { t.Errorf("expected no per-action errors (UpsertSupporter must recover from "+ diff --git a/internal/legacydo/types.go b/internal/legacydo/types.go new file mode 100644 index 00000000..07948829 --- /dev/null +++ b/internal/legacydo/types.go @@ -0,0 +1,95 @@ +// Package legacydo holds the read-only data and message formatters for the +// legacy DigitalOcean module + step types removed in issue #617. Lives in +// internal/ so that both module/ and modernize/ can import it without a +// cycle (module transitively imports modernize via plugin, so modernize +// cannot import module). +package legacydo + +import ( + "fmt" + "sort" + "strings" +) + +// RemovedInVersion is the workflow tag that ships issue #617's force-cutover. +// Used in every legacy-DO migration error and in the wfctl modernize rule. +// Update both this constant and the docs/migrations/v-godo-removal.md +// filename when the release tag is finalised. +const RemovedInVersion = "v0.52.0" + +// ModuleTypes maps each removed legacy DigitalOcean module type to its +// infra.* IaC successor (issue #617). +var ModuleTypes = map[string]string{ + "platform.do_app": "infra.container_service", + "platform.do_database": "infra.database", + "platform.do_dns": "infra.dns", + "platform.do_networking": "infra.vpc + infra.firewall", + "platform.doks": "infra.k8s_cluster", +} + +// StepTypes maps each removed legacy DigitalOcean step type to its +// successor or to a workaround when no 1:1 successor exists. +var StepTypes = map[string]string{ + "step.do_deploy": "step.iac_apply (against an infra.container_service module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.do_status": "step.iac_status (against an infra.container_service module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.do_destroy": "step.iac_destroy (against an infra.container_service module); required config keys: platform (iac.provider service name) + state_store (IaC state backend module name)", + "step.do_logs": "no direct pipeline-step equivalent; use `wfctl infra logs` ad-hoc, or rely on the DO plugin's Troubleshoot hook on step.iac_apply failure", + "step.do_scale": "no direct pipeline-step equivalent; update instance_count in the infra.container_service module config and re-run step.iac_apply", +} + +// IsModuleType reports whether t is a removed legacy DO module type. +func IsModuleType(t string) bool { _, ok := ModuleTypes[t]; return ok } + +// IsStepType reports whether t is a removed legacy DO step type. +func IsStepType(t string) bool { _, ok := StepTypes[t]; return ok } + +// FormatModuleError builds the actionable migration error for a legacy +// DO module type. iacProviderLoaded indicates whether the iac.provider factory +// is registered in the engine — used to branch between the "install plugin" +// and "config-only issue" messages. +func FormatModuleError(legacyType, moduleName string, iacProviderLoaded bool) error { + successor, ok := ModuleTypes[legacyType] + if !ok { + return nil + } + pluginLine := "Install workflow-plugin-digitalocean: https://github.com/GoCodeAlone/workflow-plugin-digitalocean" + if iacProviderLoaded { + pluginLine = "workflow-plugin-digitalocean is already loaded; your config still references the legacy module name." + } + var b strings.Builder + fmt.Fprintf(&b, "unsupported legacy module type %q (module %q): this type was removed from workflow core in %s — DigitalOcean IaC moved to workflow-plugin-digitalocean.\n\n", legacyType, moduleName, RemovedInVersion) + b.WriteString(pluginLine) + b.WriteString("\n\nMigrate this module to: ") + b.WriteString(successor) + b.WriteString(" (provider: digitalocean)\n\nFull mapping:\n") + keys := make([]string, 0, len(ModuleTypes)) + for k := range ModuleTypes { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&b, " %s → %s\n", k, ModuleTypes[k]) + } + b.WriteString("\nSee docs/migrations/v0.52.0-godo-removal.md") + return fmt.Errorf("%s", b.String()) +} + +// FormatStepError builds the actionable migration error for a legacy +// DO step type. +func FormatStepError(legacyType string, iacProviderLoaded bool) error { + successor, ok := StepTypes[legacyType] + if !ok { + return nil + } + pluginLine := "Install workflow-plugin-digitalocean: https://github.com/GoCodeAlone/workflow-plugin-digitalocean" + if iacProviderLoaded { + pluginLine = "workflow-plugin-digitalocean is already loaded; your config still references the legacy step name." + } + var b strings.Builder + fmt.Fprintf(&b, "unsupported legacy step type %q: this step was removed from workflow core in %s — DigitalOcean IaC moved to workflow-plugin-digitalocean.\n\n", legacyType, RemovedInVersion) + b.WriteString(pluginLine) + b.WriteString("\n\nMigrate this step to: ") + b.WriteString(successor) + b.WriteString("\n\nSee docs/migrations/v0.52.0-godo-removal.md") + return fmt.Errorf("%s", b.String()) +} diff --git a/modernize/legacy_do_rule.go b/modernize/legacy_do_rule.go new file mode 100644 index 00000000..5adcc248 --- /dev/null +++ b/modernize/legacy_do_rule.go @@ -0,0 +1,148 @@ +package modernize + +import ( + "fmt" + + "github.com/GoCodeAlone/workflow/internal/legacydo" + "gopkg.in/yaml.v3" +) + +// Import note: `modernize` MUST NOT import `module` directly. `module` +// transitively imports `modernize` via `plugin` (plugin/manifest.go + +// plugin/engine_plugin.go), so `modernize → module` creates an import cycle. +// Shared constants live in `internal/legacydo`, a leaf package that imports +// only stdlib and is safe for both `module` and `modernize` to consume. + +// legacyDORule flags legacy DigitalOcean module + step types and rewrites +// module types to their infra.* IaC successors (issue #617). +// +// IMPORTANT: The Fix function ONLY renames the `type:` key for module types +// — it does NOT inject the required `config.provider: digitalocean` setting, +// because that requires modifying a sibling mapping that may already contain +// unrelated keys the operator must review. The rule's Check Message and the +// migration guide both instruct the operator to add the provider key manually +// after running modernize. The `testdata/legacy-do-config.expected.yaml` +// fixture documents the post-modernize shape: module types renamed, provider NOT +// auto-added, step types unchanged (non-fixable). Adding provider injection in +// a future iteration is tracked as a follow-up (see migration guide). +// +// Step types (step.do_deploy/status/destroy) are flagged but NOT auto-rewritten +// because step.iac_apply/status/destroy require different config keys +// (platform + state_store) rather than the legacy app: key. Auto-rewriting +// the type alone produces an invalid config. The operator must rewrite step +// config manually per the migration guide (docs/migrations/v0.52.0-godo-removal.md). +// +// Auto-fixable: 4 of 5 modules (platform.do_app/database/dns/doks). +// Not auto-fixable: platform.do_networking (1→2 split), all 5 step types +// (step.do_deploy/status/destroy config shape mismatch; step.do_logs/scale +// have no pipeline-step successor). +func legacyDORule() Rule { + moduleMap := map[string]string{ + "platform.do_app": "infra.container_service", + "platform.do_database": "infra.database", + "platform.do_dns": "infra.dns", + "platform.doks": "infra.k8s_cluster", + // platform.do_networking is intentionally NOT auto-fixed: it splits + // 1→2 (infra.vpc + infra.firewall), which requires structural + // rewrite the operator must review. + } + // stepMap holds the successor type name for the migration error message only. + // These findings are NOT auto-fixable: step.iac_apply/status/destroy require + // different config keys (platform + state_store) vs the legacy app: key, so + // rewriting type alone would produce an invalid config. + stepMap := map[string]string{ + "step.do_deploy": "step.iac_apply", + "step.do_status": "step.iac_status", + "step.do_destroy": "step.iac_destroy", + } + gapTypes := map[string]string{ + "platform.do_networking": "splits into infra.vpc + infra.firewall — manual rewrite required", + "step.do_logs": "no pipeline-step successor; use `wfctl infra logs` or rely on DO plugin Troubleshoot", + "step.do_scale": "no pipeline-step successor; edit instance_count and re-run step.iac_apply", + } + + return Rule{ + ID: "legacy-do-types", + Description: "Rewrite legacy DigitalOcean module/step types to infra.* IaC successors (issue #617).", + Severity: "error", + Check: func(root *yaml.Node, raw []byte) []Finding { + var out []Finding + walkTypeNodes(root, func(typeVal *yaml.Node) { + if successor, ok := moduleMap[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in %s; rewrite to %s (provider: digitalocean) — requires workflow-plugin-digitalocean", typeVal.Value, legacydo.RemovedInVersion, successor), + Fixable: true, + }) + } + if successor, ok := stepMap[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + // Fixable is false: step.iac_apply/status/destroy require + // different config keys (platform + state_store) compared + // to the legacy app: key. Rewriting the type alone would + // produce an invalid config. Operator must rewrite manually. + Message: fmt.Sprintf("%s removed in %s; manually rewrite to %s with config keys platform + state_store (see docs/migrations/v0.52.0-godo-removal.md) — requires workflow-plugin-digitalocean", typeVal.Value, legacydo.RemovedInVersion, successor), + Fixable: false, + }) + } + if reason, ok := gapTypes[typeVal.Value]; ok { + out = append(out, Finding{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Message: fmt.Sprintf("%s removed in %s — %s", typeVal.Value, legacydo.RemovedInVersion, reason), + Fixable: false, + }) + } + }) + return out + }, + Fix: func(root *yaml.Node) []Change { + var out []Change + walkTypeNodes(root, func(typeVal *yaml.Node) { + if successor, ok := moduleMap[typeVal.Value]; ok { + old := typeVal.Value + typeVal.Value = successor + out = append(out, Change{ + RuleID: "legacy-do-types", + Line: typeVal.Line, + Description: fmt.Sprintf("rewrote %s → %s", old, successor), + }) + } + // stepMap types are intentionally NOT rewritten: step.iac_apply/ + // status/destroy require different config keys (platform + + // state_store) vs the legacy app: key. Auto-rewriting the type + // alone produces an invalid config; operator must rewrite manually. + // gapTypes are also intentionally not modified. + }) + return out + }, + } +} + +// walkTypeNodes traverses a YAML AST and invokes visit on every value node +// whose parent mapping key is "type". This differs from the package's existing +// walkNodes helper which visits every node — extracted as a separate helper +// because the type-key constraint produces tighter visitor code at call sites. +// If a future refactor unifies the two, prefer adding a key-filter parameter +// to walkNodes over keeping the duplication. +func walkTypeNodes(n *yaml.Node, visit func(*yaml.Node)) { + if n == nil { + return + } + if n.Kind == yaml.MappingNode { + for i := 0; i+1 < len(n.Content); i += 2 { + k, v := n.Content[i], n.Content[i+1] + if k.Value == "type" && v.Kind == yaml.ScalarNode { + visit(v) + } + walkTypeNodes(v, visit) + } + return + } + for _, c := range n.Content { + walkTypeNodes(c, visit) + } +} diff --git a/modernize/legacy_do_rule_test.go b/modernize/legacy_do_rule_test.go new file mode 100644 index 00000000..d2acf452 --- /dev/null +++ b/modernize/legacy_do_rule_test.go @@ -0,0 +1,112 @@ +package modernize + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestLegacyDORule_Rewrites(t *testing.T) { + cases := []struct { + name string + yamlIn string + wantNew string // must appear in fixed YAML + wantDrop string // must NOT appear in fixed YAML (the legacy type) + }{ + { + name: "platform.do_app → infra.container_service (provider NOT auto-injected)", + yamlIn: "modules:\n - name: api\n type: platform.do_app\n config:\n region: nyc\n", + wantNew: "infra.container_service", + wantDrop: "platform.do_app", + }, + { + name: "platform.do_database → infra.database", + yamlIn: "modules:\n - name: db\n type: platform.do_database\n config: {}\n", + wantNew: "infra.database", + wantDrop: "platform.do_database", + }, + { + name: "platform.do_dns → infra.dns", + yamlIn: "modules:\n - name: dns\n type: platform.do_dns\n config: {}\n", + wantNew: "infra.dns", + wantDrop: "platform.do_dns", + }, + { + name: "platform.doks → infra.k8s_cluster", + yamlIn: "modules:\n - name: k8s\n type: platform.doks\n config: {}\n", + wantNew: "infra.k8s_cluster", + wantDrop: "platform.doks", + }, + } + rule := legacyDORule() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var root yaml.Node + if err := yaml.Unmarshal([]byte(tc.yamlIn), &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } + findings := rule.Check(&root, []byte(tc.yamlIn)) + if len(findings) == 0 { + t.Fatalf("expected a finding, got 0") + } + rule.Fix(&root) + out, err := yaml.Marshal(&root) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(out) + if !strings.Contains(s, tc.wantNew) { + t.Errorf("fixed YAML missing %q; got:\n%s", tc.wantNew, s) + } + if strings.Contains(s, tc.wantDrop) { + t.Errorf("fixed YAML still contains legacy %q; got:\n%s", tc.wantDrop, s) + } + }) + } +} + +func TestLegacyDORule_GapTypesFlaggedNotRewritten(t *testing.T) { + // Non-fixable types: the rule must flag them as findings (Fixable: false) + // and must NOT modify the YAML after Fix() runs. + // + // Includes: + // - step.do_logs/scale: no 1:1 pipeline-step successor (GAP types). + // - platform.do_networking: splits 1→2, manual rewrite required. + // - step.do_deploy/status/destroy: step.iac_apply/status/destroy require + // different config keys (platform + state_store vs legacy app:), so + // auto-rewriting the type alone produces an invalid config. + cases := []struct { + name string + legacy string + yamlIn string + }{ + {"step.do_logs", "step.do_logs", "pipelines:\n - steps:\n - type: step.do_logs\n"}, + {"step.do_scale", "step.do_scale", "pipelines:\n - steps:\n - type: step.do_scale\n"}, + {"platform.do_networking", "platform.do_networking", "modules:\n - name: net\n type: platform.do_networking\n config: {}\n"}, + {"step.do_deploy", "step.do_deploy", "pipelines:\n - steps:\n - type: step.do_deploy\n config:\n app: api\n"}, + {"step.do_status", "step.do_status", "pipelines:\n - steps:\n - type: step.do_status\n config:\n app: api\n"}, + {"step.do_destroy", "step.do_destroy", "pipelines:\n - steps:\n - type: step.do_destroy\n config:\n app: api\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var root yaml.Node + if err := yaml.Unmarshal([]byte(tc.yamlIn), &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } + rule := legacyDORule() + findings := rule.Check(&root, []byte(tc.yamlIn)) + if len(findings) == 0 { + t.Fatalf("expected a finding for %q", tc.legacy) + } + if findings[0].Fixable { + t.Errorf("%q must be marked Fixable: false (no auto-rewrite); got Fixable: true", tc.legacy) + } + rule.Fix(&root) + out, _ := yaml.Marshal(&root) + if !strings.Contains(string(out), tc.legacy) { + t.Errorf("Fix MUST NOT remove legacy %q; got:\n%s", tc.legacy, out) + } + }) + } +} diff --git a/modernize/modernize.go b/modernize/modernize.go index 0c90a0f9..bba531f4 100644 --- a/modernize/modernize.go +++ b/modernize/modernize.go @@ -43,6 +43,7 @@ func AllRules() []Rule { emptyRoutesRule(), camelCaseConfigRule(), requestParseConfigRule(), + legacyDORule(), } } diff --git a/modernize/testdata/legacy-do-config.expected.yaml b/modernize/testdata/legacy-do-config.expected.yaml new file mode 100644 index 00000000..f1d8c586 --- /dev/null +++ b/modernize/testdata/legacy-do-config.expected.yaml @@ -0,0 +1,45 @@ +modules: + - name: api + type: infra.container_service + config: + region: nyc + - name: db + type: infra.database + config: + engine: pg + size: db-s-1vcpu-1gb + - name: dns + type: infra.dns + config: + domain: example.com + - name: net + type: platform.do_networking + config: + vpc_cidr: 10.0.0.0/16 + - name: k8s + type: infra.k8s_cluster + config: + region: nyc3 + node_pool: + size: s-2vcpu-4gb + count: 3 + +pipelines: + deploy: + steps: + - type: step.do_deploy + config: + app: api + - type: step.do_status + config: + app: api + - type: step.do_destroy + config: + app: api + - type: step.do_logs + config: + app: api + - type: step.do_scale + config: + app: api + instance_count: 5 diff --git a/modernize/testdata/legacy-do-config.yaml b/modernize/testdata/legacy-do-config.yaml new file mode 100644 index 00000000..1772cd2a --- /dev/null +++ b/modernize/testdata/legacy-do-config.yaml @@ -0,0 +1,45 @@ +modules: + - name: api + type: platform.do_app + config: + region: nyc + - name: db + type: platform.do_database + config: + engine: pg + size: db-s-1vcpu-1gb + - name: dns + type: platform.do_dns + config: + domain: example.com + - name: net + type: platform.do_networking + config: + vpc_cidr: 10.0.0.0/16 + - name: k8s + type: platform.doks + config: + region: nyc3 + node_pool: + size: s-2vcpu-4gb + count: 3 + +pipelines: + deploy: + steps: + - type: step.do_deploy + config: + app: api + - type: step.do_status + config: + app: api + - type: step.do_destroy + config: + app: api + - type: step.do_logs + config: + app: api + - type: step.do_scale + config: + app: api + instance_count: 5 diff --git a/module/cloud_account_do.go b/module/cloud_account_do.go deleted file mode 100644 index 12748c1c..00000000 --- a/module/cloud_account_do.go +++ /dev/null @@ -1,74 +0,0 @@ -package module - -import ( - "context" - "fmt" - "os" - - "github.com/digitalocean/godo" - "golang.org/x/oauth2" -) - -func init() { - RegisterCredentialResolver(&doStaticResolver{}) - RegisterCredentialResolver(&doEnvResolver{}) - RegisterCredentialResolver(&doAPITokenResolver{}) -} - -// doStaticResolver resolves DigitalOcean credentials from static config fields. -type doStaticResolver struct{} - -func (r *doStaticResolver) Provider() string { return "digitalocean" } -func (r *doStaticResolver) CredentialType() string { return "static" } - -func (r *doStaticResolver) Resolve(m *CloudAccount) error { - credsMap, _ := m.config["credentials"].(map[string]any) - if credsMap != nil { - m.creds.Token, _ = credsMap["token"].(string) - } - return nil -} - -// doEnvResolver resolves DigitalOcean credentials from environment variables. -type doEnvResolver struct{} - -func (r *doEnvResolver) Provider() string { return "digitalocean" } -func (r *doEnvResolver) CredentialType() string { return "env" } - -func (r *doEnvResolver) Resolve(m *CloudAccount) error { - m.creds.Token = os.Getenv("DIGITALOCEAN_TOKEN") - if m.creds.Token == "" { - m.creds.Token = os.Getenv("DO_TOKEN") - } - return nil -} - -// doAPITokenResolver resolves a DigitalOcean API token from explicit config. -type doAPITokenResolver struct{} - -func (r *doAPITokenResolver) Provider() string { return "digitalocean" } -func (r *doAPITokenResolver) CredentialType() string { return "api_token" } - -func (r *doAPITokenResolver) Resolve(m *CloudAccount) error { - credsMap, _ := m.config["credentials"].(map[string]any) - if credsMap == nil { - return fmt.Errorf("api_token credential requires 'token'") - } - token, _ := credsMap["token"].(string) - if token == "" { - return fmt.Errorf("api_token credential requires 'token'") - } - m.creds.Token = token - return nil -} - -// doClient returns a configured *godo.Client using the Token credential. -// The caller must have resolved credentials with provider=digitalocean before calling this. -func (m *CloudAccount) doClient() (*godo.Client, error) { - if m.creds == nil || m.creds.Token == "" { - return nil, fmt.Errorf("cloud.account %q: DigitalOcean token not set", m.name) - } - ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: m.creds.Token}) - httpClient := oauth2.NewClient(context.Background(), ts) - return godo.NewClient(httpClient), nil -} diff --git a/module/godo_absent_test.go b/module/godo_absent_test.go new file mode 100644 index 00000000..e86dcd25 --- /dev/null +++ b/module/godo_absent_test.go @@ -0,0 +1,41 @@ +package module_test + +import ( + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" +) + +// TestGodoNotImported_InModulePackage asserts no file under module/ (including +// subdirectories) imports github.com/digitalocean/godo. This is the regression +// gate for issue #617. +func TestGodoNotImported_InModulePackage(t *testing.T) { + var files []string + err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.HasSuffix(path, ".go") { + files = append(files, path) + } + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } + fset := token.NewFileSet() + for _, f := range files { + af, err := parser.ParseFile(fset, f, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", f, err) + } + for _, imp := range af.Imports { + if strings.Trim(imp.Path.Value, `"`) == "github.com/digitalocean/godo" { + t.Errorf("%s imports github.com/digitalocean/godo (issue #617 — moved to workflow-plugin-digitalocean)", f) + } + } + } +} diff --git a/module/multi_region.go b/module/multi_region.go index 725e0e08..e29082ca 100644 --- a/module/multi_region.go +++ b/module/multi_region.go @@ -120,7 +120,7 @@ func (m *MultiRegionModule) Init(app modular.Application) error { case "azure": return fmt.Errorf("platform.region %q: provider %q is not yet supported; use AKS modules with Azure Traffic Manager for multi-region routing", m.name, providerType) case "digitalocean": - return fmt.Errorf("platform.region %q: provider %q is not yet supported; use platform.doks modules per region for DigitalOcean multi-region deployments", m.name, providerType) + return fmt.Errorf("platform.region %q: provider %q is not yet supported; for DigitalOcean multi-region, use infra.k8s_cluster modules per region with provider: digitalocean (requires workflow-plugin-digitalocean)", m.name, providerType) default: return fmt.Errorf("platform.region %q: unsupported provider %q (supported: mock)", m.name, providerType) } diff --git a/module/pipeline_step_do.go b/module/pipeline_step_do.go deleted file mode 100644 index 7340c479..00000000 --- a/module/pipeline_step_do.go +++ /dev/null @@ -1,220 +0,0 @@ -package module - -import ( - "context" - "fmt" - - "github.com/GoCodeAlone/modular" -) - -// ─── do_deploy ──────────────────────────────────────────────────────────────── - -// DODeployStep deploys an app to DigitalOcean App Platform. -type DODeployStep struct { - name string - app string - svc modular.Application -} - -// NewDODeployStepFactory returns a StepFactory for step.do_deploy. -func NewDODeployStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - appName, _ := cfg["app"].(string) - if appName == "" { - return nil, fmt.Errorf("do_deploy step %q: 'app' is required", name) - } - return &DODeployStep{name: name, app: appName, svc: app}, nil - } -} - -func (s *DODeployStep) Name() string { return s.name } - -func (s *DODeployStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - m, err := resolveDOAppModule(s.svc, s.app, s.name) - if err != nil { - return nil, err - } - state, err := m.Deploy() - if err != nil { - return nil, fmt.Errorf("do_deploy step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "app": s.app, - "id": state.ID, - "status": state.Status, - "live_url": state.LiveURL, - "deployment_id": state.DeploymentID, - }}, nil -} - -// ─── do_status ──────────────────────────────────────────────────────────────── - -// DOStatusStep checks the status of a DO App Platform app. -type DOStatusStep struct { - name string - app string - svc modular.Application -} - -// NewDOStatusStepFactory returns a StepFactory for step.do_status. -func NewDOStatusStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - appName, _ := cfg["app"].(string) - if appName == "" { - return nil, fmt.Errorf("do_status step %q: 'app' is required", name) - } - return &DOStatusStep{name: name, app: appName, svc: app}, nil - } -} - -func (s *DOStatusStep) Name() string { return s.name } - -func (s *DOStatusStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - m, err := resolveDOAppModule(s.svc, s.app, s.name) - if err != nil { - return nil, err - } - state, err := m.Status() - if err != nil { - return nil, fmt.Errorf("do_status step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "app": s.app, - "status": state.Status, - "live_url": state.LiveURL, - "state": state, - }}, nil -} - -// ─── do_logs ────────────────────────────────────────────────────────────────── - -// DOLogsStep retrieves logs from a DO App Platform app. -type DOLogsStep struct { - name string - app string - svc modular.Application -} - -// NewDOLogsStepFactory returns a StepFactory for step.do_logs. -func NewDOLogsStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - appName, _ := cfg["app"].(string) - if appName == "" { - return nil, fmt.Errorf("do_logs step %q: 'app' is required", name) - } - return &DOLogsStep{name: name, app: appName, svc: app}, nil - } -} - -func (s *DOLogsStep) Name() string { return s.name } - -func (s *DOLogsStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - m, err := resolveDOAppModule(s.svc, s.app, s.name) - if err != nil { - return nil, err - } - logs, err := m.Logs() - if err != nil { - return nil, fmt.Errorf("do_logs step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "app": s.app, - "logs": logs, - }}, nil -} - -// ─── do_scale ───────────────────────────────────────────────────────────────── - -// DOScaleStep scales a DO App Platform app. -type DOScaleStep struct { - name string - app string - instances int - svc modular.Application -} - -// NewDOScaleStepFactory returns a StepFactory for step.do_scale. -func NewDOScaleStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - appName, _ := cfg["app"].(string) - if appName == "" { - return nil, fmt.Errorf("do_scale step %q: 'app' is required", name) - } - instances, _ := intFromAny(cfg["instances"]) - if instances <= 0 { - instances = 1 - } - return &DOScaleStep{name: name, app: appName, instances: instances, svc: app}, nil - } -} - -func (s *DOScaleStep) Name() string { return s.name } - -func (s *DOScaleStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - m, err := resolveDOAppModule(s.svc, s.app, s.name) - if err != nil { - return nil, err - } - state, err := m.Scale(s.instances) - if err != nil { - return nil, fmt.Errorf("do_scale step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "app": s.app, - "instances": s.instances, - "status": state.Status, - }}, nil -} - -// ─── do_destroy ─────────────────────────────────────────────────────────────── - -// DODestroyStep tears down a DO App Platform app. -type DODestroyStep struct { - name string - app string - svc modular.Application -} - -// NewDODestroyStepFactory returns a StepFactory for step.do_destroy. -func NewDODestroyStepFactory() StepFactory { - return func(name string, cfg map[string]any, app modular.Application) (PipelineStep, error) { - appName, _ := cfg["app"].(string) - if appName == "" { - return nil, fmt.Errorf("do_destroy step %q: 'app' is required", name) - } - return &DODestroyStep{name: name, app: appName, svc: app}, nil - } -} - -func (s *DODestroyStep) Name() string { return s.name } - -func (s *DODestroyStep) Execute(_ context.Context, _ *PipelineContext) (*StepResult, error) { - m, err := resolveDOAppModule(s.svc, s.app, s.name) - if err != nil { - return nil, err - } - if err := m.Destroy(); err != nil { - return nil, fmt.Errorf("do_destroy step %q: %w", s.name, err) - } - return &StepResult{Output: map[string]any{ - "app": s.app, - "destroyed": true, - }}, nil -} - -// ─── helpers ────────────────────────────────────────────────────────────────── - -func resolveDOAppModule(app modular.Application, appName, stepName string) (*PlatformDOApp, error) { - if app == nil { - return nil, fmt.Errorf("step %q: no application context", stepName) - } - svc, ok := app.SvcRegistry()[appName] - if !ok { - return nil, fmt.Errorf("step %q: app %q not found in registry", stepName, appName) - } - m, ok := svc.(*PlatformDOApp) - if !ok { - return nil, fmt.Errorf("step %q: app %q is not a *PlatformDOApp (got %T)", stepName, appName, svc) - } - return m, nil -} diff --git a/module/pipeline_step_legacy_do_migration_test.go b/module/pipeline_step_legacy_do_migration_test.go new file mode 100644 index 00000000..2163aacc --- /dev/null +++ b/module/pipeline_step_legacy_do_migration_test.go @@ -0,0 +1,57 @@ +package module_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/module" +) + +func TestLegacyDOStepError_PluginNotLoaded(t *testing.T) { + // step.do_logs / step.do_scale have GAP messages; the others map 1:1 to step.iac_*. + cases := []struct{ step, mustContain string }{ + {"step.do_deploy", "step.iac_apply"}, + {"step.do_status", "step.iac_status"}, + {"step.do_destroy", "step.iac_destroy"}, + {"step.do_logs", "wfctl infra logs"}, + {"step.do_scale", "instance_count"}, + } + for _, tc := range cases { + t.Run(tc.step, func(t *testing.T) { + r := module.NewStepRegistry() // fresh registry — iacProviderLoaded defaults to false + _, err := r.Create(tc.step, "x", map[string]any{}, nil) + if err == nil { + t.Fatalf("expected error for %q", tc.step) + } + msg := err.Error() + for _, want := range []string{ + "removed from workflow core", + "workflow-plugin-digitalocean", + "Install workflow-plugin-digitalocean", + tc.mustContain, + } { + if !strings.Contains(msg, want) { + t.Errorf("error for %q missing %q; got: %s", tc.step, want, msg) + } + } + }) + } +} + +func TestLegacyDOStepError_PluginLoaded(t *testing.T) { + // Symmetric to TestLegacyDOModuleError_PluginLoaded — flips the per-registry + // flag and confirms the step guard's "already loaded" branch fires. + r := module.NewStepRegistry() + r.SetIaCProviderLoaded(true) + _, err := r.Create("step.do_deploy", "x", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "already loaded") { + t.Errorf("plugin-loaded branch must say 'already loaded'; got: %s", msg) + } + if strings.Contains(msg, "Install workflow-plugin-digitalocean") { + t.Errorf("plugin-loaded branch must NOT instruct install; got: %s", msg) + } +} diff --git a/module/pipeline_step_registry.go b/module/pipeline_step_registry.go index e5468d10..50d3a3b4 100644 --- a/module/pipeline_step_registry.go +++ b/module/pipeline_step_registry.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/internal/legacydo" ) // StepFactory creates a PipelineStep from its name and config. @@ -11,7 +12,8 @@ type StepFactory func(name string, config map[string]any, app modular.Applicatio // StepRegistry maps step type strings to factory functions. type StepRegistry struct { - factories map[string]StepFactory + factories map[string]StepFactory + iacProviderLoaded bool // set by SetIaCProviderLoaded; consumed by Create } // NewStepRegistry creates an empty StepRegistry. @@ -26,12 +28,23 @@ func (r *StepRegistry) Register(stepType string, factory StepFactory) { r.factories[stepType] = factory } +// SetIaCProviderLoaded is called by the engine after module factory registration +// is complete and before pipeline construction. Per-registry state — no global — +// so parallel test runs that build independent StepRegistry instances do not +// share or race the flag. +func (r *StepRegistry) SetIaCProviderLoaded(loaded bool) { + r.iacProviderLoaded = loaded +} + // Create instantiates a PipelineStep of the given type. // app must be a modular.Application; it is typed as any to satisfy // the interfaces.StepRegistrar interface without an import cycle. func (r *StepRegistry) Create(stepType, name string, config map[string]any, app any) (PipelineStep, error) { factory, ok := r.factories[stepType] if !ok { + if legacydo.IsStepType(stepType) { + return nil, legacydo.FormatStepError(stepType, r.iacProviderLoaded) + } return nil, fmt.Errorf("unknown step type: %s", stepType) } a, _ := app.(modular.Application) diff --git a/module/platform_do_app.go b/module/platform_do_app.go deleted file mode 100644 index f2f5ec24..00000000 --- a/module/platform_do_app.go +++ /dev/null @@ -1,430 +0,0 @@ -package module - -import ( - "context" - "fmt" - "time" - - "github.com/GoCodeAlone/modular" - "github.com/digitalocean/godo" -) - -// DOAppState holds the current state of a DigitalOcean App Platform app. -type DOAppState struct { - ID string `json:"id"` - Name string `json:"name"` - Region string `json:"region"` - Status string `json:"status"` // pending, deploying, running, error, deleted - LiveURL string `json:"liveUrl"` - Instances int `json:"instances"` - Image string `json:"image"` - DeployedAt time.Time `json:"deployedAt"` - DeploymentID string `json:"deploymentId"` -} - -// doAppBackend is the interface DO App Platform backends implement. -type doAppBackend interface { - deploy(m *PlatformDOApp) (*DOAppState, error) - status(m *PlatformDOApp) (*DOAppState, error) - logs(m *PlatformDOApp) (string, error) - scale(m *PlatformDOApp, instances int) (*DOAppState, error) - destroy(m *PlatformDOApp) error -} - -// PlatformDOApp manages DigitalOcean App Platform applications. -// Config: -// -// account: name of a cloud.account module (provider=digitalocean) -// provider: digitalocean | mock -// name: app name -// region: DO region slug (e.g. nyc) -// image: container image reference -// instances: number of instances (default: 1) -// http_port: container HTTP port (default: 8080) -// envs: environment variables map -type PlatformDOApp struct { - name string - config map[string]any - provider CloudCredentialProvider - state *DOAppState - backend doAppBackend -} - -// NewPlatformDOApp creates a new PlatformDOApp module. -func NewPlatformDOApp(name string, cfg map[string]any) *PlatformDOApp { - return &PlatformDOApp{name: name, config: cfg} -} - -// Name returns the module name. -func (m *PlatformDOApp) Name() string { return m.name } - -// Init resolves the cloud.account service and initializes the backend. -func (m *PlatformDOApp) Init(app modular.Application) error { - appName, _ := m.config["name"].(string) - if appName == "" { - appName = m.name - } - - region, _ := m.config["region"].(string) - if region == "" { - region = "nyc" - } - - image, _ := m.config["image"].(string) - - instances, _ := intFromAny(m.config["instances"]) - if instances == 0 { - instances = 1 - } - - accountName, _ := m.config["account"].(string) - providerType, _ := m.config["provider"].(string) - if providerType == "" { - providerType = "mock" - } - - if accountName != "" { - svc, ok := app.SvcRegistry()[accountName] - if !ok { - return fmt.Errorf("platform.do_app %q: account service %q not found", m.name, accountName) - } - prov, ok := svc.(CloudCredentialProvider) - if !ok { - return fmt.Errorf("platform.do_app %q: service %q does not implement CloudCredentialProvider", m.name, accountName) - } - m.provider = prov - if providerType == "mock" { - providerType = prov.Provider() - } - } - - m.state = &DOAppState{ - Name: appName, - Region: region, - Image: image, - Instances: instances, - Status: "pending", - } - - switch providerType { - case "mock": - m.backend = &doAppMockBackend{} - case "digitalocean": - acc, ok := app.SvcRegistry()[accountName].(*CloudAccount) - if !ok { - return fmt.Errorf("platform.do_app %q: account %q is not a *CloudAccount", m.name, accountName) - } - client, err := acc.doClient() - if err != nil { - return fmt.Errorf("platform.do_app %q: %w", m.name, err) - } - m.backend = &doAppRealBackend{client: client} - default: - return fmt.Errorf("platform.do_app %q: unsupported provider %q", m.name, providerType) - } - - if err := app.RegisterService(m.name, m); err != nil { - return err - } - return app.RegisterService(m.name+".iac", &DOAppPlatformAdapter{m}) -} - -// ProvidesServices declares the service this module provides. -func (m *PlatformDOApp) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "DO App: " + m.name, Instance: m}, - {Name: m.name + ".iac", Description: "DO App IaC adapter: " + m.name, Instance: &DOAppPlatformAdapter{m}}, - } -} - -// RequiresServices returns nil. -func (m *PlatformDOApp) RequiresServices() []modular.ServiceDependency { return nil } - -// Deploy deploys the application to App Platform. -func (m *PlatformDOApp) Deploy() (*DOAppState, error) { return m.backend.deploy(m) } - -// Status returns the current app deployment state. -func (m *PlatformDOApp) Status() (*DOAppState, error) { return m.backend.status(m) } - -// Logs retrieves recent application logs. -func (m *PlatformDOApp) Logs() (string, error) { return m.backend.logs(m) } - -// Scale sets the number of app instances. -func (m *PlatformDOApp) Scale(instances int) (*DOAppState, error) { - return m.backend.scale(m, instances) -} - -// Destroy tears down the application. -func (m *PlatformDOApp) Destroy() error { return m.backend.destroy(m) } - -// envVars parses environment variable config. -func (m *PlatformDOApp) envVars() map[string]string { - result := make(map[string]string) - raw, ok := m.config["envs"].(map[string]any) - if !ok { - return result - } - for k, v := range raw { - if s, ok := v.(string); ok { - result[k] = s - } - } - return result -} - -// httpPort returns the configured HTTP port. -func (m *PlatformDOApp) httpPort() int { - if p, ok := intFromAny(m.config["http_port"]); ok && p > 0 { - return p - } - return 8080 -} - -// buildAppSpec constructs a godo AppSpec from module config. -func (m *PlatformDOApp) buildAppSpec() *godo.AppSpec { - envs := m.envVars() - var appEnvs []*godo.AppVariableDefinition - for k, v := range envs { - appEnvs = append(appEnvs, &godo.AppVariableDefinition{ - Key: k, - Value: v, - Scope: godo.AppVariableScope_RunTime, - }) - } - - return &godo.AppSpec{ - Name: m.state.Name, - Region: m.state.Region, - Services: []*godo.AppServiceSpec{ - { - Name: m.state.Name, - Image: &godo.ImageSourceSpec{ - RegistryType: godo.ImageSourceSpecRegistryType_DockerHub, - Repository: m.state.Image, - }, - InstanceCount: int64(m.state.Instances), - InstanceSizeSlug: "basic-xxs", - HTTPPort: int64(m.httpPort()), - Envs: appEnvs, - }, - }, - } -} - -// ─── PlatformProvider adapter ────────────────────────────────────────────────── - -// DOAppPlatformAdapter wraps PlatformDOApp to implement PlatformProvider. -type DOAppPlatformAdapter struct { - *PlatformDOApp -} - -// Plan implements PlatformProvider. Returns a plan based on current state. -func (a *DOAppPlatformAdapter) Plan() (*PlatformPlan, error) { - actionType := "create" - detail := fmt.Sprintf("Deploy app %s to region %s (image: %s, instances: %d)", - a.state.Name, a.state.Region, a.state.Image, a.state.Instances) - if a.state.ID != "" { - actionType = "update" - detail = fmt.Sprintf("Update app %s (image: %s, instances: %d)", - a.state.Name, a.state.Image, a.state.Instances) - } - return &PlatformPlan{ - Provider: "digitalocean", - Resource: "app_platform", - Actions: []PlatformAction{ - {Type: actionType, Resource: a.state.Name, Detail: detail}, - }, - }, nil -} - -// Apply implements PlatformProvider. Deploys via the backend. -func (a *DOAppPlatformAdapter) Apply() (*PlatformResult, error) { - st, err := a.Deploy() - if err != nil { - return &PlatformResult{Success: false, Message: err.Error()}, err - } - return &PlatformResult{ - Success: true, - Message: fmt.Sprintf("App %s deployed (id: %s, url: %s)", st.Name, st.ID, st.LiveURL), - State: st, - }, nil -} - -// Status implements PlatformProvider. -func (a *DOAppPlatformAdapter) Status() (any, error) { - return a.PlatformDOApp.Status() -} - -// Destroy implements PlatformProvider. -func (a *DOAppPlatformAdapter) Destroy() error { - return a.PlatformDOApp.Destroy() -} - -// ─── mock backend ────────────────────────────────────────────────────────────── - -type doAppMockBackend struct{} - -func (b *doAppMockBackend) deploy(m *PlatformDOApp) (*DOAppState, error) { - m.state.ID = fmt.Sprintf("mock-app-%s", m.state.Name) - m.state.DeploymentID = fmt.Sprintf("mock-deploy-%s-%d", m.state.Name, time.Now().Unix()) - m.state.LiveURL = fmt.Sprintf("https://%s.ondigitalocean.app", m.state.Name) - m.state.Status = "running" - m.state.DeployedAt = time.Now() - return m.state, nil -} - -func (b *doAppMockBackend) status(m *PlatformDOApp) (*DOAppState, error) { - return m.state, nil -} - -func (b *doAppMockBackend) logs(m *PlatformDOApp) (string, error) { - if m.state.Status == "pending" { - return "", fmt.Errorf("do_app %q: app not deployed", m.state.Name) - } - return fmt.Sprintf("[mock] %s: app running on %s with %d instance(s)", m.state.Name, m.state.LiveURL, m.state.Instances), nil -} - -func (b *doAppMockBackend) scale(m *PlatformDOApp, instances int) (*DOAppState, error) { - m.state.Instances = instances - return m.state, nil -} - -func (b *doAppMockBackend) destroy(m *PlatformDOApp) error { - if m.state.Status == "deleted" { - return nil - } - m.state.Status = "deleted" - m.state.LiveURL = "" - return nil -} - -// ─── real backend ────────────────────────────────────────────────────────────── - -type doAppRealBackend struct { - client *godo.Client -} - -func (b *doAppRealBackend) deploy(m *PlatformDOApp) (*DOAppState, error) { - spec := m.buildAppSpec() - - if m.state.ID != "" { - // Update existing app. - updated, _, err := b.client.Apps.Update(context.Background(), m.state.ID, &godo.AppUpdateRequest{Spec: spec}) - if err != nil { - return nil, fmt.Errorf("do_app update: %w", err) - } - return doAppToState(updated), nil - } - - // Create new app. - created, _, err := b.client.Apps.Create(context.Background(), &godo.AppCreateRequest{Spec: spec}) - if err != nil { - return nil, fmt.Errorf("do_app create: %w", err) - } - state := doAppToState(created) - m.state.ID = state.ID - m.state.LiveURL = state.LiveURL - m.state.Status = state.Status - m.state.DeployedAt = state.DeployedAt - return m.state, nil -} - -func (b *doAppRealBackend) status(m *PlatformDOApp) (*DOAppState, error) { - if m.state.ID == "" { - return m.state, nil - } - a, _, err := b.client.Apps.Get(context.Background(), m.state.ID) - if err != nil { - return nil, fmt.Errorf("do_app get: %w", err) - } - state := doAppToState(a) - m.state.Status = state.Status - m.state.LiveURL = state.LiveURL - return m.state, nil -} - -func (b *doAppRealBackend) logs(m *PlatformDOApp) (string, error) { - if m.state.ID == "" || m.state.DeploymentID == "" { - return "", fmt.Errorf("do_app: not deployed") - } - logInfo, _, err := b.client.Apps.GetLogs( - context.Background(), - m.state.ID, - m.state.DeploymentID, - m.state.Name, - godo.AppLogTypeRun, - true, - 100, - ) - if err != nil { - return "", fmt.Errorf("do_app logs: %w", err) - } - if logInfo != nil && logInfo.LiveURL != "" { - return fmt.Sprintf("live log stream: %s", logInfo.LiveURL), nil - } - return "(no live log URL)", nil -} - -func (b *doAppRealBackend) scale(m *PlatformDOApp, instances int) (*DOAppState, error) { - if m.state.ID == "" { - return nil, fmt.Errorf("do_app scale: app not deployed") - } - spec := m.buildAppSpec() - if len(spec.Services) > 0 { - spec.Services[0].InstanceCount = int64(instances) - } - updated, _, err := b.client.Apps.Update(context.Background(), m.state.ID, &godo.AppUpdateRequest{Spec: spec}) - if err != nil { - return nil, fmt.Errorf("do_app scale: %w", err) - } - m.state.Instances = instances - return doAppToState(updated), nil -} - -func (b *doAppRealBackend) destroy(m *PlatformDOApp) error { - if m.state.ID == "" { - return nil - } - _, err := b.client.Apps.Delete(context.Background(), m.state.ID) - if err != nil { - return fmt.Errorf("do_app destroy: %w", err) - } - m.state.Status = "deleted" - m.state.LiveURL = "" - return nil -} - -// doAppToState converts a godo.App to DOAppState. -func doAppToState(a *godo.App) *DOAppState { - state := &DOAppState{ - ID: a.ID, - Status: "pending", - } - if a.Spec != nil { - state.Name = a.Spec.Name - state.Region = a.Spec.Region - if len(a.Spec.Services) > 0 { - state.Instances = int(a.Spec.Services[0].InstanceCount) - if a.Spec.Services[0].Image != nil { - state.Image = a.Spec.Services[0].Image.Repository - } - } - } - if a.LiveURL != "" { - state.LiveURL = a.LiveURL - } - if a.ActiveDeployment != nil { - state.DeploymentID = a.ActiveDeployment.ID - state.DeployedAt = a.ActiveDeployment.CreatedAt - switch a.ActiveDeployment.Phase { - case godo.DeploymentPhase_Active: - state.Status = "running" - case godo.DeploymentPhase_Deploying, godo.DeploymentPhase_PendingDeploy, - godo.DeploymentPhase_Building, godo.DeploymentPhase_PendingBuild: - state.Status = "deploying" - case godo.DeploymentPhase_Error: - state.Status = "error" - } - } - return state -} diff --git a/module/platform_do_app_test.go b/module/platform_do_app_test.go deleted file mode 100644 index b4d9f6dd..00000000 --- a/module/platform_do_app_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package module_test - -import ( - "context" - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -func newDOAppApp(t *testing.T) (*module.MockApplication, *module.PlatformDOApp) { - t.Helper() - app := module.NewMockApplication() - m := module.NewPlatformDOApp("my-app", map[string]any{ - "provider": "mock", - "name": "my-web-app", - "region": "nyc", - "image": "registry.example.com/my-app:v1.0.0", - "instances": 2, - "http_port": 8080, - "envs": map[string]any{ - "APP_ENV": "production", - "PORT": "8080", - }, - }) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - return app, m -} - -// ─── module lifecycle ───────────────────────────────────────────────────────── - -func TestDO_App_Init(t *testing.T) { - _, m := newDOAppApp(t) - if m.Name() != "my-app" { - t.Errorf("expected name=my-app, got %q", m.Name()) - } -} - -func TestDO_App_InitRegistersService(t *testing.T) { - app, _ := newDOAppApp(t) - svc, ok := app.Services["my-app"] - if !ok { - t.Fatal("expected my-app in service registry") - } - if _, ok := svc.(*module.PlatformDOApp); !ok { - t.Fatalf("registry entry is %T, want *PlatformDOApp", svc) - } -} - -func TestDO_App_Deploy(t *testing.T) { - _, m := newDOAppApp(t) - state, err := m.Deploy() - if err != nil { - t.Fatalf("Deploy: %v", err) - } - if state.Status != "running" { - t.Errorf("expected status=running, got %q", state.Status) - } - if state.ID == "" { - t.Error("expected non-empty app ID after deploy") - } - if state.LiveURL == "" { - t.Error("expected non-empty LiveURL after deploy") - } - if state.DeploymentID == "" { - t.Error("expected non-empty DeploymentID after deploy") - } -} - -func TestDO_App_Status(t *testing.T) { - _, m := newDOAppApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - state, err := m.Status() - if err != nil { - t.Fatalf("Status: %v", err) - } - if state.Status != "running" { - t.Errorf("expected status=running, got %q", state.Status) - } -} - -func TestDO_App_Logs(t *testing.T) { - _, m := newDOAppApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - logs, err := m.Logs() - if err != nil { - t.Fatalf("Logs: %v", err) - } - if logs == "" { - t.Error("expected non-empty logs") - } -} - -func TestDO_App_Logs_NotDeployed(t *testing.T) { - _, m := newDOAppApp(t) - _, err := m.Logs() - if err == nil { - t.Error("expected error for logs on undeployed app, got nil") - } -} - -func TestDO_App_Scale(t *testing.T) { - _, m := newDOAppApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - state, err := m.Scale(5) - if err != nil { - t.Fatalf("Scale: %v", err) - } - if state.Instances != 5 { - t.Errorf("expected instances=5, got %d", state.Instances) - } -} - -func TestDO_App_Destroy(t *testing.T) { - _, m := newDOAppApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - if err := m.Destroy(); err != nil { - t.Fatalf("Destroy: %v", err) - } - state, err := m.Status() - if err != nil { - t.Fatalf("Status after destroy: %v", err) - } - if state.Status != "deleted" { - t.Errorf("expected status=deleted, got %q", state.Status) - } - if state.LiveURL != "" { - t.Error("expected empty LiveURL after destroy") - } -} - -func TestDO_App_DestroyIdempotent(t *testing.T) { - _, m := newDOAppApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - if err := m.Destroy(); err != nil { - t.Fatalf("first Destroy: %v", err) - } - if err := m.Destroy(); err != nil { - t.Errorf("second Destroy should be idempotent, got: %v", err) - } -} - -// ─── PlatformProvider adapter ───────────────────────────────────────────────── - -func TestDO_App_AdapterImplementsPlatformProvider(t *testing.T) { - app, _ := newDOAppApp(t) - svc, ok := app.Services["my-app.iac"] - if !ok { - t.Fatal("expected my-app.iac in service registry") - } - if _, ok := svc.(module.PlatformProvider); !ok { - t.Fatalf("my-app.iac service (%T) does not implement PlatformProvider", svc) - } -} - -func TestDO_App_AdapterPlan(t *testing.T) { - app, _ := newDOAppApp(t) - prov := app.Services["my-app.iac"].(module.PlatformProvider) - plan, err := prov.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if plan.Provider != "digitalocean" { - t.Errorf("expected provider digitalocean, got %s", plan.Provider) - } - if plan.Resource != "app_platform" { - t.Errorf("expected resource app_platform, got %s", plan.Resource) - } - if len(plan.Actions) == 0 { - t.Fatal("expected at least one action") - } - if plan.Actions[0].Type != "create" { - t.Errorf("expected action type create, got %s", plan.Actions[0].Type) - } -} - -func TestDO_App_AdapterPlanUpdate(t *testing.T) { - app, _ := newDOAppApp(t) - m := app.Services["my-app"].(*module.PlatformDOApp) - // Deploy first so the app has an ID - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - prov := app.Services["my-app.iac"].(module.PlatformProvider) - plan, err := prov.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if plan.Actions[0].Type != "update" { - t.Errorf("expected action type update after deploy, got %s", plan.Actions[0].Type) - } -} - -func TestDO_App_AdapterApply(t *testing.T) { - app, _ := newDOAppApp(t) - prov := app.Services["my-app.iac"].(module.PlatformProvider) - result, err := prov.Apply() - if err != nil { - t.Fatalf("Apply() error: %v", err) - } - if !result.Success { - t.Errorf("expected success, got message: %s", result.Message) - } - if result.State == nil { - t.Error("expected non-nil state") - } -} - -func TestDO_App_AdapterStatus(t *testing.T) { - app, _ := newDOAppApp(t) - prov := app.Services["my-app.iac"].(module.PlatformProvider) - st, err := prov.Status() - if err != nil { - t.Fatalf("Status() error: %v", err) - } - if st == nil { - t.Error("expected non-nil status") - } -} - -func TestDO_App_AdapterDestroy(t *testing.T) { - app, _ := newDOAppApp(t) - prov := app.Services["my-app.iac"].(module.PlatformProvider) - // Deploy first - if _, err := prov.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := prov.Destroy(); err != nil { - t.Fatalf("Destroy() error: %v", err) - } - // Verify status shows deleted - st, err := prov.Status() - if err != nil { - t.Fatalf("Status after destroy: %v", err) - } - appState, ok := st.(*module.DOAppState) - if !ok { - t.Fatalf("expected *DOAppState, got %T", st) - } - if appState.Status != "deleted" { - t.Errorf("expected status deleted, got %s", appState.Status) - } -} - -func TestDO_App_UnsupportedProvider(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDOApp("bad-app", map[string]any{ - "provider": "gcp", - "name": "bad", - }) - if err := m.Init(app); err == nil { - t.Error("expected error for unsupported provider, got nil") - } -} - -func TestDO_App_InvalidAccountRef(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDOApp("fail-app", map[string]any{ - "provider": "mock", - "account": "nonexistent", - "name": "fail", - }) - if err := m.Init(app); err == nil { - t.Error("expected error for nonexistent account, got nil") - } -} - -// ─── pipeline steps ─────────────────────────────────────────────────────────── - -func setupDOAppStepApp(t *testing.T) (*module.MockApplication, *module.PlatformDOApp) { - t.Helper() - return newDOAppApp(t) -} - -func TestDO_DeployStep(t *testing.T) { - app, _ := setupDOAppStepApp(t) - factory := module.NewDODeployStepFactory() - step, err := factory("deploy", map[string]any{"app": "my-app"}, app) - if err != nil { - t.Fatalf("factory: %v", err) - } - result, err := step.Execute(context.Background(), &module.PipelineContext{Current: map[string]any{}}) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if result.Output["app"] != "my-app" { - t.Errorf("expected app=my-app, got %v", result.Output["app"]) - } - if result.Output["status"] != "running" { - t.Errorf("expected status=running, got %v", result.Output["status"]) - } -} - -func TestDO_StatusStep(t *testing.T) { - app, m := setupDOAppStepApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - factory := module.NewDOStatusStepFactory() - step, err := factory("status", map[string]any{"app": "my-app"}, app) - if err != nil { - t.Fatalf("factory: %v", err) - } - result, err := step.Execute(context.Background(), &module.PipelineContext{Current: map[string]any{}}) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if result.Output["app"] != "my-app" { - t.Errorf("expected app=my-app, got %v", result.Output["app"]) - } -} - -func TestDO_LogsStep(t *testing.T) { - app, m := setupDOAppStepApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - factory := module.NewDOLogsStepFactory() - step, err := factory("logs", map[string]any{"app": "my-app"}, app) - if err != nil { - t.Fatalf("factory: %v", err) - } - result, err := step.Execute(context.Background(), &module.PipelineContext{Current: map[string]any{}}) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if result.Output["logs"] == "" { - t.Error("expected non-empty logs in output") - } -} - -func TestDO_ScaleStep(t *testing.T) { - app, m := setupDOAppStepApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - factory := module.NewDOScaleStepFactory() - step, err := factory("scale", map[string]any{"app": "my-app", "instances": 4}, app) - if err != nil { - t.Fatalf("factory: %v", err) - } - result, err := step.Execute(context.Background(), &module.PipelineContext{Current: map[string]any{}}) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if result.Output["instances"] != 4 { - t.Errorf("expected instances=4, got %v", result.Output["instances"]) - } -} - -func TestDO_DestroyStep(t *testing.T) { - app, m := setupDOAppStepApp(t) - if _, err := m.Deploy(); err != nil { - t.Fatalf("Deploy: %v", err) - } - factory := module.NewDODestroyStepFactory() - step, err := factory("destroy", map[string]any{"app": "my-app"}, app) - if err != nil { - t.Fatalf("factory: %v", err) - } - result, err := step.Execute(context.Background(), &module.PipelineContext{Current: map[string]any{}}) - if err != nil { - t.Fatalf("Execute: %v", err) - } - if result.Output["destroyed"] != true { - t.Errorf("expected destroyed=true, got %v", result.Output["destroyed"]) - } -} - -func TestDO_DeployStep_MissingApp(t *testing.T) { - factory := module.NewDODeployStepFactory() - _, err := factory("deploy", map[string]any{}, module.NewMockApplication()) - if err == nil { - t.Error("expected error for missing app, got nil") - } -} - -func TestDO_DeployStep_AppNotFound(t *testing.T) { - factory := module.NewDODeployStepFactory() - step, err := factory("deploy", map[string]any{"app": "ghost"}, module.NewMockApplication()) - if err != nil { - t.Fatalf("factory: %v", err) - } - _, err = step.Execute(context.Background(), &module.PipelineContext{Current: map[string]any{}}) - if err == nil { - t.Error("expected error for missing app in registry, got nil") - } -} diff --git a/module/platform_do_database.go b/module/platform_do_database.go deleted file mode 100644 index a124ccb4..00000000 --- a/module/platform_do_database.go +++ /dev/null @@ -1,263 +0,0 @@ -package module - -import ( - "context" - "fmt" - "time" - - "github.com/GoCodeAlone/modular" - "github.com/digitalocean/godo" -) - -// DODatabaseState holds the current state of a DO Managed Database. -type DODatabaseState struct { - ID string `json:"id"` - Name string `json:"name"` - Engine string `json:"engine"` // pg, mysql, redis, mongodb, kafka - Version string `json:"version"` - Size string `json:"size"` // e.g. db-s-1vcpu-1gb - Region string `json:"region"` - NumNodes int `json:"numNodes"` - Status string `json:"status"` // pending, online, resizing, migrating, error - Host string `json:"host"` - Port int `json:"port"` - DatabaseName string `json:"databaseName"` - User string `json:"user"` - Password string `json:"password"` //nolint:gosec // G117: DigitalOcean database state DTO; password is a standard DB connection field - URI string `json:"uri"` - CreatedAt time.Time `json:"createdAt"` -} - -// doDatabaseBackend is the interface for DO managed database backends. -type doDatabaseBackend interface { - create(m *PlatformDODatabase) (*DODatabaseState, error) - status(m *PlatformDODatabase) (*DODatabaseState, error) - destroy(m *PlatformDODatabase) error -} - -// PlatformDODatabase manages DigitalOcean Managed Databases. -// Config: -// -// account: name of a cloud.account module (provider=digitalocean) -// provider: digitalocean | mock -// engine: pg | mysql | redis | mongodb | kafka -// version: engine version string (e.g. "16" for pg) -// size: droplet size slug (e.g. db-s-1vcpu-1gb) -// region: DO region slug (e.g. nyc1) -// num_nodes: number of nodes (default: 1) -// name: database cluster name -type PlatformDODatabase struct { - name string - config map[string]any - state *DODatabaseState - backend doDatabaseBackend -} - -// NewPlatformDODatabase creates a new PlatformDODatabase module. -func NewPlatformDODatabase(name string, cfg map[string]any) *PlatformDODatabase { - return &PlatformDODatabase{name: name, config: cfg} -} - -func (m *PlatformDODatabase) Name() string { return m.name } - -func (m *PlatformDODatabase) Init(app modular.Application) error { - dbName, _ := m.config["name"].(string) - if dbName == "" { - dbName = m.name - } - engine, _ := m.config["engine"].(string) - if engine == "" { - engine = "pg" - } - version, _ := m.config["version"].(string) - size, _ := m.config["size"].(string) - if size == "" { - size = "db-s-1vcpu-1gb" - } - region, _ := m.config["region"].(string) - if region == "" { - region = "nyc1" - } - numNodes, _ := intFromAny(m.config["num_nodes"]) - if numNodes == 0 { - numNodes = 1 - } - - m.state = &DODatabaseState{ - Name: dbName, - Engine: engine, - Version: version, - Size: size, - Region: region, - NumNodes: numNodes, - Status: "pending", - } - - providerType, _ := m.config["provider"].(string) - if providerType == "" { - providerType = "mock" - } - - switch providerType { - case "mock": - m.backend = &doDatabaseMockBackend{} - case "digitalocean": - accountName, _ := m.config["account"].(string) - acc, ok := app.SvcRegistry()[accountName].(*CloudAccount) - if !ok { - return fmt.Errorf("platform.do_database %q: account %q is not a *CloudAccount", m.name, accountName) - } - client, err := acc.doClient() - if err != nil { - return fmt.Errorf("platform.do_database %q: %w", m.name, err) - } - m.backend = &doDatabaseRealBackend{client: client} - default: - return fmt.Errorf("platform.do_database %q: unsupported provider %q", m.name, providerType) - } - - return app.RegisterService(m.name, m) -} - -func (m *PlatformDODatabase) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "DO Database: " + m.name, Instance: m}, - } -} - -func (m *PlatformDODatabase) RequiresServices() []modular.ServiceDependency { return nil } - -// PlatformProvider implementation — directly, no adapter needed since this is new. - -func (m *PlatformDODatabase) Plan() (*PlatformPlan, error) { - actionType := "create" - detail := fmt.Sprintf("Create %s %s database %q in %s (%s, %d nodes)", - m.state.Engine, m.state.Version, m.state.Name, m.state.Region, m.state.Size, m.state.NumNodes) - if m.state.ID != "" { - actionType = "update" - detail = fmt.Sprintf("Update database %q (size: %s, %d nodes)", - m.state.Name, m.state.Size, m.state.NumNodes) - } - return &PlatformPlan{ - Provider: "digitalocean", - Resource: "managed_database", - Actions: []PlatformAction{{Type: actionType, Resource: m.state.Name, Detail: detail}}, - }, nil -} - -func (m *PlatformDODatabase) Apply() (*PlatformResult, error) { - st, err := m.backend.create(m) - if err != nil { - return &PlatformResult{Success: false, Message: err.Error()}, err - } - m.state = st - return &PlatformResult{ - Success: true, - Message: fmt.Sprintf("Database %s online (host: %s:%d)", st.Name, st.Host, st.Port), - State: st, - }, nil -} - -func (m *PlatformDODatabase) Status() (any, error) { - return m.backend.status(m) -} - -func (m *PlatformDODatabase) Destroy() error { - return m.backend.destroy(m) -} - -// ─── mock backend ────────────────────────────────────────────────────────────── - -type doDatabaseMockBackend struct{} - -func (b *doDatabaseMockBackend) create(m *PlatformDODatabase) (*DODatabaseState, error) { - m.state.ID = "mock-db-" + m.state.Name - m.state.Status = "online" - m.state.Host = m.state.Name + ".db.ondigitalocean.com" - m.state.Port = 25060 - m.state.DatabaseName = "defaultdb" - m.state.User = "doadmin" - m.state.Password = "mock-password" - m.state.URI = fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=require", - m.state.User, m.state.Password, m.state.Host, m.state.Port, m.state.DatabaseName) - m.state.CreatedAt = time.Now().UTC() - return m.state, nil -} - -func (b *doDatabaseMockBackend) status(m *PlatformDODatabase) (*DODatabaseState, error) { - return m.state, nil -} - -func (b *doDatabaseMockBackend) destroy(m *PlatformDODatabase) error { - m.state.Status = "deleted" - m.state.ID = "" - return nil -} - -// ─── real backend ────────────────────────────────────────────────────────────── - -type doDatabaseRealBackend struct { - client *godo.Client -} - -func (b *doDatabaseRealBackend) create(m *PlatformDODatabase) (*DODatabaseState, error) { - req := &godo.DatabaseCreateRequest{ - Name: m.state.Name, - EngineSlug: m.state.Engine, - Version: m.state.Version, - SizeSlug: m.state.Size, - Region: m.state.Region, - NumNodes: m.state.NumNodes, - } - db, _, err := b.client.Databases.Create(context.Background(), req) - if err != nil { - return nil, fmt.Errorf("create database: %w", err) - } - return doDatabaseFromGodo(db), nil -} - -func (b *doDatabaseRealBackend) status(m *PlatformDODatabase) (*DODatabaseState, error) { - if m.state.ID == "" { - return m.state, nil - } - db, _, err := b.client.Databases.Get(context.Background(), m.state.ID) - if err != nil { - return nil, fmt.Errorf("get database: %w", err) - } - return doDatabaseFromGodo(db), nil -} - -func (b *doDatabaseRealBackend) destroy(m *PlatformDODatabase) error { - if m.state.ID == "" { - return nil - } - _, err := b.client.Databases.Delete(context.Background(), m.state.ID) - if err != nil { - return fmt.Errorf("delete database: %w", err) - } - m.state.Status = "deleted" - return nil -} - -func doDatabaseFromGodo(db *godo.Database) *DODatabaseState { - st := &DODatabaseState{ - ID: db.ID, - Name: db.Name, - Engine: db.EngineSlug, - Version: db.VersionSlug, - Size: db.SizeSlug, - Region: db.RegionSlug, - NumNodes: db.NumNodes, - Status: db.Status, - CreatedAt: db.CreatedAt, - } - if db.Connection != nil { - st.Host = db.Connection.Host - st.Port = db.Connection.Port - st.DatabaseName = db.Connection.Database - st.User = db.Connection.User - st.Password = db.Connection.Password - st.URI = db.Connection.URI - } - return st -} diff --git a/module/platform_do_database_test.go b/module/platform_do_database_test.go deleted file mode 100644 index b1a28dcd..00000000 --- a/module/platform_do_database_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package module - -import "testing" - -func TestPlatformDODatabase_MockBackend(t *testing.T) { - m := &PlatformDODatabase{ - name: "test-db", - config: map[string]any{ - "provider": "mock", - "engine": "pg", - "version": "16", - "size": "db-s-1vcpu-1gb", - "region": "nyc1", - "num_nodes": 1, - "name": "test-db", - }, - state: &DODatabaseState{ - Name: "test-db", - Engine: "pg", - Version: "16", - Size: "db-s-1vcpu-1gb", - Region: "nyc1", - NumNodes: 1, - Status: "pending", - }, - backend: &doDatabaseMockBackend{}, - } - - // Test PlatformProvider interface - var _ PlatformProvider = m - - // Plan - plan, err := m.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if plan.Provider != "digitalocean" { - t.Errorf("expected provider digitalocean, got %s", plan.Provider) - } - if plan.Resource != "managed_database" { - t.Errorf("expected resource managed_database, got %s", plan.Resource) - } - - // Apply - result, err := m.Apply() - if err != nil { - t.Fatalf("Apply() error: %v", err) - } - if !result.Success { - t.Error("expected success") - } - - // Status - st, err := m.Status() - if err != nil { - t.Fatalf("Status() error: %v", err) - } - if st == nil { - t.Error("expected non-nil status") - } - - // Destroy - if err := m.Destroy(); err != nil { - t.Fatalf("Destroy() error: %v", err) - } -} diff --git a/module/platform_do_dns.go b/module/platform_do_dns.go deleted file mode 100644 index 0f2cb2f2..00000000 --- a/module/platform_do_dns.go +++ /dev/null @@ -1,357 +0,0 @@ -package module - -import ( - "context" - "fmt" - "strings" - - "github.com/GoCodeAlone/modular" - "github.com/digitalocean/godo" -) - -// DODNSState holds the current state of DigitalOcean DNS. -type DODNSState struct { - DomainName string `json:"domainName"` - Records []DODNSRecordState `json:"records"` - Status string `json:"status"` // pending, active, deleting, deleted -} - -// DODNSRecordState describes a single DigitalOcean DNS record. -type DODNSRecordState struct { - ID int `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Data string `json:"data"` - TTL int `json:"ttl"` -} - -// doDNSBackend is the interface DO DNS backends implement. -type doDNSBackend interface { - plan(m *PlatformDODNS) (*DODNSPlan, error) - apply(m *PlatformDODNS) (*DODNSState, error) - status(m *PlatformDODNS) (*DODNSState, error) - destroy(m *PlatformDODNS) error -} - -// DODNSPlan describes planned DNS changes. -type DODNSPlan struct { - Domain string `json:"domain"` - Records []DODNSRecordState `json:"records"` - Changes []string `json:"changes"` -} - -// PlatformDODNS manages DigitalOcean domains and DNS records. -// Config: -// -// account: name of a cloud.account module (provider=digitalocean) -// provider: digitalocean | mock -// domain: domain name (e.g. example.com) -// records: list of DNS record definitions (name, type, data, ttl) -type PlatformDODNS struct { - name string - config map[string]any - provider CloudCredentialProvider - state *DODNSState - backend doDNSBackend -} - -// NewPlatformDODNS creates a new PlatformDODNS module. -func NewPlatformDODNS(name string, cfg map[string]any) *PlatformDODNS { - return &PlatformDODNS{name: name, config: cfg} -} - -// Name returns the module name. -func (m *PlatformDODNS) Name() string { return m.name } - -// Init resolves the cloud.account service and initializes the backend. -func (m *PlatformDODNS) Init(app modular.Application) error { - domain, _ := m.config["domain"].(string) - if domain == "" { - return fmt.Errorf("platform.do_dns %q: 'domain' is required", m.name) - } - - accountName, _ := m.config["account"].(string) - providerType, _ := m.config["provider"].(string) - if providerType == "" { - providerType = "mock" - } - - if accountName != "" { - svc, ok := app.SvcRegistry()[accountName] - if !ok { - return fmt.Errorf("platform.do_dns %q: account service %q not found", m.name, accountName) - } - prov, ok := svc.(CloudCredentialProvider) - if !ok { - return fmt.Errorf("platform.do_dns %q: service %q does not implement CloudCredentialProvider", m.name, accountName) - } - m.provider = prov - if providerType == "mock" { - providerType = prov.Provider() - } - } - - m.state = &DODNSState{ - DomainName: domain, - Status: "pending", - } - - switch providerType { - case "mock": - m.backend = &doDNSMockBackend{} - case "digitalocean": - acc, ok := app.SvcRegistry()[accountName].(*CloudAccount) - if !ok { - return fmt.Errorf("platform.do_dns %q: account %q is not a *CloudAccount", m.name, accountName) - } - client, err := acc.doClient() - if err != nil { - return fmt.Errorf("platform.do_dns %q: %w", m.name, err) - } - m.backend = &doDNSRealBackend{client: client} - default: - return fmt.Errorf("platform.do_dns %q: unsupported provider %q", m.name, providerType) - } - - if err := app.RegisterService(m.name, m); err != nil { - return err - } - return app.RegisterService(m.name+".iac", &DODNSPlatformAdapter{m}) -} - -// ProvidesServices declares the service this module provides. -func (m *PlatformDODNS) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "DO DNS: " + m.name, Instance: m}, - {Name: m.name + ".iac", Description: "DO DNS IaC adapter: " + m.name, Instance: &DODNSPlatformAdapter{m}}, - } -} - -// RequiresServices returns nil. -func (m *PlatformDODNS) RequiresServices() []modular.ServiceDependency { return nil } - -// Plan returns the planned DNS changes. -func (m *PlatformDODNS) Plan() (*DODNSPlan, error) { return m.backend.plan(m) } - -// Apply creates or updates the domain and records. -func (m *PlatformDODNS) Apply() (*DODNSState, error) { return m.backend.apply(m) } - -// Status returns the current DNS state. -func (m *PlatformDODNS) Status() (*DODNSState, error) { return m.backend.status(m) } - -// Destroy deletes the domain and all records. -func (m *PlatformDODNS) Destroy() error { return m.backend.destroy(m) } - -// recordConfigs parses DNS record configs from module config. -func (m *PlatformDODNS) recordConfigs() []DODNSRecordState { - raw, ok := m.config["records"].([]any) - if !ok { - return nil - } - var records []DODNSRecordState - for _, item := range raw { - rec, ok := item.(map[string]any) - if !ok { - continue - } - name, _ := rec["name"].(string) - rtype, _ := rec["type"].(string) - data, _ := rec["data"].(string) - ttl, _ := intFromAny(rec["ttl"]) - if ttl == 0 { - ttl = 300 - } - records = append(records, DODNSRecordState{ - Type: strings.ToUpper(rtype), - Name: name, - Data: data, - TTL: ttl, - }) - } - return records -} - -// ─── PlatformProvider adapter ────────────────────────────────────────────────── - -// DODNSPlatformAdapter wraps PlatformDODNS to implement PlatformProvider. -type DODNSPlatformAdapter struct { - *PlatformDODNS -} - -// Plan implements PlatformProvider. -func (a *DODNSPlatformAdapter) Plan() (*PlatformPlan, error) { - p, err := a.PlatformDODNS.Plan() - if err != nil { - return nil, err - } - var actions []PlatformAction - for _, change := range p.Changes { - actionType := "create" - if change == "no changes" { - actionType = "noop" - } - actions = append(actions, PlatformAction{ - Type: actionType, - Resource: p.Domain, - Detail: change, - }) - } - return &PlatformPlan{ - Provider: "digitalocean", - Resource: "dns", - Actions: actions, - }, nil -} - -// Apply implements PlatformProvider. -func (a *DODNSPlatformAdapter) Apply() (*PlatformResult, error) { - st, err := a.PlatformDODNS.Apply() - if err != nil { - return &PlatformResult{Success: false, Message: err.Error()}, err - } - return &PlatformResult{ - Success: true, - Message: fmt.Sprintf("DNS domain %s configured with %d records", st.DomainName, len(st.Records)), - State: st, - }, nil -} - -// Status implements PlatformProvider. -func (a *DODNSPlatformAdapter) Status() (any, error) { - return a.PlatformDODNS.Status() -} - -// Destroy implements PlatformProvider. -func (a *DODNSPlatformAdapter) Destroy() error { - return a.PlatformDODNS.Destroy() -} - -// ─── mock backend ────────────────────────────────────────────────────────────── - -type doDNSMockBackend struct{} - -func (b *doDNSMockBackend) plan(m *PlatformDODNS) (*DODNSPlan, error) { - if m.state.Status == "active" { - return &DODNSPlan{ - Domain: m.state.DomainName, - Changes: []string{"no changes"}, - }, nil - } - records := m.recordConfigs() - changes := []string{fmt.Sprintf("create domain %q", m.state.DomainName)} - for _, r := range records { - changes = append(changes, fmt.Sprintf("create %s record %q → %s", r.Type, r.Name, r.Data)) - } - return &DODNSPlan{ - Domain: m.state.DomainName, - Records: records, - Changes: changes, - }, nil -} - -func (b *doDNSMockBackend) apply(m *PlatformDODNS) (*DODNSState, error) { - if m.state.Status == "active" { - return m.state, nil - } - records := m.recordConfigs() - for i := range records { - records[i].ID = i + 1 - } - m.state.Records = records - m.state.Status = "active" - return m.state, nil -} - -func (b *doDNSMockBackend) status(m *PlatformDODNS) (*DODNSState, error) { - return m.state, nil -} - -func (b *doDNSMockBackend) destroy(m *PlatformDODNS) error { - if m.state.Status == "deleted" { - return nil - } - m.state.Records = nil - m.state.Status = "deleted" - return nil -} - -// ─── real backend ────────────────────────────────────────────────────────────── - -type doDNSRealBackend struct { - client *godo.Client -} - -func (b *doDNSRealBackend) plan(m *PlatformDODNS) (*DODNSPlan, error) { - records := m.recordConfigs() - changes := []string{fmt.Sprintf("create/update domain %q", m.state.DomainName)} - for _, r := range records { - changes = append(changes, fmt.Sprintf("create %s record %q → %s", r.Type, r.Name, r.Data)) - } - return &DODNSPlan{ - Domain: m.state.DomainName, - Records: records, - Changes: changes, - }, nil -} - -func (b *doDNSRealBackend) apply(m *PlatformDODNS) (*DODNSState, error) { - // Create domain if it doesn't exist. - _, _, err := b.client.Domains.Create(context.Background(), &godo.DomainCreateRequest{ - Name: m.state.DomainName, - }) - if err != nil { - // Domain may already exist — continue. - _ = err - } - - records := m.recordConfigs() - for i, r := range records { - req := &godo.DomainRecordEditRequest{ - Type: r.Type, - Name: r.Name, - Data: r.Data, - TTL: r.TTL, - } - created, _, err := b.client.Domains.CreateRecord(context.Background(), m.state.DomainName, req) - if err != nil { - return nil, fmt.Errorf("do_dns create record %q: %w", r.Name, err) - } - records[i].ID = created.ID - } - m.state.Records = records - m.state.Status = "active" - return m.state, nil -} - -func (b *doDNSRealBackend) status(m *PlatformDODNS) (*DODNSState, error) { - recs, _, err := b.client.Domains.Records(context.Background(), m.state.DomainName, nil) - if err != nil { - return nil, fmt.Errorf("do_dns list records: %w", err) - } - var records []DODNSRecordState - for _, r := range recs { - records = append(records, DODNSRecordState{ - ID: r.ID, - Type: r.Type, - Name: r.Name, - Data: r.Data, - TTL: r.TTL, - }) - } - m.state.Records = records - return m.state, nil -} - -func (b *doDNSRealBackend) destroy(m *PlatformDODNS) error { - for _, r := range m.state.Records { - if _, err := b.client.Domains.DeleteRecord(context.Background(), m.state.DomainName, r.ID); err != nil { - return fmt.Errorf("do_dns delete record %d: %w", r.ID, err) - } - } - if _, err := b.client.Domains.Delete(context.Background(), m.state.DomainName); err != nil { - return fmt.Errorf("do_dns delete domain: %w", err) - } - m.state.Records = nil - m.state.Status = "deleted" - return nil -} diff --git a/module/platform_do_dns_test.go b/module/platform_do_dns_test.go deleted file mode 100644 index 78d8c7f7..00000000 --- a/module/platform_do_dns_test.go +++ /dev/null @@ -1,270 +0,0 @@ -package module_test - -import ( - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -func newDODNSApp(t *testing.T) (*module.MockApplication, *module.PlatformDODNS) { - t.Helper() - app := module.NewMockApplication() - m := module.NewPlatformDODNS("prod-do-dns", map[string]any{ - "provider": "mock", - "domain": "example.com", - "records": []any{ - map[string]any{"name": "api", "type": "A", "data": "10.0.0.1", "ttl": 300}, - map[string]any{"name": "www", "type": "CNAME", "data": "example.com", "ttl": 3600}, - }, - }) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - return app, m -} - -// ─── module lifecycle ───────────────────────────────────────────────────────── - -func TestDO_DNS_Init(t *testing.T) { - _, m := newDODNSApp(t) - if m.Name() != "prod-do-dns" { - t.Errorf("expected name=prod-do-dns, got %q", m.Name()) - } -} - -func TestDO_DNS_InitRegistersService(t *testing.T) { - app, _ := newDODNSApp(t) - svc, ok := app.Services["prod-do-dns"] - if !ok { - t.Fatal("expected prod-do-dns in service registry") - } - if _, ok := svc.(*module.PlatformDODNS); !ok { - t.Fatalf("registry entry is %T, want *PlatformDODNS", svc) - } -} - -func TestDO_DNS_Plan_PendingState(t *testing.T) { - _, m := newDODNSApp(t) - plan, err := m.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) - } - if plan.Domain != "example.com" { - t.Errorf("expected domain=example.com, got %q", plan.Domain) - } - if len(plan.Changes) == 0 { - t.Error("expected changes in plan") - } - if len(plan.Records) != 2 { - t.Errorf("expected 2 records in plan, got %d", len(plan.Records)) - } -} - -func TestDO_DNS_Plan_NoopAfterApply(t *testing.T) { - _, m := newDODNSApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - plan, err := m.Plan() - if err != nil { - t.Fatalf("second Plan: %v", err) - } - if len(plan.Changes) == 0 || plan.Changes[0] != "no changes" { - t.Errorf("expected 'no changes', got %v", plan.Changes) - } -} - -func TestDO_DNS_Apply(t *testing.T) { - _, m := newDODNSApp(t) - state, err := m.Apply() - if err != nil { - t.Fatalf("Apply: %v", err) - } - if state.Status != "active" { - t.Errorf("expected status=active, got %q", state.Status) - } - if state.DomainName != "example.com" { - t.Errorf("expected domain=example.com, got %q", state.DomainName) - } - if len(state.Records) != 2 { - t.Errorf("expected 2 records after apply, got %d", len(state.Records)) - } -} - -func TestDO_DNS_Status(t *testing.T) { - _, m := newDODNSApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - state, err := m.Status() - if err != nil { - t.Fatalf("Status: %v", err) - } - if state.Status != "active" { - t.Errorf("expected status=active, got %q", state.Status) - } -} - -func TestDO_DNS_Destroy(t *testing.T) { - _, m := newDODNSApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := m.Destroy(); err != nil { - t.Fatalf("Destroy: %v", err) - } - state, err := m.Status() - if err != nil { - t.Fatalf("Status after destroy: %v", err) - } - if state.Status != "deleted" { - t.Errorf("expected status=deleted, got %q", state.Status) - } - if len(state.Records) != 0 { - t.Errorf("expected 0 records after destroy, got %d", len(state.Records)) - } -} - -func TestDO_DNS_DestroyIdempotent(t *testing.T) { - _, m := newDODNSApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := m.Destroy(); err != nil { - t.Fatalf("first Destroy: %v", err) - } - if err := m.Destroy(); err != nil { - t.Errorf("second Destroy should be idempotent, got: %v", err) - } -} - -// ─── PlatformProvider adapter ───────────────────────────────────────────────── - -func TestDO_DNS_AdapterImplementsPlatformProvider(t *testing.T) { - app, _ := newDODNSApp(t) - svc, ok := app.Services["prod-do-dns.iac"] - if !ok { - t.Fatal("expected prod-do-dns.iac in service registry") - } - if _, ok := svc.(module.PlatformProvider); !ok { - t.Fatalf("prod-do-dns.iac service (%T) does not implement PlatformProvider", svc) - } -} - -func TestDO_DNS_AdapterPlan(t *testing.T) { - app, _ := newDODNSApp(t) - prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) - plan, err := prov.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if plan.Provider != "digitalocean" { - t.Errorf("expected provider digitalocean, got %s", plan.Provider) - } - if plan.Resource != "dns" { - t.Errorf("expected resource dns, got %s", plan.Resource) - } - if len(plan.Actions) == 0 { - t.Fatal("expected at least one action") - } -} - -func TestDO_DNS_AdapterPlanNoop(t *testing.T) { - app, m := newDODNSApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) - plan, err := prov.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if len(plan.Actions) != 1 { - t.Fatalf("expected 1 noop action, got %d", len(plan.Actions)) - } - if plan.Actions[0].Type != "noop" { - t.Errorf("expected noop action after apply, got %s", plan.Actions[0].Type) - } -} - -func TestDO_DNS_AdapterApply(t *testing.T) { - app, _ := newDODNSApp(t) - prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) - result, err := prov.Apply() - if err != nil { - t.Fatalf("Apply() error: %v", err) - } - if !result.Success { - t.Errorf("expected success, got message: %s", result.Message) - } - if result.State == nil { - t.Error("expected non-nil state") - } -} - -func TestDO_DNS_AdapterStatus(t *testing.T) { - app, _ := newDODNSApp(t) - prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) - st, err := prov.Status() - if err != nil { - t.Fatalf("Status() error: %v", err) - } - if st == nil { - t.Error("expected non-nil status") - } -} - -func TestDO_DNS_AdapterDestroy(t *testing.T) { - app, _ := newDODNSApp(t) - prov := app.Services["prod-do-dns.iac"].(module.PlatformProvider) - if _, err := prov.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := prov.Destroy(); err != nil { - t.Fatalf("Destroy() error: %v", err) - } - st, err := prov.Status() - if err != nil { - t.Fatalf("Status after destroy: %v", err) - } - dnsState, ok := st.(*module.DODNSState) - if !ok { - t.Fatalf("expected *DODNSState, got %T", st) - } - if dnsState.Status != "deleted" { - t.Errorf("expected status deleted, got %s", dnsState.Status) - } -} - -func TestDO_DNS_MissingDomain(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDODNS("bad-dns", map[string]any{ - "provider": "mock", - }) - if err := m.Init(app); err == nil { - t.Error("expected error for missing domain, got nil") - } -} - -func TestDO_DNS_UnsupportedProvider(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDODNS("bad-dns", map[string]any{ - "provider": "aws", - "domain": "example.com", - }) - if err := m.Init(app); err == nil { - t.Error("expected error for unsupported provider, got nil") - } -} - -func TestDO_DNS_InvalidAccountRef(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDODNS("fail-dns", map[string]any{ - "provider": "mock", - "account": "nonexistent", - "domain": "example.com", - }) - if err := m.Init(app); err == nil { - t.Error("expected error for nonexistent account, got nil") - } -} diff --git a/module/platform_do_networking.go b/module/platform_do_networking.go deleted file mode 100644 index 1596f406..00000000 --- a/module/platform_do_networking.go +++ /dev/null @@ -1,370 +0,0 @@ -package module - -import ( - "context" - "fmt" - - "github.com/GoCodeAlone/modular" - "github.com/digitalocean/godo" -) - -// DOVPCState holds the current state of a DigitalOcean VPC. -type DOVPCState struct { - ID string `json:"id"` - Name string `json:"name"` - Region string `json:"region"` - IPRange string `json:"ipRange"` - Status string `json:"status"` // pending, active, deleting, deleted - FirewallIDs []string `json:"firewallIds"` - LBID string `json:"lbId"` - Tags map[string]string `json:"tags"` -} - -// DOFirewallRule describes a single firewall rule (inbound or outbound). -type DOFirewallRule struct { - Protocol string `json:"protocol"` // tcp, udp, icmp - PortRange string `json:"portRange"` // e.g. "80" or "8000-9000" - Sources string `json:"sources"` // CIDR, tag, or load_balancer_uid -} - -// DOFirewallConfig describes a DigitalOcean firewall. -type DOFirewallConfig struct { - Name string `json:"name"` - InboundRules []DOFirewallRule `json:"inboundRules"` - OutboundRules []DOFirewallRule `json:"outboundRules"` -} - -// DONetworkPlan describes planned networking changes. -type DONetworkPlan struct { - VPC string `json:"vpc"` - Firewalls []DOFirewallConfig `json:"firewalls"` - Changes []string `json:"changes"` -} - -// doNetworkingBackend is the interface DO networking backends implement. -type doNetworkingBackend interface { - plan(m *PlatformDONetworking) (*DONetworkPlan, error) - apply(m *PlatformDONetworking) (*DOVPCState, error) - status(m *PlatformDONetworking) (*DOVPCState, error) - destroy(m *PlatformDONetworking) error -} - -// PlatformDONetworking manages DigitalOcean VPCs, firewalls, and load balancers. -// Config: -// -// account: name of a cloud.account module (provider=digitalocean) -// provider: digitalocean | mock -// vpc: vpc config (name, region, ip_range) -// firewalls: list of firewall configs -type PlatformDONetworking struct { - name string - config map[string]any - provider CloudCredentialProvider - state *DOVPCState - backend doNetworkingBackend -} - -// NewPlatformDONetworking creates a new PlatformDONetworking module. -func NewPlatformDONetworking(name string, cfg map[string]any) *PlatformDONetworking { - return &PlatformDONetworking{name: name, config: cfg} -} - -// Name returns the module name. -func (m *PlatformDONetworking) Name() string { return m.name } - -// Init resolves the cloud.account service and initializes the backend. -func (m *PlatformDONetworking) Init(app modular.Application) error { - accountName, _ := m.config["account"].(string) - providerType, _ := m.config["provider"].(string) - if providerType == "" { - providerType = "mock" - } - - if accountName != "" { - svc, ok := app.SvcRegistry()[accountName] - if !ok { - return fmt.Errorf("platform.do_networking %q: account service %q not found", m.name, accountName) - } - prov, ok := svc.(CloudCredentialProvider) - if !ok { - return fmt.Errorf("platform.do_networking %q: service %q does not implement CloudCredentialProvider", m.name, accountName) - } - m.provider = prov - if providerType == "mock" { - providerType = prov.Provider() - } - } - - vpc := m.vpcConfig() - m.state = &DOVPCState{ - Name: vpc["name"], - Region: vpc["region"], - IPRange: vpc["ip_range"], - Status: "pending", - } - - switch providerType { - case "mock": - m.backend = &doNetworkingMockBackend{} - case "digitalocean": - acc, ok := app.SvcRegistry()[accountName].(*CloudAccount) - if !ok { - return fmt.Errorf("platform.do_networking %q: account %q is not a *CloudAccount", m.name, accountName) - } - client, err := acc.doClient() - if err != nil { - return fmt.Errorf("platform.do_networking %q: %w", m.name, err) - } - m.backend = &doNetworkingRealBackend{client: client} - default: - return fmt.Errorf("platform.do_networking %q: unsupported provider %q", m.name, providerType) - } - - if err := app.RegisterService(m.name, m); err != nil { - return err - } - return app.RegisterService(m.name+".iac", &DONetworkingPlatformAdapter{m}) -} - -// ProvidesServices declares the service this module provides. -func (m *PlatformDONetworking) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "DO networking: " + m.name, Instance: m}, - {Name: m.name + ".iac", Description: "DO networking IaC adapter: " + m.name, Instance: &DONetworkingPlatformAdapter{m}}, - } -} - -// RequiresServices returns nil. -func (m *PlatformDONetworking) RequiresServices() []modular.ServiceDependency { return nil } - -// Plan returns the planned networking changes. -func (m *PlatformDONetworking) Plan() (*DONetworkPlan, error) { return m.backend.plan(m) } - -// Apply creates or updates the VPC and firewalls. -func (m *PlatformDONetworking) Apply() (*DOVPCState, error) { return m.backend.apply(m) } - -// Status returns the current VPC state. -func (m *PlatformDONetworking) Status() (*DOVPCState, error) { return m.backend.status(m) } - -// Destroy deletes the VPC and associated resources. -func (m *PlatformDONetworking) Destroy() error { return m.backend.destroy(m) } - -// vpcConfig parses VPC config from module config. -func (m *PlatformDONetworking) vpcConfig() map[string]string { - result := map[string]string{ - "name": m.name, - "region": "nyc3", - "ip_range": "10.10.10.0/24", - } - raw, ok := m.config["vpc"].(map[string]any) - if !ok { - return result - } - if n, ok := raw["name"].(string); ok && n != "" { - result["name"] = n - } - if r, ok := raw["region"].(string); ok && r != "" { - result["region"] = r - } - if ip, ok := raw["ip_range"].(string); ok && ip != "" { - result["ip_range"] = ip - } - return result -} - -// firewallConfigs parses firewall configs from module config. -func (m *PlatformDONetworking) firewallConfigs() []DOFirewallConfig { - raw, ok := m.config["firewalls"].([]any) - if !ok { - return nil - } - var fws []DOFirewallConfig - for _, item := range raw { - fw, ok := item.(map[string]any) - if !ok { - continue - } - name, _ := fw["name"].(string) - fws = append(fws, DOFirewallConfig{Name: name}) - } - return fws -} - -// ─── PlatformProvider adapter ────────────────────────────────────────────────── - -// DONetworkingPlatformAdapter wraps PlatformDONetworking to implement PlatformProvider. -type DONetworkingPlatformAdapter struct { - *PlatformDONetworking -} - -// Plan implements PlatformProvider. -func (a *DONetworkingPlatformAdapter) Plan() (*PlatformPlan, error) { - p, err := a.PlatformDONetworking.Plan() - if err != nil { - return nil, err - } - var actions []PlatformAction - for _, change := range p.Changes { - actionType := "create" - if change == "no changes" { - actionType = "noop" - } - actions = append(actions, PlatformAction{ - Type: actionType, - Resource: p.VPC, - Detail: change, - }) - } - return &PlatformPlan{ - Provider: "digitalocean", - Resource: "networking", - Actions: actions, - }, nil -} - -// Apply implements PlatformProvider. -func (a *DONetworkingPlatformAdapter) Apply() (*PlatformResult, error) { - st, err := a.PlatformDONetworking.Apply() - if err != nil { - return &PlatformResult{Success: false, Message: err.Error()}, err - } - return &PlatformResult{ - Success: true, - Message: fmt.Sprintf("VPC %s created in %s", st.Name, st.Region), - State: st, - }, nil -} - -// Status implements PlatformProvider. -func (a *DONetworkingPlatformAdapter) Status() (any, error) { - return a.PlatformDONetworking.Status() -} - -// Destroy implements PlatformProvider. -func (a *DONetworkingPlatformAdapter) Destroy() error { - return a.PlatformDONetworking.Destroy() -} - -// ─── mock backend ────────────────────────────────────────────────────────────── - -type doNetworkingMockBackend struct{} - -func (b *doNetworkingMockBackend) plan(m *PlatformDONetworking) (*DONetworkPlan, error) { - if m.state.Status == "active" { - return &DONetworkPlan{ - VPC: m.state.Name, - Changes: []string{"no changes"}, - }, nil - } - fws := m.firewallConfigs() - changes := []string{fmt.Sprintf("create VPC %q in %s (%s)", m.state.Name, m.state.Region, m.state.IPRange)} - for _, fw := range fws { - changes = append(changes, fmt.Sprintf("create firewall %q", fw.Name)) - } - return &DONetworkPlan{ - VPC: m.state.Name, - Firewalls: fws, - Changes: changes, - }, nil -} - -func (b *doNetworkingMockBackend) apply(m *PlatformDONetworking) (*DOVPCState, error) { - if m.state.Status == "active" { - return m.state, nil - } - m.state.ID = fmt.Sprintf("mock-vpc-%s", m.state.Name) - m.state.Status = "active" - fws := m.firewallConfigs() - for i, fw := range fws { - m.state.FirewallIDs = append(m.state.FirewallIDs, fmt.Sprintf("mock-fw-%d-%s", i, fw.Name)) - } - return m.state, nil -} - -func (b *doNetworkingMockBackend) status(m *PlatformDONetworking) (*DOVPCState, error) { - return m.state, nil -} - -func (b *doNetworkingMockBackend) destroy(m *PlatformDONetworking) error { - if m.state.Status == "deleted" { - return nil - } - m.state.Status = "deleted" - m.state.FirewallIDs = nil - m.state.LBID = "" - return nil -} - -// ─── real backend ────────────────────────────────────────────────────────────── - -type doNetworkingRealBackend struct { - client *godo.Client -} - -func (b *doNetworkingRealBackend) plan(m *PlatformDONetworking) (*DONetworkPlan, error) { - fws := m.firewallConfigs() - changes := []string{fmt.Sprintf("create VPC %q in %s (%s)", m.state.Name, m.state.Region, m.state.IPRange)} - for _, fw := range fws { - changes = append(changes, fmt.Sprintf("create firewall %q", fw.Name)) - } - return &DONetworkPlan{ - VPC: m.state.Name, - Firewalls: fws, - Changes: changes, - }, nil -} - -func (b *doNetworkingRealBackend) apply(m *PlatformDONetworking) (*DOVPCState, error) { - req := &godo.VPCCreateRequest{ - Name: m.state.Name, - RegionSlug: m.state.Region, - IPRange: m.state.IPRange, - } - vpc, _, err := b.client.VPCs.Create(context.Background(), req) - if err != nil { - return nil, fmt.Errorf("do_networking create VPC: %w", err) - } - m.state.ID = vpc.ID - m.state.Status = "active" - - fws := m.firewallConfigs() - for _, fw := range fws { - fwReq := &godo.FirewallRequest{Name: fw.Name} - created, _, fwErr := b.client.Firewalls.Create(context.Background(), fwReq) - if fwErr != nil { - return nil, fmt.Errorf("do_networking create firewall %q: %w", fw.Name, fwErr) - } - m.state.FirewallIDs = append(m.state.FirewallIDs, created.ID) - } - return m.state, nil -} - -func (b *doNetworkingRealBackend) status(m *PlatformDONetworking) (*DOVPCState, error) { - if m.state.ID == "" { - return m.state, nil - } - vpc, _, err := b.client.VPCs.Get(context.Background(), m.state.ID) - if err != nil { - return nil, fmt.Errorf("do_networking get VPC: %w", err) - } - m.state.Name = vpc.Name - m.state.Region = vpc.RegionSlug - m.state.IPRange = vpc.IPRange - return m.state, nil -} - -func (b *doNetworkingRealBackend) destroy(m *PlatformDONetworking) error { - for _, fwID := range m.state.FirewallIDs { - if _, err := b.client.Firewalls.Delete(context.Background(), fwID); err != nil { - return fmt.Errorf("do_networking delete firewall %q: %w", fwID, err) - } - } - if m.state.ID != "" { - if _, err := b.client.VPCs.Delete(context.Background(), m.state.ID); err != nil { - return fmt.Errorf("do_networking delete VPC: %w", err) - } - } - m.state.Status = "deleted" - m.state.FirewallIDs = nil - return nil -} diff --git a/module/platform_do_networking_test.go b/module/platform_do_networking_test.go deleted file mode 100644 index 08667a08..00000000 --- a/module/platform_do_networking_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package module_test - -import ( - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -func newDONetworkingApp(t *testing.T) (*module.MockApplication, *module.PlatformDONetworking) { - t.Helper() - app := module.NewMockApplication() - m := module.NewPlatformDONetworking("staging-vpc", map[string]any{ - "provider": "mock", - "vpc": map[string]any{ - "name": "staging-vpc", - "region": "nyc3", - "ip_range": "10.20.0.0/16", - }, - "firewalls": []any{ - map[string]any{"name": "allow-web"}, - map[string]any{"name": "allow-db"}, - }, - }) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - return app, m -} - -// ─── module lifecycle ───────────────────────────────────────────────────────── - -func TestDO_Networking_Init(t *testing.T) { - _, m := newDONetworkingApp(t) - if m.Name() != "staging-vpc" { - t.Errorf("expected name=staging-vpc, got %q", m.Name()) - } -} - -func TestDO_Networking_InitRegistersService(t *testing.T) { - app, _ := newDONetworkingApp(t) - svc, ok := app.Services["staging-vpc"] - if !ok { - t.Fatal("expected staging-vpc in service registry") - } - if _, ok := svc.(*module.PlatformDONetworking); !ok { - t.Fatalf("registry entry is %T, want *PlatformDONetworking", svc) - } -} - -func TestDO_Networking_Plan_PendingState(t *testing.T) { - _, m := newDONetworkingApp(t) - plan, err := m.Plan() - if err != nil { - t.Fatalf("Plan: %v", err) - } - if plan.VPC != "staging-vpc" { - t.Errorf("expected vpc=staging-vpc, got %q", plan.VPC) - } - if len(plan.Changes) == 0 { - t.Error("expected at least one change in plan") - } - if len(plan.Firewalls) != 2 { - t.Errorf("expected 2 firewalls, got %d", len(plan.Firewalls)) - } -} - -func TestDO_Networking_Plan_NoopAfterApply(t *testing.T) { - _, m := newDONetworkingApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - plan, err := m.Plan() - if err != nil { - t.Fatalf("second Plan: %v", err) - } - if len(plan.Changes) == 0 || plan.Changes[0] != "no changes" { - t.Errorf("expected 'no changes', got %v", plan.Changes) - } -} - -func TestDO_Networking_Apply(t *testing.T) { - _, m := newDONetworkingApp(t) - state, err := m.Apply() - if err != nil { - t.Fatalf("Apply: %v", err) - } - if state.Status != "active" { - t.Errorf("expected status=active, got %q", state.Status) - } - if state.ID == "" { - t.Error("expected non-empty VPC ID after apply") - } - if len(state.FirewallIDs) != 2 { - t.Errorf("expected 2 firewall IDs, got %d", len(state.FirewallIDs)) - } -} - -func TestDO_Networking_Status(t *testing.T) { - _, m := newDONetworkingApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - state, err := m.Status() - if err != nil { - t.Fatalf("Status: %v", err) - } - if state.Status != "active" { - t.Errorf("expected status=active, got %q", state.Status) - } -} - -func TestDO_Networking_Destroy(t *testing.T) { - _, m := newDONetworkingApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := m.Destroy(); err != nil { - t.Fatalf("Destroy: %v", err) - } - state, err := m.Status() - if err != nil { - t.Fatalf("Status after destroy: %v", err) - } - if state.Status != "deleted" { - t.Errorf("expected status=deleted after destroy, got %q", state.Status) - } - if len(state.FirewallIDs) != 0 { - t.Errorf("expected no firewall IDs after destroy, got %d", len(state.FirewallIDs)) - } -} - -func TestDO_Networking_DestroyIdempotent(t *testing.T) { - _, m := newDONetworkingApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := m.Destroy(); err != nil { - t.Fatalf("first Destroy: %v", err) - } - if err := m.Destroy(); err != nil { - t.Errorf("second Destroy should be idempotent, got: %v", err) - } -} - -// ─── PlatformProvider adapter ───────────────────────────────────────────────── - -func TestDO_Networking_AdapterImplementsPlatformProvider(t *testing.T) { - app, _ := newDONetworkingApp(t) - svc, ok := app.Services["staging-vpc.iac"] - if !ok { - t.Fatal("expected staging-vpc.iac in service registry") - } - if _, ok := svc.(module.PlatformProvider); !ok { - t.Fatalf("staging-vpc.iac service (%T) does not implement PlatformProvider", svc) - } -} - -func TestDO_Networking_AdapterPlan(t *testing.T) { - app, _ := newDONetworkingApp(t) - prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) - plan, err := prov.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if plan.Provider != "digitalocean" { - t.Errorf("expected provider digitalocean, got %s", plan.Provider) - } - if plan.Resource != "networking" { - t.Errorf("expected resource networking, got %s", plan.Resource) - } - if len(plan.Actions) == 0 { - t.Fatal("expected at least one action") - } -} - -func TestDO_Networking_AdapterPlanNoop(t *testing.T) { - app, m := newDONetworkingApp(t) - if _, err := m.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) - plan, err := prov.Plan() - if err != nil { - t.Fatalf("Plan() error: %v", err) - } - if len(plan.Actions) != 1 { - t.Fatalf("expected 1 noop action, got %d", len(plan.Actions)) - } - if plan.Actions[0].Type != "noop" { - t.Errorf("expected noop action after apply, got %s", plan.Actions[0].Type) - } -} - -func TestDO_Networking_AdapterApply(t *testing.T) { - app, _ := newDONetworkingApp(t) - prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) - result, err := prov.Apply() - if err != nil { - t.Fatalf("Apply() error: %v", err) - } - if !result.Success { - t.Errorf("expected success, got message: %s", result.Message) - } - if result.State == nil { - t.Error("expected non-nil state") - } -} - -func TestDO_Networking_AdapterStatus(t *testing.T) { - app, _ := newDONetworkingApp(t) - prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) - st, err := prov.Status() - if err != nil { - t.Fatalf("Status() error: %v", err) - } - if st == nil { - t.Error("expected non-nil status") - } -} - -func TestDO_Networking_AdapterDestroy(t *testing.T) { - app, _ := newDONetworkingApp(t) - prov := app.Services["staging-vpc.iac"].(module.PlatformProvider) - if _, err := prov.Apply(); err != nil { - t.Fatalf("Apply: %v", err) - } - if err := prov.Destroy(); err != nil { - t.Fatalf("Destroy() error: %v", err) - } - st, err := prov.Status() - if err != nil { - t.Fatalf("Status after destroy: %v", err) - } - vpcState, ok := st.(*module.DOVPCState) - if !ok { - t.Fatalf("expected *DOVPCState, got %T", st) - } - if vpcState.Status != "deleted" { - t.Errorf("expected status deleted, got %s", vpcState.Status) - } -} - -func TestDO_Networking_UnsupportedProvider(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDONetworking("bad-vpc", map[string]any{ - "provider": "azure", - "vpc": map[string]any{"name": "bad"}, - }) - if err := m.Init(app); err == nil { - t.Error("expected error for unsupported provider, got nil") - } -} - -func TestDO_Networking_InvalidAccountRef(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDONetworking("fail-vpc", map[string]any{ - "provider": "mock", - "account": "nonexistent", - "vpc": map[string]any{"name": "fail"}, - }) - if err := m.Init(app); err == nil { - t.Error("expected error for nonexistent account, got nil") - } -} diff --git a/module/platform_doks.go b/module/platform_doks.go deleted file mode 100644 index 5dbbd7b2..00000000 --- a/module/platform_doks.go +++ /dev/null @@ -1,329 +0,0 @@ -package module - -import ( - "context" - "fmt" - "time" - - "github.com/GoCodeAlone/modular" - "github.com/digitalocean/godo" -) - -// DOKSClusterState holds the current state of a managed DOKS cluster. -type DOKSClusterState struct { - ID string `json:"id"` - Name string `json:"name"` - Region string `json:"region"` - Version string `json:"version"` - Status string `json:"status"` // pending, creating, running, deleting, deleted - NodePools []DOKSNodePoolState `json:"nodePools"` - Endpoint string `json:"endpoint"` - CreatedAt time.Time `json:"createdAt"` -} - -// DOKSNodePoolState describes a DOKS node pool. -type DOKSNodePoolState struct { - ID string `json:"id"` - Name string `json:"name"` - Size string `json:"size"` - Count int `json:"count"` - AutoScale bool `json:"autoScale"` - MinNodes int `json:"minNodes"` - MaxNodes int `json:"maxNodes"` -} - -// doksBackend is the internal interface DOKS backends implement. -type doksBackend interface { - create(m *PlatformDOKS) (*DOKSClusterState, error) - get(m *PlatformDOKS) (*DOKSClusterState, error) - delete(m *PlatformDOKS) error - listNodePools(m *PlatformDOKS) ([]DOKSNodePoolState, error) -} - -// PlatformDOKS manages DigitalOcean Kubernetes (DOKS) clusters. -// Config: -// -// account: name of a cloud.account module (provider=digitalocean) -// cluster_name: DOKS cluster name -// region: DO region slug (e.g. nyc3) -// version: Kubernetes version slug (e.g. 1.29.1-do.0) -// node_pool: node pool config (size, count, auto_scale, min_nodes, max_nodes) -type PlatformDOKS struct { - name string - config map[string]any - provider CloudCredentialProvider - state *DOKSClusterState - backend doksBackend -} - -// NewPlatformDOKS creates a new PlatformDOKS module. -func NewPlatformDOKS(name string, cfg map[string]any) *PlatformDOKS { - return &PlatformDOKS{name: name, config: cfg} -} - -// Name returns the module name. -func (m *PlatformDOKS) Name() string { return m.name } - -// Init resolves the cloud.account service and initializes the backend. -func (m *PlatformDOKS) Init(app modular.Application) error { - clusterName, _ := m.config["cluster_name"].(string) - if clusterName == "" { - clusterName = m.name - } - - region, _ := m.config["region"].(string) - if region == "" { - region = "nyc3" - } - - version, _ := m.config["version"].(string) - if version == "" { - version = "1.29.1-do.0" - } - - accountName, _ := m.config["account"].(string) - providerType := "mock" - - if accountName != "" { - svc, ok := app.SvcRegistry()[accountName] - if !ok { - return fmt.Errorf("platform.doks %q: account service %q not found", m.name, accountName) - } - prov, ok := svc.(CloudCredentialProvider) - if !ok { - return fmt.Errorf("platform.doks %q: service %q does not implement CloudCredentialProvider", m.name, accountName) - } - m.provider = prov - providerType = prov.Provider() - } - - m.state = &DOKSClusterState{ - Name: clusterName, - Region: region, - Version: version, - Status: "pending", - } - - switch providerType { - case "digitalocean": - acc, ok := app.SvcRegistry()[accountName].(*CloudAccount) - if !ok { - return fmt.Errorf("platform.doks %q: account %q is not a *CloudAccount", m.name, accountName) - } - client, err := acc.doClient() - if err != nil { - return fmt.Errorf("platform.doks %q: %w", m.name, err) - } - m.backend = &doksRealBackend{client: client} - default: - m.backend = &doksMockBackend{} - } - - return app.RegisterService(m.name, m) -} - -// ProvidesServices declares the service this module provides. -func (m *PlatformDOKS) ProvidesServices() []modular.ServiceProvider { - return []modular.ServiceProvider{ - {Name: m.name, Description: "DOKS cluster: " + m.name, Instance: m}, - } -} - -// RequiresServices returns nil. -func (m *PlatformDOKS) RequiresServices() []modular.ServiceDependency { return nil } - -// Create creates the DOKS cluster. -func (m *PlatformDOKS) Create() (*DOKSClusterState, error) { - return m.backend.create(m) -} - -// Get returns the current cluster state. -func (m *PlatformDOKS) Get() (*DOKSClusterState, error) { - return m.backend.get(m) -} - -// Delete removes the DOKS cluster. -func (m *PlatformDOKS) Delete() error { - return m.backend.delete(m) -} - -// ListNodePools returns the node pools for the cluster. -func (m *PlatformDOKS) ListNodePools() ([]DOKSNodePoolState, error) { - return m.backend.listNodePools(m) -} - -// nodePoolConfig parses node pool config from module config. -func (m *PlatformDOKS) nodePoolConfig() DOKSNodePoolState { - raw, ok := m.config["node_pool"].(map[string]any) - if !ok { - return DOKSNodePoolState{Name: "default", Size: "s-2vcpu-2gb", Count: 3} - } - name, _ := raw["name"].(string) - if name == "" { - name = "default" - } - size, _ := raw["size"].(string) - if size == "" { - size = "s-2vcpu-2gb" - } - count, _ := intFromAny(raw["count"]) - if count == 0 { - count = 3 - } - autoScale, _ := raw["auto_scale"].(bool) - minNodes, _ := intFromAny(raw["min_nodes"]) - maxNodes, _ := intFromAny(raw["max_nodes"]) - return DOKSNodePoolState{ - Name: name, - Size: size, - Count: count, - AutoScale: autoScale, - MinNodes: minNodes, - MaxNodes: maxNodes, - } -} - -// ─── mock backend ────────────────────────────────────────────────────────────── - -type doksMockBackend struct{} - -func (b *doksMockBackend) create(m *PlatformDOKS) (*DOKSClusterState, error) { - if m.state.Status == "running" { - return m.state, nil - } - m.state.Status = "creating" - m.state.ID = fmt.Sprintf("mock-doks-%s", m.state.Name) - m.state.Endpoint = fmt.Sprintf("https://%s.k8s.ondigitalocean.com", m.state.Name) - m.state.CreatedAt = time.Now() - np := m.nodePoolConfig() - np.ID = fmt.Sprintf("mock-pool-%s", np.Name) - m.state.NodePools = []DOKSNodePoolState{np} - m.state.Status = "running" - return m.state, nil -} - -func (b *doksMockBackend) get(m *PlatformDOKS) (*DOKSClusterState, error) { - return m.state, nil -} - -func (b *doksMockBackend) delete(m *PlatformDOKS) error { - if m.state.Status == "deleted" { - return nil - } - m.state.Status = "deleted" - m.state.NodePools = nil - return nil -} - -func (b *doksMockBackend) listNodePools(m *PlatformDOKS) ([]DOKSNodePoolState, error) { - return m.state.NodePools, nil -} - -// ─── real backend ────────────────────────────────────────────────────────────── - -// doksRealBackend uses godo to manage real DOKS clusters. -type doksRealBackend struct { - client *godo.Client -} - -func (b *doksRealBackend) create(m *PlatformDOKS) (*DOKSClusterState, error) { - np := m.nodePoolConfig() - nodePool := &godo.KubernetesNodePoolCreateRequest{ - Name: np.Name, - Size: np.Size, - Count: np.Count, - AutoScale: np.AutoScale, - MinNodes: np.MinNodes, - MaxNodes: np.MaxNodes, - } - - req := &godo.KubernetesClusterCreateRequest{ - Name: m.state.Name, - RegionSlug: m.state.Region, - VersionSlug: m.state.Version, - NodePools: []*godo.KubernetesNodePoolCreateRequest{nodePool}, - } - - cluster, _, err := b.client.Kubernetes.Create(context.Background(), req) - if err != nil { - return nil, fmt.Errorf("doks create: %w", err) - } - - return doksClusterToState(cluster), nil -} - -func (b *doksRealBackend) get(m *PlatformDOKS) (*DOKSClusterState, error) { - if m.state.ID == "" { - return m.state, nil - } - cluster, _, err := b.client.Kubernetes.Get(context.Background(), m.state.ID) - if err != nil { - return nil, fmt.Errorf("doks get: %w", err) - } - state := doksClusterToState(cluster) - m.state = state - return state, nil -} - -func (b *doksRealBackend) delete(m *PlatformDOKS) error { - if m.state.ID == "" { - return nil - } - _, err := b.client.Kubernetes.Delete(context.Background(), m.state.ID) - if err != nil { - return fmt.Errorf("doks delete: %w", err) - } - m.state.Status = "deleted" - m.state.NodePools = nil - return nil -} - -func (b *doksRealBackend) listNodePools(m *PlatformDOKS) ([]DOKSNodePoolState, error) { - if m.state.ID == "" { - return nil, nil - } - pools, _, err := b.client.Kubernetes.ListNodePools(context.Background(), m.state.ID, nil) - if err != nil { - return nil, fmt.Errorf("doks list node pools: %w", err) - } - var result []DOKSNodePoolState - for _, p := range pools { - result = append(result, DOKSNodePoolState{ - ID: p.ID, - Name: p.Name, - Size: p.Size, - Count: p.Count, - AutoScale: p.AutoScale, - MinNodes: p.MinNodes, - MaxNodes: p.MaxNodes, - }) - } - return result, nil -} - -// doksClusterToState converts a godo.KubernetesCluster to DOKSClusterState. -func doksClusterToState(c *godo.KubernetesCluster) *DOKSClusterState { - state := &DOKSClusterState{ - ID: c.ID, - Name: c.Name, - Region: c.RegionSlug, - Version: c.VersionSlug, - Status: string(c.Status.State), - CreatedAt: c.CreatedAt, - } - if c.Endpoint != "" { - state.Endpoint = c.Endpoint - } - for _, p := range c.NodePools { - state.NodePools = append(state.NodePools, DOKSNodePoolState{ - ID: p.ID, - Name: p.Name, - Size: p.Size, - Count: p.Count, - AutoScale: p.AutoScale, - MinNodes: p.MinNodes, - MaxNodes: p.MaxNodes, - }) - } - return state -} diff --git a/module/platform_doks_test.go b/module/platform_doks_test.go deleted file mode 100644 index 8509fdbe..00000000 --- a/module/platform_doks_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package module_test - -import ( - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -func newDOKSApp(t *testing.T) (*module.MockApplication, *module.PlatformDOKS) { - t.Helper() - app := module.NewMockApplication() - m := module.NewPlatformDOKS("my-cluster", map[string]any{ - "cluster_name": "staging-cluster", - "region": "nyc3", - "version": "1.29.1-do.0", - "node_pool": map[string]any{ - "name": "default", - "size": "s-2vcpu-2gb", - "count": 3, - }, - }) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - return app, m -} - -// ─── module lifecycle ───────────────────────────────────────────────────────── - -func TestDO_DOKS_Init(t *testing.T) { - _, m := newDOKSApp(t) - if m.Name() != "my-cluster" { - t.Errorf("expected name=my-cluster, got %q", m.Name()) - } -} - -func TestDO_DOKS_InitRegistersService(t *testing.T) { - app, _ := newDOKSApp(t) - svc, ok := app.Services["my-cluster"] - if !ok { - t.Fatal("expected my-cluster in service registry") - } - if _, ok := svc.(*module.PlatformDOKS); !ok { - t.Fatalf("registry entry is %T, want *PlatformDOKS", svc) - } -} - -func TestDO_DOKS_Create(t *testing.T) { - _, m := newDOKSApp(t) - state, err := m.Create() - if err != nil { - t.Fatalf("Create: %v", err) - } - if state.Status != "running" { - t.Errorf("expected status=running, got %q", state.Status) - } - if state.ID == "" { - t.Error("expected non-empty ID after create") - } - if state.Endpoint == "" { - t.Error("expected non-empty Endpoint after create") - } - if len(state.NodePools) == 0 { - t.Error("expected at least one node pool after create") - } -} - -func TestDO_DOKS_Get(t *testing.T) { - _, m := newDOKSApp(t) - if _, err := m.Create(); err != nil { - t.Fatalf("Create: %v", err) - } - state, err := m.Get() - if err != nil { - t.Fatalf("Get: %v", err) - } - if state.Status != "running" { - t.Errorf("expected status=running, got %q", state.Status) - } -} - -func TestDO_DOKS_ListNodePools(t *testing.T) { - _, m := newDOKSApp(t) - if _, err := m.Create(); err != nil { - t.Fatalf("Create: %v", err) - } - pools, err := m.ListNodePools() - if err != nil { - t.Fatalf("ListNodePools: %v", err) - } - if len(pools) == 0 { - t.Error("expected at least one node pool") - } - if pools[0].Name != "default" { - t.Errorf("expected pool name=default, got %q", pools[0].Name) - } - if pools[0].Count != 3 { - t.Errorf("expected count=3, got %d", pools[0].Count) - } -} - -func TestDO_DOKS_Delete(t *testing.T) { - _, m := newDOKSApp(t) - if _, err := m.Create(); err != nil { - t.Fatalf("Create: %v", err) - } - if err := m.Delete(); err != nil { - t.Fatalf("Delete: %v", err) - } - state, err := m.Get() - if err != nil { - t.Fatalf("Get after delete: %v", err) - } - if state.Status != "deleted" { - t.Errorf("expected status=deleted, got %q", state.Status) - } - if len(state.NodePools) != 0 { - t.Errorf("expected no node pools after delete, got %d", len(state.NodePools)) - } -} - -func TestDO_DOKS_DeleteIdempotent(t *testing.T) { - _, m := newDOKSApp(t) - if _, err := m.Create(); err != nil { - t.Fatalf("Create: %v", err) - } - if err := m.Delete(); err != nil { - t.Fatalf("first Delete: %v", err) - } - if err := m.Delete(); err != nil { - t.Errorf("second Delete should be idempotent, got: %v", err) - } -} - -func TestDO_DOKS_DefaultNodePool(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDOKS("default-cluster", map[string]any{ - "cluster_name": "default-cluster", - }) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - state, err := m.Create() - if err != nil { - t.Fatalf("Create: %v", err) - } - if len(state.NodePools) == 0 { - t.Error("expected default node pool") - } - if state.NodePools[0].Size != "s-2vcpu-2gb" { - t.Errorf("expected default size=s-2vcpu-2gb, got %q", state.NodePools[0].Size) - } -} - -func TestDO_DOKS_InvalidAccountRef(t *testing.T) { - app := module.NewMockApplication() - m := module.NewPlatformDOKS("fail-cluster", map[string]any{ - "cluster_name": "fail-cluster", - "account": "nonexistent", - }) - if err := m.Init(app); err == nil { - t.Error("expected error for nonexistent account, got nil") - } -} diff --git a/plugin/external/adapter.go b/plugin/external/adapter.go index 3c304cc9..82a0a4e0 100644 --- a/plugin/external/adapter.go +++ b/plugin/external/adapter.go @@ -303,6 +303,21 @@ func createTypedConfigRequest(descriptor *pb.ContractDescriptor, cfg map[string] } return s, nil, nil } + // Contracts that declare a typed Mode (STRICT_PROTO or + // PROTO_WITH_LEGACY_STRUCT) but leave ConfigMessage empty have no + // per-instance config schema — primarily input-only steps like + // step.eventbus.ack/publish/consume where data flows through the + // InputMessage proto, but also applies to any contract Kind that + // legitimately omits a config schema. Encode cfg as legacy struct + // only; typed payload is nil. The plugin's typed factory reads data + // from the input message (or other typed payload), not from config. + if descriptor.ConfigMessage == "" { + s, err := mapToStruct(cfg) + if err != nil { + return nil, nil, fmt.Errorf("encode config as Struct (no typed config schema): %w", err) + } + return s, nil, nil + } // Strip engine-internal "_"-prefix keys before proto decode. STRICT_PROTO // and PROTO_WITH_LEGACY_STRUCT modules use protojson with DiscardUnknown // = false (convert.go:62), which rejects engine internals like diff --git a/plugin/external/adapter_test.go b/plugin/external/adapter_test.go index 8f1f54e4..2d723f81 100644 --- a/plugin/external/adapter_test.go +++ b/plugin/external/adapter_test.go @@ -1022,6 +1022,48 @@ func TestCreateTypedConfigRequestStripsInternalKeysForStrictProtoStep(t *testing } } +// TestCreateTypedConfigRequestEmptyConfigMessageStrictProto covers +// contracts that declare STRICT_PROTO with InputMessage + OutputMessage but +// no ConfigMessage (input-only steps like step.eventbus.ack / +// step.eventbus.publish). The engine must NOT attempt to encode an +// unnamed typed proto; typed payload is nil, legacy struct mirrors cfg +// (nil cfg → nil legacy via mapToStruct(nil); non-nil cfg → populated +// struct). +func TestCreateTypedConfigRequestEmptyConfigMessageStrictProto(t *testing.T) { + descriptor := &pb.ContractDescriptor{ + Kind: pb.ContractKind_CONTRACT_KIND_STEP, + StepType: "step.eventbus.ack", + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + InputMessage: "workflow.plugin.eventbus.v1.AckRequest", + OutputMessage: "workflow.plugin.eventbus.v1.AckResponse", + // ConfigMessage intentionally empty — step has no per-instance + // config schema; data flows via the input message. + } + // nil cfg — mapToStruct(nil) returns nil; legacy is permitted to be nil. + legacy, typed, err := createTypedConfigRequest(descriptor, nil, nil) + if err != nil { + t.Fatalf("createTypedConfigRequest with nil cfg + empty ConfigMessage: %v", err) + } + if typed != nil { + t.Fatalf("expected nil typed *anypb.Any for input-only step contract; got %v", typed) + } + if legacy != nil { + t.Fatalf("expected nil legacy struct for nil cfg; got %v", legacy.Fields) + } + // Non-nil cfg — fields populated into legacy struct; typed still nil. + cfg := map[string]any{"timeout_ms": float64(5000)} + legacy2, typed2, err := createTypedConfigRequest(descriptor, cfg, nil) + if err != nil { + t.Fatalf("createTypedConfigRequest with cfg + empty ConfigMessage: %v", err) + } + if typed2 != nil { + t.Fatalf("expected nil typed *anypb.Any for input-only step contract; got %v", typed2) + } + if legacy2 == nil || legacy2.Fields["timeout_ms"] == nil { + t.Fatalf("expected legacy struct with timeout_ms populated; got %v", legacy2) + } +} + // TestCreateTypedConfigRequestRetainsInternalKeysInLegacyStruct asserts the // legacy-struct path keeps "_"-prefix keys on its *structpb.Struct payload. // Legacy modules consume "_config_dir" at the plugin side to resolve filesystem- diff --git a/plugins/platform/plugin.go b/plugins/platform/plugin.go index 0b021ee2..c416a25a 100644 --- a/plugins/platform/plugin.go +++ b/plugins/platform/plugin.go @@ -31,8 +31,8 @@ func New() *Plugin { Author: "GoCodeAlone", Description: "Platform infrastructure modules, workflow handler, reconciliation trigger, and template step", Tier: plugin.TierCore, - ModuleTypes: []string{"platform.provider", "platform.resource", "platform.context", "platform.kubernetes", "platform.ecs", "platform.dns", "platform.networking", "platform.apigateway", "platform.autoscaling", "platform.region", "platform.region_router", "platform.doks", "platform.do_networking", "platform.do_dns", "platform.do_app", "platform.do_database", "iac.state", "app.container", "argo.workflows"}, - StepTypes: []string{"step.platform_template", "step.k8s_plan", "step.k8s_apply", "step.k8s_status", "step.k8s_destroy", "step.ecs_plan", "step.ecs_apply", "step.ecs_status", "step.ecs_destroy", "step.iac_plan", "step.iac_apply", "step.iac_status", "step.iac_destroy", "step.iac_drift_detect", "step.dns_plan", "step.dns_apply", "step.dns_status", "step.network_plan", "step.network_apply", "step.network_status", "step.apigw_plan", "step.apigw_apply", "step.apigw_status", "step.apigw_destroy", "step.scaling_plan", "step.scaling_apply", "step.scaling_status", "step.scaling_destroy", "step.app_deploy", "step.app_status", "step.app_rollback", "step.region_deploy", "step.region_promote", "step.region_failover", "step.region_status", "step.region_weight", "step.region_sync", "step.argo_submit", "step.argo_status", "step.argo_logs", "step.argo_delete", "step.argo_list", "step.do_deploy", "step.do_status", "step.do_logs", "step.do_scale", "step.do_destroy"}, + ModuleTypes: []string{"platform.provider", "platform.resource", "platform.context", "platform.kubernetes", "platform.ecs", "platform.dns", "platform.networking", "platform.apigateway", "platform.autoscaling", "platform.region", "platform.region_router", "iac.state", "app.container", "argo.workflows"}, + StepTypes: []string{"step.platform_template", "step.k8s_plan", "step.k8s_apply", "step.k8s_status", "step.k8s_destroy", "step.ecs_plan", "step.ecs_apply", "step.ecs_status", "step.ecs_destroy", "step.iac_plan", "step.iac_apply", "step.iac_status", "step.iac_destroy", "step.iac_drift_detect", "step.dns_plan", "step.dns_apply", "step.dns_status", "step.network_plan", "step.network_apply", "step.network_status", "step.apigw_plan", "step.apigw_apply", "step.apigw_status", "step.apigw_destroy", "step.scaling_plan", "step.scaling_apply", "step.scaling_status", "step.scaling_destroy", "step.app_deploy", "step.app_status", "step.app_rollback", "step.region_deploy", "step.region_promote", "step.region_failover", "step.region_status", "step.region_weight", "step.region_sync", "step.argo_submit", "step.argo_status", "step.argo_logs", "step.argo_delete", "step.argo_list"}, TriggerTypes: []string{"reconciliation"}, WorkflowTypes: []string{"platform"}, }, @@ -94,21 +94,6 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { "argo.workflows": func(name string, cfg map[string]any) modular.Module { return module.NewArgoWorkflowsModule(name, cfg) }, - "platform.doks": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformDOKS(name, cfg) - }, - "platform.do_networking": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformDONetworking(name, cfg) - }, - "platform.do_dns": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformDODNS(name, cfg) - }, - "platform.do_app": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformDOApp(name, cfg) - }, - "platform.do_database": func(name string, cfg map[string]any) modular.Module { - return module.NewPlatformDODatabase(name, cfg) - }, "platform.region_router": func(name string, cfg map[string]any) modular.Module { return module.NewMultiRegionRoutingModule(name, cfg) }, @@ -244,21 +229,6 @@ func (p *Plugin) StepFactories() map[string]plugin.StepFactory { "step.argo_list": func(name string, cfg map[string]any, app modular.Application) (any, error) { return module.NewArgoListStepFactory()(name, cfg, app) }, - "step.do_deploy": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewDODeployStepFactory()(name, cfg, app) - }, - "step.do_status": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewDOStatusStepFactory()(name, cfg, app) - }, - "step.do_logs": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewDOLogsStepFactory()(name, cfg, app) - }, - "step.do_scale": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewDOScaleStepFactory()(name, cfg, app) - }, - "step.do_destroy": func(name string, cfg map[string]any, app modular.Application) (any, error) { - return module.NewDODestroyStepFactory()(name, cfg, app) - }, } } @@ -425,74 +395,5 @@ func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { {Key: "regions", Label: "Regions", Type: schema.FieldTypeArray, Required: true, Description: "List of region definitions (name, provider, endpoint, priority, health_check)"}, }, }, - { - Type: "platform.doks", - Label: "DigitalOcean Kubernetes (DOKS)", - Category: "infrastructure", - Description: "Manages DigitalOcean Kubernetes Service clusters (mock or real DO backend)", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (provider=digitalocean)"}, - {Key: "cluster_name", Label: "Cluster Name", Type: schema.FieldTypeString, Description: "DOKS cluster name"}, - {Key: "region", Label: "Region", Type: schema.FieldTypeString, Description: "DO region slug (e.g. nyc3)"}, - {Key: "version", Label: "Kubernetes Version", Type: schema.FieldTypeString, Description: "Kubernetes version slug (e.g. 1.29.1-do.0)"}, - {Key: "node_pool", Label: "Node Pool", Type: schema.FieldTypeMap, Description: "Node pool config (size, count, auto_scale, min_nodes, max_nodes)"}, - }, - }, - { - Type: "platform.do_networking", - Label: "DigitalOcean VPC & Firewalls", - Category: "infrastructure", - Description: "Manages DigitalOcean VPCs, firewalls, and load balancers (mock or real DO backend)", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (provider=digitalocean)"}, - {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | digitalocean"}, - {Key: "vpc", Label: "VPC Config", Type: schema.FieldTypeMap, Required: true, Description: "VPC configuration (name, region, ip_range)"}, - {Key: "firewalls", Label: "Firewalls", Type: schema.FieldTypeArray, Description: "List of firewall definitions"}, - }, - }, - { - Type: "platform.do_dns", - Label: "DigitalOcean DNS", - Category: "infrastructure", - Description: "Manages DigitalOcean domains and DNS records (mock or real DO backend)", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (provider=digitalocean)"}, - {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | digitalocean"}, - {Key: "domain", Label: "Domain", Type: schema.FieldTypeString, Required: true, Description: "Domain name (e.g. example.com)"}, - {Key: "records", Label: "Records", Type: schema.FieldTypeArray, Description: "List of DNS record definitions (name, type, data, ttl)"}, - }, - }, - { - Type: "platform.do_app", - Label: "DigitalOcean App Platform", - Category: "application", - Description: "Deploys containerized apps to DigitalOcean App Platform (mock or real DO backend)", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (provider=digitalocean)"}, - {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | digitalocean"}, - {Key: "name", Label: "App Name", Type: schema.FieldTypeString, Description: "App Platform application name"}, - {Key: "region", Label: "Region", Type: schema.FieldTypeString, Description: "DO region slug (e.g. nyc)"}, - {Key: "image", Label: "Container Image", Type: schema.FieldTypeString, Description: "Container image reference"}, - {Key: "instances", Label: "Instances", Type: schema.FieldTypeNumber, Description: "Number of instances (default: 1)"}, - {Key: "http_port", Label: "HTTP Port", Type: schema.FieldTypeNumber, Description: "Container HTTP port (default: 8080)"}, - {Key: "envs", Label: "Environment Variables", Type: schema.FieldTypeMap, Description: "Environment variables for the app"}, - }, - }, - { - Type: "platform.do_database", - Label: "DigitalOcean Managed Database", - Category: "infrastructure", - Description: "Manages DigitalOcean Managed Databases (PostgreSQL, MySQL, Redis, MongoDB, Kafka)", - ConfigFields: []schema.ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: schema.FieldTypeString, Description: "Name of the cloud.account module (provider=digitalocean)"}, - {Key: "provider", Label: "Provider", Type: schema.FieldTypeString, Description: "mock | digitalocean"}, - {Key: "engine", Label: "Engine", Type: schema.FieldTypeString, Description: "Database engine: pg | mysql | redis | mongodb | kafka"}, - {Key: "version", Label: "Version", Type: schema.FieldTypeString, Description: "Engine version (e.g. 16 for PostgreSQL)"}, - {Key: "size", Label: "Size", Type: schema.FieldTypeString, Description: "Droplet size slug (e.g. db-s-1vcpu-1gb)"}, - {Key: "region", Label: "Region", Type: schema.FieldTypeString, Description: "DO region slug (e.g. nyc1)"}, - {Key: "num_nodes", Label: "Node Count", Type: schema.FieldTypeNumber, Description: "Number of nodes (default: 1)"}, - {Key: "name", Label: "Cluster Name", Type: schema.FieldTypeString, Description: "Database cluster name"}, - }, - }, } } diff --git a/plugins/platform/plugin_test.go b/plugins/platform/plugin_test.go index 155e585f..42b83253 100644 --- a/plugins/platform/plugin_test.go +++ b/plugins/platform/plugin_test.go @@ -73,11 +73,6 @@ func TestStepFactories(t *testing.T) { "step.argo_logs", "step.argo_delete", "step.argo_list", - "step.do_deploy", - "step.do_status", - "step.do_logs", - "step.do_scale", - "step.do_destroy", } for _, stepType := range expectedSteps { @@ -109,11 +104,6 @@ func TestModuleFactories(t *testing.T) { "app.container", "platform.region", "argo.workflows", - "platform.doks", - "platform.do_networking", - "platform.do_dns", - "platform.do_app", - "platform.do_database", "platform.region_router", } diff --git a/schema/module_schema.go b/schema/module_schema.go index 10b8ba09..1015d47c 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -2685,95 +2685,6 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { }, }) - // ---- Platform DigitalOcean App ---- - - r.Register(&ModuleSchema{ - Type: "platform.do_app", - Label: "DigitalOcean App Platform", - Category: "infrastructure", - Description: "Deploys containerized apps to DigitalOcean App Platform (mock or real DO backend)", - Outputs: []ServiceIODef{{Name: "app", Type: "JSON", Description: "Deployed app endpoint and status on DigitalOcean App Platform"}}, - ConfigFields: []ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "provider", Label: "Provider", Type: FieldTypeString, Description: "mock | digitalocean"}, - {Key: "name", Label: "App Name", Type: FieldTypeString, Description: "App Platform application name"}, - {Key: "region", Label: "Region", Type: FieldTypeString, Description: "DO region slug (e.g. nyc)"}, - {Key: "image", Label: "Container Image", Type: FieldTypeString, Description: "Container image reference"}, - {Key: "instances", Label: "Instances", Type: FieldTypeNumber, Description: "Number of instances"}, - {Key: "http_port", Label: "HTTP Port", Type: FieldTypeNumber, Description: "Container HTTP port"}, - {Key: "envs", Label: "Environment Variables", Type: FieldTypeMap, Description: "Environment variables for the app"}, - }, - }) - - // ---- Platform DigitalOcean Database ---- - - r.Register(&ModuleSchema{ - Type: "platform.do_database", - Label: "DigitalOcean Managed Database", - Category: "infrastructure", - Description: "Manages DigitalOcean Managed Databases (PostgreSQL, MySQL, Redis, MongoDB, Kafka)", - Outputs: []ServiceIODef{{Name: "database", Type: "sql.DB", Description: "Managed database connection for DigitalOcean database cluster"}}, - ConfigFields: []ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "provider", Label: "Provider", Type: FieldTypeString, Description: "mock | digitalocean"}, - {Key: "engine", Label: "Engine", Type: FieldTypeString, Description: "Database engine: pg | mysql | redis | mongodb | kafka"}, - {Key: "version", Label: "Version", Type: FieldTypeString, Description: "Engine version"}, - {Key: "size", Label: "Size", Type: FieldTypeString, Description: "Droplet size slug"}, - {Key: "region", Label: "Region", Type: FieldTypeString, Description: "DO region slug"}, - {Key: "num_nodes", Label: "Node Count", Type: FieldTypeNumber, Description: "Number of nodes"}, - {Key: "name", Label: "Cluster Name", Type: FieldTypeString, Description: "Database cluster name"}, - }, - }) - - // ---- Platform DigitalOcean DNS ---- - - r.Register(&ModuleSchema{ - Type: "platform.do_dns", - Label: "DigitalOcean DNS", - Category: "infrastructure", - Description: "Manages DigitalOcean domains and DNS records (mock or real DO backend)", - Outputs: []ServiceIODef{{Name: "zone", Type: "JSON", Description: "Provisioned DigitalOcean DNS zone and records"}}, - ConfigFields: []ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "provider", Label: "Provider", Type: FieldTypeString, Description: "mock | digitalocean"}, - {Key: "domain", Label: "Domain", Type: FieldTypeString, Required: true, Description: "Domain name (e.g. example.com)"}, - {Key: "records", Label: "Records", Type: FieldTypeArray, Description: "List of DNS record definitions"}, - }, - }) - - // ---- Platform DigitalOcean Networking ---- - - r.Register(&ModuleSchema{ - Type: "platform.do_networking", - Label: "DigitalOcean VPC & Firewalls", - Category: "infrastructure", - Description: "Manages DigitalOcean VPCs, firewalls, and load balancers (mock or real DO backend)", - Outputs: []ServiceIODef{{Name: "vpc", Type: "JSON", Description: "Provisioned DigitalOcean VPC and firewall configuration"}}, - ConfigFields: []ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "provider", Label: "Provider", Type: FieldTypeString, Description: "mock | digitalocean"}, - {Key: "vpc", Label: "VPC Config", Type: FieldTypeMap, Required: true, Description: "VPC configuration (name, region, ip_range)"}, - {Key: "firewalls", Label: "Firewalls", Type: FieldTypeArray, Description: "List of firewall definitions"}, - }, - }) - - // ---- Platform DOKS ---- - - r.Register(&ModuleSchema{ - Type: "platform.doks", - Label: "DigitalOcean Kubernetes (DOKS)", - Category: "infrastructure", - Description: "Manages DigitalOcean Kubernetes Service clusters (mock or real DO backend)", - Outputs: []ServiceIODef{{Name: "cluster", Type: "JSON", Description: "Provisioned DOKS cluster endpoint and kubeconfig"}}, - ConfigFields: []ConfigFieldDef{ - {Key: "account", Label: "Cloud Account", Type: FieldTypeString, Description: "Name of the cloud.account module"}, - {Key: "cluster_name", Label: "Cluster Name", Type: FieldTypeString, Description: "DOKS cluster name"}, - {Key: "region", Label: "Region", Type: FieldTypeString, Description: "DO region slug (e.g. nyc3)"}, - {Key: "version", Label: "Kubernetes Version", Type: FieldTypeString, Description: "Kubernetes version slug"}, - {Key: "node_pool", Label: "Node Pool", Type: FieldTypeMap, Description: "Node pool config"}, - }, - }) - // ---- Platform ECS ---- r.Register(&ModuleSchema{ @@ -3002,11 +2913,6 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { {"step.dns_apply", "DNS Apply", "Applies DNS zone and record changes"}, {"step.dns_plan", "DNS Plan", "Plans DNS changes without applying them"}, {"step.dns_status", "DNS Status", "Gets the current status of a DNS zone"}, - {"step.do_deploy", "DO Deploy", "Deploys to DigitalOcean App Platform"}, - {"step.do_destroy", "DO Destroy", "Destroys a DigitalOcean App Platform application"}, - {"step.do_logs", "DO Logs", "Retrieves logs from DigitalOcean App Platform"}, - {"step.do_scale", "DO Scale", "Scales a DigitalOcean App Platform application"}, - {"step.do_status", "DO Status", "Gets the status of a DigitalOcean App Platform application"}, {"step.ecs_apply", "ECS Apply", "Applies ECS Fargate service deployment"}, {"step.ecs_destroy", "ECS Destroy", "Destroys an ECS Fargate service"}, {"step.ecs_plan", "ECS Plan", "Plans ECS service deployment changes"}, diff --git a/schema/schema.go b/schema/schema.go index d92c9d00..fe6f3029 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -208,11 +208,6 @@ var coreModuleTypes = []string{ "platform.autoscaling", "platform.context", "platform.dns", - "platform.do_app", - "platform.do_database", - "platform.do_dns", - "platform.do_networking", - "platform.doks", "platform.ecs", "platform.kubernetes", "platform.networking", @@ -296,11 +291,6 @@ var coreModuleTypes = []string{ "step.dns_apply", "step.dns_plan", "step.dns_status", - "step.do_deploy", - "step.do_destroy", - "step.do_logs", - "step.do_scale", - "step.do_status", "step.docker_build", "step.docker_push", "step.docker_run", diff --git a/schema/step_schema_builtins.go b/schema/step_schema_builtins.go index 674be6bd..1195ef92 100644 --- a/schema/step_schema_builtins.go +++ b/schema/step_schema_builtins.go @@ -1896,80 +1896,6 @@ func (r *StepSchemaRegistry) registerBuiltins() { }, }) - // ---- DigitalOcean Deploy ---- - - r.Register(&StepSchema{ - Type: "step.do_deploy", - Plugin: "platform", - Description: "Deploys an application to DigitalOcean App Platform.", - ConfigFields: []ConfigFieldDef{ - {Key: "app", Type: FieldTypeString, Description: "Name of the platform.do_app module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "app_id", Type: "string", Description: "DigitalOcean app ID"}, - {Key: "live_url", Type: "string", Description: "App live URL"}, - }, - }) - - // ---- DigitalOcean Destroy ---- - - r.Register(&StepSchema{ - Type: "step.do_destroy", - Plugin: "platform", - Description: "Destroys a DigitalOcean App Platform application.", - ConfigFields: []ConfigFieldDef{ - {Key: "app", Type: FieldTypeString, Description: "Name of the platform.do_app module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "destroyed", Type: "boolean", Description: "Whether the app was destroyed"}, - }, - }) - - // ---- DigitalOcean Logs ---- - - r.Register(&StepSchema{ - Type: "step.do_logs", - Plugin: "platform", - Description: "Retrieves logs from a DigitalOcean App Platform application.", - ConfigFields: []ConfigFieldDef{ - {Key: "app", Type: FieldTypeString, Description: "Name of the platform.do_app module", Required: true}, - {Key: "component", Type: FieldTypeString, Description: "App component name"}, - }, - Outputs: []StepOutputDef{ - {Key: "logs", Type: "string", Description: "Application log output"}, - }, - }) - - // ---- DigitalOcean Scale ---- - - r.Register(&StepSchema{ - Type: "step.do_scale", - Plugin: "platform", - Description: "Scales a DigitalOcean App Platform application.", - ConfigFields: []ConfigFieldDef{ - {Key: "app", Type: FieldTypeString, Description: "Name of the platform.do_app module", Required: true}, - {Key: "instances", Type: FieldTypeNumber, Description: "Desired instance count", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "instances", Type: "number", Description: "New instance count"}, - }, - }) - - // ---- DigitalOcean Status ---- - - r.Register(&StepSchema{ - Type: "step.do_status", - Plugin: "platform", - Description: "Gets the status of a DigitalOcean App Platform application.", - ConfigFields: []ConfigFieldDef{ - {Key: "app", Type: FieldTypeString, Description: "Name of the platform.do_app module", Required: true}, - }, - Outputs: []StepOutputDef{ - {Key: "phase", Type: "string", Description: "App deployment phase"}, - {Key: "live_url", Type: "string", Description: "App live URL"}, - }, - }) - // ---- ECS Apply ---- r.Register(&StepSchema{ diff --git a/schema/testdata/editor-schemas.golden.json b/schema/testdata/editor-schemas.golden.json index 9d602760..bd69e44a 100644 --- a/schema/testdata/editor-schemas.golden.json +++ b/schema/testdata/editor-schemas.golden.json @@ -2859,257 +2859,6 @@ } ] }, - "platform.do_app": { - "type": "platform.do_app", - "label": "DigitalOcean App Platform", - "category": "infrastructure", - "description": "Deploys containerized apps to DigitalOcean App Platform (mock or real DO backend)", - "outputs": [ - { - "name": "app", - "type": "JSON", - "description": "Deployed app endpoint and status on DigitalOcean App Platform" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "provider", - "label": "Provider", - "type": "string", - "description": "mock | digitalocean" - }, - { - "key": "name", - "label": "App Name", - "type": "string", - "description": "App Platform application name" - }, - { - "key": "region", - "label": "Region", - "type": "string", - "description": "DO region slug (e.g. nyc)" - }, - { - "key": "image", - "label": "Container Image", - "type": "string", - "description": "Container image reference" - }, - { - "key": "instances", - "label": "Instances", - "type": "number", - "description": "Number of instances" - }, - { - "key": "http_port", - "label": "HTTP Port", - "type": "number", - "description": "Container HTTP port" - }, - { - "key": "envs", - "label": "Environment Variables", - "type": "map", - "description": "Environment variables for the app" - } - ] - }, - "platform.do_database": { - "type": "platform.do_database", - "label": "DigitalOcean Managed Database", - "category": "infrastructure", - "description": "Manages DigitalOcean Managed Databases (PostgreSQL, MySQL, Redis, MongoDB, Kafka)", - "outputs": [ - { - "name": "database", - "type": "sql.DB", - "description": "Managed database connection for DigitalOcean database cluster" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "provider", - "label": "Provider", - "type": "string", - "description": "mock | digitalocean" - }, - { - "key": "engine", - "label": "Engine", - "type": "string", - "description": "Database engine: pg | mysql | redis | mongodb | kafka" - }, - { - "key": "version", - "label": "Version", - "type": "string", - "description": "Engine version" - }, - { - "key": "size", - "label": "Size", - "type": "string", - "description": "Droplet size slug" - }, - { - "key": "region", - "label": "Region", - "type": "string", - "description": "DO region slug" - }, - { - "key": "num_nodes", - "label": "Node Count", - "type": "number", - "description": "Number of nodes" - }, - { - "key": "name", - "label": "Cluster Name", - "type": "string", - "description": "Database cluster name" - } - ] - }, - "platform.do_dns": { - "type": "platform.do_dns", - "label": "DigitalOcean DNS", - "category": "infrastructure", - "description": "Manages DigitalOcean domains and DNS records (mock or real DO backend)", - "outputs": [ - { - "name": "zone", - "type": "JSON", - "description": "Provisioned DigitalOcean DNS zone and records" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "provider", - "label": "Provider", - "type": "string", - "description": "mock | digitalocean" - }, - { - "key": "domain", - "label": "Domain", - "type": "string", - "description": "Domain name (e.g. example.com)", - "required": true - }, - { - "key": "records", - "label": "Records", - "type": "array", - "description": "List of DNS record definitions" - } - ] - }, - "platform.do_networking": { - "type": "platform.do_networking", - "label": "DigitalOcean VPC \u0026 Firewalls", - "category": "infrastructure", - "description": "Manages DigitalOcean VPCs, firewalls, and load balancers (mock or real DO backend)", - "outputs": [ - { - "name": "vpc", - "type": "JSON", - "description": "Provisioned DigitalOcean VPC and firewall configuration" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "provider", - "label": "Provider", - "type": "string", - "description": "mock | digitalocean" - }, - { - "key": "vpc", - "label": "VPC Config", - "type": "map", - "description": "VPC configuration (name, region, ip_range)", - "required": true - }, - { - "key": "firewalls", - "label": "Firewalls", - "type": "array", - "description": "List of firewall definitions" - } - ] - }, - "platform.doks": { - "type": "platform.doks", - "label": "DigitalOcean Kubernetes (DOKS)", - "category": "infrastructure", - "description": "Manages DigitalOcean Kubernetes Service clusters (mock or real DO backend)", - "outputs": [ - { - "name": "cluster", - "type": "JSON", - "description": "Provisioned DOKS cluster endpoint and kubeconfig" - } - ], - "configFields": [ - { - "key": "account", - "label": "Cloud Account", - "type": "string", - "description": "Name of the cloud.account module" - }, - { - "key": "cluster_name", - "label": "Cluster Name", - "type": "string", - "description": "DOKS cluster name" - }, - { - "key": "region", - "label": "Region", - "type": "string", - "description": "DO region slug (e.g. nyc3)" - }, - { - "key": "version", - "label": "Kubernetes Version", - "type": "string", - "description": "Kubernetes version slug" - }, - { - "key": "node_pool", - "label": "Node Pool", - "type": "map", - "description": "Node pool config" - } - ] - }, "platform.ecs": { "type": "platform.ecs", "label": "ECS Fargate Service", @@ -5672,41 +5421,6 @@ "description": "Gets the current status of a DNS zone", "configFields": [] }, - "step.do_deploy": { - "type": "step.do_deploy", - "label": "DO Deploy", - "category": "pipeline", - "description": "Deploys to DigitalOcean App Platform", - "configFields": [] - }, - "step.do_destroy": { - "type": "step.do_destroy", - "label": "DO Destroy", - "category": "pipeline", - "description": "Destroys a DigitalOcean App Platform application", - "configFields": [] - }, - "step.do_logs": { - "type": "step.do_logs", - "label": "DO Logs", - "category": "pipeline", - "description": "Retrieves logs from DigitalOcean App Platform", - "configFields": [] - }, - "step.do_scale": { - "type": "step.do_scale", - "label": "DO Scale", - "category": "pipeline", - "description": "Scales a DigitalOcean App Platform application", - "configFields": [] - }, - "step.do_status": { - "type": "step.do_status", - "label": "DO Status", - "category": "pipeline", - "description": "Gets the status of a DigitalOcean App Platform application", - "configFields": [] - }, "step.docker_build": { "type": "step.docker_build", "label": "Docker Build",