All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

0.3.0 — 2026-08-31

Three defects in 0.2.0 made this library unable to complete a single launch/3 against real GCP, and a fourth made it uninstallable for most applications. All four are fixed here, alongside a security pass, the first read/observe surface (logs, tags, preemption), and boot disks from snapshots or existing disks.

Upgrading from 0.2.0 requires code changes — see Breaking immediately below. Under SemVer 0.x a minor bump is the vehicle for that; there is no compatible path that keeps the old error shape.

Breaking

  • One error type, everywhere. Every public function now returns {:ok, result} or {:error, %GcpCompute.Error{}}. GcpCompute.Config.new/1, production/1, local/1, from_env/2 and GcpCompute.Instance.spec/1 returned {:error, binary} in 0.2.0; they now return {:error, %GcpCompute.Error{reason: :invalid_config | :invalid_spec}}, so a single with can thread config construction and API calls through one error clause. Exception.message/1 remains the human-readable accessor, and the bang variants (new!/1, local!/1, spec!/1) still raise ArgumentError carrying that message. If you matched on the string, match on the struct.

  • {:req, "~> 0.5"}{:req, "~> 0.7"}. Consumers pinned below req 0.7 are now excluded. Deliberate: req 0.7 is what exposes the :decoders hook used for native JSON decoding (0.6 had only the now-deprecated :decode_json), and the declared range should not be wider than what CI actually tests. Bumping req also pulled mint 1.9.0 → 1.9.3 and hpax 1.0.3 → 1.0.4, clearing 5 security advisories (3 HIGH, 2 MEDIUM) in the resolved tree; mix hex.audit is clean.

  • A non-https:// base_url is now rejected unless you pass allow_insecure: true. If you pointed 0.2.0 at an http:// emulator or proxy, add that flag (local/1 sets it for you). Sending a live bearer token in cleartext is the thing being prevented.

  • Pagination options are snake_case, and unknown keys are rejected. list_page/2 silently dropped keys it did not recognise in 0.2.0, so list_page(config, pageToken: t) returned an unpaginated first page forever. Use :page_token, :max_results, :order_by, :filter; anything else returns %GcpCompute.Error{reason: :invalid_argument}.

  • Instances.insert_and_wait/3 returns an error instead of raising for a name-less spec: %GcpCompute.Error{reason: :missing_instance_name}, previously ArgumentError.

  • The GcpCompute.Util helper module is private (@moduledoc false, dropped from the HexDocs module groups). It was never intended as public contract; if you called it, that call is now unsupported.

  • elixir: "~> 1.19" relaxed to "~> 1.15". Widening, so not breaking, but listed here because it is the reason 0.2.0 could not be installed alongside most production apps — a hard mix deps.get conflict. A CI matrix (1.15 / 1.18 / 1.19) now tests the claim rather than asserting it.

