Task-oriented recipes for Stevedore. Each recipe states the intent (why you'd reach for it),
gives a complete, copy-paste block, and shows the expected result (the return shape, with
#=>). Start at the top for the common "pull / mirror / build" path; the later recipes cover
signing, serving, and deploying. The pure data types are collected in the
Core building blocks appendix at the end.
Status. Everything here runs against the shipped API. The small, deterministic snippets also live as
iex>doctests in the module@docs and are exercised by the test suite; this file is the cross-cutting, task-first guide. The only roadmap item beyond it is Tank integration, which lives in a separate repo.
Conventions you'll see throughout
- Reference syntax is Skopeo's transport-prefixed form (see
docs/REFERENCES.md):docker://name:tag,oci:path[:tag],oci-archive:path[:tag],docker-archive:path[:tag],dir:path,static:path[:tag]. - Return shapes follow the error rules in
AGENTS.md:{:ok, value}/{:error, reason}, wherereasonis a subsystem%...Error{}(context-rich), a bare atom like:not_found(context-free), or{:bad_input, _}(caller mistake). Functions neverraisefor caller mistakes — except the!-suffixed ones (e.g.Archive.write!/1), which do. - Digests are over raw bytes. Manifests carry both
raw(the exact bytes) and decodedjson; the digest is always ofraw, so it stays stable as data moves. - Optional dependencies. The
docker://client needs:req; the registry server needs:plug/:bandit; zstd layers need:ezstd. Everything else is dependency-free. Calling a mode whose dep is missing raises a clear error. Recipes that need one say so up front.
1. Pull & inspect an image
Intent: see what's inside a remote image — manifest, config, tags — without managing the HTTP
client by hand. (Needs :req.)
{:ok, ref} = Stevedore.Reference.parse("alpine:3.20")
# One-liners — fetch and decode for you (anonymous bearer auth + digest verification handled):
{:ok, manifest} = Stevedore.inspect(ref)
#=> {:ok, #Stevedore.Manifest<index, 8 manifests, sha256:7264a8db6415…>}
# (a %Stevedore.Manifest{} struct — raw bytes + decoded json kept inside)
{:ok, raw} = Stevedore.inspect(ref, raw: true) #=> raw manifest bytes
{:ok, config} = Stevedore.inspect(ref, config: true)
#=> {:ok, #Stevedore.Config<linux/amd64, 1 layer>}
# (a %Stevedore.Config{} struct; host platform)
{:ok, config} = Stevedore.inspect(ref, config: true, platform: [os: "linux", architecture: "arm64"])
{:ok, tags} = Stevedore.list_tags(ref) #=> {:ok, ["3.20", "latest", ...]} (paginated)
Stevedore.manifest_digest(raw)
#=> #Stevedore.Digest<sha256:7264a8db6415…>
# (a %Stevedore.Digest{} — digest of any manifest bytes)Need the raw client? Drop to Stevedore.Registry when you want the exact bytes a runtime
(e.g. Tank) consumes — manifest + blobs, digests verified against Docker-Content-Digest:
{:ok, %{media_type: mt, digest: dg, raw: raw, json: json}} = Stevedore.Registry.manifest(ref)
Stevedore.Digest.compute(raw) == dg #=> true
# Resolve a platform from an index, then fetch that child's config + a layer (digest-verified):
{:ok, manifest} = Stevedore.Manifest.parse(raw, mt)
{:ok, child} = Stevedore.Manifest.select(manifest) # host platform
{:ok, sub} = Stevedore.Registry.manifest(%{ref | tag: nil, digest: child.digest})
{:ok, image} = Stevedore.Manifest.parse(sub.raw, sub.media_type)
{:ok, cfg_desc} = Stevedore.Manifest.config(image)
{:ok, config_bytes} = Stevedore.Registry.blob(%{ref | tag: nil, digest: child.digest}, cfg_desc.digest)Blob fetch is robust to CDN redirects: req strips the Authorization header on any cross-host
redirect, so the registry token never leaks to a presigned URL.
Reuse the auth token across a pull. Each Registry call re-runs the 401 → token handshake by
default. For a multi-layer pull, start a Stevedore.Auth.Cache and thread it as :token_cache:
the first call earns the token, the rest send it preemptively (skipping the extra round-trips). A
stale token still falls back to a fresh handshake, so results never change — only the request
count. Off by default; nothing starts until you create a cache.
{:ok, cache} = Stevedore.Auth.Cache.start_link([])
# Direct Registry calls share one token instead of re-authing each time:
{:ok, top} = Stevedore.Registry.manifest(ref, token_cache: cache)
{:ok, bytes} = Stevedore.Registry.blob(ref, cfg_desc.digest, token_cache: cache)
# `inspect` and `copy` accept it too — one handshake for a whole multi-layer mirror:
Stevedore.copy("docker://alpine:3.20", "oci:./alpine:3.20", token_cache: cache)Private repositories — pass credentials explicitly, or load them from the Docker config:
{:ok, _} = Stevedore.Registry.manifest(ref, creds: {:basic, "user", "token"})
{:ok, auths} = Stevedore.Auth.from_docker_config(nil) # defaults to ~/.docker/config.json
#=> {:ok, %{"ghcr.io" => {:basic, "user", "pass"}, ...}} (missing file -> {:ok, %{}})2. Mirror, convert & select platforms
Intent: move an image between any two transports with digests preserved by default.
Stevedore.copy/3 is the verb everything composes from: registry, OCI layout, tarball, or dir:
tree, in any direction. (docker:// endpoints need :req.)
# Pull a registry image into a local OCI layout (host platform by default):
{:ok, %{digest: d}} = Stevedore.copy("docker://alpine:3.20", "oci:./alpine:3.20")
# Mirror the whole multi-arch index between registries (every child preserved):
{:ok, _} = Stevedore.copy("docker://alpine:3.20", "docker://ghcr.io/me/alpine:3.20", all: true)
# Copy a single chosen platform (written as a plain manifest):
{:ok, _} = Stevedore.copy("docker://alpine:3.20", "oci:./arm", platform: "linux/arm64")
# Copy a subset of an index (rebuilds the index — this changes the index digest):
{:ok, _} = Stevedore.copy("docker://alpine:3.20", "dir:./out", platforms: ["linux/amd64", "linux/arm64"])
# Convert OCI <-> Docker v2s2 (re-serializes, so the manifest digest changes):
{:ok, _} = Stevedore.copy("oci:./alpine:3.20", "docker://ghcr.io/me/alpine:3.20", format: :docker)
# Round-trip through tarballs; on-disk round-trips keep the manifest digest stable:
{:ok, _} = Stevedore.copy("oci:./alpine:3.20", "oci-archive:./alpine.oci.tar:3.20")
{:ok, _} = Stevedore.copy("oci:./alpine:3.20", "docker-archive:./alpine.docker.tar:alpine:3.20")Blob-skip and mount happen automatically: before transferring a blob, copy checks
has_blob? on the destination and skips it; on registry→registry copies it tries a cross-repo
mount first. So re-copying is cheap and idempotent.
Driving transports directly — when you want the structs, not the string sugar (e.g. to set
registry options, or to target a Static tree with an explicit repository name):
{:ok, {transport, ref}} = Stevedore.Transport.Parse.parse("docker://alpine:3.20", scheme: "https")
# Every transport answers the same interface (dispatched on the struct):
{:ok, fetched} = Stevedore.Transport.get_manifest(transport, ref)
{:ok, bytes} = Stevedore.Transport.get_blob(transport, fetched.digest)
true_or_false = Stevedore.Transport.has_blob?(transport, fetched.digest)
# A Static tree sink needs a repository name; copy fills it from a registry source, or set it:
static = %Stevedore.Transport.Static{path: "./public", name: "library/alpine"}
{:ok, _} = Stevedore.copy("docker://alpine:3.20", {static, "3.20"})3. Bulk sync & delete
Intent: mirror or prune many images in one call, with failures isolated per job.
# A list of {source, dest} jobs (or maps with :source/:dest/:opts):
{:ok, results} =
Stevedore.sync([
{"docker://alpine:3.20", "oci:./mirror/alpine:3.20"},
{"docker://debian:12", "oci:./mirror/debian:12"}
])
#=> {:ok, [{job, {:ok, %{digest: _}}}, {job, {:error, _}}]} (one result per job; one failure won't abort the rest)
:ok = Stevedore.delete("oci:./mirror/alpine:3.20")The CLI wraps this with a spec file — see recipe 11.
4. Build an image from a directory (no Dockerfile)
Intent: assemble an image from a filesystem tree — no build daemon, no Dockerfile. Stevedore assembles images from layers + a config; it never runs build steps. The deterministic tar + timestamp-free gzip make the result reproducible (same input → same digest).
{:ok, image} = Stevedore.Build.from_dir("./rootfs", %{cmd: ["/bin/sh"]})
# A built %Image{} carries its blob bytes, so it's a valid copy *source* — publish it straight off:
{:ok, %{digest: _}} = Stevedore.copy(image, "docker://ghcr.io/me/app:1.0")The crucial correctness point — kept straight for you — is diff_id (sha256 of the uncompressed
tar, in rootfs.diff_ids) vs the layer descriptor digest (sha256 of the compressed bytes, in
the manifest):
image.layers
#=> [#Stevedore.Descriptor<application/vnd.oci.image.layer.v1.tar+gzip sha256:9fb3aa2f8b80… 3401613B>, …]
# (each a %Stevedore.Descriptor{}; the *compressed* layer digests)
image.config.rootfs_diff_ids
#=> [#Stevedore.Digest<sha256:8d3ac3489996…>, …]
# (each a %Stevedore.Digest{}; the *uncompressed* digests — different!)5. Build from layer tarballs & append
Intent: build with full control over each layer's bytes, then stack more on top. Layers are
plain uncompressed tar binaries (see Archive in the
appendix for assembling entries).
entries = [
%{name: "etc/", type: :directory, mode: 0o755, size: 0, linkname: nil, content: nil},
%{name: "etc/app.conf", type: :regular, mode: 0o644, size: 2, linkname: nil, content: "hi"}
]
{:ok, image} =
Stevedore.Build.image(
[Stevedore.Archive.write!(entries)],
%{entrypoint: ["/bin/app"], env: ["LANG=C.UTF-8"], working_dir: "/srv"},
platform: "linux/amd64", format: :oci, compression: :gzip
)
# Append a layer (adds one history entry), then publish the built image straight to a registry:
{:ok, image} = Stevedore.Build.append(image, Stevedore.Archive.write!(patch_entries))
{:ok, %{digest: _}} = Stevedore.copy(image, "docker://ghcr.io/me/app:1.0")6. Modify an image (retag / relabel / rebase / flatten)
Intent: retag, rewrite config, annotate, rebase onto a new base, or flatten — recomputing all
dependent digests, without re-pulling unchanged layers. Every verb returns a new %Image{}.
# Rewrite the runtime config (map merges labels, replaces the rest):
image = Stevedore.Mutate.config(image, %{entrypoint: ["/bin/app", "--prod"], user: "1000:1000"})
# ...or with a function over the parsed Config struct:
image = Stevedore.Mutate.config(image, fn cfg -> %{cfg | env: ["DEBUG=0" | cfg.env || []]} end)
image = Stevedore.Mutate.annotations(image, %{"org.opencontainers.image.source" => "https://example/repo"})
image = Stevedore.Mutate.retag(image, "1.0.1") # sets the tag a later copy will use
# Rebase: swap the base layers for a new base, keeping the app layers on top.
# Verifies the image actually starts with old_base's layers (else {:error, :base_mismatch}):
{:ok, rebased} = Stevedore.Mutate.rebase(image, old_base, new_base)
# Flatten the whole stack into one layer (whiteout-aware):
{:ok, flat} = Stevedore.Mutate.flatten(image)7. Analyze image contents (merged FS / diff / read / SBOM)
Intent: read what's inside an image in memory, without root, honoring whiteouts. Works on a
built/pulled %Image{} or a list of raw layer binaries (bottom→top).
# The whiteout-aware effective filesystem (paths are normalized: no leading "/"):
{:ok, view} = Stevedore.Layer.merged_view(image)
view["etc/os-release"]
#=> %{path: "etc/os-release", type: :regular, mode: 420, size: 98, linkname: nil, from_layer: 2}
# A single layer's entries (a built image's descriptor needs opts[:image] to fetch the bytes;
# a raw/compressed binary is sniffed automatically):
{:ok, entries} = Stevedore.Layer.entries(hd(image.layers), image: image)
{:ok, entries} = Stevedore.Layer.entries(some_gzip_layer_binary)
# Diff two layers (added / modified / removed paths, ignoring whiteout markers):
{:ok, %{added: a, modified: m, removed: r}} = Stevedore.Layer.diff(layer_a_bin, layer_b_bin)
# Query and read files from the effective filesystem (leading "/" optional):
{:ok, nodes} = Stevedore.Analyze.files(image, ~r{^usr/bin/}) # Regex or (path -> bool)
{:ok, release} = Stevedore.Analyze.read_file(image, "/etc/os-release") #=> {:ok, bytes} | {:error, :enoent}
# Best-effort SBOM from well-known metadata files (heuristic, no scanner, no shelling out):
{:ok, sbom} = Stevedore.Analyze.sbom(image)
#=> {:ok, %{"os" => %{"NAME" => "Alpine Linux", ...} | nil,
# "packages" => [%{"name" => "musl", "version" => "1.2.4", "type" => "apk"}, ...]}}Analyzing a remote image: pull each layer blob with
Stevedore.Registry.blob/3and pass the binaries toStevedore.Layer.merged_view([blob1, blob2, ...]), orcopyit into anoci:layout first.
8. Sign, verify & attach referrers (OCI 1.1)
Intent: produce a cosign-compatible signature, verify against a default-deny policy, and attach
artifacts (signatures, SBOMs, scans) to an image. All crypto is native (:public_key); nothing
shells out to cosign/gpg/openssl.
key = Stevedore.Sign.Sigstore.generate_key() #=> %{private: <PEM>, public: <PEM>}
# Sign an image -> a cosign signature artifact (an %Image{} with the payload layer + signature
# annotation, a subject pointing at the image, and the sha256-<hex>.sig tag):
{:ok, signature} = Stevedore.Sign.sigstore(image, key)
signature.tag #=> "sha256-<digest hex>.sig"
# A native detached signature over the manifest digest (DER bytes; not the GPG wire format):
{:ok, der} = Stevedore.Sign.simple(image, key)Verify against a policy (default-deny) — an unknown key fails closed:
{:ok, [_ | _]} = Stevedore.Verify.image(image, %{keys: [key.public]}, signatures: [signature])
{:error, %Stevedore.Verify.Error{reason: :no_valid_signature}} =
Stevedore.Verify.image(image, %{keys: [other_pubkey]}, signatures: [signature])
# require: :all needs every policy key to have a valid signature (:any is the default):
{:ok, _} = Stevedore.Verify.image(image, %{keys: [k1.public, k2.public], require: :all}, signatures: sigs)Attach & list referrers — publish artifacts attached to an image, then discover them:
{:ok, {transport, _}} = Stevedore.Transport.Parse.parse("docker://ghcr.io/me/app:1.0")
subject_digest = image.manifest.digest
# Attach a signature artifact (or any %Image{}) — sets its subject and pushes it:
{:ok, _artifact_digest} = Stevedore.Referrers.attach(transport, subject_digest, signature)
# Attach an arbitrary artifact from raw bytes (e.g. an SBOM):
sbom = %{media_type: "application/spdx+json", data: spdx_json, artifact_type: "application/spdx+json"}
{:ok, _} = Stevedore.Referrers.attach(transport, subject_digest, sbom)
# List referrers (Referrers API, with the <algo>-<hex> tag-schema fallback):
{:ok, index} = Stevedore.Referrers.list(transport, subject_digest)
{:ok, referrers} = Stevedore.Manifest.manifests(index) #=> descriptors carrying :artifact_type
# Verify by fetching signatures over the transport (no need to hold them yourself):
{:ok, _} = Stevedore.Verify.image(subject_digest, %{keys: [key.public]}, transport: transport)9. Serve a writable /v2 registry
Intent: run a real registry (push + pull) backed by a directory. Opt-in (:plug/:bandit);
nothing boots until you call start_link/1.
{:ok, _pid} =
Stevedore.start_link(
store: "/var/lib/stevedore", # filesystem root for the registry data
port: 5000,
# The authz seam: action is :pull | :push | :delete. Default allows pull, denies writes.
authorize: fn _conn, action, _scope ->
if action == :pull, do: :ok, else: {:error, :unauthorized}
end
)
# Now any client speaks to it. Push with Stevedore's own client and pull it back:
{:ok, _} = Stevedore.copy("oci:./alpine:3.20", "docker://localhost:5000/library/alpine:3.20", scheme: "http")
{:ok, _} = Stevedore.copy("docker://localhost:5000/library/alpine:3.20", "oci:./roundtrip:3.20", scheme: "http")Mount the API inside a host Plug router (you supply the upload-session process and storage):
# In your supervision tree:
{Stevedore.Server.Uploads, name: MyApp.Uploads}
# In your Plug.Router:
forward "/v2", to: Stevedore.Plug,
init_opts: [store: "/var/lib/stevedore", uploads: MyApp.Uploads,
authorize: fn _conn, _action, _scope -> :ok end]The server implements the full pull/push surface (manifests, blobs, chunked upload sessions,
_catalog, tags/list, and the OCI 1.1 referrers endpoint built from stored subject fields).
10. Deploy a static, read-only registry
Intent: serve images from a dumb web server or object store — no registry process. tree/3
writes the v2/... layout and returns the per-manifest headers a static server can't infer.
{:ok, headers} = Stevedore.Deploy.tree("docker://alpine:3.20", "./public", name: "library/alpine")
headers["/v2/library/alpine/manifests/3.20"]
#=> %{"Content-Type" => "application/vnd.oci.image.manifest.v1+json", "Docker-Content-Digest" => "sha256:…"}
# Emit a server config that adds those headers (Docker-Distribution-Api-Version, Content-Type,
# Docker-Content-Digest) and serves the tree at /v2/...:
{:ok, nginx} = Stevedore.Deploy.nginx_config("./public", port: 5000)
{:ok, caddy} = Stevedore.Deploy.caddy_config("./public")
File.write!("registry.nginx.conf", nginx)11. The CLI (mix stevedore.*)
Intent: the same verbs from the shell — same transport-prefixed references, consistent errors,
non-zero exit on failure. Run mix help stevedore.<task> for full options.
# Copy / mirror
mix stevedore.copy docker://alpine:3.20 oci:./alpine:3.20
mix stevedore.copy docker://alpine:3.20 docker://ghcr.io/me/alpine:3.20 --all
mix stevedore.copy oci:./alpine:3.20 docker://ghcr.io/me/alpine:3.20 --format docker
# Inspect (default summary, or --raw for the manifest bytes)
mix stevedore.inspect docker://alpine:3.20
mix stevedore.inspect oci:./alpine:3.20 --raw
# List tags / delete
mix stevedore.list_tags docker://library/alpine
mix stevedore.delete oci:./alpine:3.20
# Bulk sync from a spec file ("SRC DST" per line; # comments)
mix stevedore.sync ./mirror.txt
# Sign / verify (PEM keys)
mix stevedore.sign docker://ghcr.io/me/app:1.0 --key cosign.key
mix stevedore.verify docker://ghcr.io/me/app:1.0 --key cosign.pub
# Deploy a static registry and emit a server config
mix stevedore.deploy docker://alpine:3.20 ./public --name library/alpine --server nginx --config registry.nginx.conf
12. End-to-end: build → sign → serve → verify
Intent: tie it together — assemble an image, run a registry, push it, sign it, and verify it back.
# 1. Build an image from a rootfs directory.
{:ok, image} = Stevedore.Build.from_dir("./rootfs", %{entrypoint: ["/bin/app"]})
# 2. Start a local registry (allow writes for this example).
{:ok, _} = Stevedore.start_link(store: "/tmp/registry", port: 5000,
authorize: fn _, _, _ -> :ok end)
ref = "docker://localhost:5000/me/app:1.0"
# 3. Push the built image.
{:ok, %{digest: digest}} = Stevedore.copy(image, ref, scheme: "http")
# 4. Sign it and attach the signature as a referrer.
key = Stevedore.Sign.Sigstore.generate_key()
{:ok, signature} = Stevedore.Sign.sigstore(image, key)
{:ok, {transport, _}} = Stevedore.Transport.Parse.parse(ref, scheme: "http")
{:ok, _} = Stevedore.Referrers.attach(transport, digest, signature)
# 5. Verify it, fetching the signature back over the registry.
{:ok, [_ | _]} = Stevedore.Verify.image(digest, %{keys: [key.public]}, transport: transport)
# 6. Inspect what's inside, and extract an SBOM.
{:ok, sbom} = Stevedore.Analyze.sbom(image)Core building blocks
The pure data types — no processes, no I/O. The recipes above lean on these; reach for them directly when you're working below the high-level verbs.
Stevedore.Reference — parse & normalize an image name
Turn a human image string into a normalized, addressable reference (applying the Docker Hub defaults everyone relies on).
{:ok, ref} = Stevedore.Reference.parse("alpine:3.20")
ref.registry #=> "registry-1.docker.io" # bare names default to Docker Hub
ref.repository #=> "library/alpine" # single-segment repos get the library/ prefix
ref.tag #=> "3.20"
ref.digest #=> nil
# A pinned-by-digest reference (no default tag is applied):
{:ok, ref} = Stevedore.Reference.parse("ghcr.io/owner/app@sha256:e3b0c4…")
ref.registry #=> "ghcr.io"
ref.digest.algorithm #=> :sha256
# Round-trips to a canonical string that re-parses equal:
Stevedore.Reference.to_string(ref) #=> "ghcr.io/owner/app@sha256:e3b0c4…"
# Caller mistakes are tagged, never raised:
Stevedore.Reference.parse("alpine@sha256:nothex") #=> {:error, {:bad_input, _}}Stevedore.Digest — content addressing
Compute, verify, and render the algorithm:hex digests that identify every blob.
d = Stevedore.Digest.compute("hello") # default :sha256; pass :sha512 for the other
to_string(d) #=> "sha256:2cf24dba5fb0…" (String.Chars too)
Stevedore.Digest.verify("hello", d) #=> :ok
Stevedore.Digest.verify("tampered", d) #=> {:error, :digest_mismatch}
Stevedore.Digest.to_path(d) #=> "sha256/2cf24dba5fb0…" (OCI blob layout)
# Parsing validates the algorithm allowlist and hex length/case — so a bad digest can never reach
# the on-disk Store and traverse out of the blob tree:
Stevedore.Digest.parse("sha256:../../etc") #=> {:error, {:bad_input, _}}Stevedore.MediaType — classify media types
Decide what a descriptor points at, and how a layer is compressed, without hardcoding the (many) OCI and Docker type strings.
Stevedore.MediaType.manifest?("application/vnd.oci.image.manifest.v1+json") #=> true
Stevedore.MediaType.index?("application/vnd.docker.distribution.manifest.list.v2+json") #=> true
Stevedore.MediaType.gzip?("application/vnd.oci.image.layer.v1.tar+gzip") #=> true
Stevedore.MediaType.zstd?("application/vnd.oci.image.layer.v1.tar+zstd") #=> true
# Canonical constants + the Accept set used when fetching manifests:
Stevedore.MediaType.oci_manifest() #=> "application/vnd.oci.image.manifest.v1+json"
Stevedore.MediaType.all_manifest_types() #=> [all manifest + index types, OCI and Docker]Stevedore.Descriptor — a typed, digest-addressed pointer
The element a manifest uses to reference its config, layers, and (for an index) its per-platform children.
{:ok, desc} =
Stevedore.Descriptor.from_json_full(%{
"mediaType" => "application/vnd.oci.image.manifest.v1+json",
"digest" => "sha256:e3b0c4…",
"size" => 7,
"platform" => %{"os" => "linux", "architecture" => "arm64", "variant" => "v8"}
})
desc.platform #=> %{os: "linux", architecture: "arm64", variant: "v8", os_version: nil}
Stevedore.Descriptor.to_json(desc) #=> JSON-ready map; empty optional fields are omittedStevedore.Manifest — image manifest or index
Parse manifest bytes once and ask structural questions; pick a platform from a multi-arch index.
raw is preserved so the digest is stable.
{:ok, manifest} = Stevedore.Manifest.parse(raw_bytes, content_type_or_nil)
Stevedore.Manifest.kind(manifest) #=> :manifest | :index (sniffed if no media type)
# For a single image manifest:
{:ok, config_descriptor} = Stevedore.Manifest.config(manifest)
{:ok, layer_descriptors} = Stevedore.Manifest.layers(manifest)
# For a multi-arch index: list children, or select one by platform (defaults to the host):
{:ok, children} = Stevedore.Manifest.manifests(index)
{:ok, descriptor} = Stevedore.Manifest.select(index, os: "linux", architecture: "arm64")
{:error, :no_match} = Stevedore.Manifest.select(index, os: "linux", architecture: "ppc64le")
Stevedore.Manifest.host_platform() #=> [os: "linux", architecture: "amd64"] (BEAM arch mapped)Stevedore.Config — the image runtime config
Read entrypoint/cmd/env/user/workdir/labels and the rootfs.diff_ids (digests of the uncompressed
layers — distinct from the manifest's compressed layer digests).
{:ok, config} = Stevedore.Config.parse(config_blob_bytes)
config.entrypoint #=> ["/bin/app"] | nil
config.os #=> "linux"
config.rootfs_diff_ids #=> [#Stevedore.Digest<sha256:8d3ac3489996…>, ...] (each a %Stevedore.Digest{})Stevedore.Archive — tar & compression
Layers are tarballs; read and write them without shelling out to tar. gzip is native; zstd uses
the optional :ezstd NIF.
entries = [
%{name: "etc/", type: :directory, mode: 0o755, size: 0, linkname: nil, content: nil},
%{name: "etc/hi", type: :regular, mode: 0o644, size: 2, linkname: nil, content: "hi"}
]
tar = Stevedore.Archive.write!(entries) # ustar; raises on an unencodable entry
{:ok, ^entries} = Stevedore.Archive.read(tar) # also reads GNU long-name + PAX from real images
# Compression:
gz = Stevedore.Archive.gzip(tar) #=> binary (deterministic: no timestamp in header)
{:ok, ^tar} = Stevedore.Archive.gunzip(gz)
Stevedore.Archive.zstd_available?() #=> false unless {:ezstd, "~> 1.1"} is added
# Stevedore.Archive.zstd(tar) / unzstd(zstd) # raise a clear error when :ezstd is absentStevedore.Store — content-addressed storage
On-disk transports persist blobs by digest through one interface, so backends are interchangeable. Writes are atomic and digest-verified.
digest = Stevedore.Digest.compute("blob-bytes")
# Filesystem store — config is a root path (or `[root: path]`); blobs land at <root>/blobs/<algo>/<hex>:
:ok = Stevedore.Store.Local.put("/var/lib/stevedore", digest, "blob-bytes")
{:ok, "blob-bytes"} = Stevedore.Store.Local.get("/var/lib/stevedore", digest)
true = Stevedore.Store.Local.exists?("/var/lib/stevedore", digest)
Stevedore.Store.Local.put("/var/lib/stevedore", digest, "WRONG") #=> {:error, :digest_mismatch}
# In-memory store (tests / ephemeral) — config is the agent pid:
{:ok, store} = Stevedore.Store.Memory.start_link([])
:ok = Stevedore.Store.Memory.put(store, digest, "blob-bytes")
Stevedore.Store.Memory.local_path(store, digest) #=> :unsupported (no on-disk path)