Conversation
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>
There was a problem hiding this comment.
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.volumedriver with create/read/update/delete/diff/health behavior and provider registration. - Extend
infra.dropletcreation to accept more config (user_data, VPC, SSH keys, tags, booleans, named volume attachments) and exposeprivate_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.
| // 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(), |
| 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 { |
| 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 |
| // 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") |
| 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), |
| 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 |
…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>
…xtension # Conflicts: # plugin.json
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.
…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>
There was a problem hiding this comment.
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.
| } | ||
| return int(t), true, nil | ||
| } | ||
| return defaultVal, false, nil |
| // 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) { |
| // 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. | ||
|
|
| // 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, | ||
| }) | ||
| } |
| if fs := strFromConfig(desired.Config, "filesystem_type", ""); fs != "" { | ||
| curFS, _ := current.Outputs["filesystem_type"].(string) | ||
| if curFS != "" && curFS != fs { |
| 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 | ||
| } |
| 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 | ||
| } |
| // 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, | ||
| }) | ||
| } |
…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>
Summary
Two related changes for self-hosted Postgres on DO:
1. Extend
infra.dropletconfig 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 scriptvpc_uuid(string) — VPC to place droplet inssh_keys(mixed list) — fingerprints (string) AND/OR numeric IDs (int/int64/float64); fractional floats rejected to prevent silent ID truncationtags([]string)enable_backups(bool) → maps toBackupsmonitoring(bool)ipv6(bool)volumes([]string) — list of Volume names (not UUIDs); driver looks them up viaStorage.ListVolumesfiltered 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 constructDATABASE_URLfrominfra_output: <droplet>.private_ip.2. New
infra.volumedriver for DO Block Storage:region,size_gb,filesystem_type,description,tagsIDFormatUUID)StorageActions.Resize; size shrink →ForceNew(DO doesn't support shrinking)"available"so downstream callers don't read empty as "unknown failure"Driving use case
core-dumpis 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
Full repo
go test ./...andgo vet ./...clean.Notable decisions
Healthy: trueon 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".dropletSSHKeysFromConfig. Strings → fingerprints.int/int64/float64→ numeric IDs (structpb collapses to float64). Fractional floats explicitly rejected.ListVolumescall. Missing volume returns spec'd error"droplet volumes: volume %q not found".providerid_format_test.go, theCapabilitiesrequired-list inprovider_test.go, andsizing.go(noopSizingfor 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.volumeis a brand-new resource type.Test plan
🤖 Generated with Claude Code