Skip to content

feat(do): v0.7.0 — SupportedCanonicalKeys + full AppSpec field fill (Tasks 32-36)#13

Merged
intel352 merged 4 commits intomainfrom
feat/v0.7.0-appspec-fill
Apr 23, 2026
Merged

feat(do): v0.7.0 — SupportedCanonicalKeys + full AppSpec field fill (Tasks 32-36)#13
intel352 merged 4 commits intomainfrom
feat/v0.7.0-appspec-fill

Conversation

@intel352
Copy link
Copy Markdown
Contributor

Summary

  • Bumps github.com/GoCodeAlone/workflow v0.17.0 → v0.18.2 (now released)
  • Adds SupportedCanonicalKeys() to DOProvider — returns the full canonical IaC key set
  • Extracts buildAppSpec() into internal/drivers/app_platform_buildspec.go, covering the complete canonical key set for DO App Platform:
    • AppServiceSpec: autoscaling, routes, health_check, liveness_check, CORS, internal_ports, build/run commands, dockerfile_path, source_dir, termination, log_destinations, alerts, instance_size_slug (via abstract size tier or provider_specific.digitalocean.instance_size_slug)
    • AppSpec top-level: domains, alerts, ingress, egress, maintenance, vpc_ref, features, disable_edge_cache, disable_email_obfuscation, enhanced_threat_control_enabled
    • AppJobSpec: jobs[] → PRE_DEPLOY / POST_DEPLOY / FAILED_DEPLOY / SCHEDULED with image, env_vars, env_vars_secret, cron schedule, termination
    • AppWorkerSpec: workers[] with autoscaling, size tier, termination
    • AppStaticSiteSpec: static_sites[] with routes, CORS, env_vars, catchall/error documents
  • Fixes envVarsFromConfig to support canonical "env_vars_secret" alongside legacy "secret_env_vars"
  • Updates fakeIaCProvider in tests for new interface method
  • Adds 30+ targeted tests + BMW pre-deploy integration scenario (migrate + tenant-ensure PRE_DEPLOY jobs)

Test plan

  • GOWORK=off go build ./... — green
  • GOWORK=off go test -race ./... — all 4 packages pass
  • gofmt -l — no violations
  • No replace directives in go.mod
  • Code-reviewer local review approved

🤖 Generated with Claude Code

intel352 and others added 2 commits April 22, 2026 20:14
…(Tasks 32-36)

- Bump github.com/GoCodeAlone/workflow dependency from v0.17.0 to v0.18.2
- Add SupportedCanonicalKeys() to DOProvider returning the full canonical key set
- Extract buildAppSpec() into app_platform_buildspec.go, covering:
  - AppServiceSpec: autoscaling, routes, health_check, liveness_check, cors,
    internal_ports, build_command, run_command, dockerfile_path, source_dir,
    termination, log_destinations, alerts, instance_size_slug (via size tier)
  - AppSpec top-level: domains, alerts, ingress, egress, maintenance, vpc, features,
    disable_edge_cache, disable_email_obfuscation, enhanced_threat_control_enabled
  - AppJobSpec: canonical jobs[] → PRE_DEPLOY/POST_DEPLOY/FAILED_DEPLOY/SCHEDULED
    with image, env_vars, env_vars_secret, cron schedule, termination
  - AppWorkerSpec: canonical workers[] with autoscaling, size tier, termination
  - AppStaticSiteSpec: canonical static_sites[] with routes, cors, env_vars,
    catchall_document, error_document
- Support both "env_vars_secret" (canonical) and "secret_env_vars" (legacy) keys
- Update fakeIaCProvider in tests to implement SupportedCanonicalKeys()
- Add 30+ targeted tests covering each canonical key group and a BMW pre-deploy scenario

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the DigitalOcean Workflow plugin to the latest workflow library and expands DO App Platform support by advertising the full canonical IaC key set and building a more complete godo.AppSpec from canonical config.

Changes:

  • Bump github.com/GoCodeAlone/workflow from v0.17.0 to v0.18.2.
  • Add SupportedCanonicalKeys() to DOProvider (and update test fakes accordingly).
  • Extract/introduce a comprehensive buildAppSpec() implementation with extensive new tests; update env var secret handling to accept canonical env_vars_secret.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
