platform.engineering/gcpofficialv0.1.17stableresource

installation
$formae plugin install gcp
changelog as of v0.1.18-dev.0 · structured per keep a changelog
unreleased
Fixed
  • 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.

v0.1.16stable
Fixed
  • 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.
v0.1.15stable
Fixed
  • A List that walks a parent collection first - databases under Cloud SQL instances, NATs and interfaces under routers, record sets under managed zones, and the other instance-, cluster- and zone-scoped types - now answers a disabled API or an unauthorized principal the way a direct List does: an empty list and a log line, instead of an error that fails discovery every cycle. The walkers rendered the parent's failure to a string on the way out, which hid the API's 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.
v0.1.14stable
Fixed
  • 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.
    • Cloud Run paths fall back to the target's region when no location is set. Cloud Run v2 has no zones and its locations are regions, so a target that sets only region - the shape /formae:connect writes - was interpolating an empty segment and asking for locations//services.
    • GKE lists substitute locations/- for an absent location, since a GKE location may be a zone or a region and only the wildcard covers both.
v0.1.13stable
Changed
  • Six nested classes now 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.
Added
  • 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.

Changed
  • Every 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.
Fixed
  • 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.

Added
  • 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.

Fixed
  • 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.

Fixed
  • 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.
Fixed
  • 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.
  • Storage names containing a slash survive a native-ID round trip. Both folder types are named with a trailing slash that is part of the identity ("reports/" is not "reports"), and the parser took a single path segment, so the slash was dropped and the rebuilt URL addressed a folder that does not exist. The name is now taken whole and escaped when addressed — a no-op for every pre-existing storage name, none of which contains a slash.
Fixed
  • 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.

Fixed
  • 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.

Fixed
  • A nested Cloud SQL resource no longer takes its native ID from the create Operation's 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.
  • A deleted 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.
  • Cloud SQL now retries a 409 "another operation was already in progress" instead of failing the resource. Operations are serialised per instance and every nested type shares its instance's queue, so a mutation issued while another is still running is ordinary contention rather than a fault. It joins the existing "database is being accessed by other users" case.
  • 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.
Added
  • 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.

Changed
  • 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].
Removed
  • 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.
Fixed
  • A 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.
  • A 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.
  • A 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.
Fixed
  • An operation-status poll that cannot be read no longer fails the operation it was polling, whatever the reason. The earlier fix listed the transport errors it would tolerate, which left an unclassified error (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.
  • A Monitoring resource is no longer discovered a second time as an unmanaged copy of itself. Monitoring answers 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.
Fixed
  • A failed operation-status poll no longer fails the operation it was polling. A transient transport error (network, timeout, throttling, 5xx) says nothing about the operation, but reporting it as a failure made the caller re-issue the whole create - which then collided with what the first attempt had already built. One network blip while polling an AlloyDB instance create surfaced as PRIMARY_ALREADY_EXISTS. A definitive answer (not found, denied, bad request) still fails.
Fixed
  • 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.
Fixed
  • 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.
Fixed
  • An update of a resource that uses optimistic locking now reports the updated properties. That path returned no 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.
Fixed
  • Sub-resources that live inside a parent object are now discoverable. A policy rule, a firewall policy association, a router interface, named set or route policy and a log view have no collection URL of their own, and discovery lists with no properties, so each 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.
Fixed
  • 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.
Added
  • 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).
Changed
  • 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.

Changed
  • The two location-scoped Logging native-ID parsers are now one LocationScopedNativeID(resourceType) factory, and LoggingSavedQueryAPI is renamed LoggingLocationAPI since both saved queries and log scopes share it.
Changed
  • The update-body filter is now 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").
  • New 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.
Fixed
  • 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.

Fixed
  • 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.
Changed
  • Bump 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).
v0.1.12stable
Changed
  • Bump examples to the latest formae 0.88.0 schema.
