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 lib gcp_compute config.ex
Raw

lib/gcp_compute/config.ex

defmodule GcpCompute.Config do
@schema NimbleOptions.new!(
project: [type: :string, required: true, doc: "GCP project id."],
zone: [
type: :string,
default: "us-central1-a",
doc: "Default zone for zonal operations."
],
token_provider: [
type: :mod_arg,
default: {GcpCompute.TokenProvider.Goth, GcpCompute.Goth},
doc: "A `{module, arg}` implementing `GcpCompute.TokenProvider`."
],
base_url: [
type: :string,
default: "https://compute.googleapis.com/compute/v1",
doc: "Compute API base URL. Override for the emulator or a proxy."
],
req_options: [
type: :keyword_list,
default: [],
doc: """
Extra options merged into every `Req` request (e.g. `:adapter` for
tests, `:retry`). Treated as **trusted application config**: the
request `:method`, `:url`, and `:auth` are always computed by the
library and layered on top, so `req_options` cannot override the
target URL or drop the bearer token. It can still set transport and
connection options (`:connect_options`, including `verify`) — that
is the app owner's choice, not attacker-controlled input.
"""
],
allow_insecure: [
type: :boolean,
default: false,
doc: """
Permit a non-`https://` `:base_url`. Off by default: a bearer token
is never sent in cleartext unless you explicitly opt in. Set `true`
only for a local emulator/proxy on a trusted network (`local/1` sets
it for you). Independent of the token provider — even a `Static`
token stays TLS-only unless this is set.
"""
]
)
@moduledoc """
Validated configuration — and the client handle you pass to every API call.
A `%Config{}` is cheap, immutable, and stateless: build it once and hand it to
`GcpCompute.Instances`/`GcpCompute.Operations` (or the `GcpCompute` facade).
There is no process to supervise — `Req` manages connection pooling under the
hood.
## Building a config
# Production: tokens minted by Goth
{:ok, config} =
GcpCompute.Config.production(
project: "my-project",
zone: "us-central1-a",
goth: MyApp.Goth
)
# From application env
{:ok, config} = GcpCompute.Config.from_env(:my_app, :gcp_compute)
# Local / emulator / tests (static token)
{:ok, config} = GcpCompute.Config.local(project: "my-project")
## Options
#{NimbleOptions.docs(@schema)}
"""
alias GcpCompute.TokenProvider
@enforce_keys [:project, :zone, :token_provider, :base_url, :req_options, :allow_insecure]
defstruct [:project, :zone, :token_provider, :base_url, :req_options, :allow_insecure]
@type t :: %__MODULE__{
project: String.t(),
zone: String.t(),
token_provider: {module(), term()},
base_url: String.t(),
req_options: keyword(),
allow_insecure: boolean()
}
@doc """
Build and validate a config from a keyword list.
Returns `{:ok, config}` or `{:error, message}` with a precise validation
message.
"""
@spec new(keyword()) :: {:ok, t()} | {:error, String.t()}
def new(opts) when is_list(opts) do
with {:ok, valid} <- validate(opts),
config = struct(__MODULE__, valid),
:ok <- require_https(config) do
{:ok, config}
end
end
defp validate(opts) do
case NimbleOptions.validate(opts, @schema) do
{:ok, valid} -> {:ok, valid}
{:error, %NimbleOptions.ValidationError{} = error} -> {:error, Exception.message(error)}
end
end
@doc "Same as `new/1` but raises `ArgumentError` on invalid options."
@spec new!(keyword()) :: t()
def new!(opts) do
case new(opts) do
{:ok, config} -> config
{:error, message} -> raise ArgumentError, message
end
end
@doc """
Build a production config that mints tokens via Goth.
Pass `:goth` (the registered Goth name) instead of `:token_provider`.
{:ok, config} = GcpCompute.Config.production(project: "p", goth: MyApp.Goth)
Uses a Goth token provider with `allow_insecure` defaulting to `false`, so
`new/1`'s https rule applies: the `:base_url` must be `https://` and a bearer
token can't be sent in cleartext.
"""
@spec production(keyword()) :: {:ok, t()} | {:error, String.t()}
def production(opts) do
{goth, opts} = Keyword.pop(opts, :goth, GcpCompute.Goth)
opts |> Keyword.put_new(:token_provider, {TokenProvider.Goth, goth}) |> new()
end
# A bearer token must never cross the wire in cleartext. Require `https://`
# for every config unless the app owner explicitly opts in with
# `allow_insecure: true` (an emulator/proxy on a trusted network). Gating on an
# explicit flag — not the token provider — means even a `Static` provider
# holding a *real* out-of-band token stays TLS-only by default. Applied by
# `new/1`, so it covers `production/1` and `from_env/2` too.
defp require_https(%__MODULE__{allow_insecure: true}), do: :ok
defp require_https(%__MODULE__{base_url: url}) do
if https?(url),
do: :ok,
else:
{:error,
"base_url must use https:// (got: #{url}); pass allow_insecure: true to permit " <>
"http (e.g. a local emulator on a trusted network)"}
end
# Case-insensitive: a valid `HTTPS://` URL should not be rejected.
defp https?(url), do: url |> String.downcase() |> String.starts_with?("https://")
@doc """
Build a config for local development, the emulator, or tests.
Defaults to a `Static` token provider so you never touch Goth. Pass `:token`
to set the static token (default `"local-token"`), and `:base_url` to point at
an emulator. Defaults `allow_insecure: true` so an `http://` emulator URL works
out of the box — override it to `false` to force TLS even locally.
{:ok, config} = GcpCompute.Config.local(project: "p", base_url: "http://localhost:8080/compute/v1")
"""
@spec local(keyword()) :: {:ok, t()} | {:error, String.t()}
def local(opts) do
{token, opts} = Keyword.pop(opts, :token, "local-token")
opts
|> Keyword.put_new(:token_provider, {TokenProvider.Static, token})
|> Keyword.put_new(:allow_insecure, true)
|> new()
end
@doc "Same as `local/1` but raises on invalid options."
@spec local!(keyword()) :: t()
def local!(opts) do
case local(opts) do
{:ok, config} -> config
{:error, message} -> raise ArgumentError, message
end
end
@doc """
Load options from application env and build a config.
config :my_app, :gcp_compute,
project: "my-project",
zone: "europe-west4-a",
token_provider: {GcpCompute.TokenProvider.Goth, MyApp.Goth}
{:ok, config} = GcpCompute.Config.from_env(:my_app, :gcp_compute)
"""
@spec from_env(atom(), atom()) :: {:ok, t()} | {:error, String.t()}
def from_env(app, key) when is_atom(app) and is_atom(key) do
app |> Application.get_env(key, []) |> new()
end
@doc "Fetch a fresh access token using the configured `GcpCompute.TokenProvider`."
@spec fetch_token(t()) :: {:ok, TokenProvider.token()} | {:error, term()}
def fetch_token(%__MODULE__{token_provider: {module, arg}}), do: module.fetch_token(arg)
end
defimpl Inspect, for: GcpCompute.Config do
import Inspect.Algebra
# Redact the token provider arg (Static holds a raw token) and req_option
# values (may carry adapters/proxy creds) so configs never leak secrets in
# logs, inspect output, or crash reports.
def inspect(config, opts) do
{provider_mod, _arg} = config.token_provider
fields = [
project: config.project,
zone: config.zone,
base_url: config.base_url,
token_provider: {provider_mod, :REDACTED},
req_options: Keyword.keys(config.req_options),
allow_insecure: config.allow_insecure
]
concat(["#GcpCompute.Config<", to_doc(fields, opts), ">"])
end
end