internal/provider.go Adds SupportedCanonicalKeys() implementation returning canonical key set.
internal/provider_test.go Adds coverage asserting SupportedCanonicalKeys() includes expected keys.
internal/module_instance_test.go Updates fake provider to implement the new interface method.
internal/drivers/app_platform.go Routes Create/Update through buildAppSpec(); updates env secret key handling (env_vars_secret + legacy alias).
internal/drivers/app_platform_buildspec.go New canonical-config → godo.AppSpec builder and related parsing helpers.
internal/drivers/app_platform_buildspec_test.go Large new test suite for buildAppSpec() behavior.
go.mod Updates dependency on github.com/GoCodeAlone/workflow to v0.18.2.
go.sum Updates checksums for the new workflow version.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +50 to +68
// The DOProvider test lives in internal package. Here we just verify that
// buildAppSpec (via Create) handles the full canonical key set without panicking.
required := []string{
"name", "region", "image", "http_port", "instance_count", "size",
"env_vars", "autoscaling", "routes", "health_check", "domains",
"jobs", "workers", "static_sites", "sidecars", "provider_specific",
}
// All canonical keys should be accepted without error.
cfg := map[string]any{
"image": "registry.digitalocean.com/myrepo/myapp:v1",
"http_port": 8080,
"instance_count": 1,
}
spec := buildSpecViaCreate(t, cfg)
if spec == nil {
t.Fatal("expected non-nil AppSpec")
}
// Just ensure all the required key names are checked.
_ = required
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is effectively a no-op: it declares required canonical keys but never asserts anything about them (the slice is later assigned to _). Consider either removing this test or making it actually verify that buildspec creation accepts/handles each canonical key (or at minimum, that the provider reports/supports them as intended).

