Skip to content

fix(compute,sql): normalize read-back so bootstrap reapply is drift-free#79

Merged
naxty merged 14 commits into
mainfrom
fix/reapply-drift-normalization
Jul 16, 2026
Merged

fix(compute,sql): normalize read-back so bootstrap reapply is drift-free#79
naxty merged 14 commits into
mainfrom
fix/reapply-drift-normalization

Conversation

@naxty

@naxty naxty commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

What

Follow-up to 0.1.8. A formae apply re-run of the GCP bootstrap reported drift/replace on resources that hadn't actually changed — two createOnly fields read back in a different representation than declared:

  • GCP::SQL::DatabaseInstance settings.ipConfiguration.privateNetwork — Cloud SQL echoes it as a full https://www.googleapis.com/compute/v1/projects/... URL, but a network resolvable reference (connection.res.network) resolves to the projects/... path → spurious update every reconcile. The response transformer now normalizes it to the projects/... form.

  • GCP::Compute::Instance AttachedDisk.deviceName — was writeOnly and stripped from the disk read-back, so a forma that sets deviceName drifted (desired had it, read lacked it) → spurious update. Now kept in the read-back and marked hasProviderDefault (createOnly): a declared value round-trips, and GCP's auto-assigned deviceName (persistent-disk-N) is suppressed as drift for disks that don't declare one.

Why

These were the last two reapply drifts on the private-IP Cloud SQL bootstrap. With them fixed (plus the bootstrap wrapping sourceImage in formae.value(...).setOnce — an image family resolves to a concrete image on read), a plain formae apply reconciles clean with no --force.

Tests

Unit tests for both (privatenetwork_test.go, instance_normalize_test.go). Full build + vet clean.

naxty added 14 commits July 10, 2026 10:29
Two createOnly fields read back in a different form than declared, forcing a
spurious update/replace on reconcile:

- Cloud SQL settings.ipConfiguration.privateNetwork: the API echoes a full
  'https://www.googleapis.com/compute/v1/projects/...' URL; normalize to the
  'projects/...' path a network resolvable reference resolves to.
- Instance AttachedDisk.deviceName: was writeOnly + stripped on read, so a
  declared deviceName drifted (desired has it, read lacked it). Keep it in the
  disk read-back and mark it hasProviderDefault instead — a declared value now
  round-trips, and GCP's auto-assigned deviceName is suppressed for disks that
  do not set one.

Unit tests for both.
RouterNat is a custom provisioner; hasProviderDefault is not applied to
custom-provisioner resources at patch-gen, so type/autoNetworkTier/
enableEndpointIndependentMapping/endpointTypes (server defaults) read back as
perpetual drift. Strip them in Read. Documented tradeoff: a user explicitly
setting one loses drift detection until RouterNat is config-driven or core
honors the hint for custom provisioners.
The forma declares db privateNetwork from a network resolvable's .selfLink
(full compute URL), but Cloud SQL stores the bare projects/... path. The
earlier normalize shortened the read-back instead, so it drifted the OTHER
way (actual=short vs desired=full). Expand short->full on read so it
round-trips against the declared selfLink.
1 req/s serialized every plugin API call; a stack destroy queued reads
behind it long enough to blow the 20s operator timeout and cascade-fail.
GCP quotas comfortably handle 10/s (matches azure/oci/k8s).
A Cloud SQL instance ships only the engine's default database; there was no
way to declare an application database in a forma (the bootstrap created it
imperatively via a startup-script curl to the Admin API). Add a config-driven
GCP::SQL::Database nested under its instance
(/projects/{p}/instances/{i}/databases/{name}), mirroring google_sql_database.

- schema: gcp.sql.sqlDatabase (name/instance/project createOnly, charset/
  collation optional, instance accepts a DatabaseInstance resolvable)
- api: sqlPathBuilder + parseSQLNativeID/extractSQLNativeID handle the nested
  parent path (4- and 6-segment native IDs)
- registered via ParentResourceConfig{ParentType: instances, PropertyName: instance}
StatusMessage renders as the CLI 'reason:' for a resource. Every provisioner
set it on in-progress/success too ('networks creation in progress'), so a clean
apply showed a spurious reason on each resource. Clear StatusMessage on any
non-failure result at the plugin's operation boundary (Create/Update/Delete/
Status in gcp.go) — one choke point covering base and all custom provisioners —
so a reason appears only when an operation actually failed.
Two read-back drifts made a fresh VM reconcile into a (failing) update:
- metadata: API returns items:[{key,value}]+fingerprint/kind; schema is
  items:{key:value}. Convert back to the map form and drop the provider fields.
