Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .github/workflows/codemod-report.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: codemod-report
on:
pull_request:
branches: [main]
permissions:
# Copilot review #14 (round 4): scope to least privilege —
# contents:read for checkout, issues:write for the sticky PR
# comment (PR comments are issues at the GitHub API layer). The
# prior pull-requests:write grant was unused (no PR-state
# mutations) and inconsistent with the other workflows in this
# repo (ci.yml uses contents:read only).
contents: read
issues: write
env:
GOPRIVATE: github.com/GoCodeAlone/*
jobs:
codemod-report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Configure Git for private repos
env:
RELEASES_TOKEN: ${{ secrets.RELEASES_TOKEN }}
run: |
if [ -n "${RELEASES_TOKEN}" ]; then
git config --global url."https://x-access-token:${RELEASES_TOKEN}@github.com/".insteadOf "https://github.com/"
fi
- name: Run iac-codemod refactor-apply (dry-run)
run: |
set -euo pipefail
WFCTL_VERSION="$(go list -m github.com/GoCodeAlone/workflow | awk '{print $2}')"
go run "github.com/GoCodeAlone/workflow/cmd/iac-codemod@${WFCTL_VERSION}" \
refactor-apply -dry-run . > /tmp/codemod-report.md
# Short summary for the sticky PR comment.
head -30 /tmp/codemod-report.md > /tmp/codemod-summary.md
echo "" >> /tmp/codemod-summary.md
echo "_Full report (90-day retention) attached as workflow artifact._" >> /tmp/codemod-summary.md
- uses: actions/upload-artifact@v4
with:
name: codemod-report-${{ github.event.pull_request.number }}
path: /tmp/codemod-report.md
retention-days: 90
- name: Comment summary on PR (first-party action SHA-pinned)
# rev5 — pin to commit SHA even though first-party. Tag mutability is a
# known risk; Renovate config tracks upstream releases via .github/renovate.json.
#
# Copilot review #5: PRs from forks run with a read-only GITHUB_TOKEN
# (per GitHub's pull_request workflow security model), so issues:create-
# comment + issues:update-comment fail 403. Skip the comment step on
# fork PRs — the artifact upload above still runs unconditionally so
# the report is reachable from the Actions tab without a comment.
if: github.event.pull_request.head.repo.fork == false
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const fs = require('fs');
const summary = fs.readFileSync('/tmp/codemod-summary.md', 'utf8');
const marker = '<!-- codemod-report-sticky -->';
const body = `${marker}\n${summary}`;
// Paginate to handle PRs with >30 comments (default page size).
const allComments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
});
const existing = allComments.find(c => c.body && c.body.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
Comment thread
intel352 marked this conversation as resolved.
83 changes: 83 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,84 @@ All notable changes to workflow-plugin-digitalocean are documented here.

## [Unreleased]

## [v0.10.0]

### Added

- **`iacProvider.computePlanVersion: v2` opt-in** (PR P-DO TP4) — wfctl's
runtime dispatcher now routes Apply through `wfctlhelpers.ApplyPlan`
instead of the legacy in-provider switch. The new dispatch path adds:
Replace decomposition + `ReplaceIDMap` propagation, JIT
`${MODULE.id}` / `${VAR}` substitution, the input-drift postcondition,
per-action context cancellation between iterations, and the
`interfaces.UpsertSupporter` recovery contract.

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.

- **`ValidatePlan` (`interfaces.ProviderValidator`)** (PR P-DO TP3) —
read-only, no-remote-call cross-resource constraint check that runs
at `wfctl infra align` time before any cloud API call. 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`, …) rejected with
`PlanDiagnosticError`.
2. Zone-bound resources (`infra.vpc`, `infra.droplet`, `infra.volume`)
require a zone slug; bare group slugs rejected with
`PlanDiagnosticError`.
3. Cross-resource: App Platform `vpc_ref` must reference a VPC whose
region zone belongs to the App Platform's region group; database
`vpc_ref` must resolve to an in-plan VPC. Locks the recurring
"App Platform in nyc cannot reach VPC in sfo3" production bug
class (root-cause issue D from the conformance design).

Severity mapping: Error always fails align; Warning fails only under
`--strict`; Info never affects exit. ValidatePlan is read-only and
makes no remote calls per the W-4 contract.

- **Conformance test (`provider_conformance_test.go`)** (PR P-DO TP5)
— invokes `iac/conformance.Run` against a freshly-constructed
`DOProvider`. Behind the `conformance` build tag; opt in with
`go test -tags=conformance ./internal/...`. Six non-cloud scenarios
run by default; `CONFORMANCE_LIVE_CLOUD=1` (with
`DIGITALOCEAN_ACCESS_TOKEN`) opts into the cloud-touching probes.

- **`.github/workflows/codemod-report.yml`** (PR P-DO TP1) — per-PR
workflow runs `iac-codemod refactor-apply -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-line summary so drive-by reviewers see findings without
downloading the artifact.

### Changed

- **`DOProvider.Apply` collapsed to wrap `wfctlhelpers.ApplyPlan`**
(PR P-DO TP2) — the in-Apply per-action switch
(create/update/replace/delete + upsert recovery + nil-out diagnostic)
is replaced with a single dispatch to the helper. 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`) is preserved by wrapping `ApplyPlan` with
the second-pass loop. The wrapper deviates from the codemod's canonical
single-statement shape; the deviation is documented and marked with
`// wfctl:skip-iac-codemod` so `AssertApplyDelegatesToHelper`
recognises it as intentional.

- **DO drivers `AppPlatformDriver`, `VPCDriver`, `FirewallDriver`,
`DatabaseDriver`** structurally satisfy the canonical
`interfaces.UpsertSupporter` (their existing `SupportsUpsert() bool`
method is bit-identical to the new interface; no driver-side changes
needed).

- **`DOProvider.Apply` `delete` action with `Current == nil`** — 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 v1-era pre-flight precondition error is retired;
`provider_apply_test.go::TestDOProvider_Apply_DeleteAction_MissingCurrent`
was rewritten to lock the new contract.

### Fixed

- **`infra.vpc` exposes `id` output** — wfctl `infra_output: <vpc>.id`
Expand All @@ -14,6 +92,11 @@ All notable changes to workflow-plugin-digitalocean are documented here.
`Outputs["id"]` so the standard `<vpc>.id` reference works. Surfaced
by core-dump deploy run 25278900082.

### Bumped

- `github.com/GoCodeAlone/workflow` → `e2c582bece90` (workflow main HEAD
with W-7 conformance suite + W-8 codemod merged).

## [v0.9.1]

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/GoCodeAlone/workflow-plugin-digitalocean
go 1.26.0

require (
github.com/GoCodeAlone/workflow v0.20.5
github.com/GoCodeAlone/workflow v0.20.6-0.20260505011403-e2c582bece90
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.12
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ github.com/GoCodeAlone/modular/modules/jsonschema v1.15.0 h1:xb1mI4NZkzvNKQ2F6nk
github.com/GoCodeAlone/modular/modules/jsonschema v1.15.0/go.mod h1:hhGouwAVsonmJ4Lain4jINZ9nZCoc9l9eF3BHbmR8eE=
github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.8.0 h1:cvdLHbM/vzvygQTcAWSJsy+dAPzzwWyjzKMmTBFcFIo=
github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.8.0/go.mod h1:/9ipMG4qM2CHQ14BfXKdVlYRJelef6M8MFI5TbZv67M=
github.com/GoCodeAlone/workflow v0.20.5 h1:CtsQ7vGiF0xlJQ7w/bhWgW06ojw6E45kxjqgRB6jAJQ=
github.com/GoCodeAlone/workflow v0.20.5/go.mod h1:ypkCqXTwnIPqNjS8h38KZfwzdVsgwgkS1d6Dq0lXyQQ=
github.com/GoCodeAlone/workflow v0.20.6-0.20260505011403-e2c582bece90 h1:A4efhC8phY+M8KOB9ccga8COdjYMw+OKHhUuWcKgltA=
github.com/GoCodeAlone/workflow v0.20.6-0.20260505011403-e2c582bece90/go.mod h1:IaonttCuyJcjT+XryXUuh3ATvTI7jx0t8e3n+ASCOOA=
github.com/GoCodeAlone/yaegi v0.17.2 h1:WK6Y6e0t1a6U7r+S2dN3CGWW1PizYD3zO0zneToZPxM=
github.com/GoCodeAlone/yaegi v0.17.2/go.mod h1:z5Pr6Wse6QJcQvpgxTxzMAevFarH0N37TG88Y9dprx0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
Expand Down
165 changes: 50 additions & 115 deletions internal/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/GoCodeAlone/workflow-plugin-digitalocean/internal/drivers"
"github.com/GoCodeAlone/workflow/iac/wfctlhelpers"
"github.com/GoCodeAlone/workflow/interfaces"
"github.com/digitalocean/godo"
"golang.org/x/oauth2"
Expand Down Expand Up @@ -217,134 +218,68 @@ func resourceOutputFromState(state interfaces.ResourceState) *interfaces.Resourc
}
}

// upsertSupporter is an optional interface for ResourceDrivers that support
// locating a resource by name alone (empty ProviderID) in their Read method.
// Only drivers that implement name-based discovery should implement this
// interface. Apply gates the ErrResourceAlreadyExists → upsert path on it to
// prevent calling Read with an empty ProviderID on drivers that require one.
type upsertSupporter interface {
SupportsUpsert() bool
}

// deferredUpdater is an optional interface for ResourceDrivers that accumulate
// resource-level updates that cannot be applied until all plan creates complete.
// The canonical case is DatabaseDriver deferring type=app trusted_sources entries
// that reference apps created later in the same plan. Apply calls
// FlushDeferredUpdates once after the main action loop; errors are appended to
// FlushDeferredUpdates once after the main dispatch loop; errors are appended to
// ApplyResult.Errors so the failure is visible to the operator.
//
// This is a DO-plugin-specific extension point not yet hoisted into
// wfctlhelpers.ApplyPlan; the second-pass flush below preserves the regression
// gate while the v2 dispatch handles the per-action loop.
type deferredUpdater interface {
HasDeferredUpdates() bool
FlushDeferredUpdates(ctx context.Context) error
}

// Apply executes the plan.
// Apply executes the plan via wfctlhelpers.ApplyPlan, then runs the
// DO-specific deferred-update second pass.
//
// PR P-DO TP2: under iacProvider.computePlanVersion: v2 wfctl dispatches
// directly through wfctlhelpers.ApplyPlan and does not call this method.
// The implementation here remains for legacy v1 callers (wfctl < v0.21.0
// or any in-process embedder of the gRPC plugin) and for the deferred-flush
// behavior that wfctlhelpers does not yet hoist.
//
// Per-action upsert recovery, JIT substitution, the Replace cascade, and
// the input-drift postcondition all live in wfctlhelpers.ApplyPlan now —
// drivers that opt into the upsert recovery path implement
// interfaces.UpsertSupporter (DO drivers AppPlatform, VPC, Firewall,
// Database all do; signature: SupportsUpsert() bool). The local
// upsertSupporter interface previously declared here is no longer needed:
// its SupportsUpsert() bool method is structurally identical to
// interfaces.UpsertSupporter, so the existing driver implementations
// satisfy the canonical interface without code change.
//
// wfctl:skip-iac-codemod
//
// The body intentionally wraps wfctlhelpers.ApplyPlan rather than
// matching the codemod's canonical single-statement
// `return wfctlhelpers.ApplyPlan(ctx, p, plan)` shape: the
// post-helper deferred-update flush below is a DO-plugin-specific
// regression gate (see provider_deferred_test.go and CHANGELOG entry
// for staging-deploy-blockers Blocker 2) that wfctlhelpers does not
// hoist. The skip marker tells the codemod's
// AssertApplyDelegatesToHelper analyzer this deviation is intentional.
// When wfctlhelpers grows a deferred-update lifecycle hook, the
// wrapper can collapse and the marker can drop.
func (p *DOProvider) Apply(ctx context.Context, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) {
result := &interfaces.ApplyResult{PlanID: plan.ID}
for _, action := range plan.Actions {
d, err := p.ResourceDriver(action.Resource.Type)
if err != nil {
result.Errors = append(result.Errors, interfaces.ActionError{
Resource: action.Resource.Name, Action: action.Action, Error: err.Error(),
})
continue
}
var out *interfaces.ResourceOutput
switch action.Action {
case "create":
out, err = d.Create(ctx, action.Resource)
if errors.Is(err, interfaces.ErrResourceAlreadyExists) {
// Upsert: resource exists in the provider but is absent from
// local state. Only attempt upsert if the driver supports
// name-based discovery (SupportsUpsert returns true).
// Drivers that pass ProviderID directly to their API client
// (VPC, database, firewall, etc.) do not support this path.
us, ok := d.(upsertSupporter)
if !ok || !us.SupportsUpsert() {
// Propagate original error; upsert not available for this type.
break
}
createErr := err
ref := interfaces.ResourceRef{
Name: action.Resource.Name,
Type: action.Resource.Type,
}
existing, readErr := d.Read(ctx, ref)
if readErr != nil {
// Preserve createErr so ErrResourceAlreadyExists remains
// in the error chain for callers using errors.Is.
err = fmt.Errorf("upsert: read after conflict: %w", errors.Join(createErr, readErr))
break
}
if existing.ProviderID == "" {
err = fmt.Errorf("upsert: resource %q found by name but ProviderID is empty; cannot update: %w", ref.Name, createErr)
break
}
ref.ProviderID = existing.ProviderID
out, err = d.Update(ctx, ref, action.Resource)
}
case "update":
ref := interfaces.ResourceRef{
Name: action.Resource.Name,
Type: action.Resource.Type,
ProviderID: action.Current.ProviderID,
}
out, err = d.Update(ctx, ref, action.Resource)
case "replace":
if action.Current == nil {
err = fmt.Errorf("replace action missing current resource state")
break
}
ref := interfaces.ResourceRef{
Name: action.Resource.Name,
Type: action.Resource.Type,
ProviderID: action.Current.ProviderID,
}
if err = d.Delete(ctx, ref); err != nil {
break
}
out, err = d.Create(ctx, action.Resource)
case "delete":
// Delete uses Current for the ref (ProviderID). Resource.Config is
// empty for deletes — the desired state is "absent" — but
// Resource.Type and Name are still set (required for driver lookup above).
if action.Current == nil {
err = fmt.Errorf("delete action for %q missing current resource state", action.Resource.Name)
break
}
ref := interfaces.ResourceRef{
Name: action.Current.Name,
Type: action.Current.Type,
ProviderID: action.Current.ProviderID,
}
err = d.Delete(ctx, ref)
// out remains nil — deleted resources have no post-apply output.
default:
err = fmt.Errorf("unknown action %q", action.Action)
}
if err != nil {
result.Errors = append(result.Errors, interfaces.ActionError{
Resource: action.Resource.Name, Action: action.Action, Error: err.Error(),
})
continue
}
if out != nil {
result.Resources = append(result.Resources, *out)
} else if action.Action != "delete" {
// A nil output without an error from create/update/replace is a driver
// bug — surface it explicitly so callers aren't silently missing state
// entries. "delete" legitimately returns nil out, so it's excluded.
result.Errors = append(result.Errors, interfaces.ActionError{
Resource: action.Resource.Name, Action: action.Action,
Error: "driver returned nil output without error",
})
}
result, err := wfctlhelpers.ApplyPlan(ctx, p, plan)
if err != nil {
// ApplyPlan only returns a top-level error on context cancellation
// — per-action failures land on result.Errors. Surface the
// cancellation alongside whatever partial result is in hand so
// callers can still inspect any actions that completed.
return result, err
}

// Second pass: flush deferred updates accumulated by drivers during the main
// action loop. These arise when a resource's config (e.g. DB trusted_sources
// with type=app) references another resource provisioned later in the same
// plan. By this point all plan creates have completed and the referenced
// resources exist. Each driver type is flushed at most once.
// Second pass: flush deferred updates accumulated by drivers during the
// main action loop. These arise when a resource's config (e.g. DB
// trusted_sources with type=app) references another resource provisioned
// later in the same plan. By this point all plan creates have completed
// and the referenced resources exist. Each driver type is flushed at
// most once.
seen := make(map[string]struct{}, len(plan.Actions))
for _, action := range plan.Actions {
if _, done := seen[action.Resource.Type]; done {
Expand Down
Loading
Loading