Added

  • Boot disks from a snapshot or an existing disk. Instance.spec/1 grew :source_snapshot (creates a new disk from a snapshot — the snapshot is never mutated, so any number of instances can boot the same one), :source_disk (attaches an existing disk as-is), :disk_type (pd-ssd, or a full zones/.../diskTypes/... URL) and :boot_disk_auto_delete.

    Bare names are qualified (nightlyglobal/snapshots/nightly); a full path crosses projects untouched. A boot disk has exactly one source, and passing two is rejected locally as :invalid_spec naming both options — GCP's own answer is a 400 about a JSON field the caller never typed (Cannot specify both 'source' and 'initializeParams', confirmed live).

    autoDelete defaults differ on purpose: true for a disk created from an image or snapshot, false for one you attached, so deleting an instance never destroys a disk it did not create. Verified live.

    :source_image keeps its behaviour — omitting every source still yields debian-12 — but its default is now resolved at build time rather than in the schema, so "no source given" is distinguishable from "image given".

  • Spot preemption is now discoverable. A preempted Spot VM using the default instanceTerminationAction: "DELETE" is deleted, so get/3 answers 404 and the instance can no longer say why it vanished:

    • Instances.preemption/3 (GcpCompute.instance_preemption/3) — the compute.instances.preempted operation, or nil. Operations outlive instances, so this answers for a name that no longer resolves; nil is also the answer for one that never existed.
    • Instances.preempted?/3 (GcpCompute.instance_preempted?/3) — the boolean.
    • Instances.operations/3 (GcpCompute.instance_operations/3) — every operation recorded against an instance, newest first. The audit trail for "what happened to my VM?"
    • Instances.simulate_maintenance_event/3 — on a Spot VM this triggers a real preemption, which is GCP's documented way to test that your code survives one. Measured live: RUNNINGSTOPPING at ~60 s → gone (404) at ~73 s.
    • Instance.spot?/1 — reads scheduling.provisioningModel, so it reflects what GCP provisioned rather than what was requested.

    No retry-on-preemption loop: that is orchestration, and belongs in the layer described under Roadmap rather than in a REST client.

  • GcpCompute.Operation gained :status_message, :insert_time, :start_time and :end_time (parsed DateTimes, nil on anything malformed). :status_message is what carries "Instance was preempted.", and the timestamps are how you tell when — previously only reachable through :raw.

  • examples/smoke_advanced.exs — 30 live checks covering disks, network/firewall reachability over real TCP, and Spot preemption. Firewall expectations are derived from the project's live rule set rather than hardcoded, and the port assertions refuse to run until the VM's serial console confirms the test listener bound (read with instance_logs/3) — because :timeout cannot distinguish "the firewall dropped it" from "nothing was ever listening", and a dead listener did pass as a firewall result until that gate existed. The one check needing compute.firewalls.create skips loudly instead of passing quietly.

  • GcpCompute.instance_logs/3 (Instances.serial_port_output/3) — read a VM's serial console output, which is where a :startup_script's output lands and the only log surface the Compute API itself offers. Returns %{contents: binary, start: integer, next: integer}; pass a previous :next back as :start to read only new bytes, since the buffer is ~145 KB on a booted Debian image. :port is validated locally against 1..4 (the API answers 400 otherwise). Cloud Logging is a separate API and is deliberately not wrapped.

    Note the API returns start/next as strings ("145467"); they are coerced to integers here so arithmetic on an offset cannot silently break.

  • instance.tags — network tags are now readable. Instance.spec/1 already accepted :tags, but the struct had no field for them, so a tag could be set and never read back except out of :raw. Surfaced as a plain list, mirroring how :labels is surfaced as a plain map; the tags.fingerprint stays in :raw for anyone calling instances.setTags directly.

  • :ssh_keys option%{username => public_key}, rendered into the ssh-keys metadata entry GCE expects (username:key, newline-separated), because that ordering and separator are easy to fumble. An explicit metadata: %{"ssh-keys" => …} still wins. This library authorises keys and does not open SSH connections: an in-library client would mean owning host-key verification, key parsing and known_hosts, and GCE SSH also involves OS Login, a separate API. Prefer :startup_script + instance_logs/3 for "run a command and read the output" — no inbound access required.

Security

  • Cleartext bearer tokens rejected. GcpCompute.Config.new/1 now requires an https:// base_url unless you explicitly pass allow_insecure: true (for a local emulator/proxy on a trusted network). Gating on that flag rather than the token provider means even a Static provider holding a real out-of-band token stays TLS-only by default. local/1 sets allow_insecure: true for you. The scheme check is case-insensitive. Applies to from_env/2 and any direct new/1 use, not just production/1.
  • req_options can no longer override auth or URL. The request :method, :url, and :auth are always computed by the library and layered on top of the app-supplied req_options, which is treated as trusted transport config.
  • Fail closed on a missing token. A nil/empty token now returns {:error, %GcpCompute.Error{reason: :missing_token}} before any request goes out, instead of sending an unauthenticated call.
  • No secrets in error messages. Token-fetch failures use a fixed "token fetch failed" message; the raw reason is kept only in :body, which GcpCompute.Error's Inspect implementation redacts.
  • Error messages describe shape, never value. TokenProvider.Static names the type of a rejected arg ("a map without a :token key", "a function of the wrong arity") instead of inspecting it — a mis-shaped arg is usually a credential (%{"access_token" => …}, {:bearer, …}), and :message reaches logs and crash reports. Raw values live in the redacted :body.
  • A non-binary or unwrapped token can no longer reach a stack frame. ensure_token/1 guards on is_binary/1, so a non-binary token returns :missing_token instead of raising FunctionClauseError with {:bearer, token} in the frame; a provider that returns something other than {:ok, map} / {:error, reason} returns %GcpCompute.Error{reason: :invalid_token_provider_return} instead of raising CaseClauseError, whose message would inspect/1 the token into crash reports and :exception telemetry metadata.
  • Resource names are validated, not just encoded. . is RFC 3986 unreserved, so percent-encoding leaves "", "." and ".." intact. Every public entry point now rejects them with {:error, %GcpCompute.Error{reason: :invalid_name}} before any request — covering the instance name, the project, the zone (config and per-call), and an operation's name/zone/region.
  • allow_insecure: true warns on a routable host. Combining it with a non-https:// base_url whose host is not loopback / link-local / RFC1918 / unique-local IPv6 / an internal-only name logs a Logger.warning: that combination sends a live bearer token in cleartext.
  • base_url userinfo is never printed. https://user:pass@proxy/… is a valid URL; the credential is stripped from Inspect output and from the TLS failure message.
  • Token-provider modules are validated at config time. Code.ensure_loaded?/1
    • function_exported?(mod, :fetch_token, 1), so a typo'd module fails at the config site instead of raising UndefinedFunctionError on the first request.
  • :persistent_term config caching is documented as secret-at-rest. GcpCompute.config/2's doc spells out that the cached %Config{} is process-global, unencrypted, enumerable, lands in erl_crash.dump, and is not protected by the Inspect redaction. Use Goth in production so no long-lived credential is stored there.

