Skip to content

feat(gcp): add 24 types across eight services, seven of them new - #198

Merged
naxty merged 12 commits into
naxty/gcpDepthBatchfrom
naxty/gcpServicesBatch
Sep 3, 2026
Merged

feat(gcp): add 24 types across eight services, seven of them new#198
naxty merged 12 commits into
naxty/gcpDepthBatchfrom
naxty/gcpServicesBatch

Conversation

@naxty

@naxty naxty commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Stacked on #196 — targets naxty/gcpDepthBatch, so the diff here is this batch only.
Merge #196 first.

24 new resource types across eight services, seven of them entirely new to the plugin, plus
four defect fixes on types that already shipped. Every candidate was probed against the live API
before any code was written, by nine agents working in parallel; everything that survived was then
run through conformance serially.

Conformance: 25 cases, 50 runs, all green

Run locally one case at a time after make install, TIMEOUT=12. "CRUD" is the full lifecycle:
apply, verify, extract, force-reconcile, update, destroy, re-apply, out-of-band delete, sync.

Service Types CRUD Discovery
Network Services (new) Mesh, ServiceLbPolicy, EndpointPolicy, HttpRoute, GrpcRoute, TcpRoute, TlsRoute 7/7 7/7
Cloud Deploy (new) Target, DeliveryPipeline, CustomTargetType, DeployPolicy, Automation 5/5 5/5
Dataform (new) Repository, Workspace, ReleaseConfig, WorkflowConfig 4/4 4/4
Binary Authorization (new) Attestor, PlatformPolicy 2/2 2/2
Parameter Manager (new) Parameter, ParameterVersion 2/2 2/2
API Keys (new) Key 1/1 1/1
Cloud Build (new) BuildTrigger 1/1 1/1
Firestore (new) Database 1/1 1/1
BigQuery RowAccessPolicy 1/1 1/1
KMS (fix) KeyRing — case restored 1/1 1/1
Compute (regression check) region-http-lb, for the RegionTargetHttpProxy change 1/1

Nothing in this PR is unverified.

Four defects on already-shipping types

Each was found by writing the missing conformance case — which is the point of writing them.

  • GCP::KMS::KeyRing could be created and never destroyed. keyRings.delete did not exist when
    the type was written; the plugin said so in a comment, a doc comment and a unit test, and the
    fixture had been dropped for exactly that reason. The method exists now and works. Nine key
    rings leaked by the nightly before the case was dropped have been reclaimed.
  • GCP::Compute::RegionTargetHttpsProxy could never be updated. That collection enforces the
    patch fingerprint rather than treating it as advisory — Required field 'resource.fingerprint' not specified — and optimistic locking was off, so every update failed with a 400.
  • GCP::Compute::RegionTargetHttpProxy claimed an update it does not have. No patch method
    exists; a PATCH landed on a URL the API does not serve and came back as Google's HTML 404 page.
  • GCP::BigQuery::Table.datasetId rejected a resolvable, so a table could only name a dataset
    some other forma had already created.