- deviceName: a disk that declares none gets GCP's auto 'persistent-disk-N';
  the drift gate compares raw (ignores hasProviderDefault), so echoing it drifted.
  Keep declared names, drop the auto form.

Without these the instance update also hit an empty-fingerprint error; with them
the VM no longer reconciles into an update at all.
The schema modeled metadata.items as Mapping<String,String> (a map) while the
GCE API uses items: [{key,value}]. The plugin bridged with map<->array
transforms, but that shape gap tripped core's desired/actual merge (object path
sjson.Set onto an array -> spurious "0": key -> invalid JSON -> persist crash,
PLA-276) whenever a stored baseline and a read disagreed on shape.

Model it faithfully instead: items is Listing<MetadataItem>{key,value} +
provider-default fingerprint. metadata now passes through on write (no map->
array build) and only drops the 'kind' discriminator on read. Desired array ==
actual array -> no drift, no shape-merge, no crash. Validated e2e: fresh VM,
reapply is a clean no-op, list_changes empty.

- schema: MetadataItem + Metadata.items Listing, fingerprint hasProviderDefault
- drop buildMetadata + normalizeInstanceMetadata; drop 'kind' on read
- remove obsolete metadata_test; update transformer test
Members are plain strings, not resolvable refs, so formae can't order a binding
delete before its service account. When the SA is deleted first, the binding
delete hit googleapi 400 'Service account ... does not exist' and Failed. Treat
that as success — the binding is effectively gone. Reuses the same 400/does-not-
exist predicate as the create-propagation retry.
Deleting a PSA connection right after its Cloud SQL instance is gone fails with
'Producer services ... are still using this connection' — Google releases the
connection asynchronously (minutes). The delete op's Status mapped this to a
terminal ServiceInternalError, failing the teardown (and blocking the range +
VPC). Map it to NotStabilized instead so formae's operator retries the delete
with backoff until the producer releases — no manual peering delete needed.
The NotStabilized retry couldn't help: the delete op is async and already
DONE-failed, so re-polling just returns 'producer still using' until the
operator times out, and the producer lag is many minutes regardless.

Instead, when the connection delete fails with producer-still-using, fall back
to removing the consumer VPC peering directly via the Compute API
(Networks.RemovePeering servicenetworking-googleapis-com) — the documented
recovery. It severs the connection immediately and unblocks the reserved range
+ VPC, so 'formae destroy' completes in one pass with no manual gcloud step.
A GCP::SQL::Database delete right after its client disconnects fails with
'database ... is being accessed by other users' — Cloud SQL reaps lingering
Postgres sessions on a lag, and databases.delete (a server-side DROP) refuses
while any session remains. The config-driven base mapped every async-op error
to a terminal ServiceInternalError, so the teardown failed and needed a manual
re-run once sessions cleared.

Add an optional OperationConfig.RetryableError predicate: when it matches a
completed-with-error operation, the base reports NotStabilized instead of a
terminal failure, so formae core re-runs the delete until it converges. Wire it
for Cloud SQL on the 'being accessed by other users' message.
…h too

Cloud SQL returns 'database ... is being accessed by other users' directly on
the DELETE request (not via the async operation), so it hit the terminal
deleteFailureResult at base_resource.go:268 and never reached the op-status
RetryableError hook. Apply the same RetryableError classification on the
sync delete-error path → report NotStabilized so core retries while sessions
drain, instead of failing the teardown.
The plugin canonicalizes settings.ipConfiguration.privateNetwork to the full
compute selfLink on read (what the Cloud SQL API returns, and what the private-
IP bootstrap declares via a network resolvable). The cloudsql-instance CRUD
conformance testdata declared the short 'projects/.../networks/default' form, so
after the read-back-expand change it mismatched (expected short, got full) and
failed at create/extract/sync. Declare the full form for a clean round-trip.
@naxty naxty merged commit e076f80 into main Jul 16, 2026
93 of 94 checks passed
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.

1 participant