Skip to content

feat(droplet+volume): user_data, vpc, ssh_keys, volumes, tags, bools + new infra.volume driver#55

Merged
intel352 merged 22 commits intomainfrom
feat/droplet-volume-extension
May 3, 2026
Merged

feat(droplet+volume): user_data, vpc, ssh_keys, volumes, tags, bools + new infra.volume driver#55
intel352 merged 22 commits intomainfrom
feat/droplet-volume-extension

Conversation

@intel352
Copy link
Copy Markdown
Contributor

@intel352 intel352 commented May 3, 2026

Summary

Two related changes for self-hosted Postgres on DO:

1. Extend infra.droplet config with the keys needed to provision a real workload Droplet that participates in a VPC and persists data to a Block Storage volume:

  • user_data (string) — cloud-init script
  • vpc_uuid (string) — VPC to place droplet in
  • ssh_keys (mixed list) — fingerprints (string) AND/OR numeric IDs (int/int64/float64); fractional floats rejected to prevent silent ID truncation
  • tags ([]string)
  • enable_backups (bool) → maps to Backups
  • monitoring (bool)
  • ipv6 (bool)
  • volumes ([]string) — list of Volume names (not UUIDs); driver looks them up via Storage.ListVolumes filtered by Name+Region (DO Block Storage is region-bound, cross-region attach is impossible). Missing volume returns explicit error rather than silent drop.

Outputs gain private_ip (the VPC private IPv4) so downstream secrets can construct DATABASE_URL from infra_output: <droplet>.private_ip.

