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/2andGcpCompute.Instance.spec/1returned{:error, binary}in 0.2.0; they now return{:error, %GcpCompute.Error{reason: :invalid_config | :invalid_spec}}, so a singlewithcan thread config construction and API calls through one error clause.Exception.message/1remains the human-readable accessor, and the bang variants (new!/1,local!/1,spec!/1) still raiseArgumentErrorcarrying 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:decodershook 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.auditis clean.A non-
https://base_urlis now rejected unless you passallow_insecure: true. If you pointed 0.2.0 at anhttp://emulator or proxy, add that flag (local/1sets 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/2silently dropped keys it did not recognise in 0.2.0, solist_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/3returns an error instead of raising for a name-less spec:%GcpCompute.Error{reason: :missing_instance_name}, previouslyArgumentError.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 hardmix deps.getconflict. 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/1grew: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 fullzones/.../diskTypes/...URL) and:boot_disk_auto_delete.Bare names are qualified (
nightly→global/snapshots/nightly); a full path crosses projects untouched. A boot disk has exactly one source, and passing two is rejected locally as:invalid_specnaming 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).autoDeletedefaults differ on purpose:truefor a disk created from an image or snapshot,falsefor one you attached, so deleting an instance never destroys a disk it did not create. Verified live.:source_imagekeeps its behaviour — omitting every source still yieldsdebian-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, soget/3answers 404 and the instance can no longer say why it vanished:Instances.preemption/3(GcpCompute.instance_preemption/3) — thecompute.instances.preemptedoperation, ornil. Operations outlive instances, so this answers for a name that no longer resolves;nilis 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:RUNNING→STOPPINGat ~60 s → gone (404) at ~73 s.Instance.spot?/1— readsscheduling.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.Operationgained:status_message,:insert_time,:start_timeand:end_time(parsedDateTimes,nilon anything malformed).:status_messageis 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 withinstance_logs/3) — because:timeoutcannot 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 needingcompute.firewalls.createskips 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:nextback as:startto read only new bytes, since the buffer is ~145 KB on a booted Debian image.:portis 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/nextas 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/1already 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:labelsis surfaced as a plain map; thetags.fingerprintstays in:rawfor anyone callinginstances.setTagsdirectly.:ssh_keysoption —%{username => public_key}, rendered into thessh-keysmetadata entry GCE expects (username:key, newline-separated), because that ordering and separator are easy to fumble. An explicitmetadata: %{"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 andknown_hosts, and GCE SSH also involves OS Login, a separate API. Prefer:startup_script+instance_logs/3for "run a command and read the output" — no inbound access required.
Security
- Cleartext bearer tokens rejected.
GcpCompute.Config.new/1now requires anhttps://base_urlunless you explicitly passallow_insecure: true(for a local emulator/proxy on a trusted network). Gating on that flag rather than the token provider means even aStaticprovider holding a real out-of-band token stays TLS-only by default.local/1setsallow_insecure: truefor you. The scheme check is case-insensitive. Applies tofrom_env/2and any directnew/1use, not justproduction/1. req_optionscan no longer override auth or URL. The request:method,:url, and:authare always computed by the library and layered on top of the app-suppliedreq_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, whichGcpCompute.Error'sInspectimplementation redacts. - Error messages describe shape, never value.
TokenProvider.Staticnames the type of a rejectedarg("a map without a :token key", "a function of the wrong arity") instead ofinspecting it — a mis-shaped arg is usually a credential (%{"access_token" => …},{:bearer, …}), and:messagereaches 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/1guards onis_binary/1, so a non-binary token returns:missing_tokeninstead of raisingFunctionClauseErrorwith{: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 raisingCaseClauseError, whose message wouldinspect/1the token into crash reports and:exceptiontelemetry 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: truewarns on a routable host. Combining it with a non-https://base_urlwhose host is not loopback / link-local / RFC1918 / unique-local IPv6 / an internal-only name logs aLogger.warning: that combination sends a live bearer token in cleartext.base_urluserinfo is never printed.https://user:pass@proxy/…is a valid URL; the credential is stripped fromInspectoutput and from the TLS failure message.- Token-provider modules are validated at config time.
Code.ensure_loaded?/1function_exported?(mod, :fetch_token, 1), so a typo'd module fails at the config site instead of raisingUndefinedFunctionErroron the first request.
:persistent_termconfig caching is documented as secret-at-rest.GcpCompute.config/2's doc spells out that the cached%Config{}is process-global, unencrypted, enumerable, lands inerl_crash.dump, and is not protected by theInspectredaction. Use Goth in production so no long-lived credential is stored there.
Fixed
spot: falsecombined withmax_run_durationno longer fails with 400. The API rejectsscheduling.maxRunDurationunlessinstanceTerminationActionis also set — "max-run-duration for given provisioning model is not supported without an instance termination action" — andInstance.spec/1only set that field on the Spot branch. Solaunch(config, "w", spot: false, max_run_duration: 3600)was a guaranteed failure. Setting:max_run_durationnow 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_idmust be an RFC 4122 UUID, which GCP enforces with 400idempotentRequestError.Bodiless POSTs now send
Content-Length, solaunch/3works at all. The Compute API rejects a POST that carries noContent-Lengthwith HTTP 411 Length Required, andReqsends none for a bodiless request. Every bodiless POST this library makes was affected —operations.wait,instances.start,instances.stop— which meantlaunch/3,terminate/3, every*_and_waithelper andwait_for_operation/3failed against real GCP on the first call, orphaning the instance thatinserthad just created. Fixed by sending an explicit empty body, which makes Req emitcontent-length: 0.Found only by running
examples/smoke_test.exsagainst 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 singlelaunch/3. The regression test therefore pins the cause (a bodiless POST must carrybody: "", notnil) rather than the symptom.A bad poll option no longer costs a VM.
insert_and_wait/3,delete_and_wait/3andlaunch/3validate:timeout/:poll_intervalbefore the POST or DELETE. PreviouslyGcpCompute.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_insecurehost 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 throughlist/2. A 200 response with"items"as an object, a string, or a list containing non-objects raisedFunctionClauseError/Protocol.UndefinedErrorout oflist_instances/2andlist_instances_page/2; it now degrades to[]like the other nested parsers.Error.from_response/2is shape-checked. A server sending a non-binary"message"or a non-list"errors"no longer produces a struct that violatesGcpCompute.Error.t().Poll options are rejected where nothing consumes them.
get/3,list/2,list_page/2andinsert/3return%GcpCompute.Error{reason: :invalid_argument}for:timeout/:poll_intervalinstead 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 whatget_instance/3already did for a bad name.Response JSON is decoded with Elixir's native
JSONon 1.18+. Req takes the decoder from:decoders, so the library now supplies[json: &JSON.decode/1]whenJSONis available and falls back toJasonbelow Elixir 1.18 — resolved at compile time, so there is no per-request dispatch and the~> 1.15floor is preserved. Measured on a 440 KB / 500-instancelist_page/2response, 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!/1inencode_body/1with no injection point, and both req and goth require:jasonnon-optionally.operations.waitno longer dies at Req's 15 s socket timeout. The endpoint blocks server-side for ~2 minutes, so everyinsert_and_wait/launchon 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_timeoutand 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
:noprocand tear down the calling process — aTask, aGenServer, 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 produceFunctionClauseErroror "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 binaryerror, or an"errors"map on a failed operation no longer raise — the complete original body is always available in:raw.Operations.poll_until_done/3cannot spin. A status this library cannot classify as done madeOperation.done?/1permanently false, turning the loop into thousands of authenticated requests plus a token fetch each. There is now a:poll_intervalfloor (default 1 s) clamped to the remaining budget, and:timeoutis validated —:infinityis rejected rather than silently accepted intoSystem.monotonic_time/1 + :infinity'sArithmeticError.A nameless operation fails closed. A 200
/waitbody of{}returns%GcpCompute.Error{reason: :invalid_response}instead of raising from inside the poll loop.Pagination options actually paginate.
list_page/2silently dropped any key it did not recognise, solist_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 inlib/needs anything newer, and the old requirement made the package uninstallable — a hardmix deps.getconflict — for most production apps. A CI matrix (1.15 / 1.18 / 1.19) now tests the claim rather than asserting it.package[:files]includesguides/.mix docsinside the published tarball previously failed withFile.Error, sincedocs()lists all three guides as extras. Both workflows now build the tarball, unpack it, and runmix docsfrom 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-errorswas 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: trueremains the default. A bareGcpCompute.launch(config, "worker-1")provisions a preemptible Spot VM withautomaticRestart: falseandinstanceTerminationAction: "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, onlaunch/3, and in the:spotoption doc. Passspot: falsefor anything whose disk you care about.external_ip: trueis likewise kept and documented.
Changed
The test stub moved to Req 0.7's
adapter: modform.adapter: funis deprecated in 0.7 and slated for removal in 0.8, and it emitted a warning per stubbed request.The twelve
GcpCompute.*_instance*wrappers andwait_for_operation/3aredefdelegates now, and every public function on the facade carries a@spec(defdelegateinherits neither), so signature drift is a compile error and Dialyzer actually checks the facade.GcpCompute.Telemetry.detach_default_logger/0added alongsideattach_default_logger/1.A trailing
/on:base_urlis trimmed, sobase_url <> pathcannot yield//projects/....:receive_timeoutbehaviour 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:
GcpComputefacade:launch/3,terminate/3,insert_instance/3,insert_instance_and_wait/3,get_instance/3,list_instances/2,list_instances_page/2,delete_instance/3,delete_instance_and_wait/3,start_instance/3,stop_instance/3,wait_for_operation/3,config/2,clear_config/2, plusnew/1,production/1,local/1,from_env/2.GcpCompute.Config— NimbleOptions-validated config / client handle.GcpCompute.TokenProviderbehaviour withGoth(optional dep) andStaticadapters.GcpCompute.Instance—spec/1insert-body builder (Spot VM defaults) plus parsing andexternal_ip/1/internal_ip/1accessors.GcpCompute.InstancesandGcpCompute.Operationsmodules.GcpCompute.OperationandGcpCompute.Errordata types.GcpCompute.Telemetry—[:gcp_compute, :request, :start | :stop | :exception]spans.
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 hardmix deps.getconflict for most applications. - Bodiless POSTs carried no
Content-Length, which the Compute API rejects with HTTP 411. That breaksoperations.wait,instances.startandinstances.stop— solaunch/3,terminate/3, every*_and_waithelper andwait_for_operation/3failed against real GCP on the first call, orphaning the instanceinserthad just created.
0.1.0 was never published.