defmodule Stevedore do @moduledoc """ A library-first, daemonless OCI toolkit for Elixir — everything you can do to a container image **except run it**. Stevedore handles OCI artifacts *at rest* (as bytes): fetch, inspect, copy, mirror, build, modify, analyze, sign, verify, and serve images. Running them (namespaces, mounts, cgroups) is out of scope. ## Layers The library is a pure core with optional shells: * **Core data types** — `Stevedore.Reference`, `Stevedore.Digest`, `Stevedore.MediaType`, `Stevedore.Descriptor`, `Stevedore.Manifest`, `Stevedore.Config`, and `Stevedore.Archive`. * **The `docker://` client** — `Stevedore.Registry` (requires the optional `:req` dep) plus `Stevedore.Auth` for the bearer-token flow. * **The `Stevedore.Store` seam** — content-addressed blob I/O, with `Store.Local` and `Store.Memory`. The functions below are the high-level verbs. See `docs/EXAMPLES.md` for a cookbook of task-oriented recipes. Nothing here starts a process; adding `:stevedore` as a dependency is weightless. """ # Stevedore.inspect/2 intentionally shadows the rarely-needed Kernel.inspect/2. import Kernel, except: [inspect: 2] alias Stevedore.{Config, Copy, Digest, Manifest, Reference, Registry, Transport} alias Stevedore.Transport.Parse @doc """ Fetches and parses the manifest for `ref` from its registry. Options: * `:raw` — return the raw manifest bytes instead of a `t:Stevedore.Manifest.t/0`. * `:config` — fetch and parse the image config, returning a `t:Stevedore.Config.t/0` (selecting the host platform, or `:platform`, when `ref` is a multi-arch index). * `:platform` — a keyword (`os`/`architecture`/`variant`) used with `:config` on an index. * plus any `Stevedore.Registry` option (`:creds`, `:scheme`, …). """ @spec inspect(Reference.t(), keyword()) :: {:ok, Manifest.t() | binary() | Config.t()} | {:error, term()} def inspect(%Reference{} = ref, opts \\ []) do with {:ok, fetched} <- Registry.manifest(ref, opts) do cond do opts[:raw] -> {:ok, fetched.raw} opts[:config] -> fetch_config(ref, fetched, opts) true -> Manifest.parse(fetched.raw, fetched.media_type) end end end @doc "Lists the tags in `ref`'s repository." @spec list_tags(Reference.t(), keyword()) :: {:ok, [String.t()]} | {:error, term()} def list_tags(%Reference{} = ref, opts \\ []), do: Registry.list_tags(ref, opts) @doc """ Starts the standalone `/v2` registry server (`Stevedore.Server`). The only thing in Stevedore that boots a process tree, and only when called. Requires the optional `:bandit`/`:plug` deps. See `Stevedore.Server` for options (`:store`, `:port`, `:authorize`, …). """ @spec start_link(keyword()) :: Supervisor.on_start() def start_link(opts \\ []), do: Stevedore.Server.start_link(opts) @doc """ Copies an image from `source` to `dest`, preserving digests. Returns `{:ok, %{digest: ...}}`. Endpoints are transport-prefixed strings (`docker://`, `oci:`, `dir:`, `docker-archive:`, `oci-archive:`, `static:`) or `{transport, ref}` tuples. Options: `:all` (copy a whole index), `:platform`/`:platforms` (select from an index), plus transport options like `:creds`. ## Examples Stevedore.copy("docker://alpine:3.20", "oci:./alpine:3.20") Stevedore.copy("docker://alpine:3.20", "docker://ghcr.io/me/alpine:3.20", all: true) """ @spec copy(Copy.endpoint(), Copy.endpoint(), keyword()) :: {:ok, %{digest: Digest.t()}} | {:error, term()} def copy(source, dest, opts \\ []), do: Copy.run(source, dest, opts) @doc """ Copies many images from a declarative list of jobs. Each job is `{source, dest}` or a map with `:source`/`:dest` (and optional per-job `:opts`). Returns a result per job. """ @spec sync([{Copy.endpoint(), Copy.endpoint()} | map()], keyword()) :: {:ok, [{term(), term()}]} def sync(jobs, opts \\ []) when is_list(jobs) do results = Enum.map(jobs, fn job -> {source, dest, job_opts} = normalize_job(job) {job, copy(source, dest, Keyword.merge(opts, job_opts))} end) {:ok, results} end @doc """ Deletes the manifest named by `endpoint` (a transport-prefixed string or `{transport, ref}`). """ @spec delete(Copy.endpoint(), keyword()) :: :ok | {:error, term()} def delete(endpoint, opts \\ []) def delete(string, opts) when is_binary(string) do with {:ok, {transport, ref}} <- Parse.parse(string, opts), do: Transport.delete(transport, ref) end def delete({%_{} = transport, ref}, _opts), do: Transport.delete(transport, ref) @doc """ Computes the digest of a manifest from its raw bytes (or a `t:Stevedore.Manifest.t/0`). ## Examples iex> digest = Stevedore.manifest_digest(~s({"schemaVersion":2})) iex> digest.algorithm :sha256 """ @spec manifest_digest(binary() | Manifest.t()) :: Digest.t() def manifest_digest(%Manifest{raw: raw}), do: Digest.compute(raw) def manifest_digest(raw) when is_binary(raw), do: Digest.compute(raw) @spec normalize_job({Copy.endpoint(), Copy.endpoint()} | map()) :: {Copy.endpoint(), Copy.endpoint(), keyword()} defp normalize_job({source, dest}), do: {source, dest, []} defp normalize_job(%{source: source, dest: dest} = job), do: {source, dest, Map.get(job, :opts, [])} # Resolve `ref` to a single image manifest (selecting a platform from an index), fetch its # config descriptor's blob, and parse it. @spec fetch_config(Reference.t(), map(), keyword()) :: {:ok, Config.t()} | {:error, term()} defp fetch_config(ref, fetched, opts) do with {:ok, manifest} <- Manifest.parse(fetched.raw, fetched.media_type), {:ok, manifest, image_ref} <- resolve_image(ref, manifest, opts), {:ok, descriptor} <- Manifest.config(manifest), {:ok, bytes} <- Registry.blob(image_ref, descriptor.digest, opts) do Config.parse(bytes) end end @spec resolve_image(Reference.t(), Manifest.t(), keyword()) :: {:ok, Manifest.t(), Reference.t()} | {:error, term()} defp resolve_image(ref, manifest, opts) do case Manifest.kind(manifest) do :manifest -> {:ok, manifest, ref} :index -> with {:ok, descriptor} <- Manifest.select(manifest, opts[:platform] || []), image_ref = %{ref | tag: nil, digest: descriptor.digest}, {:ok, fetched} <- Registry.manifest(image_ref, opts), {:ok, image_manifest} <- Manifest.parse(fetched.raw, fetched.media_type) do {:ok, image_manifest, image_ref} end end end end