# 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
