Skip to content

Build REST and MCP service images

The official apizr-oci extension builds a local service image from a direct repository exposure bundle. It does not build a governed execution worker, push to a registry or install business dependencies. Install and enable it explicitly; the minimal Apizr environment does not acquire Docker, REST or MCP dependencies. This first package is version 0.0.0, available from the checkout, not published.

Prerequisites and installation

Use Python 3.11–3.14, uv, Docker Engine with BuildKit/Buildx, and an explicit local Unix socket. The target is linux/amd64 or linux/arm64. Cross-platform builds require an already configured emulator; Apizr does not install one. Choose and review a Python base image with python, venv and pip, pinned by SHA-256 digest. The base, Docker binaries and dependency wheels remain trusted code.

Build the two wheels, then leave the checkout. These preparation commands may access package indexes; installation through apizr plugins install is offline. The plugin gets its own copy of Apizr's validation code and Pydantic in its own locked environment. No Docker SDK is required anywhere.

work=$(mktemp -d)
uv build --wheel --out-dir "$work/plugin-wheels"
uv build --wheel plugins/oci --out-dir "$work/plugin-wheels"
cd "$work"
uv venv --seed --python 3.14 prepare
prepare/bin/python -m pip download --only-binary=:all: --dest plugin-wheels \
  plugin-wheels/outerspace_apizr-0.3.0-py3-none-any.whl \
  plugin-wheels/apizr_oci-0.0.0-py3-none-any.whl
uv venv --python 3.14 core
uv pip install --python core/bin/python --offline --no-index \
  --find-links plugin-wheels plugin-wheels/outerspace_apizr-0.3.0-py3-none-any.whl

Create lock_wheels.py for the reviewed wheels downloaded for this interpreter and platform. It records the exact bytes; hashes establish integrity, not trust in the author. Keep one wheel per distribution and all transitive dependencies. Do not include sdists or unrelated wheels.

import hashlib
import sys
import zipfile
from email.parser import BytesParser
from pathlib import Path

lines = []
for wheel in sorted(Path(sys.argv[1]).glob("*.whl")):
    with zipfile.ZipFile(wheel) as archive:
        name = next(n for n in archive.namelist() if n.endswith(".dist-info/METADATA"))
        metadata = BytesParser().parsebytes(archive.read(name))
    digest = hashlib.sha256(wheel.read_bytes()).hexdigest()
    lines.append(f"{metadata['Name']}=={metadata['Version']} --hash=sha256:{digest}\n")
Path(sys.argv[2]).write_text("".join(lines))
prepare/bin/python lock_wheels.py plugin-wheels plugin.lock
plugin_sha=$(prepare/bin/python -c 'import hashlib; from pathlib import Path; print(hashlib.sha256(Path("plugin-wheels/apizr_oci-0.0.0-py3-none-any.whl").read_bytes()).hexdigest())')
core/bin/apizr plugins install plugin-wheels/apizr_oci-0.0.0-py3-none-any.whl \
  --sha256 "$plugin_sha" --requirements plugin.lock --wheelhouse plugin-wheels \
  --plugins-dir "$work/plugins"
core/bin/apizr plugins enable apizr-oci --version 0.0.0 --plugins-dir "$work/plugins"

The lock uses the existing restricted requirements syntax: name==version --hash=sha256:HEX, one SHA-256 per package, comments and continuations allowed. Includes, URLs, paths, index options, markers, extras and editable installs are refused. Wheel archive and startup-file protections are reused. Metadata identity and dependency headers retain their 64 KiB bound; unused long descriptions are not parsed or loaded. Compressed/expanded archive limits still apply.

Generate a bundle and prepare server dependencies

Use the existing Git workflow, with a reviewed commit and local policies:

core/bin/apizr expose build rest --git "$REPOSITORY_HTTPS_URL" --ref "$COMMIT" \
  --policy exposure.json --readiness-policy readiness.json --output-dir rest-bundle
core/bin/apizr expose build mcp --git "$REPOSITORY_HTTPS_URL" --ref "$COMMIT" \
  --policy exposure.json --readiness-policy readiness.json --output-dir mcp-bundle

Both policies must explicitly allow direct execution. The exposure policy selects the capabilities and includes rest and mcp as appropriate. See Git sources for a complete acquisition example.

For each interface, resolve and download the server wheels using the target Python image/platform, then create the lock. This preparation may access an index; the subsequent build installs only the checked local wheel snapshot. Example for REST, replacing the digest with the base you reviewed:

base=python@sha256:caaf356f40667c496d405780745b9ac25771c189a51dfcc42430d531ea09f8a2
platform=linux/amd64
mkdir rest-wheels
docker pull --platform "$platform" "$base"
docker run --rm --user "$(id -u):$(id -g)" --env HOME=/tmp --platform "$platform" \
  --mount "type=bind,src=$work/rest-wheels,dst=/wheels" \
  --mount "type=bind,src=$work/rest-bundle/requirements.txt,dst=/requirements.txt,readonly" \
  "$base" python -m pip download --only-binary=:all: --dest /wheels -r /requirements.txt