Fixed

  • spot: false combined with max_run_duration no longer fails with 400. The API rejects scheduling.maxRunDuration unless instanceTerminationAction is also set — "max-run-duration for given provisioning model is not supported without an instance termination action" — and Instance.spec/1 only set that field on the Spot branch. So launch(config, "w", spot: false, max_run_duration: 3600) was a guaranteed failure. Setting :max_run_duration now implies a termination action: "DELETE" for Spot (unchanged) and "STOP" for a standard instance, whose owner explicitly opted out of self-deletion.

    Found by examples/smoke_coverage.exs. Also documented there: :request_id must be an RFC 4122 UUID, which GCP enforces with 400 idempotentRequestError.

  • Bodiless POSTs now send Content-Length, so launch/3 works at all. The Compute API rejects a POST that carries no Content-Length with HTTP 411 Length Required, and Req sends none for a bodiless request. Every bodiless POST this library makes was affected — operations.wait, instances.start, instances.stop — which meant launch/3, terminate/3, every *_and_wait helper and wait_for_operation/3 failed against real GCP on the first call, orphaning the instance that insert had just created. Fixed by sending an explicit empty body, which makes Req emit content-length: 0.

    Found only by running examples/smoke_test.exs against a live project. No stub can reproduce it: an in-process adapter computes no headers, so all 248 tests passed while the library could not complete a single launch/3. The regression test therefore pins the cause (a bodiless POST must carry body: "", not nil) rather than the symptom.

  • A bad poll option no longer costs a VM. insert_and_wait/3, delete_and_wait/3 and launch/3 validate :timeout / :poll_interval before the POST or DELETE. Previously GcpCompute.launch(config, "w1", timeout: :infinity) created a billed Spot VM and then returned {:error, %GcpCompute.Error{reason: :invalid_argument}} — which a caller reads as "nothing happened" — orphaning the instance.

  • allow_insecure host classification uses real IP parsing. It previously string-matched prefixes, which was wrong in both directions: 10.evil.example.com (a public DNS name) was treated as RFC1918, and every public IPv6 literal (2606:4700:4700::1111) was treated as private because it has no dots — both silencing the cleartext-token warning entirely. Hosts are now parsed with :inet.parse_address/1; loopback, RFC1918, link-local, unique-local IPv6, IPv4-mapped and CGNAT ranges are recognised, and an unclassifiable host warns rather than passing silently.

  • A malformed "items" no longer raises through list/2. A 200 response with "items" as an object, a string, or a list containing non-objects raised FunctionClauseError / Protocol.UndefinedError out of list_instances/2 and list_instances_page/2; it now degrades to [] like the other nested parsers.

  • Error.from_response/2 is shape-checked. A server sending a non-binary "message" or a non-list "errors" no longer produces a struct that violates GcpCompute.Error.t().

  • Poll options are rejected where nothing consumes them. get/3, list/2, list_page/2 and insert/3 return %GcpCompute.Error{reason: :invalid_argument} for :timeout / :poll_interval instead of silently ignoring them.

  • A non-spec instance body is an error, not a raise. insert_instance(config, 5) returns %GcpCompute.Error{reason: :invalid_spec} naming the type, matching what get_instance/3 already did for a bad name.

  • Response JSON is decoded with Elixir's native JSON on 1.18+. Req takes the decoder from :decoders, so the library now supplies [json: &JSON.decode/1] when JSON is available and falls back to Jason below Elixir 1.18 — resolved at compile time, so there is no per-request dispatch and the ~> 1.15 floor is preserved. Measured on a 440 KB / 500-instance list_page/2 response, end to end through Req: 5.61 ms → 3.58 ms, 36% faster, byte-identical output. Response decoding was the single largest CPU cost in the library, larger than all of its own parsing. req_options: [decoders: ...] still overrides it.

    Note that Jason remains in the dependency tree and still handles request encoding: Req hardcodes Jason.encode_to_iodata!/1 in encode_body/1 with no injection point, and both req and goth require :jason non-optionally.

  • operations.wait no longer dies at Req's 15 s socket timeout. The endpoint blocks server-side for ~2 minutes, so every insert_and_wait / launch on a real project (20–60 s provisioning) failed mid-poll and orphaned the VM it had just created. The library now owns a 30 s default :receive_timeout and raises it to 150 s for the long poll. Precedence: library default → config.req_options → per-call.

  • A Goth process-liveness failure is an error, not an exit. A missing or unstarted Goth server used to exit :noproc and tear down the calling process — a Task, a GenServer, a Phoenix request — from inside a function whose contract is {:ok, _} | {:error, _}. Now %GcpCompute.Error{reason: :goth_not_running} / :goth_fetch_timeout.

  • A non-map 2xx body is an error, not a crash. A proxy, emulator, or captive portal returning "", an HTML page, or a JSON array used to produce FunctionClauseError or "Access does not support binaries" two modules downstream; it now returns %GcpCompute.Error{reason: :invalid_response} with the body retained.

  • Nested parsers degrade instead of raising. "disks", "networkInterfaces" or "accessConfigs" arriving as objects, a scalar "maxRunDuration", a binary error, or an "errors" map on a failed operation no longer raise — the complete original body is always available in :raw.

  • Operations.poll_until_done/3 cannot spin. A status this library cannot classify as done made Operation.done?/1 permanently false, turning the loop into thousands of authenticated requests plus a token fetch each. There is now a :poll_interval floor (default 1 s) clamped to the remaining budget, and :timeout is validated — :infinity is rejected rather than silently accepted into System.monotonic_time/1 + :infinity's ArithmeticError.

  • A nameless operation fails closed. A 200 /wait body of {} returns %GcpCompute.Error{reason: :invalid_response} instead of raising from inside the poll loop.

  • Pagination options actually paginate. list_page/2 silently dropped any key it did not recognise, so list_page(config, page_token: t) returned an unpaginated first page forever. Options are snake_case (:page_token, :max_results, :order_by, :filter) and translated to camelCase; unknown keys are rejected with %GcpCompute.Error{reason: :invalid_argument}.

  • elixir: "~> 1.19" relaxed to "~> 1.15". Nothing in lib/ needs anything newer, and the old requirement made the package uninstallable — a hard mix deps.get conflict — for most production apps. A CI matrix (1.15 / 1.18 / 1.19) now tests the claim rather than asserting it.

  • package[:files] includes guides/. mix docs inside the published tarball previously failed with File.Error, since docs() lists all three guides as extras. Both workflows now build the tarball, unpack it, and run mix docs from the unpacked copy.

  • CI's strict compile no longer skipped on a cache hit. The cache key hashes mix.lock, not source, so --warnings-as-errors was skipped on almost every PR.

