Nested types no longer report Missing parent property on every discovery
cycle. parentProperty is read off the parent resource, but
CertificateMapEntry, GatewaySecurityPolicyRule, ParameterVersion,
Automation and the three Dataform children named their own field - the one
their response transformer synthesises on the child, which the parent has no
reason to carry. Each parent's identifier is name and each already shortens
it, so that is what the list parameter is taken from now.
A zonal GKE cluster's node pools are discoverable again.
GCP::Container::NodePool finds its cluster through the clusterName the
cluster's response transformer lifts out of the reported path, and that
parse demanded a locations segment. A zonal cluster reports zones
instead - the v1 API answers both and keeps the older spelling for anything
not regional - so the parse failed, clusterName was never set, and
discovery had no cluster to list node pools under. Both spellings are
accepted now, for cluster and node pool paths alike.
A parented List whose parent has been deleted reports no resources instead of
failing the discovery command. Discovery lists children against a parent it
read earlier in the same cycle, and the parent can be gone by the time the
request goes out: a swept Bigtable instance answers Instance ... not found,
a removed bucket answers The specified bucket does not exist. Both are 404s
about the parent, not about the children, and reported as errors they failed
the whole cycle for as long as the stale parent sat in the store. Only a list
that names a parent is treated this way - a 404 on an unparented collection
means the URL is wrong, and that still surfaces.
GCP::Storage::BucketAccessControl and GCP::Storage::DefaultObjectAccessControl
no longer fail discovery on a bucket with uniform bucket-level access when
the bucket is named. Discovery walks the buckets it found and asks for each
one's ACLs, and 0.1.15 only taught the project-wide walk to leave uniform
buckets out; the per-bucket path still asked and got the 400. The bucket's
IAM configuration is read first now, and a uniform bucket answers with an
empty list and an Info line.SERVICE_DISABLED marker and status from the
classifier; they return the wrapped error now, so the answer stays
reachable. A brand-new project with nothing enabled logged 29 such errors
per discovery cycle. A test names any site that flattens a transport error
again, whether through fmt.Errorf or errors.New; the router NAT walker
used the latter and was caught by the second pass.GCP::Storage::BucketAccessControl and GCP::Storage::DefaultObjectAccessControl
discovery no longer fails in a project whose buckets all have uniform
bucket-level access, which is the default for a new bucket. Such a bucket
has no legacy ACLs and Cloud Storage answers its ACL collections with 400,
so the walk asked, failed on every bucket, and reported the failure every
cycle. The bucket listing already says which buckets are uniform, so those
are left out before any ACL is asked for; buckets that still carry legacy
ACLs are walked as before.A List that could not look now answers with an empty list and says so in
the plugin's log, instead of failing the whole synchronization command on
every cycle. Two answers qualify, and no caller can act on either:
The API is not enabled in this project. GCP answers a disabled service
with 403 PERMISSION_DENIED, indistinguishable from a real authorization
failure by status alone; the machine-readable marker is SERVICE_DISABLED in
the error's details, with errors empty (verified live against
gkehub.googleapis.com, where the parsed error carries three details and no
error items). Discovery lists every registered type every cycle and no
project enables all ~250 of them, so this is an ordinary state: logged at
Info, naming the type and the API's own words.
We are not authorized. A missing role, or - on a hosted installation
authenticating through workload identity federation - an API that only serves
identities able to own what is being listed and refuses a federated principal
with "Invalid end user or user type not supported". No IAM grant fixes that
second one: Cloud Logging's SavedQuery needs logging.queries.list, which
is in no published role, owner included (13,702 permissions, checked).
Logged at Warn, because unlike a disabled API this is usually a gap worth
closing.
The log line is what makes this safe. An empty list without one is indistinguishable from "no resources exist", which is how a permission gap becomes formae quietly believing infrastructure is gone.
Both are List-only, deliberately. On a Read the same answer means a
resource formae already tracks has become unreadable, which is a real
failure - reporting "nothing here" would have core reconcile it away.
The guard sits at the plugin's single List entrypoint rather than in
base.List: about twenty resource packages implement List themselves, most
walking a parent collection through their own transport calls, and either
answer can surface from any of those requests.
A Read refused on authorization now logs what the API said. The read result
carries an error code and never a message, and core's own record of a
terminal failure is counting terminal failure type=... operation=read with
no URL, no status and no text at any level - so a refused read was the one
failure nothing in the system could explain. Sixteen Cloud SQL databases
failed this way on a production installation for over a day, 542 times in 24
hours, and identifying the cause needed the call reproduced by hand against
live GCP. Logged at Warn and restricted to 401 and 403: core still reports
and counts the failure, and a second Error voice on every read is what
buried the signal to begin with.
A Cloud SQL database, user, SSL certificate or backup run whose instance was
deleted out of band no longer fails every synchronization. Cloud SQL answers
child reads with 403 notAuthorized when the instance is gone. The plugin
now checks the parent using the same credentials and reports NotFound only
when that lookup returns 404 instanceDoesNotExist. An existing or
inaccessible parent, an unrelated 404, or a failed lookup preserves the
original child error, so an authorization failure cannot by itself remove
managed resources from inventory.
GCP::NetworkSecurity::UrlList and GCP::NetworkSecurity::GatewaySecurityPolicy
are discoverable. Both are regional, and discovery lists with no properties at
all, so both arrived with an empty location and fell back to global - which
the package already documented as a 400 for exactly these two collections.
Every discovery cycle logged Malformed name for both. They now list across
regions with the - wildcard, verified live along with
locations/-/gatewaySecurityPolicies/-/rules, which answers 200: unlike Cloud
Run, this API takes two wildcards in one path, so nested rules are enumerated
without naming a region either.
A GCP::Compute::Firewall that references its network by resolvable
self-link no longer re-applies as a spurious replace. The response
transformer stripped the API prefix from network, storing
projects/{p}/global/networks/{n} while the desired value resolved from
net.res.selfLink is the full URL the API returns. formae diffs raw strings,
so the two never matched, and network is createOnly - so every reconcile of
an unchanged forma planned a delete and recreate. Firewall was the last
holdout of a convention PLA-265 reversed for Subnetwork, Router and Instance
in July.
The Compute API settles which form is canonical. Its discovery document lists
three accepted forms for Firewall.network (full URL,
projects/{p}/global/networks/{n}, global/networks/default), but
Network.selfLink is "[Output Only] Server-defined URL for the resource" and
a read always answers with the full URL whichever form was written - GCP's own
default-allow-icmp reads back as a full URL. Lenient on input, canonical on
output, so the self-link is the only form that survives a round trip.
testdata/firewall.pkl and firewall-replace.pkl now reference the network
by net.res.selfLink instead of interpolating a literal path. The literal was
why no gate caught this: it is the one form the strip made match, so the case
passed while never exercising the idiom every other compute fixture uses - and
it left the firewall with no dependency edge to its network, so nothing
ordered the create after it or the destroy before it.
A freshly created GCP::Monitoring::MetricDescriptor is confirmed readable
before its create reports success. metricDescriptors.create answers 200 with
the descriptor, but a GET on it 404s for a second or two afterwards - measured
live on 2026-09-08 against project development-477117: 404 at t=0s, 404 at
t=1s, 200 from t=2s. MonitoringOperations declares Synchronous: true, so
base reported the create complete straight from the create response and
Status was a no-op, meaning nothing waited. A synchronization landing inside
that window read "not found" and formae tombstoned a descriptor that existed.
That is not only a red nightly: on a live installation the same race removes a
managed resource from the inventory on a timing coincidence alone, and the
next reconcile recreates or orphans it. The create now polls its own read
until the descriptor appears, and gives up rather than failing a create that
demonstrably succeeded. A List fallback was measured and rejected: GET and
LIST become visible at the same instant, so listing offers no earlier signal.
Five resource types are discoverable, and a sixth class of silent failure is
gone. Discovery lists with no properties at all, so a type that lives under a
parent has no parent to name and a location-based type may have no location.
Each API's own answer was probed live (project development-477117,
2026-09-08) rather than assumed:
GCP::CloudRun::Revision, ::Execution and ::Task now substitute the
API's wildcard parent on a list - services/-, jobs/- and
jobs/-/executions/-. All three answer 200, and services/-/revisions
returned a real revision under a real service, so the wildcard enumerates
rather than merely being tolerated. Only one wildcard is allowed per path
(locations/- together with services/- answers 400), so the location
stays concrete.GCP::Container::NodePool walks its clusters instead, because GKE has no
wildcard there: clusters/-/nodePools answers 404 and
locations/-/clusters/-/nodePools answers 400. Clusters are enumerated
with locations/-, which finds zonal and regional clusters alike.GCP::BigQuery::Table walks every dataset, exactly as ::Routine already
did. datasets/-/tables answers 404 Not found: Dataset {p}:-, so there
was no wildcard to use./formae:connect writes - was interpolating
an empty segment and asking for locations//services.locations/- for an absent location, since a GKE
location may be a zone or a region and only the wildcard covers both.extend formae.SubResource instead of being plain
classes: ApiGateway::ApiConfig's ApiDocument and OpenApiDocument,
Compute::Instance's AttachedDisk, MetadataItem and Metadata, and
Memcache::Instance's NodeConfig. Schema extraction only walks nested
classes that formally extend SubResource, so these classes' @gcp.FieldHint
annotations never reached the schema and Metadata.items never got the
sub-resource render path's absent/explicit-null/explicit-empty handling.
Both now apply. Two hasProviderDefault hints activate
(AttachedDisk.deviceName, Metadata.fingerprint, the latter also
createOnly), so a provider-populated value on either field is now tolerated
instead of reading as drift on every sync, and Compute's GCP::Compute::Instance
gains its first observable disks.deviceName / metadata.fingerprint paths in
conformance runs.Polymorphic auth on the target config, with one variant, OidcAuth:
workload identity federation for an agent that has no ambient GCP credentials
of its own. The agent exchanges a short-lived OIDC identity token for Google
credentials through the paired credential broker, against the workload
identity pool provider formae connect gcp reports - no service account key
and no static credential exists anywhere in the path. Omitting auth keeps
today's behaviour exactly: GCP_CREDENTIALS_JSON, GCP_CREDENTIALS_FILE, or
Application Default Credentials. createOnly = false, so a target can move
onto or off federation without replacing its resources.
There is no DefaultChain counterpart to OidcAuth. The AWS plugin has one
because its default chain carries a profile name; GCP's takes no parameters,
so the variant would be empty and would mean only what omitting auth
already says.
Two failures are closed rather than silent. An unrecognised auth type is an
error instead of falling through to ambient credentials, and an OidcAuth
target on an agent with no paired broker fails closed - a hosted agent must
never end up acting as itself instead of as the customer. The provider
resource name is also the token audience, so it is validated at the single
credential seam: a spelling that differs from the provisioned one is rejected
before any Google client is built, rather than minting a token that fails to
exchange with an error reading like an unrelated auth problem.
GCP::SecretManager::SecretVersion now exposes its payload by reference:
version.res.secretValue. The type extends formae.Secret and names data
as its value property, so a consumer binds the payload instead of restating
it - previously the only route was hand-wrapping the plaintext with
formae.value(...).opaque, which put the secret in the consumer's own forma.
The accessor lives on the version rather than on SecretManager::Secret,
which is the container and holds no payload. Nothing about the field changes:
data was already write-only and opaque at rest, and stays hashed.
Resources formae created in order to reach this project are no longer offered for import. An agent's own substrate carries an ownership label that discovery now excludes, so a reconcile can no longer take away formae's own access. The project IAM bindings connect grants carry no labels, so they are recognised by the member string naming both formae's shared workload identity pool and its subject namespace; a binding in a similarly named pool stays visible.
GCP::NetworkServices::Mesh, ServiceLbPolicy, EndpointPolicy, HttpRoute,
GrpcRoute, TcpRoute and TlsRoute — Cloud Service Mesh, and the direct
consumer of the Network Security policies added earlier in this release. A mesh
is the routing scope sidecars configure from; the four route kinds attach to it;
an endpoint policy hands matched endpoints their TLS posture; a service load
balancing policy says how a global load balancer spreads traffic across regions.
Every collection in this API 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
therefore creates unfindable resources rather than failing, so the scope is
pinned in a map with that reasoning rather than inferred from the target.
Four API behaviours these types compensate for. EndpointPolicy's three policy
references and every route's meshes listing are full resource paths on the
wire and short names in a forma — the short form is refused outright — so both
halves of that translation exist, pinned as identity round trips. A PATCH here
validates the resource from the request body alone rather than merging it 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 ride along. This API also clears labels on any patch whose mask does
not name them, while leaving every other unmasked field alone. And it renders
JSON the proto3 way, omitting false booleans, so a declared false reads back
absent — documented at each such field rather than papered over.
Each route kind stays free of a billable prerequisite by a different route:
an HTTP route redirects, a gRPC route injects a fault, a TCP route hands the
connection to its original destination, and a TLS route — the one kind with no
destination-free action — points at a backend service with no backends.
gateways is deliberately absent from all four: a gateway allocates Envoy
proxies and bills, so the field could not be verified, and an unverified
reference reads as drift on every sync. ServiceBinding is absent too: its only
functional field is deprecated in the API's own discovery document, which leaves
a name and some labels binding nothing.
GCP::CloudDeploy::Target, DeliveryPipeline, CustomTargetType,
DeployPolicy and Automation — the declarative half of Cloud Deploy: where a
deploy may land, the promotion flow it walks, custom deploy actions, when
deploys are forbidden, and promotion without a human. 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 taken.
Cloud Deploy is where "a 200 is not proof" earns its keep twice over. A create
accepted with HTTP 200 came back as a done operation carrying an error — a
pipeline stage naming its target by full path is refused with "projects/…" is not a valid resource ID for resource type "stage.targetId" — and the pipeline
never existed. The API also serialises operations per resource and refuses a
second mutating call while one is in flight with HTTP 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 the pipeline stage passes a 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, additions
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. Discovery needs no walking provisioner: Cloud Deploy
accepts - as a wildcard pipeline.
GCP::Dataform::Repository, Workspace, ReleaseConfig and WorkflowConfig —
a Dataform repository, an editable checkout of it, which commitish to compile,
and when to execute the result. Workflow invocations and compilation results are
absent: both execute against BigQuery.
The whole API is synchronous — no operations collection exists in it at all — so it is registered that way; 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
"Request 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 — the difference between a working timeZone change
and a rejected one. invocationConfig.serviceAccount is likewise documented
optional and required in practice. 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.
GCP::ParameterManager::Parameter and ParameterVersion — a named container
for configuration values and the immutable revisions that hold the payload.
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.
A version's payload is stripped from every response, and that is a security
control rather than a drift fix: this payload is user data and may be a secret,
and unlike Secret Manager — whose API withholds secret material — Parameter
Manager hands it straight back, on a GET defaulting to view=FULL and on the
create response too. 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. disabled is ignored on
create — a create sending true answers 200 and the version is enabled anyway —
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.
GCP::BinaryAuthorization::Attestor and PlatformPolicy — a named set of
public keys that must have signed an image, and the check-based policy a GKE
cluster opts into.
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, and
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 the reason 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; without it the
policies are never listed and never appear in inventory.
GCP::ApiKeys::Key — an API key, with the restrictions that narrow which
callers may use it and which services it may reach.
It 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 in the response transformer on every
path rather than merely expected to be absent, the same treatment
compute.vpnTunnels.sharedSecret gets, and the separate getKeyString method
is never called from a read path. There is no field to declare opaque — the
value has no authored counterpart, only something to refuse to store.
Two API behaviours the type compensates for. Every response reports name in
project-number form while the target declares a project id; both address the
key, 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 a recreate under the
same name is refused and the case exercises update rather than replace. The
tombstone cannot be collected by anything: the v2 API has no purge, only
undelete, so a conformance run leaves a free, non-functional key behind until it
self-purges. The sweep collects live keys and says so.
GCP::CloudBuild::BuildTrigger — the configuration that says when a build runs
and what it runs. Creating one 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.
Three behaviours compensated for. Its trigger methods are synchronous — create
and patch answer with the trigger, unlike this API's own builds.create which
answers with an Operation — so the collection carries a synchronous operation
config. 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. That opaque 400 is the single biggest trap in this API: five different
source forms and gcloud's own builds triggers create manual all fail
identically, and the cause is the missing serviceAccount. It is documented in
the schema.
GCP::Firestore::Database — a Firestore database, the container documents and
indexes live in.
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. Create needs no polling either — it returns the finished database inline
in about a second — so the type is registered synchronous and reads back after
an update.
Three fields the API volunteers are dropped rather than defaulted: etag, which
is recomputed on every read of an untouched database; earliestVersionTime,
which moves continuously; and realtimeUpdatesMode, which 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 database's 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.
GCP::BigQuery::RowAccessPolicy — 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.
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, so the four flat properties a forma declares are assembled on the way
out and flattened back on the way in. Its update is a PUT with no field mask,
and sending one is not ignored — the API answers Unknown name "updateMask": Cannot bind query parameter. And a delete that would leave the table with no
policy is refused 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 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, so
discovery walks datasets, then tables, then policies rather than reporting an
empty inventory.
GCP::NetworkSecurity::ClientTlsPolicy — the client half of a TLS connection a
Google-managed proxy makes on your behalf: clientCertificate is what it
presents upstream, serverValidationCa is who it believes about the upstream's
own certificate, and sni is the name it asks for. A policy on its own connects
to nothing — a backend service or a mesh route names it — and it is free to
hold. Global; create, update and delete are long-running operations. name is
the resource's path rather than a body field and the update mask is built from
the body, so it leaves before every patch.
GCP::NetworkSecurity::ServerTlsPolicy — the server half: which certificate a
Google-managed proxy serves, and whether it demands a client certificate in
return. allowOpen is the "plaintext is acceptable too" switch, and it is what
makes a policy declarable with no certificate to point at — which is how the
conformance case runs with no key material, CA pool or trust config.
GCP::NetworkSecurity::BackendAuthenticationConfig — what a load balancer
trusts when it opens a TLS connection to a backend: wellKnownRoots for the
public root set or a Certificate Manager trustConfig for a private one, plus
an optional client certificate to present.
Unusually for this API the response really does carry an etag, and every patch
rolls it, so the field is tolerated as a provider default but never sent back:
replaying a stored etag stakes a claim on a version the server has already moved
past, and with the mask built from the body it would land in the mask as well.
GCP::NetworkSecurity::AuthorizationPolicy — who may talk to a service mesh
workload: a list of match rules and one verdict, ALLOW or DENY, for whatever
matches them. Enforcement belongs to the sidecars of a mesh that names the
policy, so a policy on its own enforces nothing and costs nothing. The rules are
a three-deep nest — rules, then sources and destinations, then a header match —
and every one of those classes extends formae.SubResource, because schema
extraction only walks nested classes that formally do and their @gcp.FieldHint
annotations would otherwise never reach the schema.
GCP::NetworkSecurity::GatewaySecurityPolicy — the container for Secure Web
Proxy rules, and the thing a gateway points at. Regional, which makes it the
second collection in this API that is not global: asked for locations/global
it answers 400 "Malformed name", so like urlLists it is deliberately absent
from the plugin's global-collection map and inherits the target's region.
One API behaviour the type compensates for: a policy created without a TLS
inspection policy is reported back with tlsInspectionPolicy set to the empty
string. Left alone, state carries a value against a declaration that omits the
field and every sync reads it as drift, so the empty string is stripped on read.
Attaching a real one is out of scope for the conformance suite — a TLS
inspection policy needs a CA pool with an enabled, and therefore billable,
certificate authority behind it, which is also why tlsInspectionPolicies is
not part of this batch.
GCP::NetworkSecurity::GatewaySecurityPolicyRule — one rule of a Secure Web
Proxy policy: a CEL sessionMatcher, a priority, and ALLOW or DENY. Nested
under its policy and regional with it.
gatewaySecurityPolicy addresses the rule rather than describing it: the API
rejects it as an unknown body field on create as well as on update, so it is
dropped from every request and lifted back out of the path the API reports.
Recovering it from the response rather than from the request context is what
makes discovery work: a List goes through the API's - wildcard parent, which
enumerates the rules of every policy in the region in one call, so there is no
parent in context but every listed item still carries its own full path. The
native ID gained a parser for the same reason — the generic path parser
overwrites the resource type as it walks, so a rule's id arrived with its parent
silently dropped and the read addressed a collection that does not exist.
applicationMatcher comes back as an empty string for a rule that never sent
one and is stripped, the same artifact as the policy's tlsInspectionPolicy;
tlsInspectionEnabled comes back as false and is a top-level bool, so a
schema hint reaches it and it is tolerated in place. Deleting a policy that
still has rules is refused with HTTP 400, and declaring the policy through its
resolvable is what fixes the order.
GCP::NetworkSecurity::DnsThreatDetector — a subscription that has Cloud DNS
queries checked against a threat intelligence feed, so a lookup of a
known-malicious domain is flagged; provider names who holds the intelligence.
Global — asked for a region the API answers 400 "Malformed name".
Alone in this API it is synchronous: create and update answer with the resource
itself rather than an Operation, so it carries its own OperationConfig instead
of the registry's asynchronous one. That override is required, not tidiness. A
create whose response has no /operations/ segment leaves the operation-id
extractor returning nothing, and unlike delete — which reads an empty operation
id as "already finished" — create would report the resource as in progress with
an empty request id, poll a URL that is not an operation, and fail on a resource
it had in fact created.
The patch response is also stale: a label added by a patch is absent from what
the patch returns and present in the very next read. provider is fixed at
creation, so it leaves the body on update rather than entering the field mask.
GCP::NetworkConnectivity::Spoke — what actually attaches to a Network
Connectivity Center hub. A hub on its own connects nothing; a spoke links one
VPC network into its mesh, and the hub then propagates that network's routes to
every other spoke it has accepted. Only the VPC-network shape is exposed:
linkedVpnTunnels, linkedInterconnectAttachments,
linkedRouterApplianceInstances and gateway each require an underlay that
bills by the hour, while a hub, an empty VPC and a VPC spoke are all free.
Three API behaviours the type compensates for. The discovery document files
spokes under projects.locations, which reads as regional, but a VPC spoke was
verified to create, read, patch and delete only under locations/global — so
the collection is pinned global and a target's region can never reach the URL.
hub is reported as a full resource path while a Hub resolvable yields a short
id, and the field is immutable, so the plugin expands short to
projects/{p}/locations/global/hubs/{name} on the way out and shortens it back
on the way in; one half without the other would leave the forma and stored state
disagreeing forever, with every re-apply planning a replacement the API then
refuses. And a spoke reports output-only members inside linkedVpcNetwork
(vpcNetwork, the proposed*ExportRanges pair, producerVpcSpokes) where a
hasProviderDefault hint cannot reach them, so they are stripped from the
response rather than declared.
GCP::Compute::RolloutPlan — a named, reusable schedule for rolling a change
out in stages instead of everywhere at once. Each wave names a slice of the
estate, by location or by position in the resource hierarchy, plus the
validation that has to pass before the next wave starts.
Two API behaviours the type compensates for. rolloutPlans has insert, get,
list and delete and no update method at all, so every field is immutable and the
type is registered without an update operation rather than with a patch that
would 404 — its conformance case is a -replace case. And the server stamps an
output-only number onto each wave it stores; it sits inside waves[], where a
schema hint cannot reach it, so it is stripped on read. Without that strip every
read disagrees with the declaration, and because there is no update method the
disagreement plans a replacement on every reconcile rather than a no-op patch.
A wave's includedLocations takes bare zone or region names. A scoped path is
refused outright — zones/europe-central2-b answers 400 "RolloutPlan wave has a
LocationSelector with invalid location zones/europe-central2-b".
GCP::Compute::ZoneVmExtensionPolicy — a standing instruction that named VM
extensions, the Ops Agent for instance, are installed and held at a version on
the VMs a selector picks out, in one zone. Nothing has to be baked into an image
and no VM has to be touched: the policy is the desired state and Compute
converges the fleet onto it, including VMs created after the policy exists.
instanceSelectors is optional, and a policy with none selects every VM in
the zone and installs the named extension on all of them. The schema says so
at the field, and the conformance fixtures always carry a labelSelector whose
label matches nothing, so the case exercises create, patch and delete while
selecting no machine.
One API behaviour the type compensates for: once a policy has been patched, GCP
echoes each extensionPolicies entry with a stringConfig: "" it was never
sent. It is a member of a mapping value, where a hint cannot reach it, so an
empty stringConfig is stripped on read. An empty pinnedVersion is
deliberately not stripped: "" is what a forma says to mean "track the current
release", so it is a declared value.
The global sibling, globalVmExtensionPolicies, is deliberately not
implemented. Its delete is a POST .../{name}/delete carrying a rolloutInput
body, and — verified live — that call's operation reports DONE while the policy
is still there, disappearing only minutes later when an out-of-band purge
rollout catches up. A delete the plugin cannot observe completing is a delete it
cannot promise. The zonal type has an ordinary DELETE and no such race.
GCP::Compute::RegionBackendBucket — the regional counterpart of
BackendBucket: the same backendBuckets body under regions/{region} instead
of global, so a regional URL map can route static paths to Cloud Storage and
dynamic paths to a RegionBackendService.
One API behaviour the type compensates for: loadBalancingScheme is required at
regional scope, where the global type is happy without it. An insert that omits
it is refused with 400 "Load balancing scheme is required for backend bucket of
scope REGION", so the field is declared required rather than left to fail at
apply time, and narrowed to the two schemes regional scope accepts,
EXTERNAL_MANAGED and INTERNAL_MANAGED.
That rejection is the one regional-specific behaviour verified live. The full
create/read/patch/delete cycle was not observed: the probe's insert finished
with GCS_BUCKET_ACCESS_DENIED because the service account this project is
tested with cannot read a Cloud Storage bucket, and the same failure reproduces
against the already-shipping global BackendBucket with the same bucket — an
environment gap, not a property of the type, but not a verified type either.
GCP::Redis::AclPolicy — a named set of Redis OSS ACL rules that a Memorystore
for Redis Cluster attaches: each rule binds one IAM user or service account to
one rule string. The policy is a standalone configuration object — it provisions
nothing and is billed only through the clusters that reference it — which is why
this batch covers it and not GCP::Redis::Cluster.
Two API behaviours the type compensates for. It is the one collection in the
Redis v1 API that is not a long-running operation: create answers 200 with the
finished policy and no Operation to poll, so the registry-wide operation config
is overridden per resource. And clusterAclPolicyAttachments, the output-only
per-cluster attachment status, is a nested array no schema hint can reach: it is
stripped on the way in, so a policy some cluster later attaches does not start
reading as drift against a forma that never mentioned it.
GCP::Spanner::InstanceConfig — a user-managed configuration naming where a
Spanner instance's replicas may live. Configuration only, and free to hold:
nothing is provisioned and nothing is billed until an instance is created
against it. replicas must be the base configuration's own replicas plus one or
more of its optionalReplicas.
Three API behaviours the type compensates for. The create body is wrapped and
carries the id twice: instanceConfigId beside the wrapped object and the full
resource path inside it — a create that omitted instanceConfig.name was refused
400 "Invalid CreateInstanceConfig request." with a field violation on
instance_config.name, so neither RequestWrapper nor CreateIDParam can build
it and the transformer emits the whole envelope itself. The patch field mask goes
in the body rather than the query string, and it is fixed at
displayName,labels — the only two fields the API will update — rather than
derived from the fields present, so a forma that drops its labels actually clears
them. And a read reports optionalReplicas, every replica location GCP offers
for the base configuration with a display name and a labels map each, which is
never declarable and is stripped rather than stored on every configuration.
Spanner requires a user-managed configuration's id to begin custom-, so the
conformance fixture is named custom-formae-test-sic-<runID> and
scripts/ci/clean-environment.sh carries its own pattern for that one prefixed
form — SWEEP_RE is anchored at the start of the name and would not match it.
GCP::Compute::PacketMirroring — a copy of selected VMs' traffic, delivered to
an internal passthrough load balancer for inspection. mirroredResources says
whose packets to copy — named instances, whole subnets, or network tags — and
collectorIlb says where the copies go. The collector has to be a forwarding
rule created with isMirroringCollector; an ordinary internal rule is refused.
Two API behaviours the type compensates for. network is fixed once set, and
a patch carrying it in any spelling other than the exact stored URL is refused
with "Network cannot be changed" — so the field never goes out on an update and
a change to it replaces the resource. And packetMirrorings.patch is a JSON
merge patch, so a selector left out of mirroredResources keeps its old value:
dropping every tag from a forma would otherwise leave the tags mirroring while
the plugin reported success. The absent selectors are sent as explicit empty
lists instead, which does clear them. The conformance case's update exercises
exactly that, dropping a tag the create declared.
Every reference in this resource is an object rather than a bare string, and
GCP answers each with a second canonicalUrl naming the same target by numeric
id. It is output-only and sits inside a sub-resource, where a schema hint
cannot reach it, so it is stripped on read — without that, every read disagrees
with the declaration and plans an update that changes nothing.
The case costs nothing to run: mirroring a subnet rather than named instances means no VM has to exist to have something to mirror, and the collector's backend service carries no backends.
GCP::CertificateManager::Certificate — a TLS certificate a load balancer can
serve. A managed one is obtained and renewed by Google against a
DnsAuthorization and carries no private key, which is the kind a repository
can describe; selfManaged is the other half of the type. Creation returns
immediately and a managed certificate then sits in PROVISIONING until the
authorizing DNS record resolves, which is a property of the domain rather than
of the resource.
The API field is managed. A forma cannot use that name — managed is a fixed
property of formae.Resource — so the schema calls it managedCertificate and
the plugin renames it in both directions.
GCP::CertificateManager::CertificateMapEntry — one row of a certificate map:
which certificates to serve for a hostname. Give either hostname or
matcher, where matcher = "PRIMARY" is the fallback when no hostname
matches.
A forma names the certificates and authorizations it points at by short id, because that is all a reference can yield, while the API wants a full path on the way in and answers with one — carrying the project number where the forma used the project id. Both fields are immutable, so the plugin expands the request and shortens the response; with only one half, every re-apply would plan a replacement of a certificate that a map entry still references, and the delete would then be refused.
Deleting a DnsAuthorization while a certificate still references it is
likewise reported as retryable rather than fatal: the certificate's own delete
is a long-running operation, so the refusal is a race that clears itself.
GCP::NetworkSecurity::AddressGroup — a named, reusable set of IP addresses and
CIDR blocks that firewall policy and Cloud Armor rules match against, so a rule
names one group instead of restating every address. Global, and free: a group
provisions nothing and matches no traffic until a rule points at it.
type and capacity are both fixed at creation — the API answers a capacity
change with "capacity can't be changed" — so neither goes out on an update and a
change to either replaces the resource. purpose carries a provider default of
["DEFAULT"]; its only other value, CLOUD_ARMOR, needs a Cloud Armor
Enterprise subscription and is refused outright without one, so the conformance
case leaves the field unset.
One API behaviour worth knowing: a patch whose update mask omits labels clears
them, which is not how the other types in this API behave. The mask is built
from the request body, so a forma that declares labels keeps sending them and
the wipe cannot bite — but a patch hand-built with a narrower mask would lose
them silently.
GCP::NetworkSecurity::UrlList — a named list of URL patterns for a Secure Web
Proxy policy to match on. Free, and inert until a policy names it.
Regional, alone among the types in this API. Asked for locations/global it
does not return an empty list, it fails the call with "Invalid location in
resource URL path", so the scope is pinned per collection rather than inferred.
description comes back as an empty string when it was never set, so it carries
a provider default rather than staying absent.
GCP::NetworkSecurity::SecurityProfile — the policy half of Cloud NGFW's
layer-7 inspection: what to do about a threat, not where to apply it. Global.
Creating one provisions no capacity. Inspection becomes billable only when a firewall endpoint — an organization-level resource — is attached, so a profile with nothing attached costs nothing, and the conformance case attaches nothing.
type is fixed at creation and decides which of the four settings blocks
applies; it is dropped from the patch body so it cannot enter the update mask.
etag is never sent back on a write: replaying a stored one fails the call with
409 "Provided etag is out of date" as soon as anything else has touched the
profile. And threatPreventionProfile.threatOverrides[].type is output-only but
nested, where a schema hint cannot reach it, so it is stripped on read —
otherwise every read carries a property the schema never declared.
GCP::NetworkSecurity::SecurityProfileGroup — the binding a firewall policy
rule actually names, gathering up to one security profile of each kind. Global,
and free for the same reason a profile is.
Each of the four profile fields is a full resource path on the wire but a short
name in a forma, which is what a resolvable yields. The plugin expands on the
request and shortens on the response; both halves have to exist, because
expanding without shortening would leave the declared value and the stored state
permanently disagreeing and every re-apply planning a replacement of a group
that has not changed. etag and dataPathId are server-owned and never sent.
Note: only threatPreventionProfile is exercised by the conformance fixture.
The other three references use the identical code path but are declared from the
API's discovery document rather than from a live create.
GCP::NetworkConnectivity::InternalRange — a reservation of internal IP space
in a VPC. It marks a CIDR range as spoken for so nothing else is allocated over
it; a subnet created later in the same space is rejected. Give it a range, or a
prefixLength and let the API pick a free block of that size.
GCP::NetworkConnectivity::PolicyBasedRoute — a route chosen by what the
traffic is, not only where it is going. An ordinary route matches the
destination; this also matches protocol and source range, which is how traffic
is steered through an appliance. Every field is fixed at creation, so a change
is a replacement.
It is the one type here that will not accept a network self link — it answers
network uri ... is not in the form of projects/my-project/global/networks/my-network
— and it reports back whatever form it was given. Since a reference to a
network resolves to a self link, the plugin cuts the request down and expands
the response back; without both halves the field disagrees with itself on every
re-apply and plans a replacement of the route already in place.
GCP::NetworkConnectivity::ServiceConnectionPolicy — permission, in advance,
for a managed service to place Private Service Connect endpoints in a
consumer's subnets. Regional, unlike the rest of this API, which lives under
locations/global. A patch may carry only description, labels and
pscConfig; anything else in the body enters the update mask and the API
refuses it.
The Replace phase is exercised for the first time. Every conformance case in
this plugin reported replace as skipped, because a case only tests it when a
-replace.pkl accompanies it and none existed - so a run reporting "8/8" was
really seven steps and a skip, and nothing had ever checked that replacing a
resource works. The three Service Directory cases now carry one: each changes
the immutable id, which is what forces a replace, and the harness verifies the
native ID actually changed rather than the resource being updated in place.
Cases that predate this work carry one too, so the phase is covered for types
this batch did not add: a bucket, a Pub/Sub topic, a secret, an address, a
health check, a network, a firewall rule, three logging types, a monitoring
service and dashboard, an SLO, an instance template, a backend bucket and an
IAM service account. Each changes an immutable name, which is what makes the
operation a replace rather than an update.
GCP::Memcache::Instance - Memorystore for Memcached: a managed memcached
cluster on a VPC network. It is billed by node-hour for as long as it exists
and takes twenty minutes or more to create, so its conformance case is the
slowest here at 27 minutes and the fixture asks for the smallest thing the API
accepts, one node of 1 GiB.
An instance reaches its nodes over private service access and the API refuses one outright without it, so the case builds a VPC, the range reserved for the service producer to peer into, the peering itself, and only then the instance. The instance names the peering rather than the network: it needs private service access to exist, not merely the VPC.
The authorized network is accepted in any of the three forms a forma can name one - short, path, or self link - and normalised to the single form the API takes. A reference to another resource's network property resolves to a self link, which memcache rejects outright.
GCP::Spanner::Instance and GCP::Spanner::Database - Spanner is the one
service here whose resources are billed for as long as they exist: the
smallest regional instance is 100 processing units, a tenth of a node. A forma
declaring one is spending money until it is destroyed, and the module doc says
so.
Two shapes the generic engine does not cover. An instance's id travels as
instanceId alongside an instance object rather than as a name in the body
or a query parameter, so the create body is assembled. And Spanner creates a
database by executing a CREATE DATABASE statement rather than taking a name,
so the plugin builds that statement from the name a forma declares.
A database name is capped at 30 characters, which the usual test prefix plus a run id exceeds, so the fixture names it short - it does not need the prefix, because deleting an instance takes its databases with it.
GCP::BigQuery::Connection - a named handle BigQuery uses to reach something
outside itself. A cloudResource connection carries no configuration of its
own: BigQuery mints a service account for it, and granting that account access
is how a query reaches the resource. Holding one costs nothing.
It lives behind a separate API - a different host and a location-based path - so it has its own package rather than joining BigQuery's, whose config points at bigquery.googleapis.com.
The service account is reported as cloudResourceServiceAccountId rather than
nested inside cloudResource, because a schema hint is only emitted for a
top-level field: a nested one cannot be marked as server-filled and reads as a
property the forma never declared. Hiding it would have been the smaller
change and the wrong one - granting that account access is the point of the
type.
The connection's native ID carries the project as a forma names it. The API answers with the project number, and the native ID is where a later read gets its path context, so a number left there comes back as the project on every sync however the response is transformed.
GCP::DNS::Policy, GCP::DNS::ResponsePolicy and
GCP::DNS::ResponsePolicyRule - a policy decides how DNS behaves for the
networks it is attached to; a response policy overrides what DNS answers for
them, one rule at a time. None costs anything to hold: Cloud DNS bills zones
and queries, and Cloud DNS had shipped with a single type until now.
Three shapes the generic engine did not expect. A response policy's id field
is responsePolicyName and a rule's is ruleName, not name - a listed item
carries no path context to fall back on, so without handling them every
response policy and rule would have listed with an empty native ID and never
been discovered. Cloud DNS also stamps a kind discriminator on nested
objects, not just the resource itself, and each survivor reads as a property
the forma never declared.
A rule hangs off its response policy, so the DNS path builder and native ID
handle a parent now; both previously assumed a flat
/projects/{p}/{collection}/{name}.
Deleting a policy detaches its networks first. Cloud DNS refuses to delete one while a network is still attached, and nothing in the forma is holding it - the network is a prerequisite that outlives the policy - so the deletion looks unblocked and simply fails.
GCP::ApiGateway::Api, GCP::ApiGateway::ApiConfig and
GCP::ApiGateway::Gateway - API Gateway serves an api from a regional
gateway: an api holds immutable configs, and a gateway names the config it
serves rather than the api, because a change to a config produces a new one.
Holding an api or a config costs nothing; API Gateway bills the calls a
gateway serves.
Apis and configs are always global while gateways are regional, so the path builder supplies the location for the first two rather than requiring one to be named. Every write is a long-running operation, and a fresh operation does not carry the resource - its metadata names the target it is building, which is where the native ID comes from. A config only exists underneath an api and there is no wildcard in the api position, so a parentless list walks the apis.
A gateway names the region it runs in. API Gateway serves eleven regions and a target's is often not among them - creating a gateway in one it does not serve answers "Location ... is not found or access is unauthorized" - so the region cannot be taken from the target. It is not scoped either: every scope available overwrites the location, one by clearing it and one by substituting the target's, and a read that lost it addressed a wildcard path rather than the gateway. A parentless list spans every region with the location wildcard, which the API accepts, rather than looking only where the target happens to be.
A config also reports its own full resource path. A gateway names the config
it serves that way, while name holds the short id a forma declares, and a
reference interpolated into a string is emitted as its envelope rather than
resolved - so without it the reference could not be expressed at all.
GCP::ServiceDirectory::Namespace, GCP::ServiceDirectory::Service and
GCP::ServiceDirectory::Endpoint - Service Directory publishes where a
service lives without running a registry: a namespace holds services and a
service holds endpoints. Holding them costs nothing, only lookups are billed,
so the whole hierarchy is testable without provisioning anything.
All three are config-driven. The id travels as a create-time query parameter,
updates are a PATCH with a mask built from the body, and every operation is
synchronous. An endpoint is addressed by a namespace and a service at once and
uses the two-property parent for it. Nothing can be listed across its parents -
locations/- answers "Unsupported location: -", and namespaces/- and
services/- both answer "Could not parse namespace name" - so a service walks
the namespaces and an endpoint walks the services inside them.
hasProviderDefault schema annotation now carries a recorded
disposition in schema/provider-default-dispositions.json, enforced by a
unit test: new annotations fail CI until classified, and rows for removed
fields fail as stale. All current annotations start as pending;
classifications land per field as the provider-default audit reaches them.GCP::SQL::Database is discoverable. A database only exists underneath an
instance and Cloud SQL cannot be asked across instances -
/projects/{p}/databases answers 404 and /projects/{p}/instances/-/databases
answers 400, so there is no wildcard to substitute - while discovery lists
with no parent to name. A parentless list now walks the project's instances,
skipping any it cannot read but reporting an error rather than an empty list
if every one fails.
GCP::Storage::ObjectAccessControl is registered, and knownParityGaps is
empty. It hangs off a bucket and an object, and nothing could carry two
parent properties - which is why its registration had been commented out.
ParentResourceConfig.SecondPropertyName now joins them as
{bucket}/{object}, the form the Storage path builder and native ID already
expected. Every type the schema declares now has a provisioner behind it.
Discovery needs more than that: it lists with neither parent to name, so an
object ACL is found by walking the buckets and then their objects. Listing a
bucket's objects with projection=full carries every object's acl inline,
which keeps that to one request per bucket rather than one per object.
GCP::Bigtable::MaterializedView matches its API and has a provisioner. The
schema declared sourceTable and cluster; the API has neither. A
materialized view belongs to an instance and is defined by a GoogleSQL
query, alongside deletionProtection, clusterStates and etag. Nothing
had noticed because the type had no provisioner either, so a forma declaring
one failed at apply before any field could be rejected. Both are fixed, and
knownParityGaps is down to one. Like a backup, it has to be bound to
Bigtable's own provisioner: the generic one sends no id query parameter, and
the API answered Invalid id for collection materializedViews : Length should be between [1,128], but found 0. The parameter is snake_case while the
collection is camelCase, so trimming the plural alone produced
materializedView_id, which the API ignored before rejecting the create for
the empty id it had never been given. It is listed by walking the instances,
for the same reason a backup is.
GCP::Bigtable::Backup works. Its schema shipped with no provisioner behind
it, so declaring a backup failed at apply - one of three types in
knownParityGaps that were declarable and unusable. Much of what it needed
was already there: the three-level path builder, the native ID handling and
the cluster extraction in Create all handled backups. Registering the type was
not enough, though - it also has to be bound to Bigtable's own provisioner,
and being left out of that list sent it to the generic one instead, which
knows nothing of the cluster a backup lives under and so addressed
/instances/{i}/backups, a route that does not exist. It is now bound, with
the transformers that expand sourceTable to the full path on the way out and
recover the instance and cluster from the path on the way back, and with a
list that walks the project's instances and uses the clusters/- wildcard
within each - discovery lists with no parent to name, and no route spans
instances. Two known parity gaps remain.
GCP::Eventarc::Trigger can be created at all. Eventarc requires the short id
in ?triggerId= and the full resource path in the body's name - which its
own schema marks Required - at the same time. base.Create reads the id out
of name and deletes it, so the body reached the API without one and every
create failed with "The request was invalid: trigger.name is empty". Trigger
now has a Create that supplies both.
GCP::BigQuery::Routine is discoverable. Its List refused to run without a
datasetId in AdditionalProperties, and discovery lists with no properties at
all - it cannot know a routine's parent, because the provisioner is
hand-written and declares no ParentResource. A parentless list now walks
every dataset in the project; a caller that names one still gets just that one.
GCP::BigQuery::Table has the same shape and is left as it is for now, with a
comment saying so: it has no conformance case, and an unverified fix is easily
mistaken for a verified one.
GCP::Monitoring::MetricDescriptor is discoverable. metricDescriptors.list
returns every descriptor a project can see - well over a thousand built-in
ones for GCP's own services - so a custom metric was somewhere in that pile
and not necessarily on the first page: discovery listed, never saw the
descriptor it had just created, and timed out. The list is now filtered to the
prefix a project can own. That is not only an optimisation: a built-in
descriptor cannot be created, changed or deleted, so it is not a resource
formae can manage and does not belong in discovery.
It is one prefix and not two because Cloud Monitoring rejects the obvious
form: metric.type = starts_with(...) OR metric.type = starts_with(...)
answers HTTP 400, "Within the 'metric' prefix, OR can only be used to connect
a list of 'labels'". A rejected filter fails the whole list, and an empty list
reads downstream as "the resource is gone" - sync tombstoned a descriptor that
was really there. external.googleapis.com/user/ descriptors are not listed
as a result; they are written by the Cloud Monitoring agent, not by a forma.
Conformance setup retries back off instead of going again immediately. The harness fetches the formae binary and starts an agent before it touches any cloud infrastructure, and a single instant retry only survives a blip shorter than the retry itself: in a 151-case matrix the package channel went away for long enough that both attempts hit it seconds apart, and two unrelated cases failed having run no test at all. Three attempts now, 10s then 30s apart. A failure that is not setup still fails on the first attempt.
Leaked SSL certificates can be swept. A project holds at most 10 globally, so
once the cap is reached any case creating one fails with
"Quota 'SSL_CERTIFICATES' exceeded" rather than anything resembling a plugin
bug. The sweep knew about ssl-policies but never about the certificates.
It is opt-in, behind FORMAE_SWEEP_SSL_CERTIFICATES=1. Certificates are the
one resource here whose removal is not obviously safe to decide
automatically: unlike a namespace or an api, one can have been installed
deliberately, and a global cap means a wrong deletion is felt project-wide.
A GCP::Storage::Object reports its properties when created. Create returned
a native ID and nothing else, so a freshly created object had no stored state
and anything referencing one resolved against nothing and stayed an unresolved
reference. That is how an object ACL reached the plugin with its object still
a reference and addressed the bucket alone.
An object name is percent-encoded in bucket-scoped paths.
conformance/acl-target.txt is one object, not a folder and a file. The
object provisioner escaped it in its own URLs but the shared path builder did
not, so an object ACL addressed a path that does not exist and the API
answered 404. The native ID keeps the name raw, and the parser now reads the
object as everything between the object marker and the trailing type and name
rather than as a single segment.
A resource addressed by two parents is refused rather than silently collapsed. When the second parent was missing, the path context kept only the first and both the request URL and the native ID became the one-parent form - which the API accepts as a perfectly valid different resource. An object ACL created without its object became a bucket ACL: create reported one native ID, discovery reported another, and nothing downstream could tell them apart. A wrong resource created successfully is worse than a failed create.
A GCP::Eventarc::Trigger can reference the Workflow it delivers to.
destination.workflow was a plain String, so a forma could name a workflow
but not reference it - and ordering comes only from resolvable references, so
formae was free to create the trigger before the workflow it targets. It now
accepts a resolvable, and the request expands the bare name into the full path
Eventarc wants while the response shortens it back.
A resource identified by anything other than name can be discovered. The
generic list path required every listed item to carry a name before it would
consult the API's own native-ID extractor, so a Cloud Storage ACL entry -
identified by entity, with no name at all - produced nothing, and the list
came back empty with no error. The extractor is now asked first, and the
name-shaped path is the fallback.
Fourteen Storage fields the schema itself documents as "(output only)" are
marked as provider defaults, across the three ACL types and AnywhereCache:
projectTeam, entityId, generation, domain and email. A
DefaultObjectAccessControl reported drift on projectTeam the moment it was
created, because the comment said output-only and the hint did not.
Bigtable creates hand their properties back. BaseResource.Status does not
read the resource once an asynchronous operation completes, and an async
create returns no properties either - so nothing that referenced a Bigtable
resource could resolve. A table declaring instance.res.name failed with
"instance is required for nested resources" on an instance that was plainly
declared, because the create it referenced handed back nothing to resolve
from. Status now routes through base.StatusWithRead.
Resolvables that pointed at a property name the schema does not have. A
GCP::Storage::Bucket resolvable's name targeted "Name", so every forma
referencing bucket.res.name failed to apply with source resource ... has no property "Name" - which is what a bucket ACL case hit. GCP's JSON is
camelCase, and 17 resolvable targets across Bucket, AnywhereCache, the
three ACL types, Container::Cluster and Container::NodePool were
capitalised. Corrected wherever the lowercase field is declared in the same
file, which makes each one provable rather than guessed.
Bucket's selfLink resolvable is removed outright: the type has no
selfLink property at all, so it could never resolve.
GCP::Bigtable creates unwrap wrapped property values. The hand-written
provisioner read properties directly while base.Create unwraps them first,
so a wrapped value read as a plain string came back empty - surfacing as
"instance is required for nested resources" on a table whose instance was
declared.
GCP::Storage::BucketAccessControl and GCP::Storage::DefaultObjectAccessControl
are discoverable. An ACL entry lives at /b/{bucket}/acl, and Cloud Storage
has no endpoint spanning buckets - no - wildcard in the bucket position, as
privateca and Datastream offer - so discovery, which lists with no parent,
asked for a URL with an empty bucket segment and found nothing. Both types now
walk the project's buckets, skipping any whose ACLs cannot be read: a bucket
with uniform bucket-level access rejects the read outright, and a shared
project holds buckets a target does not own.
Creating a GCP::Datastream::ConnectionProfile no longer half-succeeds. The
API validates a profile against the source it describes, and does so inside
the long-running operation - after the profile has been created. A profile
naming a host that does not answer was therefore created and reported as
failed; formae retried, and the retry collided with the profile the first
attempt had made ("Resource ... already exists"), so the validation error
never surfaced at all. Creates now send force=true, as stream creates
already did.
GCP::Bigtable::Table no longer reports drift the moment it is created. The
API reports a table's name as the full path
projects/{p}/instances/{i}/tables/{t} while a forma declares the short id
and the instance separately, and Table was registered with the generic
response transformer, which only fills in the project. Instance and Cluster
each had one of their own; Table now does too, and recovers the instance from
the path as well - it lives nowhere else in the response.
GCP::Compute::Address.networkTier and purpose, and
GCP::Bigtable::Table.granularity, are marked as provider defaults. GCP fills
all three in when unset.
GCP::Storage::BucketAccessControl and GCP::Storage::DefaultObjectAccessControl
can be created at all. Their role field carried no @gcp.FieldHint, so the
plugin never treated it as a resource property and never sent it - and the API
rejects an ACL without one: "Access control must contain a role". Both types
had shipped in this state, with no conformance case to reveal it.
ObjectAccessControl has the same omission and is fixed alongside, though it
still has no provisioner.
Server-populated fields across nine services are marked as provider defaults,
so a forma that does not declare them no longer reports drift the moment the
resource is created. Twenty-four fields in all: every proxyHeader (GCP fills
it in with NONE), the fingerprint and labelFingerprint hashes, and the
state, status, selfLink, kind, uid, createTime and updateTime
fields on Container, Storage, CloudRun, BigQuery and Compute types.
Two lookalikes are deliberately left alone: WorkflowTemplate.id is the
identifier a forma chooses, and ExternalVpnGatewayInterface.id is a
caller-supplied 0-based index that VpnTunnel references.
Six server-populated Compute fields are marked as provider defaults, so a
forma that does not declare them no longer reports drift the moment the
resource is created: Address.labelFingerprint, effectiveLabels,
terraformLabels, users and selfLink; TargetHttpsProxy.fingerprint and
RegionTargetHttpsProxy.fingerprint; TargetSslProxy.proxyHeader; and
ForwardingRule.labelFingerprint. Each was reported as "not expected and not
a provider default" the first time a conformance case exercised the type.
A successful long-running operation is no longer reported as a failure. The
status checker treated the mere presence of an error key as a failure, but a
finished operation may carry "error": {} - present and empty, which is an
absent status, not an error. Every affected create was reported failed after
it had already succeeded; formae then retried it, and the retry answered
"Resource ... already exists", which masked the original operation entirely.
An error now counts only when it carries a message or a non-zero
google.rpc.Code.
Affected Datastream, Eventarc, Certificate Authority Service and Filestore -
every package with an asynchronous create. All four had an identical copy of
the checker; they now share one in base.
GCP::Datastream::Route is discoverable. A route only exists underneath a
private connection, and discovery lists with no parent to name, so the plugin
asked for /projects/{p}/locations/{l}/routes - a 404 - and no route was ever
found. Datastream accepts - in the private-connection position, so a
parentless list now asks across every one. No parent-walking List needed,
unlike Analytics Hub, which has no such wildcard.
Bigtable's nested types can now reference their parent. Table.instance,
Cluster.instance, Backup.instance/cluster/sourceTable and the same
three on MaterializedView were plain String, so a forma could name a
parent but not reference it. Ordering in a forma comes only from resolvable
references, so declaring an instance and a table together gave formae no edge
between them and nothing guaranteed the instance existed first. All eight
fields now accept (String|formae.Resolvable).
Four Compute types no longer claim to support updates their API cannot
perform: TargetPool, TargetSslProxy, TargetTcpProxy and
RegionTargetTcpProxy. None of targetPools, targetSslProxies,
targetTcpProxies or regionTargetTcpProxies has a patch or an update
method - they offer only setters such as setBackendService and
setSslCertificates - so an update planned a PATCH to a URL the API does not
serve. A change now replaces.
Found by writing the first conformance case for TargetPool, then checking
every Compute definition against the discovery document: 62 scanned, 4 wrong.
Three of the four had no conformance case at all, and the fourth
(TargetTcpProxy) had one with no update fixture.
GCP::Storage::Object - a single object in a bucket with its content declared
inline, for the small files infrastructure is made of: a config document, a
startup script, a static index page. The content is part of the forma, so it
is part of the plan and the state; wrap it with formae.value(...).opaque to
keep it out of both.
Uploading needed two additions to the transport, which until now sent only
JSON: RawBody/ContentType for sending bytes verbatim, and SendRaw for
reading them back - an object's bytes are a declared property, so without
reading them a changed object would never read as changed.
A conformance case for GCP::Bigtable::MaterializedView.
A conformance case for GCP::Bigtable::Backup, and FORMAE_TEST_FUTURE_TIMESTAMP
in the environment conformance cases run with. A backup's expireTime must be
an absolute timestamp between 6 hours and 90 days out, and Pkl has no clock, so
a fixture cannot compute one.
A conformance case for GCP::Bigtable::Cluster, the first to use the
on-demand list. A cluster is an additional replica of an instance and the
instance must be PRODUCTION - a DEVELOPMENT instance cannot have a second
cluster - so the forma holds two billed nodes while it runs. It is excluded
from the automatic matrix and run by naming it in debug-conformance.
testdata/on-demand-cases.txt, a list of conformance cases excluded from the
automatic CI and nightly matrix. Every other case runs on every push to main
and every night; a case named here runs only when dispatched explicitly
through debug-conformance. It exists so a resource that cannot be covered for
free can still be covered at all, rather than being left with no conformance
case because covering it would add spend to every run.
A conformance case for GCP::SQL::Database, which shipped without one. It
builds a db-f1-micro instance to hold the database, mirroring the
cloudsql-instance case's private-IP settings.
A conformance case for GCP::Eventarc::Trigger, the last Eventarc type
without one. It delivers to a Workflow, which is free to define and needs no
container image or network attachment.
A conformance case for GCP::BigQuery::Routine. It is the first case to
exercise any BigQuery type: Dataset and Table also shipped without one,
and this case builds a dataset to hold the routine.
A conformance case for GCP::Monitoring::MetricDescriptor, which shipped
without one. A custom metric descriptor stands on its own and costs nothing,
so the case declares one and nothing else.
iamConfiguration on GCP::Storage::Bucket, so a forma can say whether a
bucket uses uniform bucket-level access. A UBLA bucket is controlled by IAM
alone and rejects ACLs outright, so without this field a bucket meant to carry
a BucketAccessControl depended on a project or organisation default.
A conformance case for GCP::Bigtable::Table, the first for any nested
Bigtable type.
Conformance cases for GCP::Storage::BucketAccessControl and
GCP::Storage::DefaultObjectAccessControl. Both shipped without one.
GCP::Storage::ObjectAccessControl still has none: it needs an object to
attach to, and there is no GCP::Storage::Object type.
Conformance cases for GCP::Compute::Address, GCP::Compute::TargetPool,
GCP::Compute::BackendBucket, GCP::Compute::TargetHttpsProxy,
GCP::Compute::TargetSslProxy, GCP::Compute::RouterNat and
GCP::Compute::RegionTargetTcpProxy. All seven shipped without one, so
nothing ever exercised them against the live API. That leaves
GCP::Compute::RegionTargetHttpsProxy as the only untested Compute type: it
needs a regional SSL certificate, and a regional MANAGED certificate is not
supported, so the case would have to commit a self-managed key pair.
GCP::Storage::Notification - publishes a bucket's object change events to a
Pub/Sub topic. Cloud Storage publishes as the project's own service agent, so
the topic must already grant that principal roles/pubsub.publisher; the new
GCP::PubSub::TopicIamMember is what expresses that, and the conformance case
declares it rather than relying on a grant made by hand.
GCP::PubSub::TopicIamMember and GCP::PubSub::SubscriptionIamMember - a
single (role, member) binding on a topic's or subscription's IAM policy,
managed read-modify-write so sibling bindings survive. A binding is modelled
rather than the whole policy because a policy is shared with principals
outside the forma - GCP's own service agents write to it - and declaring the
whole policy would delete their bindings on every apply.
GCP::Compute::RegionSnapshot - a regional incremental disk backup. Distinct
from the global Snapshot already shipped: that one lives at
/global/snapshots, this one at /regions/{region}/snapshots and stays in
its region.
GCP::Filestore::Backup and GCP::Filestore::Snapshot. Snapshot ships
without a conformance case: every tier that supports snapshots is
enterprise-class, and EnterpriseStorageGibPerRegion is 0 in the shared test
project, so a create cannot succeed there at all. Raising that quota is what
would let the case exist. A backup copies one
file share and outlives the instance it came from; a snapshot lives inside the
instance and goes when it does.
Snapshots are nested under an instance and Filestore has no wildcard in that position, so discovery walks the instances rather than asking for a URL with an empty segment.
GCP::Datastream::Stream, GCP::Datastream::PrivateConnection and
GCP::Datastream::Route - the rest of the creatable Datastream surface. A
stream is what actually moves data; a connection profile on its own moves
nothing. A private connection peers a VPC with Datastream's network for
sources that are not publicly reachable, and a route tells it which address to
reach the source on.
Creating a stream sends force=true. Datastream validates a stream against
its source at create time, so without it a stream whose source is not
reachable at apply time fails on validation rather than on anything wrong with
the declaration.
GCP::CertificateAuthority::CertificateAuthority and
GCP::CertificateAuthority::CertificateTemplate. A CA is what actually signs;
a CaPool with no CA in it issues nothing. A template is a reusable issuance
policy, location-scoped rather than pool-scoped.
A CA is deleted with skipGracePeriod, because a plain DELETE does not delete
it: it moves to state DELETED and sits there for 30 days, still holding its id
and still billed. ignoreActiveCertificates and ignoreDependentResources go
along so a CA that did issue something still tears down.
Not implemented: certificates. The API has no delete for them - a
certificate can only be revoked - so they are not a CRUD resource.
GCP::AnalyticsHub::DataExchange, GCP::AnalyticsHub::Listing and
GCP::AnalyticsHub::QueryTemplate - the whole creatable surface of Analytics
Hub. An exchange is the container a publisher shares through, a listing
publishes one BigQuery dataset into an exchange, and a query template is the
data-clean-room construct that lets a subscriber run a routine against shared
data without seeing the rows.
Neither listings nor query templates have a URL spanning exchanges, and there is no wildcard in the parent position, so discovery - which lists with no parent to name - walks the exchanges and asks each one.
Analytics Hub ids allow only letters, digits and underscores, so its test fixtures are underscore-named where every other fixture here is hyphen-named - and its cleanup sweep greps accordingly.
GCP::Eventarc::Enrollment - the routing rule of an Eventarc Advanced setup:
a CEL expression matched against the events on a MessageBus, and the
Pipeline matching events are handed to. A bus without an enrollment routes
nothing, so this is what makes the existing MessageBus and Pipeline types
useful together.
GCP::Eventarc::GoogleApiSource - routes this project's own Google API events
onto a MessageBus. Only one is allowed per project per region, the same
constraint MessageBus already carries.
Both name other Advanced resources through a scalar path field, so both get
the expand-on-write / shorten-on-read pair a forma needs to pass a resolvable
(bus.res.name) instead of hand-writing a full path - the scalar counterpart
of what pipelineRequestTransformer already does for a pipeline's nested
destinations.
GCP::PubSub::Snapshot - captures a subscription's acknowledgement state so
the subscription can later be seeked back to it. Pub/Sub creates a snapshot by
PUTting to its resource path, and the create body is not the resource: it
takes the subscription to snapshot, while the snapshot itself reports only
the topic that subscription was attached to. subscription is therefore
write-only, so it is sent on create and left out of drift detection.
GCP::CertificateManager::CertificateMap — groups the certificates a load
balancer serves, selected per hostname by its entries. A target HTTPS proxy
points at a map rather than a single certificate, which is how one proxy
serves many domains.
GCP::CertificateManager::DnsAuthorization — proves control of a domain.
Creating one returns a CNAME to publish; only issuing a managed certificate
waits on that record resolving, so the authorization itself is immediate.
GCP::CertificateManager::TrustConfig — the certificate authorities a load
balancer will accept client certificates from, for mutual TLS. It must carry
at least one trust store or allowlisted certificate; Certificate Manager
rejects an empty one at create. Note that it appends a trailing newline to
every pemCertificate it stores, whatever was sent, so a PEM declared
without one drifts on every re-apply.
GCP::Logging::LogBucket — where log entries are actually retained. A sink
routes entries into a bucket and a view is a window onto one, so this is what
decides how long logs live and where.
Deleting a bucket does not remove it: it enters DELETE_REQUESTED and stays
for seven days so it can be undeleted, and a get answers 200 with that state
rather than 404. The plugin reports a bucket in that state as gone, so an
out-of-band delete leaves inventory and discovery does not offer buckets on
their way out.
locked is modelled so an existing locked bucket reads correctly, but note a
locked bucket can never be deleted and locking cannot be undone. Every project
also has _Default and _Required buckets created by GCP, so discovery
reports two per project that nobody declared.
GCP::Storage::ManagedFolder — an IAM boundary inside a bucket, letting a
policy be attached to a prefix without granting it over the whole bucket.
Requires uniform bucket-level access.GCP::Storage::Folder — a real directory node, available only in a bucket
created with a hierarchical namespace. Renaming one moves everything beneath
it, where a managed folder only governs who may read a prefix.GCP::Storage::Bucket models iamConfiguration.uniformBucketLevelAccess and
hierarchicalNamespace. Neither folder type can exist without them, and
hierarchical namespace is fixed at creation — a bucket is created flat or
hierarchical and cannot convert.GCP::Storage::Bucket's resolvable names properties the resource actually
has. It pointed at "Id", "SelfLink" and "Name" — capitalised, matching
nothing — so bkt.res.name resolved to no value and any resource
referencing a bucket reached the plugin with the reference unresolved. It
went unnoticed because nothing in the repository referenced a bucket until the
folder types did. selfLink is removed: the bucket has no such property.GCP::DNS::ResourceRecordSet — what a managed zone actually serves: one name,
one record type, and the data behind it. This completes Cloud DNS.
It is the only resource in the plugin addressed by two path segments
(.../rrsets/{name}/{type}). Both travel in the native ID joined by a slash,
which is unambiguous because a DNS name may contain dots but never a slash,
and both are fixed at creation — changing either is a different record set,
not an edit.
Every zone is born with an SOA and an NS record set nobody declared, so discovery reports two per zone as unmanaged.
GCP::Bigtable::AppProfile — decides how an application's requests are routed
across an instance's clusters. Every instance has a default profile; this is
how a workload gets its own routing without affecting the rest.
schema/pkl/bigtable/materialized_view.pkl describes the real API. It
demanded a cluster — materialized views are instance-scoped — and omitted
query, which is required, so the module was declarable and broken on
contact. It still has no provisioner and remains a recorded parity gap: the
create query needs Bigtable SQL semantics that could not be settled from CI
logs.
BigtableProvisioner routes Status through base.StatusWithRead. It
embedded *base.BaseResource and overrode only Create, so it inherited the
raw Status, which reports success and no properties. A completed async
create therefore left the resource with nothing to read, and a reference to a
Bigtable instance never resolved — a table declared alongside its instance
failed with "instance is required for nested resources". Affects Instance,
Cluster and Table.
BigtableProvisioner.Create unwraps wrapped property values, as base.Create
already did. Without it any property carrying a reference read as empty.
An app-profile create is treated as synchronous. appProfiles.create answers
with the resource, not an Operation, so polling looked for an operation id
that was never there and asked the bare base URL, which answers 404.
GCP::Bigtable::Table accepts a resolvable for instance. As a plain
String a table could only ever name an instance that already existed, so it
could not be declared in the same forma as its instance — which is why the
type has no conformance case to this day.
The Bigtable native-ID parser no longer switches on each collection by name. An unlisted collection parsed to an empty resource type and read nothing, silently; every instance-scoped collection now falls through one branch, with cluster-scoped backups the only special case.
GCP::DNS::Policy — governs resolution for the VPC networks attached to it:
inbound forwarding from an on-premises resolver, alternative name servers,
and query logging. A policy attached to no network is valid and applies to
nothing.
GCP::DNS::ResponsePolicy — the container for rules that override resolution
for its networks, the private-DNS equivalent of a hosts file.
GCP::DNS::ResponsePolicyRule — one override, saying what a given DNS name
resolves to. Discovered by walking the response policies, since discovery
lists with no properties and Cloud DNS has no wildcard for that segment.
Cloud DNS does not agree with itself about what an identifier is called: a
managed zone and a policy use name, a response policy uses
responsePolicyName, a rule uses ruleName. A forma declares name for all
of them and the plugin translates at the API boundary, so the inconsistency
stays inside the plugin. The rule collection is likewise rules in the URL
but responsePolicyRules in a list response.
GCP::SQL::User — a database user on a Cloud SQL instance. An instance ships
with no usable login of its own, so this is what makes one reachable by an
application. password is write-only and createOnly: the API never returns
it, so it cannot reach stored state, and rotating it replaces the user.
MySQL's host is deliberately not modelled: it is part of a user's identity
rather than a property of it, and a DeleteRequest carries no properties for
the plugin to read it back from, so supporting it means encoding it in the
native ID. Users created without one get MySQL's default.
GCP::SQL::SslCert — a client certificate for connecting over mutual TLS.
Addressed by a server-generated sha1Fingerprint rather than by the
commonName a forma declares, and the only sqladmin resource whose create
answers with the resource itself rather than only an Operation. The private
key is returned exactly once and is dropped rather than persisted: keeping it
would put a private key in stored state and guarantee drift on every later
read.
GCP::SQL::BackupRun — one on-demand backup of an instance. Addressed by the
numeric id sqladmin assigns, which arrives as backupContext.backupId on the
create Operation. Unlike Spanner's and Bigtable's backups it takes no absolute
expiry, so its fixture cannot rot.
All three are discovered by walking the instances: discovery lists with no properties, so it can name no instance to look in, and sqladmin has no wildcard for them.
targetLink. Every sqladmin mutation answers with an Operation
whose targetLink names the instance, so a nested resource was stored
under the instance's native ID — two resources sharing one id — and the next
sync read the instance and reconciled the nested resource away as absent.
This affected GCP::SQL::Database, which has been registered for some time
but had no conformance case and so had never exercised the path;
testdata/cloudsql-database.pkl now covers it.GCP::SQL::BackupRun is now treated as gone. Cloud SQL does not
remove a deleted backup run — the record survives as a tombstone and a get
answers 200 with status: "DELETED" rather than 404 — so a backup deleted
outside formae was reported as still present and never left inventory.
Discovery also no longer offers long-dead backups as unmanaged resources to
import.GCP::SQL::Database is now discoverable. It had no parent-walking List, so
discovery — which lists with no properties — asked a collection URL with no
instance in it and found nothing. Every instance-scoped Cloud SQL type now
shares one walker, since the only thing that differed between them was how an
item names itself: users and databases by name, a certificate by
sha1Fingerprint, a backup run by its server-assigned id.GCP::Spanner::Instance — the compute and storage a Spanner deployment runs
on. Project-scoped rather than location-scoped: an instance's region is its
config, not a path segment, and is fixed at creation. config is written as
the bare instance-config id (regional-europe-central2) and qualified by the
plugin, so a forma carries no project id and stays portable between targets.
GCP::Spanner::Database — a database on an instance. Spanner has no name
field on create; the id goes into a CREATE DATABASE statement, quoted with
backticks for GoogleSQL and double quotes for PostgreSQL.
GCP::Spanner::BackupSchedule — a recurring backup of one database, which is
what extends a database beyond its point-in-time retention window. Sits two
collections deep and, unlike instances and databases, is synchronous.
Spanner rejects a wildcard for both nested collections —
instances/-/databases and databases/-/backupSchedules answer 400 "Invalid
List... request" — so databases and schedules are discovered by walking the
collections above them, following nextPageToken at each level.
Note that Spanner creates a default_daily_full_backup_schedule alongside
every database, so discovery reports one nobody declared, and that
clean-environment.sh now sweeps Spanner instances: they are billed by the
hour, and the database and backup-schedule fixtures each leave one behind.
GCP::ServiceDirectory::Namespace — the top-level container of a Service
Directory registry. Location-scoped and free; deleting one deletes every
service and endpoint under it.
GCP::ServiceDirectory::Service — a named service inside a namespace,
carrying the annotations clients read when they resolve it. Its namespace
is a path component rather than a body field, so it is dropped from the
request and lifted back out of the returned path.
GCP::ServiceDirectory::Endpoint — one address and port a service answers
on, and what a resolve call actually returns. It sits two collections deep
(namespaces/{ns}/services/{svc}/endpoints/{ep}).
Service Directory rejects a wildcard at every level — locations/- answers
"Unsupported location" and namespaces/- "Could not parse namespace name" —
so services and endpoints are discovered by walking the collections above
them, following nextPageToken at each level. A dropped namespace would
otherwise hide every service and endpoint under it.
base.ParentResourceConfig gained GrandParentType /
GrandParentPropertyName, for APIs three collections deep. Read, update and
delete rebuild the whole path from the native ID, so create was the one
operation with nothing but the declared properties to route with; it now
carries the grandparent in PathContext.CustomSegments[0].GCP::Compute::DiskAsyncReplication is withdrawn. It never shipped in a stable
release, only in 0.1.13-dev.1 and -dev.2. Its two properties are the disks the
pair joins: both immutable, and both declared as references to the disk
resources, which is how a forma names them. An extracted forma writes such a
reference unresolved, so the re-apply compares a reference against the URL in
state on an immutable path and plans a replacement of the pair already in
place. Suppressing that comparison fixes the lifecycle and makes the pair
undiscoverable, because the agent requires both fields on a resource it
persists; reporting them keeps discovery and brings the replacement back. No
plugin-side shape satisfies both, so the type is out until the agent can
resolve a reference at plan time. The work, the four conformance runs behind
that conclusion, and the two agent-side asks are in the draft PR.GCP::Compute::Disk no longer plans a delete-and-recreate of itself. Compute
reports int64 fields as JSON strings, so physicalBlockSizeBytes came back as
"4096" against a declared 4096; the field is createOnly, so the two forms
compared unequal and every re-apply planned a replacement. It now carries the
same toString transformation sizeGb already had.GCP::SQL::DatabaseInstance no longer plans an endless
replace /settings/dataDiskSizeGb against itself. The provisioner parses Cloud
SQL's string form back to a number when it reads the instance, while the schema
stringified the declared number on the way out, so the two normalisations
pointed at each other: state held 10 and the desired side produced "10".
The field is typed Int, so the plugin's parse is the half to keep.GCP::Filestore::Instance no longer plans a second copy of its own file
share. Filestore reports capacityGb as a JSON string, so state held "1024"
against a declared 1024; because a share is a list element, the mismatch did
not read as one changed field - the declared share matched nothing in state
and the plan appended it. The declared value is now sent as a string, as
Compute's int64 fields already are.Unknown) still failing
the operation - and a burst of those against compute.googleapis.com turned
into eleven red conformance jobs in one run, each reporting "failed to get
operation status" for an operation that had not failed. Only a definitive
answer - not found, denied, bad request - now ends the poll.dashboards.create with
projects/{project_number}/... and dashboards.list with
projects/{project_id}/... for the same dashboard, so a native ID taken
verbatim depended on which call produced it and the managed resource never
correlated with the discovered one. The project segment is now normalised to
the configured project.PRIMARY_ALREADY_EXISTS. A definitive answer (not found, denied, bad request)
still fails.GCP::Dataproc::WorkflowTemplate is now discoverable. Its list response is
keyed templates, which matches neither items nor the collection name, so
the parser found nothing and the type was never discovered.GCP::AlloyDB::Instance and GCP::AlloyDB::User are now discoverable. Both
live under a cluster and discovery names none, so each listed a path with an
empty cluster segment and got a 404. Instances now use the API's clusters/-
wildcard; users.list rejects that wildcard, so that one walks the clusters.ResourceProperties at all, so whatever the
update changed was missing from state until some later sync happened to pick it
up - a labels change on a Dataproc workflow template read as never applied.Update now unwraps formae.Value wrappers the way Create already did.List returned nothing unless the caller named the
parent. They now walk the parents first (securityPolicies, firewallPolicies,
routers, and Logging buckets via the locations/- wildcard).GCP::Logging::LogView's List override is now installed from the package's
init rather than its own init(). Go runs init functions in filename order and
log_view_list.go sorts before resources.go, so the generic registration
silently replaced it and discovery listed a path that 404s.GCP::Compute::BackendServiceSignedUrlKey and GCP::Compute::VpnTunnel are
now discoverable. Both declared a required write-only field (keyValue,
sharedSecret) that the API never reports, so a discovered instance could
never satisfy the schema and was silently dropped from inventory. Both fields
are now optional; the create path still rejects a missing value.GCP::Compute::InstanceTemplate — the immutable global VM blueprint a
managed instance group stamps out. Every field is createOnly; the Compute API
has no update method for templates.GCP::Compute::InstanceGroupManager — zonal managed instance group. Set
targetSize to size the group, or attach an autoscaler and let it own the
size.GCP::Compute::Autoscaler — zonal autoscaler for a managed instance group.
The API's target field is exposed as instanceGroupManager because
target is reserved by formae's base Resource class (it names the deployment
target).The AlloyDB 8-segment native-ID parser is now a
ClusterScopedNativeID(resourceType) factory shared by instances and users,
with each leaf type rejecting the other's ids rather than mis-parsing them.
alloydbInstance's resolvable now exposes cluster. A resource that needs a
serving database — alloydbUser — must be created after the instance, not
merely after the cluster, and AlloyDB rejects user creation until a primary is
GCP::BigQuery::Routine schema module — a user-defined function, table
function, or stored procedure inside a dataset. The provisioner already
existed (pkg/resources/bigquery/routine.go, registered since before this
work) but no PKL module did, so the type was impossible to declare from a
forma. Field names follow exactly what the provisioner reads and returns.
No conformance fixture: the dev service account lacks
bigquery.datasets.create, so a fixture cannot create the dataset a routine
lives in. The schema itself is verified by pkl eval and by the parity tests.
LocationScopedNativeID(resourceType) factory, and LoggingSavedQueryAPI is
renamed LoggingLocationAPI since both saved queries and log scopes share it.base.DropFieldsOnUpdate(...), shared by
Logging::ProjectSink and Logging::ProjectExclusion. Several GCP APIs build
their PATCH updateMask from the body's top-level fields, so an immutable
field left in the body lands in the mask and the call is rejected
(Cloud Logging answers "name cannot be changed").base.DropFields(...) companion to base.DropFieldsOnUpdate(...), for
properties that identify a resource's place in the URL rather than its payload
(a nested resource's parent id and location). Several GCP APIs reject those
outright as unknown body fields. Monitoring::Slo and Logging::LogView both
use it.monitoringPathBuilder now emits a parent segment
(/projects/{p}/{parentType}/{parent}/{resourceType}) when one is set, which
is what lets SLOs nest under a service. Existing Monitoring resources pass no
parent and are unaffected.GCP::Compute::BackendBucket — global LB backend that serves a Cloud Storage
bucket, so a URL map can route static paths to GCS. No conformance fixture
yet: backendBuckets.insert validates the bucket asynchronously and fails
the operation with GCS_BUCKET_NOT_FOUND, so the test needs a real bucket,
and the dev service account currently has no Storage permissions.GCP::KMS::KeyRing can be destroyed. cloudkms…keyRings.delete did not exist
when the type 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 then never reclaim it,
and the conformance case 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.
GCP::Compute::RegionTargetHttpsProxy updates reach the API.
regionTargetHttpsProxies.patch enforces the fingerprint rather than treating
it as advisory — a PATCH without one is refused outright 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 this type failed with a 400. tlsEarlyData also gained
hasProviderDefault: the API reports DISABLED back on every read whether or
not it was sent, exactly as the global sibling already documents, so a proxy
declared without it drifted on the first sync.
GCP::Compute::RegionTargetHttpProxy no longer claims an update it never had.
That collection offers delete, get, insert, list and setUrlMap and no
patch, 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.
GCP::BigQuery::Table.datasetId accepts a resolvable. It 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.
GCP::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 at all and any change
replaces, but it stops the trap for whoever enables update.
More resources could be managed but never discovered. Discovery calls List
with no hints, and several resources answered with an empty list or the wrong
location:
ArtifactRegistry::Rule — a rule lives under a repository and Artifact
Registry has no wildcard for either segment (repositories/- answers
"Repository does not exist"), so listing now walks the repositories first.
Registered as a List-only override, leaving the generic create, read, update
and delete in place.Compute::BackendServiceSignedUrlKey — now reads the aggregated
backend-service list, one call carrying every service's cdnPolicy, rather
than requiring a service to be named.Compute::DiskAsyncReplication — now reports every pair whose replication is
ACTIVE. Stopped pairs stay absent deliberately: Read treats them as gone,
so listing them would produce ids that immediately read as not-found.Eventarc::MessageBus / Pipeline — Advanced runs in a subset of regions, so
a forma pins one that is rarely the target's, and discovery looked only in the
target's. PathContext gained an IsList marker so a path builder can tell a
collection URL built for listing from one built for create or read; the
Eventarc builder uses locations/- for the Advanced collections when listing.plugin-conformance-tests to v0.2.6, which verifies opaque secret
fields against their authored plaintext by SHA-256 digest (v0.2.5 compared
the hashed value to cleartext and could not exercise opaque payloads).formae.SecretValue so their values are hashed at rest end-to-end (previously stored in cleartext on the read/actual-state path). Covers GCP::Compute::BackendService and GCP::Compute::RegionBackendService oauth2ClientSecret, GCP::Container::Cluster master-auth password, and GCP::SQL::Database rootPassword. Requires a formae agent on the matching release; minFormaeVersion is bumped to 0.88.0.GCP::CloudRun::Service exposes template.vpcAccess, letting a service
route egress through a Serverless VPC connector or direct VPC network
(connector, networkInterfaces, egress).GCP::CloudRun::Service create no longer hangs: the operation is now
polled to completion, and selfLink is normalized so a create-then-read
cycle no longer reports spurious drift.GCP::IAM::ServiceAccount delete is now async, completing only once the
account has left the list, so a delete immediately followed by a
synchronization or discovery run no longer resurrects it.GCP::Compute::InstanceGroup now manages VM membership via an instances
field (instance self-links or Instance resolvables), reconciled with
addInstances / removeInstances; namedPorts are now mutable via
setNamedPorts. This lets a GCE VM back an external HTTPS load balancer.GCP::Compute::SslCertificate.privateKey accepts a wrapped value
(formae.value(read(...).text).opaque), keeping the PEM private key out of
rendered plans and stored state.GCP::Compute::SslCertificate SELF_MANAGED certificates now send
certificate / privateKey nested under selfManaged, as the API requires.
Creation previously failed with "Self-managed certificate details must be
specified if type = SELF_MANAGED".GCP::IAM::ServiceAccount creation now accounts for IAM eventual consistency:
the create completes only once the account is listable, so a synchronization
or discovery run immediately after create no longer drops it from inventory.GCP::Compute::InstanceGroup read no longer surfaces provider-populated
network / subnetwork as spurious drift.GCP::PubSub::Topic, GCP::PubSub::Subscription, and
GCP::PubSub::Schema.GCP::SecretManager::Secret (automatic, Google-managed
replication by default).GCP::DNS::ManagedZone for public and private DNS zones.GCP::IAM::ServiceAccount for service accounts and GCP::IAM::Role
for custom project roles.GCP::Compute::Route for static VPC routes and
GCP::Compute::SecurityPolicy for Cloud Armor policies.GCP::BigQuery::Dataset and GCP::BigQuery::Table now support updates.
Previously create/delete only; mutable fields such as description, labels,
and (for tables) schema can now be changed in place.GCP::IAM::ProjectIamMember for managing a single member-role binding on a
project, without touching the rest of the project's IAM policy.id,
name, selfLink), so references to these resources resolve correctly.GCP::Compute::RouterNat for managing Cloud NAT configurations on a Cloud
Router.GCP::GKEHub::Feature and GCP::GKEHub::Membership,
can now be managed through formae. Use Membership to register GKE (or
external) clusters into a fleet and Feature to enable fleet-wide features on
those clusters.formae extract now works correctly for BigQuery Table resources. Previously,
extracting a managed table to PKL would fail with an internal error,
preventing round-trip workflows (deploy, extract, redeploy).GCP::CloudRun::Job and GCP::CloudRun::Service) with
full conformance tests.location to the GCP target configuration, giving explicit control over the
target location for regional resources.Disk.sourceImage nullable type. The field was incorrectly required, causing
validation failures when creating disks without a source image.