Skip to content

Releases: kmoneil/zig-gcp

v0.59.0

Choose a tag to compare

@kmoneil kmoneil released this 24 Oct 19:19
Immutable release. Only release title and notes can be modified.

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 NotMatch that hits is a 304, error.NotModified here. A Match beside a NotMatch on 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_match is 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.
  • listBuckets gives an IP filter without its ranges, and a caller without storage.buckets.getIpFilter reads the bucket with no filter at all, so ip_filter null 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.0

v0.58.0

Choose a tag to compare

@kmoneil kmoneil released this 23 Oct 15:43
Immutable release. Only release title and notes can be modified.

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 as false and null. 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.cpuMillis and .memoryBytes read every form.
  • A service whose default URL is disabled answers no uri and no urls.
  • 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.
  • invokerIamDisabled is 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.0

v0.57.0

Choose a tag to compare

@kmoneil kmoneil released this 23 Oct 13:55
Immutable release. Only release title and notes can be modified.

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-platform or devstorage.full_control. devstorage.read_only, devstorage.read_write and cloud-platform.read-only were 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.0

v0.56.0

Choose a tag to compare

@kmoneil kmoneil released this 21 Oct 20:26
Immutable release. Only release title and notes can be modified.

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 TransferConfig carries one Schedule for 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 typed ScheduledQuery view: 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: .bigquery by default, as bigquery.Client's, so one token serves both modules. A .cloud_platform_read_only client 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 walks listLocations: 54 locations on one page, which refuses a page size.
  • A run now answered at once, PENDING; the run read RUNNING within 3 s, and a scheduled query of literals ended SUCCEEDED 75 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.0

v0.55.0

Choose a tag to compare

@kmoneil kmoneil released this 21 Oct 18:39
Immutable release. Only release title and notes can be modified.

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 useLegacySql false, 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, so query is retried as the reads are.
  • Every type decodes exactly: bigquery.Value holds INT64 as i64, 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. A Cell keeps production's wire text beside its value. Value.format writes a value as the console shows it, and bigquery.errorPosition reads the at [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: .bigquery by default. BigQuery has no read-only scope; .cloud_platform_read_only serves 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_billed below 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_ms answers its job and nothing else; getQueryResults held 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), which FieldType reads 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.0

v0.54.0

Choose a tag to compare

@kmoneil kmoneil released this 21 Oct 14:51
Immutable release. Only release title and notes can be modified.

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 (the run scope). Production refuses every write to run.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 PATCH with updateMask=traffic,etag. Production enforces the etag only when the update mask names it; under updateMask=traffic it accepted a stale etag, a made-up one, and none at all. A service changed since its read is refused with error.Aborted, validate_only included. 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_only does 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 whose ready() 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 takes run.executions.cancel, which Cloud Run Invoker lacks. A traffic change takes run.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.0

v0.53.0

Choose a tag to compare

@kmoneil kmoneil released this 21 Oct 01:30
Immutable release. Only release title and notes can be modified.

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 .cancelling or .draining at once.
  • Each write is a PUT of 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.cancel permission, 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 .running before 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.0

v0.52.1

Choose a tag to compare

@kmoneil kmoneil released this 20 Oct 21:52
Immutable release. Only release title and notes can be modified.

A fix to the in-memory Cloud KMS. No API change, and the client was not affected.

Fixed

  • kms.testing.FakeKms sent a post-quantum public key's NIST_PQC bytes from a dead stack frame (#282). Its rawOf returned &kp.public_key.toBytes(), a slice of an array that toBytes returns by value, held in rawOf's own frame. The bytes a getPublicKey?publicKeyFormat=NIST_PQC request 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.1

v0.52.0

Choose a tag to compare

@kmoneil kmoneil released this 20 Oct 21:16
Immutable release. Only release title and notes can be modified.

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 Condition is 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=enabled always goes, with the field set. A repeat answers 200, so the patch retries like a read.
  • severity is not a policy filter field (production refuses it). Without order_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. endSnooze takes 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.read serves every read and is refused every write, which needs monitoring or cloud-platform; monitoring.write is refused all of these calls. On the IAM side: alertPolicyViewer/alertPolicyEditor and snoozeViewer/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.0

v0.51.0

Choose a tag to compare

@kmoneil kmoneil released this 20 Oct 18:43
Immutable release. Only release title and notes can be modified.

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.PreparedRequest is 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 asks cloudscheduler.jobs.run, .pause and .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