Unchanged defaults worth re-reading

Not new in 0.3.0, and deliberately kept — but the one thing most likely to surprise a new caller, so it is restated every release.

  • spot: true remains the default. A bare GcpCompute.launch(config, "worker-1") provisions a preemptible Spot VM with automaticRestart: false and instanceTerminationAction: "DELETE": when GCP preempts it, the instance deletes itself along with everything on its disk. This is deliberate — the library is built for disposable batch workers — and was reviewed and kept rather than flipped, because every example, guide and the package description are about Spot workers, and flipping it would silently make existing callers' VMs more expensive. It is now documented in the README's first section, on launch/3, and in the :spot option doc. Pass spot: false for anything whose disk you care about. external_ip: true is likewise kept and documented.

Changed

  • The test stub moved to Req 0.7's adapter: mod form. adapter: fun is deprecated in 0.7 and slated for removal in 0.8, and it emitted a warning per stubbed request.

  • The twelve GcpCompute.*_instance* wrappers and wait_for_operation/3 are defdelegates now, and every public function on the facade carries a @spec (defdelegate inherits neither), so signature drift is a compile error and Dialyzer actually checks the facade.

  • GcpCompute.Telemetry.detach_default_logger/0 added alongside attach_default_logger/1.

  • A trailing / on :base_url is trimmed, so base_url <> path cannot yield //projects/....

  • :receive_timeout behaviour is unchanged by the req bump, so the long-poll fix above is unaffected.

    Dependency, error-shape, pagination and visibility changes that require caller action are under Breaking above rather than here.

Notes

  • The Compute Engine API is REST/JSON only (no gRPC); transport is Req.
  • A higher-level sandbox orchestration layer (gen_statem, DynamicSupervisor, reaper, profiles) is planned on top of this client.

0.2.0 — 2026-07-20

First release published to Hex. The Compute REST client layer:

Do not use 0.2.0

Two defects found after publishing make it unusable in practice, both fixed in 0.3.0:

  • It declared elixir: "~> 1.19", a hard mix deps.get conflict for most applications.
  • Bodiless POSTs carried no Content-Length, which the Compute API rejects with HTTP 411. That breaks operations.wait, instances.start and instances.stop — so launch/3, terminate/3, every *_and_wait helper and wait_for_operation/3 failed against real GCP on the first call, orphaning the instance insert had just created.

0.1.0 was never published.