2. New infra.volume driver for DO Block Storage:

  • Config: region, size_gb, filesystem_type, description, tags
  • ProviderID is the volume UUID (IDFormatUUID)
  • Diff: size growth → in-place via StorageActions.Resize; size shrink → ForceNew (DO doesn't support shrinking)
  • Update: explicit error on shrink even if Plan/Diff is bypassed (defence in depth)
  • HealthCheck: successful Read → healthy; surfaces size+region in message; Status output normalised to "available" so downstream callers don't read empty as "unknown failure"

Driving use case

core-dump is replacing DO Managed Postgres with a self-hosted apache/age Droplet because DO Managed PG doesn't support the Apache AGE extension. This plugin extension is the IaC layer that change rests on.

Test results

go test ./internal/drivers/... -count=1 -race
... PASS
ok  github.com/GoCodeAlone/workflow-plugin-digitalocean/internal/drivers   7.292s

Full repo go test ./... and go vet ./... clean.

Notable decisions

  • Volume.Status doesn't exist in godo — agent assumed it would, doesn't. HealthCheck instead returns Healthy: true on successful Read with a message that surfaces size+region; Status output is the stable string "available" so downstream callers can't misread empty as "unknown failure".
  • Dual-shape ssh_keys — runtime per-element type-switch in dropletSSHKeysFromConfig. Strings → fingerprints. int/int64/float64 → numeric IDs (structpb collapses to float64). Fractional floats explicitly rejected.
  • Volumes-by-name — scoped to Name+Region in ListVolumes call. Missing volume returns spec'd error "droplet volumes: volume %q not found".
  • Touched providerid_format_test.go, the Capabilities required-list in provider_test.go, and sizing.go (noopSizing for volume since size is dimensional, not tiered) — these are manually-maintained registries the test suite enforces.

Backwards compatibility

All new Droplet keys are additive + optional + defaulted-empty. Existing droplet configs (just size/image/region) continue to work unchanged. infra.volume is a brand-new resource type.

Test plan

  • CI green
  • Copilot review
  • Tag v0.9.0 after merge for downstream consumption (workflow-registry bump → core-dump self-hosted PG)

🤖 Generated with Claude Code

intel352 and others added 5 commits May 3, 2026 04:01
Both helpers are structpb-aware: boolFromConfig falls through to the
default for non-bool values, and strSliceFromConfig accepts the typed
[]string shape Go-native callers emit AND the []any shape that survives
a YAML/JSON → structpb round-trip. Empty strings drop silently. Used by
the upcoming droplet/volume drivers; placed in util.go alongside the
existing strFromConfig/intFromConfig helpers so all drivers can reach
them without import cycles.

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

Self-hosted services (e.g. Apache-AGE Postgres on a Droplet, since DO
Managed Postgres has no AGE extension) need more than the size/image/
region the DropletDriver previously honoured. New optional config keys,
all additive and defaulted-empty (no behaviour change for existing
callers):

  - user_data        string         — cloud-init payload
  - vpc_uuid         string         — VPC the Droplet joins
  - ssh_keys         []string|[]int — fingerprints OR numeric IDs;
                                      element type detected at runtime
                                      (mixed lists OK; structpb floats
                                      must be whole numbers)
  - tags             []string
  - enable_backups   bool           — godo Backups field
  - monitoring       bool
  - ipv6             bool
  - volumes          []string of    — names resolved to IDs at create
                     volume names     time via Storage.ListVolumes(name=,
                                      region=). Region-bound: a name not
                                      present in the droplet's region
                                      returns "droplet volumes: volume
                                      %q not found".

Outputs gain `private_ip` (godo droplet.PrivateIPv4()) so downstream
services in the same VPC can be wired without a second Read.

DropletDriver now holds an optional Storage client (set by
NewDropletDriver from c.Storage; tests inject via the variadic
NewDropletDriverWithClient param). When `volumes` is non-empty but the
storage client is nil, Create returns an explicit error rather than
silently dropping the attachment.

mockDropletClient now captures the godo.DropletCreateRequest so tests
can assert on populated struct fields. Adds focused tests for each new
key, plus the SSH-keys fractional-float rejection path that prevents
silent ID truncation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New ResourceDriver covering CRUD + Diff + HealthCheck for DigitalOcean
Block Storage volumes. Backed by godo.StorageService; in-place size
growth uses godo.StorageActionsService.Resize. ProviderIDFormat is UUID
(DO volume IDs are 36-character UUIDs).

Config keys:
  - name             from spec.Name
  - region           defaults to provider region
  - size_gb          required, > 0
  - filesystem_type  optional ("ext4" / "xfs"); empty = raw block device
  - description      optional
  - tags             optional []string

Outputs: id (UUID), name, region, size_gb, filesystem_type. Status is a
stable "available" string — godo.Volume exposes no Status field, so a
successful Read on a real volume is the strongest health signal we have.

Update semantics:
  - size growth → in-place StorageActions.Resize, then re-read for state
  - size shrink → explicit error (DO has no shrink API; replace required)
  - region or filesystem_type change → Diff sets ForceNew so plan
    classifies as replace, not update

Tests cover Create / Read / Update (resize, no-op, shrink-rejected,
empty-ProviderID guard) / Delete / Diff (nil current, grow=update-only,
shrink=replace, region=replace, filesystem_type=replace, no-changes)
/ HealthCheck (success, read-error, empty-ProviderID) / ProviderIDFormat
/ Scale-not-supported. The volume mock pool supports filter-by-name so
the same struct exercises both VolumeDriver tests and the droplet
volumes-by-name path.

providerid_format_test.go adds the volume entry to the manually
maintained registry — without it, a future drift in ProviderIDFormat
would not be caught.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire VolumeDriver into Initialize() alongside the other infra drivers
and declare its IaCCapabilityDeclaration with the noScale operation set
(volumes have no scale concept; size_gb is dimensional). Add
"infra.volume" to the sizing map as a noopSizing entry so ResolveSizing
returns "n/a" for every abstract tier — the operator must set size_gb
explicitly. Provider Capabilities test gains the matching required
entry so removal of the driver registration would be caught.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
plugin.json: add infra.volume to the iacProvider.resourceTypes array
and update the descriptor to mention Block Storage volumes.

CHANGELOG: document the new infra.volume driver and the extended
Droplet config keys (user_data / vpc_uuid / ssh_keys / volumes / tags
/ enable_backups / monitoring / ipv6) plus the new private_ip Droplet
output. No README exists in this repo, so the CHANGELOG is the
canonical surface for these notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 3, 2026 08:04
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 expands the DigitalOcean IaC plugin so Droplets can provision more production-like workloads and adds first-class support for Block Storage volumes. It fits into the provider by extending infra.droplet inputs/outputs, registering a new infra.volume resource type, and updating the related tests/metadata.

Changes:

  • Add a new infra.volume driver with create/read/update/delete/diff/health behavior and provider registration.
  • Extend infra.droplet creation to accept more config (user_data, VPC, SSH keys, tags, booleans, named volume attachments) and expose private_ip.
  • Update plugin metadata, sizing/capability registries, changelog, and driver tests for the new resource/support.

Reviewed changes

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

Show a summary per file
File Description
plugin.json Advertises Block Storage volume support and adds infra.volume to provider resource types.
internal/sizing.go Registers infra.volume as a noop-sized resource using explicit size_gb.
internal/provider_test.go Updates the provider capabilities test to expect infra.volume.
internal/provider.go Registers the new volume driver and exposes its capability declaration.
internal/drivers/volume_test.go Adds unit coverage for volume CRUD, diff, health, and provider ID behavior.
internal/drivers/volume.go Implements the new DigitalOcean Block Storage volume driver.
internal/drivers/util.go Adds shared config helpers for booleans and string slices.
internal/drivers/providerid_format_test.go Adds the volume driver to the provider ID format registry test.
internal/drivers/droplet_test.go Expands droplet tests for new config fields, volume resolution, and private_ip.
internal/drivers/droplet.go Extends droplet create/output handling for SSH keys, VPC, tags, booleans, named volumes, and private_ip.
CHANGELOG.md Documents the new volume driver and droplet config extensions.

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

Comment thread internal/sizing.go
Comment on lines +71 to +74
// infra.volume sizing is dimensional (size_gb), not tiered — every
// abstract Size maps to "n/a" and the operator must set size_gb
// explicitly in spec.Config.
"infra.volume": noopSizing(),
Comment on lines +53 to +68
UserData: strFromConfig(spec.Config, "user_data", ""),
VPCUUID: strFromConfig(spec.Config, "vpc_uuid", ""),
Tags: strSliceFromConfig(spec.Config, "tags"),
Backups: boolFromConfig(spec.Config, "enable_backups", false),
Monitoring: boolFromConfig(spec.Config, "monitoring", false),
IPv6: boolFromConfig(spec.Config, "ipv6", false),
}