Two base changes, both driven by a live failure

  • ResourceConfig.ReadTreatAsMissing (added in feat(gcp): add 37 resource types across thirteen services #196) — for an API that keeps serving a
    tombstone after a 200 delete.
  • OperationConfig.RetryableError now guards the update path, not only delete. Firestore
    answers a patch issued while a create is still settling with 409 ABORTED, "There are concurrent
    database changes, please try again" — deterministic, and the exact shape of a reconcile. It was
    reported as a terminal failure. Cloud Deploy returns 409 ABORTED on concurrent mutations too,
    so this is a class, not one API.

Behaviours the probes caught that would otherwise have shipped broken

  • Firestore was undiscoverable. databases.list takes no pagination parameters and refuses
    the ones it does not know — ?pageSize=100 → 400 page_size is not supported. The whole request
    is refused, so every List returned an error instead of a list while create, read, update and
    delete all worked. Only a discovery run finds that.
  • Parameter Manager serves each region from its own host. The plain host with a regional path
    answers 403 PERMISSION_DENIED, "Read access to project was denied" — a wrong-host error
    wearing a missing-IAM-grant's clothes
    , worth knowing before anyone responds by granting a role.
    The types are registered global-only, and what base would need for the regional host is written
    down.
  • Immutable by field mask rather than by value. Dataform refuses a PATCH whose mask names
    invocationConfig, codeCompilationConfig or kmsKeyName even when the object sent is
    byte-identical, and its own discovery document calls all three plain "Optional".
  • A 200 is not proof. A Cloud Deploy create accepted with HTTP 200 came back as a done
    operation carrying an error
    , and the pipeline never existed.
  • Cloud Build's opaque 400. Five source forms and gcloud's own builds triggers create manual
    all fail identically with a 400 naming no field; the cause is a missing serviceAccount, which
    every project created in recent years needs.
  • Input-only fields that cannot be declared without reading as drift forever: BigQuery's
    grantees (and a PUT that omits it clears the IAM binding it created), Cloud Build's
    includedFiles, Firestore's tags.

Secrets

Two types mint or carry credentials, and neither puts one in state.

  • ApiKeys::KeykeyString arrives inline in the create response and nowhere else. It is
    dropped on every read path rather than merely expected to be absent, and getKeyString is never
    called from a read.
  • ParameterManager::ParameterVersion — unlike Secret Manager, this API hands the payload
    straight back on a GET. data is write-only, the payload is stripped on read, and
    versions:render is never called from a read path.

Sweeps

Eight new sections, all REST because none of these collections has a usable gcloud surface, sharing
one helper rather than eight near-identical copies — the copies are how the earlier sweeps drifted
apart. Every list URL was dry-run against the live API, and the sweep logic was then run live and
observed collecting the real leftovers
a conformance run leaves (prerequisites Destroy spares):
Cloud Deploy pipelines and targets, Dataform repositories, a Parameter Manager parameter, four
Network Services meshes. Child-before-parent ordering is encoded in the list order and was observed
working.

API keys get half a sweep, and it says so. A key delete is a soft delete: the key stops
working and drops out of listings, but a read keeps answering 200 for thirty days, and the v2 API
has no purge — only undelete. A tombstoned key cannot be collected by anything, so every run leaves
one behind until it self-purges. Free and non-functional, but recorded so nobody goes looking for
the sweep that is missing. Live keys are collected.

Dropped after probing

ServiceBinding (its only functional field is deprecated in the API's own discovery document) ·
Cloud Deploy releases/rollouts/jobRuns (a release renders through Cloud Build, a rollout
deploys; neither is a desired state) · Dataform workflowInvocations/compilationResults (execute
against BigQuery) · Binary Authorization's projects/{p}/policy singleton (no create, no delete;
PlatformPolicy gives the same power as a real collection) · Firestore Index (server-assigned
name that ignores a caller-supplied one; a build on an empty collection did not finish in five
minutes) and BackupSchedule (retained backup data bills) · Cloud Build WorkerPool (provisioned
machines, billed hourly) and the v2 Connection/Repository (need an external OAuth account) ·
Network Services gateways, the four *Extensions, wasmPlugins, agentGateways, multicast*
and edgeCache*.

Of the 15 shipped types that had no conformance case, one is now covered (KMS::KeyRing) and the
rest were each confirmed blocked with a reason: not independently creatable, needs a billable
cluster or provisioned capacity, or mutates project-wide state. Two need only a decision:
EssentialContacts::Contact needs an allowed contact domain, and GKEHub::Feature needs
gkehub.googleapis.com enabled plus roles/gkehub.admin.

One thing I got wrong, and how it was caught

verify-schema rejects duplicate schema basenames across services, so dataform/repository.pkl
and firestore/database.pkl had to be renamed. Pkl derives a module's identifier from its filename
when the import has no alias, so the rename silently broke every repository.Repository reference
and all ten of those cases failed at conformance step 1 — while pkl eval formae-plugin.pkl and
verify-schema both passed, because neither evaluates a fixture. Fixed with explicit as aliases,
and evaluating every fixture directly is now part of the gate run.

Gates

go build ./..., golangci-lint run ./... (0 issues), make test-unit (0 failures, 50 packages),
make verify-schema (243 types, 0 duplicates), pkl eval formae-plugin.pkl, bash -n on every
tracked .sh, make lint-reuse (930/930), and every new or changed fixture evaluates.

Project verified clean afterwards across every collection this batch touches.

naxty added 12 commits September 3, 2026 17:50
A mesh, the four route kinds that attach to it, an endpoint policy and a
service load balancing policy. This is the direct consumer of the Network
Security policies added earlier on this branch: an endpoint policy's
clientTlsPolicy, serverTlsPolicy and authorizationPolicy all point at them.

Every collection answers 200 at both locations/global and locations/{region},
and they are separate namespaces - a mesh created globally is absent from the
regional list and 404s on a regional GET. A wrong guess creates unfindable
resources rather than failing, so the scope is pinned in a map with that
reasoning rather than inferred from the target.

Reference fields are full paths on the wire and short names in a forma, and
the short form is refused, so both halves of the translation exist with the
identity pinned in unit tests.

A PATCH here validates the resource from the request body alone rather than
merging over stored state, so an update omitting type, hostnames or rules is
refused as though the stored value did not exist. Those fields are
non-optional and always sent. This API also clears labels on any patch whose
mask does not name them, and omits false booleans the proto3 way so a declared
false reads back absent; both are documented at the fields.

Each route kind avoids a billable prerequisite differently: HTTP redirects,
gRPC injects a fault, TCP uses its original destination, and TLS - the one
kind with no destination-free action - points at a backend service with no
backends. gateways is absent from all four because a gateway allocates Envoy
proxies and bills, so the field could not be verified. ServiceBinding is
absent because its only functional field is deprecated in the API's own
discovery document.
Target, DeliveryPipeline, CustomTargetType, DeployPolicy and Automation - the
declarative half of Cloud Deploy. Releases, rollouts and job runs are
deliberately absent: a release renders through Cloud Build and a rollout
actually deploys, so both cost money, and neither is a desired state rather
than a record of an action already taken.

This is where polling earns its keep. A create accepted with HTTP 200 came
back as a done operation carrying an error - a stage naming its target by full
path is refused with "is not a valid resource ID for resource type
stage.targetId" - and the pipeline never existed. The API also serialises
operations per resource and answers a second concurrent mutation with 409
ABORTED.

The reference forms are mixed, which is the trap: stage.targetId must be a
short id and rejects a full path, while customTarget.customTargetType is a
full path. So a pipeline stage passes its resolvable straight through while
Target expands and shortens.

executionConfigs is not declarable. Cloud Deploy fills it in whether or not it
was sent and rewrites what was sent, adding artifactStorage, executionTimeout
and a defaultPool mirror - nested too deep for a schema hint to tolerate and
too meaningful to strip. An automation's rules come back carrying an
output-only condition two levels down, which is stripped instead.

Discovery needs no walking provisioner: Cloud Deploy accepts "-" as a wildcard
delivery pipeline.
Workflow invocations and compilation results are absent: both execute against
BigQuery. Verified rather than assumed that the shipped types do not - a
workflow config with no cronSchedule left both collections empty, and this
project cannot schedule automatic releases at all.

The whole API is synchronous; no operations collection exists in it. Left on
the async path a create would report in-progress with an empty request id and
then poll the bare base URL forever.

Three fields are immutable in a way nothing in the discovery document says,
and immutable by field mask rather than by value: a PATCH whose mask names
invocationConfig, codeCompilationConfig or kmsKeyName is refused with
"update_mask contains immutable fields" even when the object sent is
byte-identical to the stored one. Since the mask is built from the body, all
three leave update bodies - that is the difference between a working timeZone
change and a rejected one.

internalMetadata, a bookkeeping blob that changes on every write, is stripped
from all four types, along with containingFolder, which can only be changed
through a custom verb and so could never be reconciled. A workspace has no
patch method at all, so it replaces.

The schema module is repository_dataform.pkl: verify-schema rejects duplicate
basenames across services and artifactregistry already ships repository.pkl.
Every mutating call in the Firestore Admin API answers with a long-running
operation and two of the three cannot be polled. The operation a PATCH names
answers 404 "Operation does not exist" on a patch that plainly applied, and
the one a DELETE names carries the deleted database under "response" but never
sets "done" - not false, absent - so an async poller waits forever on a
database already gone. Create needs no polling either: it returns the finished
database inline in about a second. So the type is registered synchronous and
reads the resource back after an update.

Three fields the API volunteers are dropped rather than defaulted. etag is
recomputed on every read of an untouched database - three consecutive GETs
gave three etags with an identical updateTime. earliestVersionTime moves
continuously. realtimeUpdatesMode is reported for every database and refused
on create for Standard-edition ones, so declaring it would put a value in
state that fails the create it came from. enhancedTextSearchQueryMode is
dropped for a different reason: the API returns it on every database and the
v1 discovery document does not mention it.

deleteProtectionState is declared explicitly in the fixture because a database
created with protection enabled cannot be deleted at all, and every run would
leak one. A deleted id is held for about five minutes, so the case exercises
update rather than replace.

Index is absent: its name is server-assigned and the API ignores a
caller-supplied one, so a declared name could never round-trip, and an index
build on an empty collection did not complete in five minutes of polling.
BackupSchedule is absent because retained backup data bills.

The schema module is database_firestore.pkl: verify-schema rejects duplicate
basenames and sql already ships database.pkl.
Global only, and that is a limit of the API rather than a simplification.
Parameter Manager serves each region from its own host,
parametermanager.<region>.rep.googleapis.com, and serves locations/global only
from the plain host. Cross the two and it answers 403 PERMISSION_DENIED, "Read
access to project was denied" - a wrong-host error wearing a missing-IAM-
grant's clothes, which is worth knowing before anyone responds to it by
granting a role. base.APIConfig.BaseURL is one constant string, so the
location segment is pinned to global and a target configured for a region
cannot walk into that 403. What base would need to support the regional host
is written down where the next person will look.

A version's payload is stripped from every response, and that is a security
control rather than a drift fix. The payload is user data and may be a secret,
and unlike Secret Manager - whose API withholds secret material - Parameter
Manager hands it straight back, both on a GET defaulting to view=FULL and in
the create response. So data is declared write-only, the payload is removed on
read, and versions:render, which would additionally resolve Secret Manager
references inside it, is never called from a read path.

The whole API is synchronous. disabled is ignored on create - a create sending
true answers 200 and the version is enabled anyway - so it carries a provider
default and takes effect only through a patch. A parameter with versions
refuses to delete and its delete takes no force flag, so the declared
reference is also what orders the teardown.
The attestor's Grafeas note lives in the Container Analysis API, which this
plugin does not ship, and that turns out not to matter: Binary Authorization
does not resolve noteReference at create time, so an attestor pointing at a
note that does not exist - in a project where Container Analysis is not even
enabled - is accepted. That is what makes the type declarable with no
prerequisite in someone else's API.

A public key sent without an id comes back with one the API computed from the
key's DER digest, so id is required rather than defaulted.
asciiArmoredPgpPublicKey is absent from the schema for the harder version of
the same problem: with a PGP key the API overwrites id with the key's
fingerprint, so declared and stored could never agree. Update is a PUT - this
API has no patch, no update mask and no long-running operation anywhere - so
omitting an optional field really does clear it.

PlatformPolicy is why the project-wide projects/{project}/policy singleton is
not modelled: it offers the same expressive power as a real, deletable
collection, where the singleton has only a get and a PUT and a "create" would
mean mutating live admission policy. The platform is a URL segment rather than
a field: v1 exposes only gke, and a field with exactly one legal value the API
never echoes back is an input-only field that reads as drift. ListItemsKey is
set because the response keys its array platformPolicies while the collection
segment is policies.

An API key is the first type here whose creation mints a secret, and the
create call is the only call that returns it: keyString arrives inline in the
completed operation and nowhere else. It is dropped on every read path rather
than merely expected to be absent, the same treatment
compute.vpnTunnels.sharedSecret gets, and getKeyString is never called from a
read. There is no field to declare opaque - the value has no authored
counterpart, only something to refuse to store.

Every key response reports name in project-number form while the target
declares a project id, so the native ID is rebuilt from context or the same
key would be managed under one identity and discovered under another. And
delete is a soft delete: the key stops working and drops out of listings, but
a read keeps answering 200 for thirty days with deleteTime set, so
ReadTreatAsMissing turns such a read into NotFound. Without it every sync
inside that month would put a deleted key back into inventory. That window
also reserves the id, so the case exercises update rather than replace.
Creating a trigger starts nothing, which was checked rather than assumed: the
project's builds collection was empty before the first probe trigger and still
empty after nine creations and seven patches. The fixture is doubly inert -
disabled, and with a sourceToBuild URL as its only source, so no webhook, no
Pub/Sub and no repo connection exists that could fire it.

Three behaviours compensated for. The trigger methods are synchronous: create
and patch answer with the trigger and delete with an empty body, unlike this
API's own builds.create which answers with an Operation. It authorizes a PATCH
against the request body's resourceName rather than the URL's, so a replayed
one turns an in-project update into a 403 on another project's path, and that
field is stripped from requests. And it silently drops false booleans, so
disabled:false comes back absent and an approvalConfig of
{approvalRequired:false} comes back as {}; both are restored on read.

A project with no legacy Cloud Build service account - every project created
in recent years - rejects a trigger that names none with a bare HTTP 400
naming no field. Five different source forms and gcloud's own "builds triggers
create manual" all fail identically, and the cause is the missing
serviceAccount. That opaque 400 is the biggest trap in this API and is
documented in the schema.

WorkerPool is absent: a private pool holds provisioned machines billed per
hour. The v2 Connection and Repository types need an external OAuth account.
…esolvable

Row-level access control on a single table: a SQL boolean predicate deciding
which rows a principal may see, so one shared table can serve each tenant only
its own rows instead of a view per tenant. This was dropped from the previous
batch on a missing bigquery.datasets.create grant, which has since landed.

Its identity is not a name but the composite rowAccessPolicyReference
{projectId,datasetId,tableId,policyId}, which every request body must carry
and which the API checks against the URL segment by segment - "Project ID in
URL path and content do not match." So the four flat properties a forma
declares are assembled on the way out and flattened back on the way in.

Update is a PUT with no field mask, and sending one is not ignored: the API
answers `Unknown name "updateMask": Cannot bind query parameter`. A delete
that would leave the table with no policy is refused outright unless
force=true is sent, so the type always sends it - without that flag every
teardown of a single-policy table fails.

grantees is accepted on insert and returned by neither get nor list, so it is
not declared: an input-only field reads as drift on every sync. It is readable
only through a separate getIamPolicy call, and a PUT that omits it clears the
roles/bigquery.filteredDataViewer binding it created. Both facts are recorded
at the field, because together they mean a formae-managed policy grants row
access to nobody until someone binds that role out of band.

Policies hang off a table and BigQuery accepts no wildcard for the dataset or
table segment - datasets/-/tables/- answers 404 blaming the dataset - so
discovery walks datasets, then tables, then policies rather than reporting an
empty inventory.

Table.datasetId was typed plain String, so dataset.res.datasetId failed
evaluation and a table could only name a dataset some other forma had already
created. The sibling Routine already had this right.
…wrong

Three defects that a missing conformance case had been hiding, plus the case.

cloudkms keyRings.delete did not exist when GCP::KMS::KeyRing was written, and
the plugin said so in a comment, a doc comment and a unit test - so a forma
could create a key ring and never reclaim it, and the fixture was dropped for
exactly that reason. The method exists now: it answers with an already-
finished Operation, the ring 404s on the very next GET, and the deleted id can
be re-used. The case is back with a -replace companion, since there is still
no keyRings.patch and the id is the only declarable field. Nine key rings
leaked by the nightly before the case was dropped have been reclaimed.

regionTargetHttpsProxies.patch enforces the fingerprint rather than treating
it as advisory - a PATCH without one is refused with "Required field
'resource.fingerprint' not specified" - so optimistic locking here is not an
optimisation, it is the only way an update lands at all, and it was off. Every
update of GCP::Compute::RegionTargetHttpsProxy failed with a 400. tlsEarlyData
also gains hasProviderDefault: the API reports DISABLED back on every read
whether or not it was sent, exactly as the global sibling already documents.

regionTargetHttpProxies has no patch method at all - delete, get, insert, list
and setUrlMap only - but GCP::Compute::RegionTargetHttpProxy claimed
SupportsUpdate, so a PATCH landed on a URL the API does not serve and came
back as Google's HTML 404 page rather than an API error. Its global sibling
does have patch, which is how the regional one came to claim it. A change now
replaces. The region-http-lb case, which uses this type and has no -update
companion, is what let it hide; it still passes.

EssentialContacts::Contact.email is marked createOnly: the API refuses a
masked change to it and the field was annotated as though it were mutable.
Inert today because the type does not support update, but it stops the trap.
… creates

Blank imports for apikeys, binaryauthorization, cloudbuild, clouddeploy,
dataform, firestore, networkservices and parametermanager; README rows and
CHANGELOG entries for the 24 new types; a disposition row for each new
hasProviderDefault annotation.

Eight new sweep sections, all REST because none of these collections has a
usable gcloud surface, and all eight sharing one helper rather than eight
near-identical copies - the copies are exactly how the earlier sweeps drifted
apart. Every list URL was dry-run against the live API before being wired in.

Ordering is encoded in the order of the lists, not left to chance: a Cloud
Deploy pipeline refuses to delete while it has an automation, a Dataform
repository while it has workspaces or configs, a Parameter Manager parameter
while it has versions and its delete takes no force flag, and a Network
Services mesh goes after the routes attached to it. Nested collections are
reached through each API's own "-" wildcard parent.

API keys get half a sweep, and it says so. A key delete is a soft delete: the
key stops working and drops out of listings, but a read keeps answering 200
for thirty days and the v2 API has no purge, only undelete. So a tombstoned
key cannot be collected by anything and every conformance run leaves one
behind until it self-purges - free and non-functional, but recorded so nobody
goes looking for the sweep that is missing. What is collected is a live key a
run created and failed to delete, which is the case that would otherwise leave
a working credential lying around.
verify-schema rejects duplicate basenames across services, so this batch's
dataform/repository.pkl and firestore/database.pkl were renamed to
repository_dataform.pkl and database_firestore.pkl - the convention
instance_spanner.pkl and table_bigquery.pkl already follow.

Pkl derives a module's identifier from its filename when the import carries no
alias, so renaming the file silently renamed the identifier and every
`repository.Repository` / `database.Database` reference stopped resolving:
"Cannot find module import `database`". Both now carry an explicit `as` alias,
exactly as testdata/spanner-database-replace.pkl already does.

Neither `pkl eval formae-plugin.pkl` nor verify-schema evaluates a fixture, so
both gates passed while all ten of these cases failed at conformance step 1.
Evaluating every fixture directly is what catches this.
… create

Two defects that only a conformance run surfaces, plus the base hook one of
them needed.

databases.list takes no pagination parameters at all. It returns every
database in the project in one response and refuses the ones it does not know:
"?pageSize=100" answers 400 "page_size is not supported." and "?maxResults=100"
answers 400 Unknown name "maxResults". The whole request is refused rather than
the parameter ignored, so the registration's PageSizeParam turned every List
into an error instead of a list - the type was undiscoverable while create,
read, update and delete all worked. Pagination is now disabled for this API.

Firestore also answers a patch issued while a create is still settling with 409
ABORTED, "There are concurrent database changes, please try again." That is
deterministic rather than occasional - a create followed immediately by a patch
reproduces it every time - and it is the exact shape of a reconcile, so the
conformance update step failed on a database that was perfectly healthy.

base already mapped a retryable error to NotStabilized so formae core re-runs
the operation, but only on the delete path, where Cloud SQL needed it. The same
check now guards both update paths, and Firestore classifies the 409. This is
not Firestore-specific: Cloud Deploy answers a concurrent mutation with 409
ABORTED too, and any API that asks for a retry was previously reported as a
terminal failure.
@naxty
naxty merged commit 504a649 into naxty/gcpDepthBatch Sep 3, 2026
33 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