prepare/bin/python lock_wheels.py rest-wheels rest.lock

Use mcp-bundle/requirements.txt, mcp-wheels and mcp.lock for MCP. Do not use macOS wheels in a Linux image. pip performs the resolution and compatibility checks. The resolved server dependency closure must equal the lock; additional business packages are refused. Installations require hashes, no index, no source builds and no network in Dockerfile RUN instructions. Base-image resolution may still contact its registry even when layers are cached; --network=none does not prevent that. Registry credentials and private bases are outside this pass.

Build and run

A complete build.json (replace the absolute paths with your prepared paths):

{
  "schema": "apizr.oci-build/v1",
  "bundle": "/tmp/apizr-example/rest-bundle",
  "interface": "rest",
  "base_image": "python@sha256:caaf356f40667c496d405780745b9ac25771c189a51dfcc42430d531ea09f8a2",
  "platform": "linux/amd64",
  "tag": "apizr-service:rest-example",
  "requirements": "/tmp/apizr-example/rest.lock",
  "wheelhouse": "/tmp/apizr-example/rest-wheels",
  "docker": {
    "executable": "/usr/bin/docker",
    "socket": "/var/run/docker.sock",
    "buildx": null
  },
  "timeout_ms": 300000,
  "max_log_bytes": 1048576
}

buildx: null uses Docker's system-installed Buildx. When it is not installed system-wide, pass its absolute executable path, for example /Applications/Docker.app/Contents/Resources/cli-plugins/docker-buildx with Docker Desktop. Its user socket is usually $HOME/.docker/run/docker.sock. The build uses a fresh Docker configuration; it does not load user contexts, credential helpers, authentication headers or inherited DOCKER_* variables. Only the explicit binary, Buildx and socket are used. TCP/SSH Docker endpoints are not supported in this pass.

core/bin/apizr plugins run apizr-oci build --arguments build.json \
  --timeout-ms 360000 --plugins-dir "$work/plugins"
docker run --rm --publish 127.0.0.1:8000:8000 apizr-service:rest-example
curl --fail -H 'Content-Type: application/json' \
  -d '{"a":2,"b":3}' http://127.0.0.1:8000/capabilities/calculator.add

For MCP, set interface to mcp, use its bundle/lock/wheelhouse and a different tag, then build the same way. Start it with docker run --rm --interactive TAG (no TTY): it speaks MCP stdio. The plugin and Docker build logs never share the service's protocol stdout. REST binds 0.0.0.0:8000; both images run as numeric user 65532:65532 without a mounted Docker socket. Business code is loaded only when the generated service starts, with its existing bundle integrity checks.

The outer apizr.extension/v1 response contains result.tag, platform, image_id, inputs_sha256 and published: false. BuildKit's metadata and Docker inspection must agree before success. image_id is the local Engine identity (classic and containerd stores differ); it is not a claim about a manifest published in a registry. inputs_sha256 hashes the canonical path/hash map of private build inputs, including wheels, bundle, lock, generated Dockerfile and target. It does not promise bit-reproducible Docker layers or attest provenance.

Limits and cancellation

Only direct repository bundles are accepted. The plugin calls validate_bundle, never load_bundle, during preparation. It copies only canonical declared files, not an entire directory, and ignores unrelated Dockerfiles, .git, .env and SSH files. Declared symlinks, path traversal and special files are refused. A bundle is limited to 4,096 declared files / 64 MiB total, its manifest to 1 MiB. The existing lock limits apply: 64 KiB lock, 128 wheels, 64 MiB per wheel, 256 MiB total compressed and 512 MiB total expanded. Prepared private bytes, not mutable source paths, reach Docker.

Plugin build duration defaults to 300,000 ms and is limited to 540,000 ms. Client stdout/stderr share a 1 MiB default budget (configurable to at most 16 MiB) and are counted while read. plugins run --timeout-ms accepts 1–600,000 ms and keeps its existing 10,000 ms default; allow headroom for startup, preparation and cleanup. The plugin's temporary context lives under the extension runtime's owned working directory, so runtime interruption removes it even when the plugin is killed. The Python build(..., workspace=...) API requires caller-owned scratch storage; use the existing extension runtime for process-group ownership and cancellation.

Killing the Docker client does not prove that daemon work stopped. A timed-out or interrupted call returns no successful image result. A daemon build, cache or unreported image may remain, including a tag assigned just before interruption. No global prune or deletion of previous user images occurs. Use disposable Docker runners for build tests and inspect the explicitly chosen tag before retrying. This mechanism is not a sandbox or a daemon resource quota.

The proof script installs both packages outside the checkout, acquires a real HTTPS Git fixture, builds and calls REST/MCP after removing sources and bundles, interrupts after an explicit Docker progress handshake, and compares all core files/distributions. The dedicated CI runner preserves its results, locks and build inputs. No image is published; only named test containers/images are removed.

References: Docker build metadata and network semantics, pip hash-checked installs.