Suggested change
// The DOProvider test lives in internal package. Here we just verify that
// buildAppSpec (via Create) handles the full canonical key set without panicking.
required := []string{
"name", "region", "image", "http_port", "instance_count", "size",
"env_vars", "autoscaling", "routes", "health_check", "domains",
"jobs", "workers", "static_sites", "sidecars", "provider_specific",
}
// All canonical keys should be accepted without error.
cfg := map[string]any{
"image": "registry.digitalocean.com/myrepo/myapp:v1",
"http_port": 8080,
"instance_count": 1,
}
spec := buildSpecViaCreate(t, cfg)
if spec == nil {
t.Fatal("expected non-nil AppSpec")
}
// Just ensure all the required key names are checked.
_ = required
// The DOProvider test lives in internal package. Here we verify that
// buildAppSpec (via Create) accepts a config containing the full canonical key set.
required := []string{
"name", "region", "image", "http_port", "instance_count", "size",
"env_vars", "autoscaling", "routes", "health_check", "domains",
"jobs", "workers", "static_sites", "sidecars", "provider_specific",
}
cfg := map[string]any{
"name": "test-app",
"region": "nyc3",
"image": "registry.digitalocean.com/myrepo/myapp:v1",
"http_port": 8080,
"instance_count": 1,
"size": "apps-s-1vcpu-0.5gb",
"env_vars": map[string]any{
"APP_ENV": "test",
},
"autoscaling": map[string]any{
"min_instance_count": 1,
"max_instance_count": 1,
},
"routes": []map[string]any{
{"path": "/"},
},
"health_check": map[string]any{
"http_path": "/healthz",
},
"domains": []string{
"example.com",
},
"jobs": []map[string]any{},
"workers": []map[string]any{},
"static_sites": []map[string]any{},
"sidecars": []map[string]any{},
"provider_specific": map[string]any{},
}
for _, key := range required {
if _, ok := cfg[key]; !ok {
t.Fatalf("test setup missing required canonical key %q", key)
}
}
spec := buildSpecViaCreate(t, cfg)
if spec == nil {
t.Fatal("expected non-nil AppSpec")
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in abb666d: TestDOProvider_SupportedCanonicalKeys now builds a rich canonical config and asserts AppSpec structure (services, jobs, workers, static_sites, domains counts) on the returned spec. The dead _ = required assignment is gone.

Comment thread internal/drivers/app_platform.go Outdated
Comment on lines +349 to +350
secrets, _ := cfg["env_vars_secret"].(map[string]any)
if len(secrets) == 0 {
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says the canonical key env_vars_secret takes precedence when both it and the legacy secret_env_vars are present, but the implementation falls back to secret_env_vars whenever env_vars_secret is an empty map. If precedence should be based on key presence (not map length), change the logic to only fall back when env_vars_secret is not set/typed correctly; otherwise update the comment to match the behavior.

Suggested change
secrets, _ := cfg["env_vars_secret"].(map[string]any)
if len(secrets) == 0 {
secretsRaw, hasCanonical := cfg["env_vars_secret"]
secrets, ok := secretsRaw.(map[string]any)
if !hasCanonical || !ok {

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in abb666d: envVarsFromConfig now uses a key-presence check (_, ok := cfg["env_vars_secret"]; ok) rather than a length check on the already-cast value. An explicitly-set empty env_vars_secret map no longer falls through to the legacy alias. Comment updated to match.

Comment on lines +30 to +35
svc := &godo.AppServiceSpec{
Name: name,
Image: imgSpec,
HTTPPort: int64(httpPort),
InstanceCount: int64(instanceCount),
Envs: envVarsFromConfig(cfg),
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildAppSpec never reads cfg["sidecars"], so any canonical sidecars configuration is silently ignored even though the PR description/tests treat it as part of the supported canonical set. Either implement sidecar parsing/mapping onto the relevant godo AppSpec fields, or drop sidecars from the advertised supported canonical keys to avoid misleading callers.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in abb666d: added doUnsupportedCanonicalKeys exclusion map in provider.go that gates SupportedCanonicalKeys(). "sidecars" (via interfaces.KeySidecars) is excluded from the advertised set until Task 37 lands, so callers are warned via wfctl validate rather than having their config silently dropped. Provider test updated with an explicit negative assertion.

Comment on lines +364 to +365
if v, ok := m["value"].(float64); ok {
spec.Value = float32(v)
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alertSpecFromMap only accepts value when it is a float64. In this codebase other numeric config helpers accept int/int64/float64, and YAML decoding commonly produces int for whole numbers—so value will be silently dropped for many configs. Consider using the existing numeric conversion helper (or a small type switch) to populate spec.Value for integer inputs too.

Suggested change
if v, ok := m["value"].(float64); ok {
spec.Value = float32(v)
switch v := m["value"].(type) {
case float64:
spec.Value = float32(v)
case int:
spec.Value = float32(v)
case int64:
spec.Value = float32(v)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in abb666d: alertSpecFromMap now uses a type switch over float64, float32, int, and int64 for the value field, covering all numeric types YAML/JSON decoding can produce. Two new tests added: TestBuildAppSpec_Alerts_IntValue (YAML integer path) and TestBuildAppSpec_Alerts_Float64Value (float path).

1. TestDOProvider_SupportedCanonicalKeys: build rich config and assert
   AppSpec structure (services/jobs/workers/static_sites counts) instead
   of assigning required to _.

2. envVarsFromConfig: use key-presence check (_, ok := cfg["env_vars_secret"])
   for legacy-alias fallback rather than len() check on already-cast value,
   so an explicitly-set empty env_vars_secret map is not accidentally overridden.

3. SupportedCanonicalKeys: exclude "sidecars" via doUnsupportedCanonicalKeys
   map (interfaces.KeySidecars) until Task 37 lands; update provider test
   to assert sidecars is absent.

4. alertSpecFromMap: replace float64-only assertion with type switch over
   float64/float32/int/int64 so YAML integer alert values are accepted;
   add TestBuildAppSpec_Alerts_IntValue and TestBuildAppSpec_Alerts_Float64Value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +82 to +86
Domains: domainsFromConfig(cfg),
Alerts: appAlertsFromConfig(cfg),
Ingress: ingressFromConfig(cfg),
Egress: egressFromConfig(cfg),
Maintenance: maintenanceFromConfig(cfg),
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New ingress mapping is introduced on the AppSpec, but there are no unit tests covering ingress config (e.g., load_balancer) and verifying the resulting spec.Ingress payload. Adding a small test case would help lock down this behavior.

Copilot generated this review using guidance from organization custom instructions.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 10b9553: added TestBuildAppSpec_Ingress_LoadBalancer (asserts load_balancer: DIGITALOCEAN maps to AppIngressSpecLoadBalancer_DigitalOcean) and TestBuildAppSpec_Ingress_EmptyIsNil (asserts an empty ingress map returns nil from the AppSpec).

Comment on lines +46 to +49
// ── SupportedCanonicalKeys ───────────────────────────────────────────────────

func TestDOProvider_SupportedCanonicalKeys(t *testing.T) {
// Verify buildAppSpec accepts all supported canonical keys without error.
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name is misleading: it doesn't exercise DOProvider.SupportedCanonicalKeys(), it exercises buildAppSpec indirectly via the App Platform driver. Consider renaming it to reflect the behavior under test (e.g., that buildAppSpec/Create accepts a representative canonical config).

Suggested change
// ── SupportedCanonicalKeys ───────────────────────────────────────────────────
func TestDOProvider_SupportedCanonicalKeys(t *testing.T) {
// Verify buildAppSpec accepts all supported canonical keys without error.
// ── Create/buildAppSpec representative canonical config coverage ─────────────
func TestAppPlatformDriver_Create_AcceptsRepresentativeCanonicalConfig(t *testing.T) {
// Verify buildAppSpec accepts a representative set of canonical keys without error.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 10b9553: renamed to TestBuildAppSpec_RepresentativeCanonicalConfig with an updated comment clarifying it exercises the buildAppSpec builder over a rich canonical config. The dedicated DOProvider.SupportedCanonicalKeys() key-list assertion lives in internal/provider_test.go#TestDOProvider_SupportedCanonicalKeys.

"jobs": []any{map[string]any{"name": "j", "kind": "pre_deploy", "run_command": "/j"}},
"workers": []any{map[string]any{"name": "w", "run_command": "/w"}},
"static_sites": []any{map[string]any{"name": "s", "build_command": "npm run build", "output_dir": "dist"}},
// sidecars are declared in SupportedCanonicalKeys but mapped in Task #4
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline comment references sidecars being "declared in SupportedCanonicalKeys" and "mapped in Task #4", but the provider code explicitly excludes sidecars until Task 37. Please update or remove this comment to avoid contradicting the current behavior.

Suggested change
// sidecars are declared in SupportedCanonicalKeys but mapped in Task #4

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 10b9553: inline comment updated to: "sidecars" is excluded from SupportedCanonicalKeys (doUnsupportedCanonicalKeys) until Task 37 lands — matches current code behaviour.

}

// logDestinationsFromConfig converts the canonical "log_destinations" list to []*godo.AppLogDestinationSpec.
// Currently maps: endpoint (HTTP), papertrail token, datadog api_key.
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment says this maps a "papertrail token", but the implementation only reads papertrail.endpoint (no token field). Please update the comment to match what is actually supported (endpoint vs token), or add token support if that was the intent.

Suggested change
// Currently maps: endpoint (HTTP), papertrail token, datadog api_key.
// Currently maps: endpoint (HTTP), papertrail endpoint, datadog api_key.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 10b9553: doc comment updated from "papertrail token" to "papertrail endpoint" to match what logDestinationsFromConfig actually reads (Endpoint field on AppLogDestinationSpecPapertrail).

if lb := strFromConfig(raw, "load_balancer", ""); lb != "" {
spec.LoadBalancer = godo.AppIngressSpecLoadBalancer(strings.ToUpper(lb))
}
// Rules are complex; skip for now unless coming from provider_specific.
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ingressFromConfig returns a non-nil AppIngressSpec even when no supported fields are set (e.g., ingress map contains only unhandled keys). This differs from other helpers (CORS/autoscaling/maintenance) that return nil when the parsed spec is effectively empty, and can cause an empty ingress block to be sent in requests. Consider returning nil unless at least one supported ingress field (currently load_balancer) is populated.

Suggested change
// Rules are complex; skip for now unless coming from provider_specific.
// Rules are complex; skip for now unless coming from provider_specific.
if spec.LoadBalancer == "" {
return nil
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 10b9553: ingressFromConfig now returns nil when spec.LoadBalancer == "" and len(spec.Rules) == 0, consistent with the CORS/autoscaling/maintenance helpers. A TestBuildAppSpec_Ingress_EmptyIsNil test verifies the new nil return path.

Comment on lines +46 to +50
CORS: corsFromConfig(cfg),
Autoscaling: autoscalingFromConfig(cfg),
Termination: serviceTerminationFromConfig(cfg),
LogDestinations: logDestinationsFromConfig(cfg),
Alerts: componentAlertsFromConfig(cfg),
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New log_destinations mapping is introduced here, but there are no unit tests asserting how log_destinations entries (endpoint / papertrail / datadog / logtail, tls_insecure) are converted into AppLogDestinationSpec. Adding a focused test would help prevent regressions in this mapping.

Copilot generated this review using guidance from organization custom instructions.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 10b9553: added TestBuildAppSpec_LogDestinations covering all 4 destination types — HTTP endpoint (with tls_insecure: true), papertrail endpoint, datadog api_key+endpoint, and logtail token — with field-level assertions on each.

1. Add TestBuildAppSpec_Ingress_LoadBalancer and TestBuildAppSpec_Ingress_EmptyIsNil
   covering the new ingress mapping (load_balancer field → AppIngressSpecLoadBalancer
   + nil return when no supported fields are present).

2. Rename TestDOProvider_SupportedCanonicalKeys → TestBuildAppSpec_RepresentativeCanonicalConfig
   to reflect actual scope (tests buildAppSpec, not the provider method). A dedicated
   DOProvider.SupportedCanonicalKeys() key-list test lives in internal/provider_test.go.

3. Update inline comment on the sidecars config entry: "excluded from SupportedCanonicalKeys
   (doUnsupportedCanonicalKeys) until Task 37 lands" — matches current code behaviour.

4. Fix doc comment on logDestinationsFromConfig: "papertrail token" → "papertrail endpoint"
   to match what the code actually reads (Endpoint field, not a token).

5. ingressFromConfig: return nil when no supported field is set (LoadBalancer == "" and
   no Rules), consistent with CORS/autoscaling/maintenance helpers.

6. Add TestBuildAppSpec_LogDestinations covering all four destination types: HTTP endpoint
   (+ tls_insecure), papertrail endpoint, datadog api_key+endpoint, logtail token.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@intel352 intel352 merged commit df6b601 into main Apr 23, 2026
3 checks passed
intel352 added a commit that referenced this pull request May 5, 2026
…t + workflow perms)

3/3 findings addressed:

#12 (validate_plan.go:16) — uuidPattern was lowercase-only; UUIDs are
   case-insensitive in practice (operator clipboards, templating
   engines, mixed-case API responses). Added (?i) flag so upper-case
   and mixed-case VPC UUIDs also classify as deferred-to-apply, not
   as plain resource names that would trigger false dangling-reference
   diagnostics. New test:
   TestDOProvider_ValidatePlan_VPCRefAsUpperCaseUUIDIsDeferred.

#13 (provider_conformance_test.go:86) — switched
   p.Initialize(context.Background(), ...) to p.Initialize(t.Context(), ...)
   so live-cloud Initialize is interrupted promptly when the test
   is canceled or hits its deadline. Removed the now-unused
   "context" import.

#14 (codemod-report.yml:12) — dropped the unused pull-requests:write
   permission. The workflow only creates/updates an issue comment
   (PR comments are issues at the GitHub API layer) so the surviving
   issues:write is sufficient. Inline doc-comment captures the
   reasoning so future maintainers don't restore the broader grant.
   Aligns with ci.yml's contents:read-only baseline.

Verified locally: go test ./... -count=1 -race PASS, go test
-tags=conformance ./internal/ -run TestConformance PASS, go vet
clean, codemod-report.yml YAML valid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intel352 added a commit that referenced this pull request May 5, 2026
…lyPlan) (#61)

* chore(deps): bump workflow to e2c582b (W-7 conformance + W-8 codemod)

Pseudo-version v0.20.6-0.20260505011403-e2c582bece90, the workflow main HEAD
that includes:
  - W-7: iac/conformance scenario suite (12 scenarios) and DO smoke gate
  - W-8: cmd/iac-codemod 4-mode AST tool

Required for TP1-TP5 of PR P-DO (IaC conformance plan §P-DO).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(plugin): codemod-report workflow uploads dry-run output as artifact + sticky PR comment summary

PR P-DO TP1: per pull_request, runs the iac-codemod refactor-apply mode
in -dry-run against the plugin source, uploads the full Markdown report as
a 90-day retention GitHub Actions artifact, and posts/updates a sticky PR
comment with the top-30 lines of the report so drive-by reviewers see the
key findings without downloading the artifact.

Supply-chain note: actions/github-script SHA-pinned per workflow security
policy (Renovate tracks upstream releases via .github/renovate.json).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(provider): collapse Apply to wfctlhelpers.ApplyPlan

PR P-DO TP2: replace the in-Apply per-action switch (create/update/replace/
delete + upsert recovery + nil-out diagnostic) with a single dispatch to
wfctlhelpers.ApplyPlan. The helper handles:

  - ErrResourceAlreadyExists upsert recovery via interfaces.UpsertSupporter
    (DO drivers AppPlatform, VPC, Firewall, Database already implement
    SupportsUpsert() bool, so they satisfy the canonical interface without
    code change — the local upsertSupporter declaration is now removed).
  - JIT ${MODULE.id} / ${VAR} substitution (W-5).
  - Replace decomposition + ReplaceIDMap propagation (W-3a/W-3b).
  - Input-drift postcondition (W-3a).
  - Per-action context cancellation between iterations.

The DO-plugin-specific deferred-update flush (DatabaseDriver type=app
trusted_sources referencing apps created later in the plan; regression
gated by provider_deferred_test.go and CHANGELOG entry for staging-deploy-
blockers Blocker 2) is preserved by wrapping ApplyPlan with the second-
pass loop that calls FlushDeferredUpdates on any deferredUpdater driver.

The wrapper deviates from the codemod's canonical
'return wfctlhelpers.ApplyPlan(ctx, p, plan)' single-statement shape; the
deviation is documented and marked with // wfctl:skip-iac-codemod so
AssertApplyDelegatesToHelper recognizes the intentional shape. When
wfctlhelpers grows a deferred-update lifecycle hook, the wrapper can
collapse and the marker can drop.

The provider_apply_test.go DeleteAction_MissingCurrent regression test
was a v1 pre-flight defense (synthesize an error when action.Current is
nil before calling Delete). Under v2 dispatch the contract is 'driver is
the authority on what an empty ProviderID means' (per wfctlhelpers/
apply.go::doUpdate's analogous comment). The test was rewritten to lock
the new contract: dispatch IS made with an empty-ProviderID ref; real
drivers like FirewallDriver surface the diagnostic via their typed
validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(provider): ValidatePlan for App Platform region-VPC constraints

PR P-DO TP3: implement interfaces.ProviderValidator (W-4) on DOProvider
to surface DO region constraints at `wfctl infra align` time before
any cloud API call. Per the W-4 design, R-A10 invokes ValidatePlan via
type-assertion; providers that do not implement it continue to work
unchanged.

The first pass covers three constraint families:

  1. App Platform infra.container_service requires a region GROUP slug
     (nyc, ams, fra, sfo, sgp, syd, tor, blr, lon). Zone slugs (nyc1,
     sfo3 …) are rejected with PlanDiagnosticError. This is the
     "copy-pasted nyc3 from a Droplet config" defense.

  2. Zone-bound resources (infra.vpc, infra.droplet, infra.volume) MUST
     use a zone slug. Bare group slugs are rejected with
     PlanDiagnosticError. Inverse of (1).

  3. Cross-resource: an App Platform with vpc_ref pointing to a VPC in
     the same plan must have a region group whose zones include the
     referenced VPC's region. This locks the recurring 'App Platform in
     nyc cannot reach VPC in sfo3' production bug class (root-cause
     issue D from the conformance design). Cross-resource resolution
     looks at desired spec first, falls back to action.Current's
     Outputs["region"] for unchanged-VPC scenarios. vpc_ref pointing
     to a name not in the plan emits a Severity=Warning so non-strict
     align tolerates external VPCs while --strict escalates.

ValidatePlan is read-only and makes no remote calls per the W-4
contract. Compile-time interface assertion lives at the bottom of
validate_plan.go.

11 TDD tests in validate_plan_test.go cover: nil/empty plan,
group-slug accepted, zone-slug rejected for AP, zone-slug accepted for
VPC/Droplet/Volume, group-slug rejected for VPC, mismatch error
(flagship), happy-path match, unknown-vpc_ref Warning, current-state
fallback, delete-action skipped, compile-time assertion.

Future extensions (deferred follow-ups): database/cache zone slugs,
load balancer zone matching against attached droplets, registry
regional restrictions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(plugin): opt into computePlanVersion: v2

PR P-DO TP4: declare iacProvider.computePlanVersion=v2 at the TOP LEVEL
of plugin.json so wfctl's runtime dispatcher (cmd/wfctl/deploy_providers.go
::iacPluginManifest) routes Apply through wfctlhelpers.ApplyPlan instead
of the legacy in-provider switch.

Schema note: the SDK manifest schema (plugin/sdk/manifest_schema.json)
expects iacProvider.computePlanVersion at the top level of plugin.json.
The existing capabilities.iacProvider sub-block (name, resourceTypes,
configSchema) is a DIFFERENT consumer (plugin discovery + capability
declaration); the two structures coexist in the same file. The runtime
loader unmarshals both into one struct (one Capabilities.IaCProvider.Name
field plus one top-level IaCProvider.ComputePlanVersion field) so a
single plugin.json read serves both code paths.

Validated via three checkers:
  - go run ./cmd/wfctl plugin validate --file plugin.json --strict-contracts (OK)
  - JSON-schema validation against plugin/sdk/manifest_schema.json (OK)
  - sdk.ParseManifest decode confirms EffectiveComputePlanVersion()==v2

Backward compat: wfctl < v0.21.0 ignores the new field; the legacy v1
dispatch (provider.Apply switch, now wrapping wfctlhelpers.ApplyPlan)
continues to work for all existing callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(provider): add conformance test + extend ValidatePlan for database vpc_ref + bump to v0.10.0

PR P-DO TP5: ship the conformance test entry point, extend ValidatePlan
to satisfy the conformance suite's cross-resource constraint scenario,
and bump plugin.json + CHANGELOG to v0.10.0.

provider_conformance_test.go (new, behind 'conformance' build tag):
  - Invokes iac/conformance.Run against a freshly-constructed and
    initialized DOProvider. Initialize is always called with a stub
    token so the driver registry is populated for the non-cloud
    scenarios that probe ResourceDriver lookups (structpb-roundtrip,
    cross-module resolution, etc.) — these exercise read-only or
    pure-data paths that don't hit DO's API.
  - LiveCloud (CONFORMANCE_LIVE_CLOUD=1 + DIGITALOCEAN_ACCESS_TOKEN)
    swaps the stub for the real token before driver instantiation.
  - SmokeOnly = testing.Short() limits to Smoke=true scenarios for
    the per-PR smoke gate's narrow contract.

ValidatePlan extension (validate_plan.go):
  - Added appendDatabaseDiagnostics: infra.database vpc_ref pointing to
    a name not in the plan emits a Severity=Error diagnostic. Closes
    the conformance Scenario_CrossResourceConstraintRejection assertion
    that 'at least one Severity=Error diagnostic' fires for a dangling
    cross-resource reference. The assertion was failing before this
    change (database vpc_ref was previously unhandled by ValidatePlan).
  - Two new TDD tests: dangling-vpc_ref → Error (mirrors the
    conformance contract in-tree); in-plan vpc_ref → no diagnostic
    (happy path).

plugin.json:
  - version 0.9.0 → 0.10.0
  - download URL paths v0.9.0 → v0.10.0 (per-OS/arch)

CHANGELOG.md:
  - New v0.10.0 section catalogues TP1-TP5 changes (ValidatePlan,
    computePlanVersion: v2 opt-in, Apply collapse, conformance test,
    codemod-report workflow), the Apply-delete v2-contract change,
    and the workflow dep bump.
  - Migrates the previously-Unreleased infra.vpc id-output fix into
    the v0.10.0 Fixed section (it ships in this release).

Test results:
  - go test -tags=conformance ./internal/ -run TestConformance: 6/6
    non-cloud scenarios PASS (CrossResourceConstraintRejection,
    DiffSurvivesGRPCRoundTrip, InfraOutputCrossModuleResolution,
    PlanStaleDiagnostic, ProtectedReplaceWithOverride,
    ProtectedReplaceWithoutOverride). Upsert-on-already-exists +
    grpc-roundtrip skip when their opt-in driver types are absent
    (DO does not expose infra.compute).
  - go test ./... -count=1 -race: ALL packages PASS.
  - go run ./cmd/wfctl plugin validate --strict-contracts: OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: Copilot review round 1 — 5 substantive findings

5/5 findings addressed:

#1 (validate_plan.go:52) — byName index must exclude delete-action
   resources. Previously a vpc_ref pointing to a VPC scheduled for
   deletion in the same plan would silently 'resolve' as if live; now
   delete-targets are excluded from the index so cross-resource
   references to them surface as Severity=Error dangling references.
   New regression test:
   TestDOProvider_ValidatePlan_DatabaseVPCRefToDeleteTargetIsDangling.

#2 (validate_plan.go:91) — appendDatabaseDiagnostics docstring
   incorrectly said 'Warning when missing'; the implementation has
   always emitted PlanDiagnosticError, and the conformance scenario +
   TDD tests both REQUIRE Error severity. Doc rewritten to match the
   implemented contract.

#3 (validate_plan.go:225) — zonesInGroup docstring promised sorted
   output but returned the raw underlying slice unsorted. Now copies +
   sorts (lexicographic) so diagnostic messages are deterministic and
   the returned slice is owned by the caller (safe to mutate). nil
   for unknown groups; the caller's strings.Join still works.

#4 (provider_conformance_test.go:70) — comment described a
   'stub-then-real swap' with two Initialize calls; the implementation
   has always made one call with the right token chosen up-front. Doc
   rewritten to match the actual single-call flow.

#5 (codemod-report.yml:73) — fork PRs run with a read-only
   GITHUB_TOKEN per GitHub's pull_request workflow security model, so
   issues:create-comment + issues:update-comment fail 403 and would
   block CI. Gate the comment step on
   github.event.pull_request.head.repo.fork == false. The artifact
   upload step still runs unconditionally so the report remains
   reachable from the Actions tab.

Verified locally: go test ./... -count=1 -race PASS, go test
-tags=conformance ./internal/ -run TestConformance PASS, go vet
clean, codemod-report.yml YAML valid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: Copilot review round 2 — 2 vpc_ref type-mismatch findings

2/2 findings addressed (both legitimate type-safety improvements):

#6 (validate_plan.go:123) — appendDatabaseDiagnostics validated that
   vpc_ref name resolved in the plan but did NOT validate that the
   target type is actually infra.vpc. A vpc_ref pointing to a Droplet
   or App Platform resource silently passed prior validation. Now
   surfaces a typed Severity=Error diagnostic when target.spec.Type
   != "infra.vpc". New regression test:
   TestDOProvider_ValidatePlan_DatabaseVPCRefToNonVPCType.

#7 (validate_plan.go:189) — same bug in appendAppPlatformDiagnostics:
   vpc_ref pointing to a non-VPC resource would silently bypass the
   region-match check (target.spec.Config["region"] would be a
   region GROUP for an App Platform target, not a zone for a VPC) and
   the operator would never see a clear diagnostic. Same fix: typed
   Error when target.spec.Type != "infra.vpc". New regression test:
   TestDOProvider_ValidatePlan_AppPlatformVPCRefToNonVPCType.

Both diagnostics carry the offending type in the message body so the
operator immediately knows whether they typo'd a name (resolves to
nothing) vs. typo'd a TYPE in a name (resolves to wrong resource).

Verified locally: go test ./... -count=1 -race PASS, go test
-tags=conformance ./internal/ -run TestConformance PASS, go vet
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: Copilot review round 3 — 4 deeper findings (vpc_ref UUID/template + classifyRegion + delete-test comment)

4/4 findings addressed (deeper architectural correctness):

#8/#9 (validate_plan.go:137,205) — vpc_ref's accepted DO API shapes
   are: (a) a VPC UUID literal, OR (b) a wfctl JIT template like
   ${vpc.id} that resolves to a UUID at apply time via
   wfctlhelpers.ApplyPlan's jitsubst.ResolveSpec. The prior validator
   unconditionally treated vpc_ref as an in-plan resource Name and
   would have rejected production configs that use the canonical UUID
   shape (godo.AppVpcSpec{ID: vpcID} consumes it directly per
   internal/drivers/app_platform_buildspec.go:674). New
   looksLikeResourceName() helper detects UUID literals (RFC-4122
   pattern) and JIT templates (${...} / $(...)), and the in-plan-
   name lookup is skipped for both. Only plain-name vpc_ref values
   trigger the dangling-reference + non-VPC-type checks. Diagnostic
   messages updated to call out the 'plain resource name' branch
   explicitly so an operator who hit the diagnostic understands it
   does not apply to UUID/template forms. Two new TDD tests cover the
   UUID-literal + JIT-template paths for both database and App
   Platform.

#10 (validate_plan.go:307) — classifyRegion emitted 'a zone slug in
   group ""' for legacy zones (nyc2, ams2) that intentionally map
   to an empty group. Now special-cases the empty-group case to emit
   'a zone slug not in any App Platform region group'. New TDD test
   exercises the path via an App Platform action with region=nyc2.

#11 (provider_apply_test.go:94) — the test comment claimed real
   drivers reject empty-ProviderID deletes via typed validation;
   FirewallDriver.Delete actually resolves by name when ProviderID
   is empty. Comment rewritten to reflect that v2 dispatch contract
   is 'driver knows what an empty ProviderID means for its resource
   shape', not 'all drivers reject empty ProviderID'.

Net new test count: 3 (UUID-deferred, JIT-template-deferred,
classify-empty-group). All existing tests still pass.

Verified locally: go test ./... -count=1 -race PASS, go test
-tags=conformance ./internal/ -run TestConformance PASS, go vet
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: Copilot review round 4 — 3 polish findings (UUID case + t.Context + workflow perms)

3/3 findings addressed:

#12 (validate_plan.go:16) — uuidPattern was lowercase-only; UUIDs are
   case-insensitive in practice (operator clipboards, templating
   engines, mixed-case API responses). Added (?i) flag so upper-case
   and mixed-case VPC UUIDs also classify as deferred-to-apply, not
   as plain resource names that would trigger false dangling-reference
   diagnostics. New test:
   TestDOProvider_ValidatePlan_VPCRefAsUpperCaseUUIDIsDeferred.

#13 (provider_conformance_test.go:86) — switched
   p.Initialize(context.Background(), ...) to p.Initialize(t.Context(), ...)
   so live-cloud Initialize is interrupted promptly when the test
   is canceled or hits its deadline. Removed the now-unused
   "context" import.

#14 (codemod-report.yml:12) — dropped the unused pull-requests:write
   permission. The workflow only creates/updates an issue comment
   (PR comments are issues at the GitHub API layer) so the surviving
   issues:write is sufficient. Inline doc-comment captures the
   reasoning so future maintainers don't restore the broader grant.
   Aligns with ci.yml's contents:read-only baseline.

Verified locally: go test ./... -count=1 -race PASS, go test
-tags=conformance ./internal/ -run TestConformance PASS, go vet
clean, codemod-report.yml YAML valid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: Copilot review round 5 — 2 findings (Initialize-ctx comment + unknown-region forward-compat)

2/2 findings addressed:

#15 (provider_conformance_test.go:83) — the prior comment claimed
   t.Context() makes Initialize cancelable, but DOProvider.Initialize
   today constructs its godo client with its own context.Background()
   and ignores the passed-in ctx. Comment rewritten to reflect that
   the change is forward-prep (so any future rev of Initialize that
   honors ctx picks up the test-scoped cancellation/deadline) rather
   than an immediate behavior fix. Tracked as a follow-up to thread
   ctx through the godo client construction.

#16 (validate_plan.go:334) — the hardcoded region/zone allowlists
   would Severity=Error any brand-new DO region the plugin hasn't
   caught up to, blocking apply until the plugin is bumped. Severity
   is now two-bucket:
     - Documented misconfig (group-where-zone-required, zone-where-
       group-required) → Error (the original anti-pattern stays loud).
     - Unknown slug (neither known group nor known zone, e.g. a
       hypothetical 'atl' or 'atl1') → Warning so non-strict align
       lets operators on bleeding-edge DO regions proceed; --strict
       still escalates for cautious operators.
   New regression test:
   TestDOProvider_ValidatePlan_UnknownRegionSlugWarnsNotErrors covers
   both VPC zone and AP group unknown-slug paths.

Net new test count: 1. All existing tests still PASS (the documented
misconfig branches use known specific slugs that hit the Error path).

Verified locally: go test ./... -count=1 -race PASS, go test
-tags=conformance ./internal/ -run TestConformance PASS, go vet
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants