Packages
gcp_compute
0.2.0
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
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