v0.1.11stable
Changed
  • Genuine secret-value fields are now typed 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.
v0.1.10
Added
  • GCP::CloudRun::Service exposes template.vpcAccess, letting a service route egress through a Serverless VPC connector or direct VPC network (connector, networkInterfaces, egress).
Fixed
  • 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.
  • Transport-layer read errors are now classified: authentication failures and unreachable endpoints map to distinct Formae error codes instead of a generic failure, improving diagnostics on misconfigured credentials or network issues.
v0.1.9stable
Added
  • 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.
Changed
  • 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.
Fixed
  • 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.
v0.1.8stable
Added
  • Pub/Sub resources — GCP::PubSub::Topic, GCP::PubSub::Subscription, and GCP::PubSub::Schema.
  • Secret Manager — GCP::SecretManager::Secret (automatic, Google-managed replication by default).
  • Cloud DNS — GCP::DNS::ManagedZone for public and private DNS zones.
  • IAM — GCP::IAM::ServiceAccount for service accounts and GCP::IAM::Role for custom project roles.
  • Compute — GCP::Compute::Route for static VPC routes and GCP::Compute::SecurityPolicy for Cloud Armor policies.
Changed
  • 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.
v0.1.7stable
Added
  • GCP::IAM::ProjectIamMember for managing a single member-role binding on a project, without touching the rest of the project's IAM policy.
Fixed
  • Router and RouterNat resolvable property paths now use camelCase (id, name, selfLink), so references to these resources resolve correctly.
  • Provider-immutable fields across the Compute, Container, GKE Hub, and Storage schemas are now marked create-only, so changing them plans a replace instead of attempting an update the provider would reject. Requires formae 0.86.0 or later.
v0.1.5stable
Added
  • GCP::Compute::RouterNat for managing Cloud NAT configurations on a Cloud Router.
v0.1.4stable
Added
  • GKE Hub (Fleet) resources, 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.
Fixed
  • 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).
v0.1.2
Fixed
  • Spurious diffs during updates and synchronization for resources where GCP populates default values (e.g. Disk licenses, guest OS features, Cloud Build worker pool settings). These fields are now correctly recognized as provider defaults.
v0.1.1
Added
  • Cloud Run resources (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.
Fixed
  • Disk.sourceImage nullable type. The field was incorrectly required, causing validation failures when creating disks without a source image.
  • Corrected nullish Pkl union types across several resource schemas.
v0.1.0
Added
  • Initial release of the GCP plugin as a standalone package built on the formae Plugin SDK.
type
resource
category
cloud
license
FSL-1.1-ALv2
originator
platform.engineering
namespace
GCP
latest
v0.1.17 · stable
platforms
repo
github.com/platform-engineering-labs/formae-plugin-gcp
versions (25)
select a version to jump to its notes
v0.1.18-dev.0dev
2026-09-19
v0.1.17stable
2026-09-15
v0.1.16stable
2026-09-12
v0.1.15stable
2026-09-12
v0.1.15-dev.0dev
2026-09-11
v0.1.14stable
2026-09-11
v0.1.14-dev.1dev
2026-09-11
v0.1.14-dev.0dev
2026-09-09
v0.1.13stable
2026-09-07
v0.1.13-dev.5dev
2026-09-05
v0.1.13-dev.4dev
2026-09-05
v0.1.13-dev.3dev
2026-08-28
v0.1.13-dev.2dev
2026-08-27
v0.1.13-dev.1dev
2026-08-26
v0.1.12stable
2026-07-27
v0.1.11stable
2026-07-25
v0.1.10-dev.0dev
2026-07-24
v0.1.10-devdev
2026-07-24
v0.1.9stable
2026-07-21
v0.1.8stable
2026-07-16
v0.1.8-devdev
2026-07-10
v0.1.7stable
2026-05-29
v0.1.6stable
2026-05-22
v0.1.5stable
2026-05-20
v0.1.4stable
2026-05-13