Repository navigation
Releases: kmoneil/zig-gcp
Release list
v0.59.0
Everything a console's bucket and object pages show, and renames without a copy (#311): a bucket's Autoclass, CORS rules, website, access logging, IP filter and replication; an object's custom time and download link; and Object.moveTo, which renames an object within its bucket in one atomic step. No breaking change: every field is new and has a default. The default user agents changed.
var info = try gcs.bucket("my-site").get();
defer info.deinit();
if (info.value.autoclass) |a| if (a.enabled) {
std.debug.print("Autoclass, down to {s}\n", .{a.terminal_storage_class.?});
};
for (info.value.cors) |rule| std.debug.print("CORS for {d} origins\n", .{rule.origins.len});
var read = try gcs.bucket("my-site").object("reports/draft.pdf").get(.{});
defer read.deinit();
var moved = try gcs.bucket("my-site").object("reports/draft.pdf").moveTo("reports/final.pdf", .{
.if_source_generation_match = read.value.generation, // move only the object read
.preconditions = .does_not_exist, // replace nothing
});
defer moved.deinit();| Name | What it carries |
|---|---|
BucketInfo |
autoclass (Autoclass), cors (CorsRules, in the bucket's order), website (Website), logging (AccessLogging), ip_filter (IpFilter: whether it is enforced, its public ranges and VPC sources, its two allowances), rpo (Rpo) and data_locations (a configurable dual-region's two regions). Read, not set |
ObjectInfo |
custom_time, media_link and time_storage_class_updated |
UploadOptions, MetadataUpdate |
custom_time, what lifecycle rules count from, checked as RFC 3339 before sending |
Object.moveTo, MoveOptions |
A move within one bucket: the source's four conditions and the destination's preconditions |
What production showed
Measured on 2026-10-24 in a test project, with every bucket made for the measurement deleted after:
- A move leaves nothing behind, not even a noncurrent version in a versioned bucket. The moved object is a new generation at metageneration 1 and keeps its metadata, custom time and access control list. A hierarchical bucket makes the folders the new name needs.
- A
NotMatchthat hits is a 304,error.NotModifiedhere. AMatchbeside aNotMatchon one field, and a move onto its own name, are refused, so this library refuses them before sending. - A move pinned with
if_source_generation_matchis safe to repeat. A repeat that meets 404 or 412 after a lost answer reads the source: gone, the destination is answered; still there, the failure stands; another generation, the failure says the move may have happened. listBucketsgives an IP filter without its ranges, and a caller withoutstorage.buckets.getIpFilterreads the bucket with no filter at all, soip_filternull means "none, or not yours to see".- A regional bucket has no
rpo; logging without a prefix gets the bucket's own name; a custom time can move later, never earlier, and is never removed. - An empty website page is refused, and a CORS rule with nothing in it is dropped. The library's own fake had these two wrong until the production run found them.
Testing
The internal fake behind the storage tests takes these settings at a bucket's create as production answered them, refusals in production's words, and lists a project's buckets. Its tests replay the measured creates and compare the answers with production's recorded ones, CORS and the website as raw JSON too. Two gated production tests hold the real service to the same answers: GCP_TEST_BUCKET_SETTINGS=1 for the settings, and the move suite, which includes a pinned move whose answer is lost. Against an emulator, which has no move, moveTo reads both ends, judges the conditions itself, then copies and deletes, with a warning that this is not atomic.
The guides: Settings read and not set and Moves. New examples: gcs_bucket prints a bucket's settings page, and gcs_mv is a small mv.
The changelog has each module's entry.
zig fetch --save git+https://github.com/kmoneil/zig-gcp#v0.59.0v0.58.0
What a Cloud Run deploy set, for a console's service and revision pages (#309): each container's environment variables and the secrets behind them, its CPU and memory, ports and mounts; each revision's scaling, concurrency, timeout, execution environment, VPC access and volumes; and a service's template, ingress and scaling. No breaking change: every field is new and has a default. The default user agents changed.
var service = try client.getService("us-central1", "checkout");
defer service.deinit();
const template = service.value.template.?;
for (template.containers) |c| {
const r = c.resources orelse continue;
std.debug.print("{s}: {?d} millicores, {?d} bytes\n", .{ c.image, r.cpuMillis(), r.memoryBytes() });
for (c.env) |e| switch (e.source) {
.value => std.debug.print(" {s}\n", .{e.name}), // the value is often a credential
.secret => |ref| std.debug.print(" {s} from secret {s} ({s})\n", .{ e.name, ref.secret, ref.version }),
};
}| Type | What it carries |
|---|---|
Container |
command, args, working_dir, env (EnvVar: a .value, or a .secret SecretRef), resources (Resources, with cpuMillis and memoryBytes), ports, volume_mounts, depends_on, beside its name and image; jobs' containers too |
Revision |
volumes (Volume: secret, empty dir, Cloud SQL, Cloud Storage, NFS), min_instances, max_instances, concurrency, timeout_s, execution_environment, vpc_access, session_affinity, encryption_key |
Service |
template (RevisionTemplate: a revision's settings by the same names), ingress, invoker_iam_disabled, default_uri_disabled, scaling (ServiceScaling) |
Job |
its task template's volumes, vpc_access, execution_environment |
An environment variable's .value is kept as deployed and is often a credential: show names where a person may see them.
What production showed
Measured on 2026-10-23 in a test project, with services, a job and a secret deployed for the measurement and deleted after:
- Production leaves out every false and zero it holds:
cpuIdle: false, a minimum of 0 and session affinity off come back absent, so here they read asfalseandnull. It fills its defaults only where a deploy gave none: 512Mi of memory and 1000m of CPU, port 8080, a 300 s timeout, a concurrency of 80, and a service scaling with a maximum of 20. - CPU, memory and secrets are kept exactly as deployed:
"1"and"1000m","1G"and"1024Mi", a bare secret id and a full secret name each come back as written.Resources.cpuMillisand.memoryBytesread every form. - A service whose default URL is disabled answers no
uriand nourls. - A secret the runtime account may not read still makes a revision, and the service fails with reason
SECRETS_ACCESS_CHECK_FAILED; its settings read the same. - A template's container has no name unless the deploy gave one; a revision's does (
hello-1), its image pinned to a digest. invokerIamDisabledis taken only from a caller who may set the service's IAM policy, so it is read by its documented shape.
Testing
FakeRun.putService, putRevision and putJob take every setting, and fill production's defaults where a test gives none, so a console's tests see what a real deploy shows. A slow fuzz property holds the round trip through the client. The gated production test (RUN_SETTINGS_TEST=1, about two minutes, nothing billed) deploys a service with every setting, a plain one and a job, and replays them on the fake, the plain ones seeded with an image alone: all seven answers agreed, the fake's defaults included.
The guides: What a deploy set and Testing. examples/run_service.zig --fake draws one service's page offline.
The changelog has each module's entry.
zig fetch --save git+https://github.com/kmoneil/zig-gcp#v0.58.0v0.57.0
Who has access, for a console's Permissions tab (#308): a project's IAM policy, which everything under it inherits; a Cloud Run service's and job's own; an object's; and what the caller may do on each. No breaking change: every call is new. The default user agents changed.
// The project's policy: what every resource under it inherits.
var policy = try projects.getProjectIamPolicy("my-project");
defer policy.deinit();
// A service's own policy, and whether it is public.
var service = try run_client.getServiceIamPolicy("us-central1", "checkout");
defer service.deinit();
const public = service.value.grants("roles/run.invoker", "allUsers");
// What the caller may do there, to hide what it may not.
var held = try run_client.testServiceIamPermissions("us-central1", "checkout", &.{ "run.services.update", "run.services.delete" });
defer held.deinit();| Call | What it does |
|---|---|
resource_manager.Client.getProjectIamPolicy(id_or_number) |
The project's policy, asked for as version 3 |
resource_manager.Client.testProjectIamPermissions(id_or_number, permissions) |
Which of the permissions asked the caller holds on the project; any service's may be asked |
run.Client.getServiceIamPolicy(location, id), getJobIamPolicy(location, id) |
A service's or job's own policy, nothing inherited |
run.Client.testServiceIamPermissions(...), testJobIamPermissions(...) |
Which of Cloud Run's permissions the caller holds there |
storage.Object.iamPolicy(options) |
An object's policy: its access control list as legacy roles |
storage.Object.testIamPermissions(permissions, options) |
Which of the object permissions the caller holds |
Every policy is core.iam.Policy, which resource_manager and run now export as iam, as the other modules do. Every testIamPermissions answer is sorted by name, as production sends it.
What production showed
Measured on 2026-10-23 in a test project, with a service, a job and buckets made for the measurement and deleted after:
- An object's policy takes only
cloud-platformordevstorage.full_control.devstorage.read_only,devstorage.read_writeandcloud-platform.read-onlywere refused ("Provided scope(s) are not authorized"), although Google's discovery document names all three. Testing permissions on an object takes any Storage scope. - In a bucket with uniform access an object's policy is refused: "Object policies are disabled for bucket ... when uniform bucket-level access is enabled." The bucket's policy is the one that applies there.
- Cloud Run cannot tell you a service is missing: a service or job that does not exist answers an empty policy, and its permissions are tested as if it existed. Location
-is a 403 that reads like a missing permission, so the client refuses it before sending. - A project that does not exist is a 403 for its policy "(or it may not exist)", and holds nothing, not an error, for a permission test.
- The limits: 100 permissions in a test for projects and Cloud Run, 84 for objects.
Testing
FakeResourceManager.setProjectPolicy and holdPermissions, and FakeRun.setIamPolicy and holdPermissions, answer the reads as production did: a test puts a policy in and says what the caller holds, so a permissions view can be tested on both sides, the actions it shows and the ones it hides. Each fake is held to production's recorded answers word for word, and has a fuzz property against a plain model in the nightly.
The gated production test (IAM_READS_TEST=1, about a minute, nothing billed) reads a real project's, service's, job's and object's policies and permissions, then replays the Resource Manager and Cloud Run calls on fakes seeded from production's answers: every answer agreed, the project's 31-binding policy byte for byte.
The guides: IAM on every resource, projects, Cloud Run and an object's policy. examples/permissions.zig --fake shows a project's and a service's policy and what the caller may do, offline.
The changelog has each module's entry.
zig fetch --save git+https://github.com/kmoneil/zig-gcp#v0.57.0v0.56.0
BigQuery Data Transfer, as a new module, datatransfer (#271): BigQuery's scheduled queries and the other transfer configs, in every location, with their schedules, their runs and each run's messages; and one write a console confirms before sending: run a config now, its exact request built first and sent once. Experimental: new in this release. No breaking change: the module is new. The default user agents changed.
// Every location's scheduled queries: production lists one location at a time.
var locations = try client.listLocations(.{});
defer locations.deinit();
for (locations.value.locations) |location| {
var configs = try client.listTransferConfigs(location.id, .{ .data_sources = &.{"scheduled_query"} });
defer configs.deinit();
for (configs.value.configs) |config| std.debug.print("{s}: {t}\n", .{ config.display_name, config.state });
}
// Run one now, the request shown first.
const options: datatransfer.StartManualRunOptions = .{ .run_time = std.Io.Clock.real.now(io) };
var request = try client.startManualRunRequest(config_name, options);
defer request.deinit();
if (try confirm(request.value.method, request.value.url, request.value.body)) {
var started = try client.startManualRun(config_name, options);
defer started.deinit();
}| Call | What it does |
|---|---|
Client.listLocations(options) |
The locations configs can live in: us, europe, the regions and BigQuery Omni's |
Client.listTransferConfigs(location, options), getTransferConfig(name) |
A location's configs, by data source (scheduled_query alone); a get adds the owner |
Client.listRuns(config_name, options), getRun(name) |
A config's runs, newest run time first, by state, every attempt or the latest a run date |
Client.listMessages(run_name, options) |
A run's messages, newest first, by severity |
Client.startManualRun(config_name, options) |
Runs a config now, or for a time given |
Client.startManualRunRequest(config_name, options) |
The run now's exact request, after the same checks with the same errors |
Client.configName(arena, location, id) |
A config's name in this project |
- Names as production gives them: production names the project by number, and every call takes a config's or run's name as a list or get gave it.
- A
TransferConfigcarries oneSchedulefor both of the API's schedule shapes (time-based, manual, event-driven, default), its next run time, its latest run's state, disabled, its destination dataset and region, its owner, its params as JSON text per key, and a typedScheduledQueryview: query, destination table template, write disposition. - A run now is sent once. It has no request id, production started a second run for the same time sent twice, and Google's clients never retry it; only a refused connection or a failed name lookup is tried again.
- One OAuth scope,
Client.Options.scope:.bigqueryby default, asbigquery.Client's, so one token serves both modules. A.cloud_platform_read_onlyclient reads everything and refuses a run now before sending, as production refuses it.
What production showed
Measured on 2026-10-21 in a test project, with scheduled queries made for the measurement and deleted after:
- There is no "every location":
-is refused ("BigQuery Data Transfer Service does not yet support location: unknown"), so the client refuses it before sending, and a console walkslistLocations: 54 locations on one page, which refuses a page size. - A run now answered at once,
PENDING; the run readRUNNINGwithin 3 s, and a scheduled query of literals endedSUCCEEDED75 s after it started. A time 60 s ahead of production's clock was taken, though the docs say it must be past. - A disabled config's run now is refused: "Transfer is disabled. Please enable the transfer config before starting transfer runs."
- A failing run carries its error ("division by zero: 1 / 0; JobID: ..."), a succeeded one an empty error; a run's messages come newest first; runs list newest run time first, configs by id.
Testing
datatransfer.testing.FakeDataTransfer holds locations, configs, runs and messages a test puts in, and runs a config now as the test says it ends: pending until moved on, or succeeded or failed at once with production's lines. Every behaviour above follows production; what it does not model it answers 501, naming it. A slow fuzz property holds it to a model, in a new nightly job.
The gated production test (DATATRANSFER_TEST=1, about three minutes, nothing billed) makes scheduled queries, runs two now through the client with the request shown first, follows them to their end, and replays the same script on a fake seeded from production's answers: all 18 outcomes agree, word for word.
The guides: Data Transfer and Testing. examples/datatransfer_runs.zig --fake --run --yes runs the list, confirm, run and follow loop offline.
The changelog has each module's entry.
zig fetch --save git+https://github.com/kmoneil/zig-gcp#v0.56.0v0.55.0
BigQuery, as a new module, bigquery (#270): the datasets a project holds, their tables with their schemas, and the job history, from any project; and queries as a console's query editor runs them: dry-run for what they would read, run under a bytes limit, waited for, read a page at a time with every type decoded exactly, and cancelled, each request built first so it can be confirmed. Experimental: new in this release. No breaking change: the module is new, and core gains a hook and a public function. The default user agents changed.
// What it would read, for nothing; then run it under a limit and read it.
var dry = try client.dryRun(.{ .query = text });
defer dry.deinit();
std.debug.print("reads {d} bytes\n", .{dry.value.total_bytes_processed orelse 0});
var page = try client.query(.{ .query = text, .maximum_bytes_billed = 100_000_000 });
defer page.deinit();
while (!page.value.complete) {
const next = try client.getQueryResults(page.value.job.?, .{});
page.deinit();
page = next;
}
for (page.value.rows) |row| std.debug.print("{f}: {f}\n", .{ row.cells[0].value, row.cells[1].value });| Call | What it does |
|---|---|
Client.listDatasets(options), getDataset(ref) |
A project's datasets, the client's own or another's (bigquery-public-data); hidden ones with all, a label filter |
Client.listTables(ref, options), getTable(ref) |
A dataset's tables; a get carries the schema with nested records, row count, bytes, partitioning, clustering, and a view's or materialized view's query |
Client.listJobs(options), getJob(ref) |
The job history, newest first, with each query's text; all users', by state and creation time |
Client.dryRun(options) |
What a query would read and bill, its columns and statement type, without running it: free, never in the history |
Client.query(options) |
Runs a query: its first page when it ends within timeout_ms, or its job to read further |
Client.getQueryResults(job, options) |
A page of a job's results, or a running job's state once the call has been held timeout_ms; by token, by size, or from any row |
Client.cancelJob(job) |
Asks a job to stop |
Client.dryRunRequest, queryRequest, cancelJobRequest |
The exact requests, after the same checks with the same errors |
- Every query is GoogleSQL: the client sends
useLegacySqlfalse, BigQuery's default being legacy SQL still. Timestamps are asked as microseconds, as Google's clients ask them, and decode exactly. - Every query carries a request id, a UUID per call unless you give one (
Client.newRequestId, so a preview and its run send the same bytes). A resend within 15 minutes answers the first job, soqueryis retried as the reads are. - Every type decodes exactly:
bigquery.Valueholds INT64 asi64, NUMERIC and BIGNUMERIC as exact decimal text, BYTES decoded, DATE, TIME, DATETIME, TIMESTAMP to the microsecond, INTERVAL in BigQuery's three parts, RANGE with unbounded ends, arrays and records. ACellkeeps production's wire text beside its value.Value.formatwrites a value as the console shows it, andbigquery.errorPositionreads theat [line:column]of a query error. - Errors by reason:
error.InvalidQuery(syntax, semantic and runtime errors),BytesBilledLimitExceeded,ResultTooLarge,QuotaExceeded; rate limits are retried even when BigQuery sends them as a 403. This needed one hook in core:core.rpc.Engine.classify_refusal. - One OAuth scope,
Client.Options.scope:.bigqueryby default. BigQuery has no read-only scope;.cloud_platform_read_onlyserves every read, dry run and query, and the client refuses a cancel under it before sending, as production does.
What production showed
Measured on 2026-10-21 in a test project:
- A query that reads a table bills at least 10 MiB; one of literals alone, or one the cache answers, bills nothing. A
maximum_bytes_billedbelow the bill refuses the run at once, billing nothing: "Query exceeded limit for bytes billed: 1000. 10485760 or higher required." - A syntax error and a runtime error (
1 / 0) are refused at once, a dry run and a run alike, with "at [line:column]" where there is one. - A query that outlives
timeout_msanswers its job and nothing else;getQueryResultsheld each call about as long as asked. - A repeated request id answers the first job, unfinished and without rows, even when that job had already ended: read its results.
- A cancel answers the job still running; it then ends done with the error
stopped. A cancel of a done job is no refusal. - DDL and DML (CREATE, INSERT) answer no row count. Schemas use the legacy type names (
INTEGER,FLOAT,BOOLEAN,RECORD), whichFieldTypereads alongside the new ones.
Testing
bigquery.testing.FakeBigQuery is BigQuery in memory, with no SQL engine: a test gives each query text its answer (schema and rows, the tables it reads, bytes, how many reads it takes to finish, the cache, or a refusal in BigQuery's words) and puts in datasets, tables and jobs. Dry runs, billing and the limit, request ids, polls, pages, cancels, job locations and refusals follow production; what it does not model it answers 501, naming it. A slow fuzz property holds it to a model, in a new nightly job.
The gated production test (BIGQUERY_TEST=1, about two minutes, inside the free tier) reads the public samples, makes a zigps_ dataset through the client's own DDL, runs, pages, polls, refuses and cancels queries, and replays the same script on a fake seeded from production's answers: all 39 outcomes agree, word for word.
The guides: BigQuery and Testing. examples/bigquery_query.zig --fake --yes runs the whole dry-run, confirm, run, wait and read loop offline.
The changelog has each module's entry.
zig fetch --save git+https://github.com/kmoneil/zig-gcp#v0.55.0v0.54.0
Cloud Run jobs and their executions, and three writes a console confirms before sending: run a job, cancel an execution, and send all of a service's traffic to one revision (#272). Each write has a twin that builds its exact request without sending it, so the request can be confirmed first. Experimental: the new reads and writes carry their own marker, apart from the module's services and revisions. No breaking change: the calls and types are new, Client.Options.scope defaults to run.readonly as before, and run.auth_scope is unchanged. The default user agents changed.
// Send all of a service's traffic back to a revision, confirmed first.
var service = try client.getService(location, "checkout");
defer service.deinit();
const options: run.RouteOptions = .{ .etag = service.value.etag };
var request = try client.routeAllTrafficRequest(location, "checkout", "checkout-00007-abc", options);
defer request.deinit();
if (try confirm(request.value.method, request.value.url, request.value.body)) {
var change = try client.routeAllTraffic(location, "checkout", "checkout-00007-abc", options);
defer change.deinit();
}| Call | What it does |
|---|---|
Client.listJobs(location, options), getJob, getJobNamed |
A region's jobs, newest first: each job's latest execution and its status, its terminal condition, its execution count and what each run runs |
Client.listExecutions(location, job, options), getExecution, getExecutionNamed |
A job's executions, newest first, or every job's with -; Execution.status() and cancellable() |
Client.runJob(location, id) |
Starts an execution of the job, no overrides |
Client.cancelExecution(location, job, id) |
Stops a pending or running execution |
Client.routeAllTraffic(location, service, revision, .{ .etag, .validate_only }) |
Sends all traffic to one revision, guarded by the etag the read gave |
Client.runJobRequest, cancelExecutionRequest, routeAllTrafficRequest |
The writes' exact requests, after the same checks with the same errors |
Client.getOperation(name) |
The operation a write answered, read again |
- The writes need
Client.Options.scope = .read_write(therunscope). Production refuses every write torun.readonly, so a read-only client refuses them before sending. A key file's credentials are fixed to the scope of their first token, so give a reading client and a writing client their own credentials. - The traffic change is a
PATCHwithupdateMask=traffic,etag. Production enforces the etag only when the update mask names it; underupdateMask=trafficit accepted a stale etag, a made-up one, and none at all. A service changed since its read is refused witherror.Aborted,validate_onlyincluded. Nothing but the traffic changes: no new revision, and the template and labels stay as they were. - A run is sent once once it may have reached the server, since each landing makes an execution. A cancel and a traffic change are retried the same way the reads are.
- The API has no status field for an execution, so
Execution.status()reads one from its conditions: pending, running, cancelling, succeeded, failed or cancelled.
What production showed
Measured on 2026-10-21 in a test project:
- A run's operation is done only when its execution ends. A 2 s job ended 100 s after its run: its container took about 90 s to start. Take the execution's name from the first answer and follow the execution. Two runs of one job may run at once.
- A cancel ended a running execution in about 16 s. A pending one ended at once and never got a completion time. An ended one refuses a cancel ("cannot be cancelled because it is not running").
- A cancel sent the moment the task starts ends the execution failed, with no reason given, and both its run's and its cancel's operations fail with code 2, "Unspecified error."
validate_onlydoes not check the revision. A change to a revision the service lacks, or to one that never became ready, is accepted. Its operation then fails, and the service keeps the asked traffic and its terminal condition goes failed, while the old revision goes on serving. Offer only revisions whoseready()is true.- A traffic change's operation was done 4 s later. Production refuses
-for a jobs list, though it takes it for services. A region that does not exist is a permission refusal for jobs. - Running a job takes
run.jobs.run. Cancelling takesrun.executions.cancel, which Cloud Run Invoker lacks. A traffic change takesrun.services.update.
Testing
run.testing.FakeRun holds jobs and executions (putJob, putExecution, and setExecutionStatus standing in for a job's container) and takes the three writes as production did, every refusal above in production's words. It answers 501 where production's answer was not measured, naming what it does not model. Services in the fake now carry an etag. A slow fuzz property holds the fake's writes to a model, in a new nightly job.
The gated production test (RUN_WRITES_TEST=1, about five minutes, inside Cloud Run's free tier) runs, cancels and routes traffic on real jobs and a real service through the client. It then replays the same script on a fake seeded from production's reads. Its last run held all 17 steps equal, and every preview matched what was sent byte for byte. Its second run is the one that found the cancel at a task's start.
The guides: Jobs, executions and writes and Testing. examples/run_actions.zig --fake shows the confirm-then-write flow offline.
The changelog has each module's entry.
zig fetch --save git+https://github.com/kmoneil/zig-gcp#v0.54.0v0.53.0
Cancel a Dataflow job and drain a streaming one (#274). Each write has a twin that builds its exact request without sending it, so the request can be confirmed before it is sent. Experimental, as the module is. No breaking change: the calls are new, dataflow.PreparedRequest is a new export, and dataflow.Error is unchanged. The default user agents changed.
// Drain a running streaming job, confirmed first, then watch it stop.
var job = try client.getJob(location, id);
defer job.deinit();
if (job.value.drainable()) {
var request = try client.drainJobRequest(location, id);
defer request.deinit();
if (try confirm(request.value.method, request.value.url, request.value.body)) {
try client.drainJob(location, id);
}
}| Call | What it does |
|---|---|
Client.cancelJob(location, id) |
Cancels a batch or streaming job |
Client.drainJob(location, id) |
Drains a streaming job: it stops reading and finishes what it holds |
Client.cancelJobRequest, Client.drainJobRequest |
The writes' exact requests, after the same checks with the same errors |
Job.cancellable(), Job.drainable() |
Whether production takes the write for the job as it stands |
- Both writes return nothing. When production takes a write, its whole answer is the job's type, and it said batch for a streaming job too. Read the job to watch it stop: a get shows
.cancellingor.drainingat once. - Each write is a
PUTof the job's requested state alone, as gcloud sends it. A repeat answers 200 and changes nothing, so both are retried the same way the reads are. - They take the
dataflow.jobs.cancelpermission, which Dataflow Viewer lacks.
What production showed
Measured on 2026-10-20 in a test project:
- A pending job refuses a cancel ("Job is not yet ready for canceling") until it runs, about 20 s after launch.
- An ended job refuses both writes, and the cancel's refusal names the state it ended in. A batch job refuses a drain in any state.
- A made-up region still reaches the job, while a real region the job is not in gets a 404. Take the region from the job as listed.
- A list's summary never carries the requested state, so read the job itself.
- A cancelled job took 240 s to end, and one cancelled as it finished ended
.done. Drains took 102 to 126 s, and a cancel during a drain wins. - A streaming job reads
.runningbefore its worker is up.
Testing
dataflow.testing.FakeDataflow takes both writes as production did, with every refusal above in production's words. It answers 501, naming what it does not model, where production's answer was not measured. setJobState to a terminal state now clears requested_state, and a list's summary no longer carries it, as production's did not. A slow fuzz property holds the fake's writes to a model, in a new nightly job.
The gated production test (DATAFLOW_WRITES_TEST=1, about 3 cents and ten minutes) cancels a real batch job and drains a real streaming one through the client. It passed twice: all 12 previews matched what was sent byte for byte, and a fake kept in step with production agreed on every write.
The guides: Cancel and drain and Testing. examples/dataflow_stop.zig --fake shows the confirm-then-write flow offline.
The changelog has each module's entry.
zig fetch --save git+https://github.com/kmoneil/zig-gcp#v0.53.0v0.52.1
A fix to the in-memory Cloud KMS. No API change, and the client was not affected.
Fixed
kms.testing.FakeKmssent a post-quantum public key's NIST_PQC bytes from a dead stack frame (#282). ItsrawOfreturned&kp.public_key.toBytes(), a slice of an array thattoBytesreturns by value, held inrawOf's own frame. The bytes agetPublicKey?publicKeyFormat=NIST_PQCrequest got were whatever later calls left there. It copies them into the request's memory now.- The client reads ML-DSA keys as PEM first, so it never took this path, and no test had either. One does now: it reads all three ML-DSA sizes in NIST_PQC and verifies a signature with exactly those bytes. Against the old code it fails in Debug, ReleaseSafe and ReleaseFast.
Found by zigvet's escape rule, which now runs in CI on every pull request.
The changelog has the entry.
zig fetch --save git+https://github.com/kmoneil/zig-gcp#v0.52.1v0.52.0
Cloud Monitoring's alert policies and snoozes: read the policies behind the incidents, enable or disable one, and silence policies for a while, each write with a twin that builds its exact request without sending it. The writes are experimental on their own. No breaking change: the calls and types are new, FakeMonitoring.Options gains project_number and caller (both with defaults), and the default user agents changed.
// Silence a policy for an hour, confirmed first.
const now = std.Io.Clock.real.now(io);
const spec: monitoring.NewSnooze = .{
.display_name = "deploy window",
.policies = &.{policy_name},
.start = now,
.end = .{ .nanoseconds = now.nanoseconds + std.time.ns_per_hour },
};
var request = try client.createSnoozeRequest(spec);
defer request.deinit();
if (try confirm(request.value.method, request.value.url, request.value.body)) {
var snooze = try client.createSnooze(spec);
defer snooze.deinit();
}Alert policies
| Call | What it does |
|---|---|
Client.listAlertPolicies(options), Client.getAlertPolicy(id) |
The policies, filtered and ordered as written; totalSize on every page |
Client.setAlertPolicyEnabled(id, enabled) |
Enables or disables one; setAlertPolicyEnabledRequest builds its exact request |
- Each
Conditionis decoded by kind (threshold, absence, log match, MQL, PromQL, SQL): the filter or query, comparison, threshold, duration, aggregations and trigger. Its raw JSON is kept beside for everything else, and for kinds Google adds later. - A patch without a mask replaces the whole policy, so
updateMask=enabledalways goes, with the field set. A repeat answers 200, so the patch retries like a read. severityis not a policy filter field (production refuses it). Withoutorder_by, policies come back sorted by name.
Snoozes
| Call | What it does |
|---|---|
Client.createSnooze(spec) |
Makes one; never sent twice once it may have reached Google, since snoozes can't be deleted |
Client.listSnoozes(options), Client.getSnooze(id) |
Newest start first; active_at asks for the ones holding at a time |
Client.endSnooze(snooze, at) |
Ends one early, in the form production takes for its state |
Client.createSnoozeRequest(spec), Client.endSnoozeRequest(snooze, at) |
The writes' exact requests |
- Production needs a different request to end a snooze depending on its state. Active for a minute or more: the end at now. Active for less: the end at its start (a cancel). Upcoming: both times now.
endSnoozetakes the snooze as read and sends the right one. Production refuses any change to an ended snooze, a repeat included, so a lost answer is read back.
What production showed
Measured on 2026-10-20 in a test project:
- A snooze closed a real open alert one second after its start and kept new ones from opening. After it ended, a new alert opened about 77 s later, the condition still true.
- Snooze lists lag behind a create: a get shows a new snooze at once, but a list took 91 s once and 79 s in the production test. Keep the create's answer rather than re-listing.
- Production doesn't check that a snooze's policies exist, and has no whole-minutes rule. The filter takes only
>=and<=on the interval's times. monitoring.readserves every read and is refused every write, which needsmonitoringorcloud-platform;monitoring.writeis refused all of these calls. On the IAM side:alertPolicyViewer/alertPolicyEditorandsnoozeViewer/snoozeEditor.- SQL conditions need a Log Analytics view; production refused one without.
Testing
FakeMonitoring.putAlertPolicy puts policies in, read, filtered, ordered and patched as production answered, refusals in its words. Snoozes go through the API, checked in production's order and words, and an active snooze closes the open alerts of its policies. A slow fuzz property holds snoozes and alerts to a model. The gated production test (MONITORING_TEST_POLICIES=1) agreed with the fake on every step, and all 10 previews matched what was sent, byte for byte.
The guides: Alert policies, Snoozes and Testing. examples/monitoring_policies.zig --fake --snooze ID --yes shows the confirm-then-write flow offline.
The changelog has each module's entry.
zig fetch --save git+https://github.com/kmoneil/zig-gcp#v0.52.0v0.51.0
Cloud Scheduler can now run a job now, pause it and resume it. Each write has a twin that builds its exact request without sending it, for a console that shows the user what it is about to send. The writes are experimental on their own. No breaking change: the calls and core.PreparedRequest are new, core.rpc.unsentOnly moved from kms (which still exports it), and the default user agents changed. One small difference in the fake: FakeScheduler now writes a successful attempt as production's status: {} where it wrote {"code":0}; both decode to code 0.
// Built, not sent: what the confirmation shows.
var request = try client.runJobRequest("us-central1", "nightly-export");
defer request.deinit();
if (try confirm(request.value.method, request.value.url, request.value.body)) {
var job = try client.runJob("us-central1", "nightly-export");
defer job.deinit();
}Run now, pause and resume
| Call | What it does |
|---|---|
Client.runJob(location, id) |
Dispatches the job now and answers it as it stood; the attempt shows seconds later. Never sent twice once it may have reached Google |
Client.pauseJob(location, id), Client.resumeJob(location, id) |
Stops the job's schedule, or starts it again; each answers the job as it then stands |
Client.runJobRequest, pauseJobRequest, resumeJobRequest |
The exact request each write sends (method, URL, body), built without sending it, after the same checks with the same errors |
core.PreparedRequestis the shared type for a request built without sending it. The write modules after this one will return it too.- A run is never re-sent once it may have left, because every run that lands hits the target again: only a refused connection or a failed name lookup is retried. Google's own client libraries don't retry a run at all.
- A pause and a resume retry like the reads. Production answers a repeat with 200 and changes nothing, though Google's reference says a repeat is refused.
- Running a paused job is
error.FailedPrecondition, "Job.state must be ENABLED for RunJob." - Scope is
cloud-platform(none narrower exists). IAM askscloudscheduler.jobs.run,.pauseand.enable(resume). The Job Runner role can run a job but can't read it back.
What production showed
Measured on 2026-10-20 over 18 runs and six kinds of target:
- A run answers in under a second with the job unchanged. The attempt starts about 1.3 s later (that is
last_attempt_time) and its result shows 3.4 to 14.9 s after the run, 6 s typical. Read again at about 5 s, then every few seconds. - Attempts carry a code and never a message: a success is
status: {}(code 0), an HTTP 404 code 5, a host that doesn't resolve or a refused port code 2, a Pub/Sub topic nobody has code 5. - A second run sent while the first is still being attempted showed no second attempt.
- A pause clears the job's next run and its last attempt; a resume brings the next run back at the cron tick.
- One race reads wrong: a run sent just before a pause can still start after it. The paused job then shows the attempt's start with no result, and a resume afterwards answers
status: {}, which reads as a success though none was recorded.
Testing
scheduler.testing.FakeScheduler takes the three writes as measured. setRunOutcome gives each run its code and the delay before its attempt shows on the fake's clock, setNextRun sets the next run a resume restores (there's no cron engine), pendingAttempts counts attempts still to show, and the race above is reproduced. Running, pausing or resuming a disabled or update_failed job answers 501, since nobody can put a job in those states to measure the real answer.
The gated production suite runs, pauses and resumes real zigps- jobs through the client, holds every preview byte for byte to what was sent, and replays each step on a fake seeded from production: its first run agreed on all of them. A new nightly fuzz job, slow-scheduler, holds the fake's writes to a model.
The guides: Scheduler and Testing. examples/scheduler_run.zig --fake --yes shows the confirm-then-poll flow offline.
The changelog has each module's entry.
zig fetch --save git+https://github.com/kmoneil/zig-gcp#v0.51.0