sshKeys, err := dropletSSHKeysFromConfig(spec.Config)
if err != nil {
return nil, fmt.Errorf("droplet create %q: %w", spec.Name, err)
}
req.SSHKeys = sshKeys

volumes, err := d.resolveDropletVolumes(ctx, spec.Config, region)
if err != nil {
Comment on lines +199 to +244
raw, ok := v.([]any)
if !ok {
// Accept a typed []string for Go-native callers as a convenience.
if ss, ok := v.([]string); ok {
out := make([]godo.DropletCreateSSHKey, 0, len(ss))
for _, s := range ss {
if s == "" {
return nil, fmt.Errorf("ssh_keys: empty fingerprint")
}
out = append(out, godo.DropletCreateSSHKey{Fingerprint: s})
}
return out, nil
}
return nil, fmt.Errorf("ssh_keys: expected list, got %T", v)
}
out := make([]godo.DropletCreateSSHKey, 0, len(raw))
for i, e := range raw {
switch t := e.(type) {
case string:
if t == "" {
return nil, fmt.Errorf("ssh_keys[%d]: empty fingerprint", i)
}
out = append(out, godo.DropletCreateSSHKey{Fingerprint: t})
case int:
if t <= 0 {
return nil, fmt.Errorf("ssh_keys[%d]: non-positive ID %d", i, t)
}
out = append(out, godo.DropletCreateSSHKey{ID: t})
case int64:
if t <= 0 {
return nil, fmt.Errorf("ssh_keys[%d]: non-positive ID %d", i, t)
}
out = append(out, godo.DropletCreateSSHKey{ID: int(t)})
case float64:
if t != float64(int64(t)) {
return nil, fmt.Errorf("ssh_keys[%d]: %v is not an integer", i, t)
}
if t <= 0 {
return nil, fmt.Errorf("ssh_keys[%d]: non-positive ID %v", i, t)
}
out = append(out, godo.DropletCreateSSHKey{ID: int(t)})
default:
return nil, fmt.Errorf("ssh_keys[%d]: unsupported element type %T (want string fingerprint or numeric ID)", i, e)
}
}
return out, nil
Comment thread internal/drivers/droplet.go Outdated
// matches outside that region are rejected since DO Block Storage cannot
// cross regions.
func (d *DropletDriver) resolveDropletVolumes(ctx context.Context, cfg map[string]any, region string) ([]godo.DropletCreateVolume, error) {
names := strSliceFromConfig(cfg, "volumes")
Comment thread internal/drivers/volume.go Outdated
Comment on lines +53 to +60
sizeGB, _ := intFromConfig(spec.Config, "size_gb", 0)
if sizeGB <= 0 {
return nil, fmt.Errorf("volume create %q: size_gb is required and must be > 0", spec.Name)
}
req := &godo.VolumeCreateRequest{
Region: region,
Name: spec.Name,
SizeGigaBytes: int64(sizeGB),
Comment on lines +135 to +179
var changes []interfaces.FieldChange
var needsReplace bool

if desiredSize, ok := intFromConfig(desired.Config, "size_gb", 0); ok && desiredSize > 0 {
curSize := outputsAsInt(current.Outputs["size_gb"])
if curSize != desiredSize {
// Growth = in-place resize; shrink = replace (DO has no shrink API).
fc := interfaces.FieldChange{
Path: "size_gb",
Old: curSize,
New: desiredSize,
}
if desiredSize < curSize {
fc.ForceNew = true
needsReplace = true
}
changes = append(changes, fc)
}
}

if region := strFromConfig(desired.Config, "region", ""); region != "" {
curRegion, _ := current.Outputs["region"].(string)
if curRegion != "" && curRegion != region {
changes = append(changes, interfaces.FieldChange{
Path: "region", Old: curRegion, New: region, ForceNew: true,
})
needsReplace = true
}
}

if fs := strFromConfig(desired.Config, "filesystem_type", ""); fs != "" {
curFS, _ := current.Outputs["filesystem_type"].(string)
if curFS != "" && curFS != fs {
changes = append(changes, interfaces.FieldChange{
Path: "filesystem_type", Old: curFS, New: fs, ForceNew: true,
})
needsReplace = true
}
}

return &interfaces.DiffResult{
NeedsUpdate: len(changes) > 0,
NeedsReplace: needsReplace,
Changes: changes,
}, nil
intel352 and others added 8 commits May 3, 2026 04:14
…inding #1)

strSliceFromConfig silently drops non-string and empty entries, which is
dangerous for volume attachments — a typo or wrong type leaves the Droplet
running without an expected disk and emits no diagnostic. Add
dropletVolumesFromConfig that returns an explicit error for any non-string
or empty entry, mirroring dropletSSHKeysFromConfig's error-message style.

Tests cover both shapes: []any{123, "data"} (type mismatch) and []any{""}
(empty string).

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

intFromConfig truncates float64 silently — size_gb: 100.9 (e.g. via JSON or
YAML float coercion) was creating a 100 GB volume with no diagnostic. Add
intStrictFromConfig that mirrors the float64 fractional-rejection logic
already in dropletSSHKeysFromConfig and use it for size_gb in Create,
Update, and Diff.

Whole-valued float64 (the structpb wire shape) still passes through.
intFromConfig is unchanged for callers where rounding is acceptable
(node_count, instance_count, probe seconds, etc.).

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

dropletSSHKeysFromConfig accepted []any and []string at the top level but
bailed on Go-native []int{101} or []int64{101} with "expected list, got
[]int" — even though numeric IDs are documented as supported and the
per-element handler already covers int / int64 / float64. Add type-switches
that mirror the per-element validation (non-positive rejection, error
message style).

Tests cover three shapes: []int{101,102}, []int64{555,666}, and the
non-positive rejection path []int{0,101}.

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

infra.volume was added to doSizingMap as noopSizing but the manually-
maintained TestResolveSizing cases table was not updated. Without the
case, the registration is unverified and a future refactor that drops
the entry would not fail this test. Add SizeS + SizeXL coverage so the
"n/a" contract is locked in alongside every other noop-sized resource.

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

Diff previously tracked only size_gb / region / filesystem_type. Changes to
description and tags were silently ignored because godo.StorageActions has
no update endpoint for either, and the godo Volume API itself sets both at
creation time only — so there is no in-place update path to make available.

Resolution: surface drift as ForceNew (matching the way region / filesystem
shrinks are handled) so the planner emits a planned replace rather than
dropping the change. Document the constraint in the diff comment.

Surface description and tags in volumeOutput so Diff has values to compare.
Reuse outputsAsStringSlice + equalStringSet (firewall.go) so reordered tags
do NOT trigger a needless replace.

Tests: tags-add forces replace; reordered tags do NOT; description change
forces replace.

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

Extended droplet fields (user_data, vpc_uuid, ssh_keys, volumes, tags,
enable_backups, monitoring, ipv6) were wired into Create only; Diff
compared just "size", so changing any of these on an existing droplet
produced no plan action and the drift was silently dropped. godo
explicitly disallows Update on droplets (PUT only resizes), so Diff is
the only place to flag these — every detected change must be ForceNew.

Surface the comparable fields in dropletOutput (vpc_uuid, enable_backups
derived from BackupIDs, tags, volumes) so Diff has values to compare;
extend Diff with order-irrelevant set comparison for tags / volumes
(reuses outputsAsStringSlice + equalStringSet from firewall.go).

user_data, monitoring, ipv6, ssh_keys are NOT drift-checked: godo.Droplet
does not surface them on Read, so comparing against current Outputs would
produce a perpetually-dirty plan. Documented as a known limitation.

Tests: vpc_uuid change forces replace; tags add forces replace; tags
reorder does NOT force replace; enable_backups toggle forces replace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Manifest.Description must match plugin.json's description (test enforced
in plugin_test.go). The merge from main pulled in a longer description
that didn't yet mention Block Storage volumes; this aligns both.
Copilot AI review requested due to automatic review settings May 3, 2026 08:25
…side plugin.json

The Validate strict plugin contracts step (added in PR #41) copies only
plugin.json to a tempfile when simulating GoReleaser's before-hook
version rewrite. But wfctl plugin validate --strict-contracts looks for
plugin.contracts.json CO-LOCATED with plugin.json (same directory). The
rewrite-validation therefore always fails with
"missing_module_contract_descriptor: module type \"iac.provider\" has no
strict contract descriptor" — even when plugin.contracts.json IS valid
in the repo root.

This was masked on main because main has a single configurable contract
file and any change rebuilds in-place; surfaced now by PR #55 which
introduces a new resource type and exercises the second validation path.

Fix: use a temp DIR and copy both plugin.json + plugin.contracts.json
into it so the rewritten plugin.json's neighbour is intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <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 14 out of 14 changed files in this pull request and generated 8 comments.


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

Comment thread internal/drivers/util.go Outdated
}
return int(t), true, nil
}
return defaultVal, false, nil
Comment on lines +168 to +178
// volumes: by-name list. Reuse strict parse so a malformed config
// surfaces here at plan time, not later at Apply.
if names, err := dropletVolumesFromConfig(desired.Config); err != nil {
return nil, err
} else if len(names) > 0 {
curVols := outputsAsStringSlice(current.Outputs["volumes"])
// curVols are volume IDs (DO Read returns IDs, not names); a name
// vs ID mismatch always reports as a change. That's intentional:
// we can't resolve names→IDs here without an API call, so any
// presence-vs-absence drift forces re-plan with the live mapping.
if !equalStringSet(curVols, names) {
Comment thread internal/drivers/droplet.go Outdated
Comment on lines +185 to +191
// user_data, monitoring, ipv6, ssh_keys: DO Read does not expose these
// fields reliably (godo.Droplet has no Monitoring/IPv6/UserData fields
// surfaced post-create), so we cannot drift-compare from current
// Outputs without producing a perpetually-dirty plan. Drift on these
// fields will surface only via re-plan after the operator destroys +
// recreates, or via an external read-side check. Documented limitation.

Comment on lines +158 to +165
// tags: order-irrelevant set comparison (DO does not preserve order).
if tags := strSliceFromConfig(desired.Config, "tags"); len(tags) > 0 {
curTags := outputsAsStringSlice(current.Outputs["tags"])
if !equalStringSet(curTags, tags) {
changes = append(changes, interfaces.FieldChange{
Path: "tags", Old: curTags, New: tags, ForceNew: true,
})
}
Comment thread internal/drivers/volume.go Outdated
Comment on lines +175 to +177
if fs := strFromConfig(desired.Config, "filesystem_type", ""); fs != "" {
curFS, _ := current.Outputs["filesystem_type"].(string)
if curFS != "" && curFS != fs {
Comment thread internal/drivers/volume.go Outdated
Comment on lines +190 to +197
if desc := strFromConfig(desired.Config, "description", ""); desc != "" {
curDesc, _ := current.Outputs["description"].(string)
if curDesc != "" && curDesc != desc {
changes = append(changes, interfaces.FieldChange{
Path: "description", Old: curDesc, New: desc, ForceNew: true,
})
needsReplace = true
}
Comment thread internal/drivers/volume.go Outdated
Comment on lines +200 to +210
if tags := strSliceFromConfig(desired.Config, "tags"); len(tags) > 0 {
curTags := outputsAsStringSlice(current.Outputs["tags"])
// equalStringSet (firewall.go) treats order-irrelevant — DO does not
// preserve tag order across reads, so reorders must NOT trigger
// replace.
if !equalStringSet(curTags, tags) {
changes = append(changes, interfaces.FieldChange{
Path: "tags", Old: curTags, New: tags, ForceNew: true,
})
needsReplace = true
}
Comment thread internal/drivers/droplet.go Outdated
Comment on lines +135 to +142
// vpc_uuid: read-side stable, drift is unambiguous.
if vpc := strFromConfig(desired.Config, "vpc_uuid", ""); vpc != "" {
curVPC, _ := current.Outputs["vpc_uuid"].(string)
if curVPC != "" && curVPC != vpc {
changes = append(changes, interfaces.FieldChange{
Path: "vpc_uuid", Old: curVPC, New: vpc, ForceNew: true,
})
}
intel352 and others added 7 commits May 3, 2026 04:36
…finding #1)

dropletOutput previously stored godo's raw VolumeIDs (UUIDs) in
Outputs["volumes"], while desired config carries volume *names*. After
every successful Create/Read, the next Diff would compare e.g.
["vol-uuid-1"] against ["pg-data"] and force-replace the Droplet —
catastrophic for stateful workloads (the PG data Droplet would be
destroyed and recreated on every deploy, losing all game state).

Resolve each VolumeID to its name via Storage.GetVolume in dropletOutput.
Cache lookups within a single Read so duplicate VolumeIDs only hit the
API once. On resolution failure (e.g. volume deleted out-of-band) fall
back to the raw ID and record the unresolved IDs in
Outputs["volumes_resolution"] so operators can debug.

Update Diff comment to reflect that comparison is now name-vs-name and
stable across plans.

Tests:
- Read with VolumeIDs returns names in Outputs
- Diff with matching names returns no change (regression guard)
- Diff with desired missing a name returns ForceNew
- Resolution failure falls back to ID + records unresolved_ids
- Duplicate IDs only call GetVolume once (cache hit)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…opilot round-2 finding #2)

Previously `intStrictFromConfig` returned (defaultVal, false, nil) when
the config value was an unsupported type (e.g. `size_gb: "100"` as a
string). The caller then emitted "size_gb is required" — a misleading
diagnostic that hid the real bug (operator quoted a number).

Return present=true with an explicit "expected integer, got <type>"
error so the caller surfaces the type problem directly.

Tests: string-typed size_gb and bool-typed size_gb both reject with
"expected integer, got <type>" before any CreateVolume call is made.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lot round-2 finding #3)

The Diff path skipped tag comparison when len(desired.tags)==0, so
clearing tags on a Droplet was silently ignored. Operators sometimes
need to strip tags to remove a Droplet from a tag-based firewall or
backup schedule; "no diff" is dangerously wrong here.

Switch the guard to "key present in desired" so empty desired vs
non-empty current surfaces as ForceNew, while absent desired still
skips (preserves backwards-compat for YAML that predates the field).

Tests: clearing tags forces replace; absent tags key does NOT trigger
drift; reorder still ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(Copilot round-2 finding #4)

The empty-side guard skipped raw(empty)->ext4 and ext4->raw transitions,
so changing filesystem_type after Create produced no diff. DO Block
Storage cannot reformat a volume in place, so any change must surface
as ForceNew rather than being silently ignored.

Switch to "key present in desired" guard so empty<->non-empty surfaces
as drift. Absent desired key still skips (backwards-compat for YAML
predating the field).

Tests: raw->ext4 and ext4->raw both force replace; absent key does NOT
trigger drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lot round-2 finding #5)

The empty-side guard skipped both empty->non-empty (adding a
description after Create) and non-empty->empty (clearing a description)
transitions. DO Block Storage exposes no description-update endpoint,
so any change must surface as ForceNew rather than being silently
ignored.

Switch to "key present in desired" guard. Absent key still skips
(backwards-compat).

Tests: add-from-empty and clear-to-empty both force replace; absent
key does NOT trigger drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same pattern as droplet finding #3 and volume description finding #5.
Clearing tags (non-empty current -> empty desired) was silently ignored
because Diff skipped when len(desired.tags)==0. DO Block Storage has no
tag-update endpoint, so any tag change must surface as ForceNew.

Switch to "key present in desired" guard. Absent key still skips.

Tests: clearing tags forces replace; absent tags key does NOT trigger
drift (existing reorder/equal-set behavior preserved).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d-2 finding #7)

The curVPC != "" guard skipped vpc_uuid drift when the current state
had no vpc_uuid. Pre-release Droplet states (created before vpc_uuid
became part of Outputs) would silently ignore an operator adding a
vpc_uuid pin to YAML — exactly when explicit drift detection matters
most.

Switch to "key present in desired" guard. Empty current vs non-empty
desired now triggers ForceNew. Absent desired key still skips
(backwards-compat).

Tests: vpc_uuid add-from-empty forces replace; absent key does NOT
trigger drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oring/ipv6 (Copilot round-2 finding #8)

Round-1 documented the gap inline but the silent fallthrough at the end
of Diff was easy to miss. Replace with an explicit block that:

- Names each undetected field and the godo reason
- States the operator workaround (`taint` / delete + re-apply)
- References follow-up issue #56 for the resolution path
- Updates the function-level Diff doc-comment to point at the inline
  rationale

Issue: #56
"Droplet Diff misses user_data / ssh_keys / monitoring / ipv6 (godo Read limitation)"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@intel352 intel352 merged commit 8463fd6 into main May 3, 2026
4 checks passed
@intel352 intel352 deleted the feat/droplet-volume-extension branch May 3, 2026 08:55
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