Packages

Spawn and manage Google Compute Engine instances over the REST API, with pluggable auth, telemetry, and an ergonomic instance builder.

Current section

Files

Jump to
gcp_compute CHANGELOG.md
Raw

CHANGELOG.md

# Changelog
All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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 (`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_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: `RUNNING``STOPPING` 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 `DateTime`s, `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 `inspect`ing 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
`defdelegate`s 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:
- `GcpCompute` facade: `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`, plus `new/1`, `production/1`, `local/1`, `from_env/2`.
- `GcpCompute.Config` — NimbleOptions-validated config / client handle.
- `GcpCompute.TokenProvider` behaviour with `Goth` (optional dep) and `Static`
adapters.
- `GcpCompute.Instance``spec/1` insert-body builder (Spot VM defaults) plus
parsing and `external_ip/1` / `internal_ip/1` accessors.
- `GcpCompute.Instances` and `GcpCompute.Operations` modules.
- `GcpCompute.Operation` and `GcpCompute.Error` data types.
- `GcpCompute.Telemetry`
`[:gcp_compute, :request, :start | :stop | :exception]` spans.
> #### Do not use 0.2.0 {: .warning}
>
> 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.
[0.3.0]: https://github.com/nyo16/gcp_compute/releases/tag/v0.3.0
[0.2.0]: https://github.com/nyo16/gcp_compute/releases/tag/v0.2.0