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
Current section
Files
gcp_compute
README.md
README.md
# GcpCompute
[](https://hex.pm/packages/gcp_compute)
[](https://hexdocs.pm/gcp_compute)
Spawn and manage **Google Compute Engine** instances from Elixir, over the
Compute REST API. Pluggable auth, telemetry on every call, and an ergonomic
builder for the (verbose) instance body.
> [!WARNING]
> **`spot: true` is the default.** A bare
> `GcpCompute.launch(config, "worker-1")` provisions a **Spot VM**: cheap and
> preemptible, and when GCP preempts it the instance **deletes itself along
> with everything on its disk** (`provisioningModel: "SPOT"`,
> `automaticRestart: false`, `instanceTerminationAction: "DELETE"`). That is
> deliberate — this library is built for disposable batch workers — but pass
> `spot: false` for anything whose disk you care about.
> **Why REST and not gRPC?** The Compute Engine API is **REST/JSON only** — it
> has no gRPC endpoint (it's the one notable exception among GCP APIs). So unlike
> [`pubsub_grpc`](https://hex.pm/packages/pubsub_grpc), there is no connection
> pool to run: a request is a handful of calls dominated by the 20–40s the VM
> takes to boot, where pooling buys nothing. `GcpCompute` talks to it with
> [`Req`](https://hexdocs.pm/req); a `GcpCompute.Config` is the only handle you
> pass around. The right place for gRPC + [`grpc_connection_pool`](https://hex.pm/packages/grpc_connection_pool)
> is an agent running *inside* the machine you spawn — see [Roadmap](#roadmap).
## Installation
```elixir
def deps do
[
{:gcp_compute, "~> 0.3.0"},
# Recommended token provider (optional dependency):
{:goth, "~> 1.4"}
]
end
```
## Quickstart
```elixir
# 1. Start Goth to mint OAuth tokens (in your application supervision tree)
children = [{Goth, name: MyApp.Goth}]
# 2. Build a config once and reuse it
{:ok, config} =
GcpCompute.Config.production(
project: "my-project",
zone: "us-central1-a",
goth: MyApp.Goth
)
# 3. Spawn a cheap, self-deleting Spot VM and wait until it exists
{:ok, instance} =
GcpCompute.insert_instance_and_wait(config,
name: "worker-1",
machine_type: "e2-micro",
spot: true,
max_run_duration: 3600, # hard server-side TTL (seconds)
startup_script: "#!/bin/bash\necho ready > /tmp/ready",
labels: %{"owner" => "platform"}
)
GcpCompute.Instance.external_ip(instance) #=> "34.x.x.x"
# 4. Tear it down (and wait for the delete operation)
{:ok, _op} = GcpCompute.delete_instance_and_wait(config, "worker-1")
```
Prefer to drive the lifecycle yourself? Every mutating call returns an
`GcpCompute.Operation` you can poll:
```elixir
{:ok, op} = GcpCompute.insert_instance(config, name: "worker-1", machine_type: "e2-micro")
{:ok, done} = GcpCompute.wait_for_operation(config, op, timeout: :timer.minutes(3))
```
## Finding instances
```elixir
# Exact name. Returns a tagged 404 when it does not exist — match, don't rescue.
case GcpCompute.get_instance(config, "worker-1") do
{:ok, vm} -> vm
{:error, %GcpCompute.Error{status: 404}} -> :not_found
end
# Everything in the config's zone (single page of items, token discarded).
{:ok, instances} = GcpCompute.list_instances(config)
# Server-side search. Note: instances.list takes GLOBS, not regexes.
{:ok, workers} = GcpCompute.list_instances(config, filter: ~s(name = "worker-*"))
{:ok, running} = GcpCompute.list_instances(config, filter: ~s(status = "RUNNING"))
{:ok, batch} = GcpCompute.list_instances(config, filter: ~s(labels.role = "batch"))
{:ok, both} = GcpCompute.list_instances(config, filter: ~s(labels.env = "prod" AND status = "RUNNING"))
# Explicit pagination when you need the token.
{:ok, %{items: page1, next_page_token: token}} =
GcpCompute.list_instances_page(config, max_results: 50)
{:ok, %{items: page2}} =
GcpCompute.list_instances_page(config, max_results: 50, page_token: token)
```
Three sharp edges, all verified against the live API:
- **Globs, not regexes.** `name = "worker-*"` works; `name ~ "worker"` is
rejected with a 400 `Invalid list filter expression`.
- **`order_by` is narrow.** `"name"` and `"creationTimestamp desc"` are accepted;
`"name desc"` is a 400.
- **Listing is per-zone.** The `aggregatedList` endpoint is not wrapped, so if you
do not know an instance's zone you must iterate zones yourself. There is also
no `stream`/auto-pagination helper — `list_instances/2` returns one page and
discards the token, so loop on `list_instances_page/2` for large zones.
## Logs, tags and SSH
```elixir
# "The VM's logs" = serial console output. This is where a :startup_script's
# output lands, and it is the only log surface the Compute API itself offers.
{:ok, %{contents: log, next: next}} = GcpCompute.instance_logs(config, "worker-1")
# Poll incrementally — the buffer is ~145 KB on a booted Debian image, so pass
# the previous :next back as :start rather than refetching the whole thing.
{:ok, %{contents: delta}} = GcpCompute.instance_logs(config, "worker-1", start: next)
# Ports 1..4; 1 carries boot and startup-script output. Anything else is
# rejected locally, before a request goes out.
{:ok, _} = GcpCompute.instance_logs(config, "worker-1", port: 2)
```
**Network tags vs labels** are different things and both round-trip:
```elixir
{:ok, vm} = GcpCompute.launch(config, "web-1",
tags: ["http-server", "ssh"], # firewall/route targets -> vm.tags
labels: %{"env" => "prod"} # key/value metadata -> vm.labels
)
vm.tags #=> ["http-server", "ssh"]
vm.labels #=> %{"env" => "prod"}
```
Mutating either *after* creation (`instances.setTags` / `setLabels`) is not
wrapped: both take a fingerprint for optimistic concurrency, and hiding a
read-modify-write behind a helper would hide a lost-update race. The
fingerprints are in `vm.raw` if you want to call those endpoints yourself.
**SSH.** This library authorises keys but does not open connections:
```elixir
{:ok, vm} = GcpCompute.launch(config, "worker-1",
ssh_keys: %{"deploy" => "ssh-ed25519 AAAAC3Nz… deploy@laptop"}
)
GcpCompute.Instance.external_ip(vm) #=> "34.x.x.x" — then use your own client
```
Deliberately no in-library SSH client: it would mean owning host-key
verification policy, key parsing and `known_hosts` — a larger security surface
than the rest of this package — and GCE SSH also involves OS Login, a separate
API. For "run a command and see the output", prefer `:startup_script` plus
`instance_logs/3`: no inbound access, no keys, no extra API.
## Boot disks: image, snapshot, or an existing disk
A boot disk has exactly one source. Passing two is rejected before the request —
GCP's own answer is a 400 naming a JSON field you never typed
(`Cannot specify both 'source' and 'initializeParams'`).
```elixir
# 1. From an image (the default: debian-12 if you say nothing).
GcpCompute.launch(config, "worker-1", disk_size_gb: 50, disk_type: "pd-ssd")
# 2. From a snapshot — a NEW disk is created, so the snapshot is never mutated
# and any number of instances can boot the same one concurrently.
GcpCompute.launch(config, "worker-2", source_snapshot: "nightly-backup")
# 3. Attaching an EXISTING disk — nothing is created; it must be in the same
# zone and not already attached.
GcpCompute.launch(config, "worker-3", source_disk: "stateful-1")
```
Bare names are qualified for you (`nightly-backup` →
`global/snapshots/nightly-backup`); pass a full path to cross projects.
**`autoDelete` defaults differ, deliberately.** A disk *created* from an image or
snapshot belongs to the instance and is deleted with it. A disk you *attached*
already existed, so deleting the instance leaves it alone. Override either way
with `boot_disk_auto_delete:`.
Snapshot and disk *management* is not wrapped: those are different resources
(`disks`, `snapshots`), and this package is the instance client. Booting *from*
one is an instance concern, which is why it lives here.
## Spot VMs and preemption
`:spot` defaults to **`true`**, so the quickstart VM is preemptible and, with the
default `instanceTerminationAction: "DELETE"`, **deletes itself** when preempted.
That is the right default for disposable batch workers and the wrong one if the
disk matters — pass `spot: false` then.
```elixir
GcpCompute.Instance.spot?(vm) #=> true
# A preempted VM is DELETED, so `get/3` answers 404 and cannot tell you why it
# went away. The operation outlives the instance, and can:
case GcpCompute.instance_preemption(config, "worker-1") do
{:ok, nil} -> :not_preempted # also the answer for a name that never existed
{:ok, op} -> {:preempted_at, op.end_time}
end
# Or the whole audit trail — "what happened to my VM?"
{:ok, ops} = GcpCompute.instance_operations(config, "worker-1")
Enum.map(ops, &{&1.operation_type, &1.status_message})
#=> [{"compute.instances.preempted", "Instance was preempted."}, {"insert", nil}]
# Test your own preemption handling on purpose. This is a REAL preemption:
GcpCompute.simulate_maintenance_event(config, "worker-1")
```
Measured against a live Spot VM: `RUNNING` → `STOPPING` at ~60 s → gone (404) at
~73 s. Budget minutes, not seconds.
There is no retry-on-preemption loop here: deciding whether to relaunch, where,
and how often is orchestration, and it belongs in the supervised layer described
under [Roadmap](#roadmap) rather than in a REST client.
## Examples & guides
- **Runnable examples** in [`examples/`](https://github.com/nyo16/gcp_compute/tree/master/examples):
- [`stubbed_demo.exs`](https://github.com/nyo16/gcp_compute/blob/master/examples/stubbed_demo.exs) —
the full insert → poll → get flow offline, **no GCP or credentials**
(`elixir examples/stubbed_demo.exs`).
- [`spawn_spot_vm.exs`](https://github.com/nyo16/gcp_compute/blob/master/examples/spawn_spot_vm.exs) —
a real (billable) Spot VM.
- **Live smoke tests** (real project, real cents) — see
[Testing without GCP](#testing-without-gcp) for what each one is for:
[`smoke_test.exs`](https://github.com/nyo16/gcp_compute/blob/master/examples/smoke_test.exs),
[`smoke_coverage.exs`](https://github.com/nyo16/gcp_compute/blob/master/examples/smoke_coverage.exs),
[`smoke_advanced.exs`](https://github.com/nyo16/gcp_compute/blob/master/examples/smoke_advanced.exs).
- **Guides:** [Getting Started](guides/getting-started.md) ·
[Configuring Machines](guides/configuring-machines.md) ·
[Testing](guides/testing.md)
## Configuration
`GcpCompute.Config` is validated with `NimbleOptions`. Three builders cover the
common cases:
```elixir
# Production — tokens minted by Goth
{:ok, config} = GcpCompute.Config.production(project: "p", goth: MyApp.Goth)
# From application env
# config :my_app, :gcp_compute,
# project: "p", zone: "europe-west4-a",
# token_provider: {GcpCompute.TokenProvider.Goth, MyApp.Goth}
{:ok, config} = GcpCompute.Config.from_env(:my_app, :gcp_compute)
# Local / emulator / tests — a static token, no Goth required
{:ok, config} = GcpCompute.Config.local(project: "p", base_url: "http://localhost:8080/compute/v1")
```
| Option | Default | Notes |
| ---------------- | ---------------------------------------------- | -------------------------------------------------- |
| `:project` | — (required) | GCP project id. |
| `:zone` | `"us-central1-a"` | Default zone; override per call with `zone:`. |
| `:token_provider`| `{TokenProvider.Goth, GcpCompute.Goth}` | `{module, arg}` implementing `GcpCompute.TokenProvider`. |
| `:base_url` | `"https://compute.googleapis.com/compute/v1"` | Point at the emulator or a proxy. **Trusted app config — never derive from user input.** |
| `:req_options` | `[]` | Merged into every `Req` request (`:retry`, `:adapter`, …). |
| `:allow_insecure`| `false` (`local/1` sets `true`) | Permit a non-`https://` `:base_url`. Off by default so a bearer token is never sent in cleartext. `new/1` warns when it is on and the host is not loopback/RFC1918/internal. |
### Pluggable auth
Auth is a behaviour, `GcpCompute.TokenProvider`, so the library never hard-depends
on Goth. Built-ins: `TokenProvider.Goth` (production), `TokenProvider.Static`
(tests/emulator). Bring your own (workload identity, metadata server, Vault) by
implementing one callback:
```elixir
defmodule MyApp.MetadataToken do
@behaviour GcpCompute.TokenProvider
@impl true
def fetch_token(_arg), do: {:ok, %{token: fetch_from_metadata_server()}}
end
```
### Configurable machines
`GcpCompute.Instance.spec/1` turns friendly options into the Compute insert body
(defaults shown):
| Option | Default |
| ------------------- | --------------------------------------------------- |
| `:machine_type` | `"e2-micro"` |
| `:source_image` | `debian-cloud/.../debian-12` |
| `:disk_size_gb` | `10` |
| `:spot` | `true` (SPOT, no auto-restart, DELETE on terminate) |
| `:max_run_duration` | `nil` (set seconds for a hard server-side TTL) |
| `:external_ip` | `true` |
| `:startup_script`, `:metadata`, `:labels`, `:tags`, `:network`, `:subnetwork`, `:service_account`, `:scopes` | — |
Need a field it doesn't cover? Pass a raw Compute body map to
`GcpCompute.Instances.insert/3` instead.
## Telemetry
Every API call is a `:telemetry.span/3`:
| Event | Metadata |
| -------------------------------------- | ------------------------------------------------- |
| `[:gcp_compute, :request, :start]` | `method`, `path`, `project` |
| `[:gcp_compute, :request, :stop]` | `+ result` (`:ok`/`:error`), `http_status` |
| `[:gcp_compute, :request, :exception]` | `+ kind`, `reason`, `stacktrace` |
```elixir
GcpCompute.Telemetry.attach_default_logger() # dev convenience
# or wire into Telemetry.Metrics / your reporter
```
## Testing without GCP
Tests stub the network via Req's native `:adapter` option — no Plug, no creds.
Req 0.7 takes a **module**, not a function:
```elixir
defmodule MyStub do
def run(request) do
{request, %Req.Response{status: 200, body: %{"name" => "worker-1"}}}
end
end
config = GcpCompute.Config.local!(project: "test", req_options: [adapter: MyStub])
```
See `test/support/req_stub.ex` for the routing-table helper used by the suite —
it asserts the bearer token on every stubbed request and fails loudly on an
unrouted one, so a dropped header or a mangled query cannot pass silently.
### …and three that need real GCP
Stubs cannot catch everything, and it is worth knowing exactly where the line
is. An in-process adapter computes no headers, so it could not reproduce this:
the Compute API rejects a bodiless POST with **HTTP 411 Length Required**, which
broke `launch/3`, `terminate/3` and every `*_and_wait` helper end to end while
the whole stub suite stayed green. Two live scenarios are the counterweight, and
between them they have found every bug the stubs could not.
**`examples/smoke_test.exs`** — the fast pre-publish check. Launches one
`e2-micro` Spot VM, verifies the parse of a real payload, tears it down, and
reports per-request timings from the library's own telemetry, including whether
a single `operations.wait` long poll exceeded Req's 15 s default
`receive_timeout` — the only real proof the long-poll fix works. An observed run
had one block for **51 s** and succeed.
**`examples/smoke_coverage.exs`** — the full surface, for the parts whose
correctness only the real server can confirm, because a stub validates our
request construction against our own assumptions. 19 checks: pagination
(`max_results`/`page_token` genuinely paginating, not just our translation table
agreeing with itself), `stop`/`start`, `spot: false`, `Operations.get/2`,
`wait_for_operation/3` called directly, a raw map body, `:request_id`, a
server-side `:filter`, and `from_env/2` + `config/2`.
**`examples/smoke_advanced.exs`** — 30 checks covering the three things that need
real infrastructure to be falsifiable at all:
- **Disks.** Boot from a snapshot and from an attached existing disk, then prove
`autoDelete: false` really preserves a disk the instance did not create. No
stub can tell you whether GCP accepts a bare snapshot name.
- **Network.** Firewall rules, ports and tags, verified by opening real TCP
connections to the VM — the only test here that leaves both the process *and*
the Google API, and the only way to know `:tags` are load-bearing rather than
decorative JSON. Expectations are **derived from the project's live rule set**,
never hardcoded, because an auto-created default network ships
`default-allow-ssh` and a custom VPC ships nothing. The sharp assertion is the
difference between a **timeout** (packets dropped by a firewall) and
**`:econnrefused`** (packets arrived, nothing listening) — conflating those two
is how you convince yourself a firewall works when it does not.
- **Spot.** Preempt a VM on purpose with `simulate_maintenance_event/3`, watch it
be deleted, then confirm `preemption/3` still finds the evidence once the
instance itself is gone.
The port assertions refuse to run until the VM's **serial console** reports the
test listener actually bound — read with `instance_logs/3`, so the scenario
dogfoods the library. That gate exists because it was needed: an early version
used `nc`, which Debian 12 images do not ship, and the dead listener passed as a
firewall result for two runs. `:timeout` cannot distinguish "firewall dropped it"
from "nothing was ever listening", so the listener has to be proven independently.
The tag check needs `compute.firewalls.create`
(`roles/compute.securityAdmin`). Without it, that one check **SKIPs with the exact
grant command** rather than passing quietly.
```bash
export GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/smoke.json
export SMOKE_PROJECT=my-project SMOKE_ZONE=us-central1-a
mix run examples/smoke_test.exs # ~1 min, 1 VM
mix run examples/smoke_coverage.exs # ~5 min, 3 VMs
mix run examples/smoke_advanced.exs # ~8 min, 4 VMs + a snapshot and a disk
# needs outbound TCP to the VM
```
All three cost fractions of a cent and clean up after themselves: `e2-micro`, a
10 GB disk, `max_run_duration` as a server-side hard TTL so GCP reclaims the
instance even if teardown fails, unique names per run, and a closing orphan
sweep. **Run all three before every publish.**
## Roadmap
This package is the **Compute client layer**. A higher-level *sandbox
orchestration* layer is planned on top of it:
- `gen_statem` per sandbox (`:provisioning → :probing → :running → :terminating`)
- `DynamicSupervisor` + `Registry`, named profiles, per-user quotas
- a reaper for TTL sweeps + orphan reconciliation (cloud resources outlive the BEAM)
- the gRPC hop: an in-VM agent reached via `grpc_connection_pool`, lifecycle
events published through `pubsub_grpc`
## License
MIT.