Tom’s Pants backends
This is the documentation for my Pants backend, which I use for my own projects - especially managing my own cloud, but also building open-source software.
Please see the documentation for more information!
See CONTRIBUTING.md for verifying changes and the Pants upgrade checklist.
Binary tool versions
The plugin defaults and download metadata were refreshed on 2026-10-16; crane
was added on 2026-10-21 and zstd on 2026-10-22. Each known-version entry pins both SHA-256 and byte size. Versions can be selected with
the corresponding subsystem’s --version option.
| Tool | Default | Additional release lines covered |
|---|---|---|
| kubectl | 1.37.1 | 1.36.5, 1.35.9, 1.34.12 |
| Kustomize | 5.8.3 | 5.7.1, 5.0.3, 4.5.7 |
| crane | 0.22.1 | Latest only |
| mdBook | 0.5.4 | Latest 0.4 (0.4.52), plus existing historical entries |
| Bitwarden CLI | 2026.10.0 | 2026.9.1, plus existing historical entries |
| zstd | 1.5.6-bcr2 | Latest only |
The OCI plugin uses crane to pull, publish, and mirror images. It writes packaged images as OCI archives itself, without an external tool.
Kubernetes and Kustomize
For kubectl, record the latest patch of each currently maintained minor and remove entries when that minor reaches EOL. Consult the official release and EOL dates on every refresh. Kubernetes 1.34 remains in the table until its EOL on 2026-10-27.
The selected kubectl must be within one minor of the cluster’s API server,
according to the version skew policy.
For example, use --kubectl-tool-version=v1.35.9 for a cluster requiring that
client minor.
Standalone Kustomize has its own release versions. The Kustomize embedded in kubectl is a separate dependency; see the upstream kubectl/Kustomize version mapping. The standalone table intentionally retains the final 4.x release and selected 5.x minors for compatibility. Inclusion in the table does not promise ongoing upstream maintenance of an older release line.
Binary providers and platforms
Kubernetes, Kustomize, crane, mdBook, and Bitwarden use official release
artifacts. crane comes from the
go-containerregistry release
tarballs.
zstd’s upstream publishes Windows binaries only, so its downloads use the static
builds from aspect-build/zstd-prebuilt,
which Bazel’s bazel-lib uses as well. The OCI plugin runs it to read
zstd-compressed .deb packages, such as Ubuntu’s.
The refreshed cross-platform tools include Linux and macOS on x86-64 and ARM64.
Recent mdBook releases use native Apple Silicon binaries, while its retained
older releases keep their Intel/Rosetta download mapping.
mdBook 0.5 rejects the removed [book].multilingual setting; remove it from
existing book configurations when switching from 0.4.
Odin and odinfmt are unchanged by this refresh.
Verification
The subsystem download tests cover every recorded version/platform pair and
exercise Pants’ URL generation, hash/size verification, and archive extraction.
Run the five subsystem_test.py files under k8s, kustomize, oci, mdbook, and
bitwarden after updating the tables. Successful downloads do not establish
runtime compatibility on a different host architecture.
Kustomize backend for Pants
This release targets Pants 2.31.0. Install it with
pants-backend-kustomize==0.7.0 in [GLOBAL].plugins in pants.toml.
Warning This plugin is in development. No stability is guaranteed! Contributions welcome.
This backends implements targets for kustomize templates.
- kustomize for overlaying state ontop of raw kubernetes files
Tool versions
The default is standalone Kustomize v5.8.3. Versions v5.7.1,
v5.0.3, and v4.5.7 are also recorded for Linux/macOS on x86-64 and ARM64.
Select one with --kustomize-tool-version=v4.5.7 or [kustomize-tool].version.
Standalone Kustomize versions are independent of the Kustomize embedded in
kubectl; older entries provide compatibility choices rather than a promise of
ongoing upstream maintenance.
Planned and missing features
- Key/secret/… generation from built artifacts
Targets
There’s currently one target.
kustomize
A code-generation target for converting a bundle of kubernetes files into a single multi-docuent YAML file with state injected from other Pants targets.
kustomize(
name="kustomize",
sources=[
"deployment.yaml",
"server.py",
"service.yaml",
"namespace.yaml",
"kustomization.yaml",
],
dependencies=[":bin"],
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
sources | Resources used by this target | Required |
dependencies | Targets to package and pass to the build context, as well as bases | [] |
decsription | A description of the target | |
tags | List of tags | [] |
For dependencies, the builder will replace labels in the kustomization.yaml with the path of the built package.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.7.0 - 2026-10-17
-
Target Pants
2.31.0. -
Require
pants_backend_k8s>=0.7.0. -
Replace remaining union-dispatched
Getcalls with polymorphic call-by-name rules. -
Default to Kustomize
v5.8.3; backfillv5.7.1andv5.0.3, and retain the final 4.x release,v4.5.7, for all four Linux/macOS architectures. -
Add download checks for every recorded version/platform pair.
0.6.1 - 2026-10-16
- Replace
pdm-pep517withpdm-backendto produce correctly normalized source archive names for PyPI. - Require
pants_backend_k8s>=0.6.1.
0.6.0 - 2026-10-16
- Now targets
pantsversion2.30. - Migrate rules to the Pants call-by-name API.
0.5.0 - 2025-05-14
- Now targets
pantsversion2.24.
0.4.0 - 2024-09-19
- Now targets
pantsversion2.22. - Add
tailorsupport - Import
gitto sandbox during expansion to support github downloads- submodule check seems to fail so it might help to add
?submodules=falseto the link to disable it
- submodule check seems to fail so it might help to add
[0.3.0] - 2023-11-19
- Target pants version is now 2.18.0
[0.2.0] - 2023-06-18
- Target pants version is now 2.16.0, with support for 2.15.0.
[0.1.2] - 2022-12-06
- Add support for specializing how other packages are injected into Kustomize files.
[0.1.1] - 2022-11-17
- Move to new repository
[0.1.0] - 2022-11-13
Initial release.
Kubernetes backend for Pants
This release targets Pants 2.31.0. Install it with
pants-backend-k8s==0.7.0 in [GLOBAL].plugins in pants.toml.
Warning This plugin is in development. No stability is guaranteed! Contributions welcome.
This backends implements targets for working with Kubernetes clusters using raw YAML.
- kubectl for cluster operations
Tool versions
The default is kubectl v1.37.1, with v1.36.5, v1.35.9, and
v1.34.12 also recorded for Linux/macOS on x86-64 and ARM64. Select a version with
--kubectl-tool-version=v1.35.9 or [kubectl-tool].version in pants.toml.
Follow the Kubernetes release/EOL dates when
choosing a minor; kubectl must be within one minor of the API server. The 1.34
entries are due for removal after their 2026-10-27 EOL.
Targets
There’s currently three targets for pants-backend-k8s:
k8s_source
A file that can be passed into other k8s fields that are not source fields. For example, k8s_object.template.
k8s_source(
name="namespace.yaml",
source="namespace.yaml",
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
source | The raw file | Required |
decsription | A description of the target | |
tags | List of tags | [] |
This’ll eventually be automated like other rules once a suitable heuristic for generation with tailor is found. PRs welcome!
k8s_object
Input for a kubernetes command, either generated via kustomize or via k8s_source.
k8s_object(
name="k8s",
description="the chat backend"
template=[":kustomize"],
namespace="chat-app",
cluster="prod",
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
template | The target to act on | Required |
namespace | Namespace to target | Optional, will use default kubectl namespace |
cluster | cluster to target | Optional, will use default kubectl cluster |
decsription | A description of the target | |
tags | List of tags | [] |
k8s_object is a generator for kubernetes target parametrized by the potential commands that are available: apply,
create, get, describe, replace, and delete.
k8s_objects
A collection of kubernetes objects that should be managed together.
k8s_objects(
name="my-service",
description="all components of service-x"
objects=[":namespace", ":deployment"],
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
objects | k8s_object targets that should be managed | Required |
decsription | A description of the target | |
tags | List of tags | [] |
Like k8s_object, k8s_objects is a generator for parametrized targets for the commands that are available: apply,
create, get, describe, replace, and delete.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.7.0 - 2026-10-17
-
Target Pants
2.31.0. -
Replace remaining union-dispatched
Getcalls with polymorphic call-by-name rules. -
Default to kubectl
v1.37.1; include the latest patches of maintained minorsv1.36.5,v1.35.9, andv1.34.12, and remove EOLv1.25.2. -
Include Linux ARM64 binaries alongside Linux x86-64 and both macOS architectures.
-
Verify every recorded version/platform download, including macOS checksums that were incorrect in the previous table.
-
Track Kubernetes EOL dates when refreshing the table; 1.34 reaches EOL on 2026-10-27.
0.6.1 - 2026-10-16
- Replace
pdm-pep517withpdm-backendto produce correctly normalized source archive names for PyPI.
0.6.0 - 2026-10-16
- Now targets
pantsversion2.30. - Migrate rules to the Pants call-by-name API.
0.5.0 - 2025-05-14
- Now targets
pantsversion2.24.
0.4.0 - 2024-09-19
- Now targets
pantsversion2.22. - Kubeconfig files are no longer automatically picked up from the host. In order to support scripted
provisioning and local configuration files, all targets now take a
kubeconfigfield. This can point to eitherkubeconfigtarget which uses a straight source or a generated, or ahost_kubeconfigtarget which will attempt to load from~/.kube/config. Both of these also allow you to specify default namespaces, contexts, clusters and users. All these fields can now also be specified on the object, whereas only cluster/context could before.
[0.3.0] - 2023-11-19
- Target pants version is now 2.18.0
[0.2.0] - 2023-06-18
- Target pants version is now 2.16.0, with support for 2.15.0.
[0.1.1] - 2022-11-17
- Move to new repository
[0.1.0] - 2022-11-12
- Initial release.
OCI backend for Pants
This release targets Pants 2.31.0. Install it with
pants-backend-oci==0.10.0 in [GLOBAL].plugins in pants.toml.
Warning This plugin is in development. No stability is guaranteed! Contributions welcome.
This is a backend implementing support for building OCI images in pants; running them, and publishing them to container registries. Images are assembled by the plugin itself: layers, config, manifest and index are written by a small Python script run in a sandboxed process, deterministically and without any container tool. Beyond that, this plugin uses these tools:
oci-builder, a scie the plugin builds with pex from its own builder scripts and a pinned python-build-standalone Python. It is built once per machine and downloads that Python and pex’ssciencetool from GitHub the first time; on first use it unpacks into theoci_buildernamed cache (pants runuses~/.cache/nceand~/.cache/pexinstead)- podman 4.0 or newer, installed by the user, for
pants run, OCI repl and scripted build steps (on macOS, run and repl only, throughpodman machine); or Docker 23.0 or newer instead, with[oci].runner = "docker"(see Running with Docker) - crane for pulling, pushing and mirroring images
pants package writes each oci_image_build to dist/ as an OCI archive, <name>.tar, with the
image named repository:tag. Load it with podman load -i <archive> or docker load -i <archive>.
Running images
Install podman on Linux and configure rootless execution,
including /etc/subuid and /etc/subgid mappings for your user. Run Pants as an unprivileged user.
Declarative packaging and registry publishing do not require podman; scripted build steps do
(see below).
pants run examples/oci/debian:image runs the image’s entrypoint and default command.
Arguments after -- replace its command arguments while keeping the entrypoint, for example
pants run examples/oci/debian:image -- --help. terminal=True allocates a TTY;
pants repl --shell=oci <image-target> also attaches stdin. Repl runs the image’s configured
command, so select an image whose command provides the interaction you need.
Podman uses a dedicated oci_podman/v2 store under Pants’ named-cache directory, with the
portable vfs storage driver. Its runtime state lives in your runtime directory,
$XDG_RUNTIME_DIR (or /run/user/<uid>), named by a hash of the store path to keep podman’s
paths short; podman ties a store to that location, so pants run and scripted build steps must
see the same XDG_RUNTIME_DIR. Exited containers are removed; imported images and layers remain
cached across runs. This store can grow and is separate from your default podman store. A run or
build step that is interrupted (Ctrl-C, a timeout) can also leave its container and image behind.
To clear all of it, imported bases included, run this while no Pants build is running; the files
belong to your subordinate user ids, hence podman unshare:
podman unshare rm -rf <named-caches-dir>/oci_podman
The plugin’s own flags select the store, runtime directory, vfs driver and cgroupfs
cgroup manager, and override any podman configuration. Otherwise pants run and scripted
build steps read the same podman configuration: the system files and your
~/.config/containers. Scripted build steps don’t see environment variables that select
other configuration files, such as CONTAINERS_CONF or XDG_CONFIG_HOME. Configuration isn’t
part of a build step’s cache key, so after changing it, rebuild with --no-local-cache.
CONTAINER_HOST and CONTAINER_CONNECTION are ignored: [oci].podman_connection alone decides
whether podman runs locally or through a connection.
Running through podman machine (macOS) or another connection
On macOS, pants run and OCI repl run images through podman machine:
brew install podman
podman machine init
podman machine start
Podman is found on [system-binaries].system_binary_paths, which includes Homebrew’s
/opt/homebrew/bin and /usr/local/bin. The podman.io installer and Podman Desktop install to
/opt/podman/bin; add it there:
[system-binaries]
system_binary_paths = ["/opt/podman/bin", "<PATH>"]
With [oci].podman_connection empty, the default, podman’s default connection is used, which
podman machine init sets up. Set it to a name from podman system connection list to use
another machine or a remote podman service, on any platform; on Linux, run and repl then use it
instead of the local store described above, while scripted build steps keep using the local store.
The option only applies with [oci].runner = "podman". If the connection can’t be reached, the run
fails with podman’s error and, when no podman machine exists or none is running, what to start.
Podman’s remote client can’t run an OCI archive directly, so each image is first loaded into the
connection’s own store as localhost/pants-oci-run:<image digest>, then run with --rm.
Arguments, terminal, stdin and exit status behave as with local podman. Loaded images are kept, so
running the same image again starts without transferring it (two first runs of an image at once
may both load it). Nothing else in that store is touched. These images accumulate as images change; remove
them while nothing runs from them:
podman image ls --filter reference=localhost/pants-oci-run
podman image rm $(podman image ls --quiet --filter reference=localhost/pants-oci-run)
Limits: scripted build steps (commands=, oci_build_layer, oci_extract) still need local Linux
podman and fail on macOS, so images that use them can’t be built there; declarative packaging and
publishing need no podman. The first run of each image copies its archive into the machine, which
takes a while for large images. The image must suit the machine’s architecture, as there is no
multi-platform support.
Running with Docker
Teams without podman can run images and scripted build steps with Docker instead. Enable Pants’ Docker backend and this plugin’s Docker runner, and select it:
[GLOBAL]
backend_packages.add = ["pants.backend.docker", "pants_backend_oci.docker"]
[oci]
runner = "docker"
Docker is found and configured by Pants’ Docker backend, through its [docker] options:
executable_search_paths finds docker, env_vars passes variables such as DOCKER_HOST and
DOCKER_CONFIG (for pants run your whole environment is passed as well), tools puts extra
tools such as credential helpers on its PATH, and run_args adds arguments to docker run, such
as published ports. If the Docker backend’s experimental podman support is on, it may pick podman’s
Docker-compatible command instead.
Docker 23.0 or newer is required: scripted build steps use BuildKit, Docker’s default builder from
23.0, to build from the loaded image and export files. docker load reads the plugin’s archives
on any version, as they also carry Docker’s manifest.json.
Each pants run and build step loads the image into the Docker daemon as
pants-<digest>:latest. Built images are removed, but loaded ones stay in the daemon’s store,
where they can be shared by later runs and steps, and accumulate. A build step that is killed
can also leave its pants-step-* image behind. Remove them all, while no Pants build is running,
with docker images --filter=reference='pants-*' -q | xargs docker rmi. Ownership inside the
container is whatever your daemon gives it: a rootful daemon keeps the image’s owners as they are.
Scripted build steps aren’t keyed on the daemon’s configuration, only on the docker binary, so
after changing the daemon rebuild with --no-local-cache.
Pants store size
Every layer and image the plugin builds is a file in the Pants store (~/.cache/pants/lmdb_store,
or your local_store_dir), and every rebuild after a change adds new ones. With multi-GB images
that adds up quickly: one repo went from 27 GB to 80 GB while benchmarking. Only pantsd trims
the store: an hour after it starts and hourly after that, it shrinks the store to a tenth of the
store limits combined, about 29 GB by default. With --no-pantsd, as is common in CI, it is never
trimmed. Lowering the files limit lowers that target:
[GLOBAL]
local_store_files_max_size_bytes = 64_000_000_000
This trims the store to about 10 GB. Files over 512 KiB, such as layers, are kept outside the store’s LMDB shards, so the limit doesn’t cap the size of a single layer. To reclaim the space at once, delete the store directory while no Pants process is running.
Scripted build steps
commands= on oci_image_build and oci_build_layer runs a command inside the image. It is an
escape hatch: everything else is built declaratively, without running anything, and is
reproducible. A scripted step is only as reproducible as its command.
Scripted steps run through podman build, from the image as an OCI archive and with
--timestamp 0, as root inside the container. They need the same local, rootless Linux podman as
pants run and share its store. Network access follows podman’s defaults. With
[oci].runner = "docker" they run through docker build instead, from the image loaded into
the daemon, and the layer a step adds is rewritten with zeroed timestamps, as podman’s
--timestamp 0 gives; network access follows Docker’s defaults.
A string command runs through [oci].command_shell (/bin/sh -c by default), so the base image
must have that shell. On a base without one, such as a distroless image, give a list of strings
instead: like a Dockerfile’s exec-form RUN, it runs as that argv with no shell, so there are no
pipes, && or variable expansion, and the program is looked up on the image’s PATH.
examples/oci/multistage builds a PEX venv this way on the distroless Python base:
oci_build_layer(
name="app-deps",
base=["//examples/oci:python3-d12#latest"],
packages=[":binary-deps"],
commands=["python", "/examples.oci.multistage/binary-deps.pex", "venv", "--scope=deps", "--compile", "/bin/app"],
env=["PEX_TOOLS=1"],
outputs=["bin/app"],
)
oci_build_layerkeeps only itsoutputsand packages them as a layer tarball for other images, with the owners and modes they have in the image and zeroed timestamps. An output may be a glob, matched one path segment per segment (usr/lib/*/libfoo.so); matches keep their paths.oci_image_buildwithcommands=adds what the command changed as one more layer on top of the image’s layers, which are kept as they are.oci_extractcopies itsoutputsout of an image without running anything, but still reads the image through podman, or Docker.
A failing command or a missing output fails the build. examples/oci/scripted shows all three.
Tool versions
The defaults are crane v0.22.1 and zstd v1.5.6-bcr2. crane uses the official
go-containerregistry release archives.
zstd, used by oci_deb_layer, oci_cacerts_layer and oci_locale_layer to read zstd-compressed packages such as
Ubuntu’s, comes from the static builds of aspect-build/zstd-prebuilt
for Linux/macOS on x86-64 and ARM64. It is downloaded like the other tools, not installed as a Python
dependency.
Use [crane].version or [zstd].version to select another recorded
version.
oci-builder runs on CPython 3.11.17 from python-build-standalone release 20261009; set
[oci].builder_python_version and [oci].builder_pbs_release to use another. Layer compression
depends on that Python’s zlib, so a different one may change layer digests (older ones may also be
slower: 3.11.10 from 20241008 gzips at about half the speed). Unlike crane and zstd, which
Pants checks against the hashes recorded in known_versions, the Python and pex’s science tool
are checked only against the checksum files published in the same GitHub releases, through pex;
no hash in this repository or in pants.toml pins them.
Layers are gzipped at level 6 in 1 MiB blocks spread over up to 16 cores, like pigz. The blocks
make the output independent of how many cores ran it; like the rest of compression, it still
depends on oci-builder’s zlib. [oci].layer_compression_level (1 to 9) trades build time for size; changing it changes
layer digests.
Registry credentials are read the way crane reads them, from the first of these that
exists: ~/.docker/config.json (or $DOCKER_CONFIG/config.json) with its credential
helpers, then $REGISTRY_AUTH_FILE, then podman’s containers/auth.json under
$XDG_RUNTIME_DIR or $XDG_CONFIG_HOME (~/.config). A docker config therefore hides a
podman login. Pulled images are fetched for
the target’s os/architecture, defaulting to linux and the host architecture, and
Docker v2 manifests are converted to OCI.
Kustomize injection publishes locally built image dependencies to their configured
registry before inserting their image digest. An omitted registry tag defaults
to latest.
Planned and missing features
- Currently there’s no support for pulling tags, as that would break determinism
- Multi-platform SHA/.sig is untested/unsupported
- Scripted build steps require local Linux podman; only
pants runand OCI repl work throughpodman machineor another podman connection.
Targets
There’s eleven targets currently implemented, of which ten are generic:
oci_pull_imageoci_pull_imagesoci_image_buildoci_image_emptyoci_build_layeroci_deb_layeroci_passwd_layeroci_group_layeroci_cacerts_layeroci_locale_layer
And one with some special language semantics:
oci_python_image- this is the same asoci_image_build, but will prefer to set the entrypoint to.pexfiles.
oci_pull_image
Pull an image from a repository with a specific digest.
oci_pull_image(
name="base-python",
repository="docker.io/library/python",
sha="b78b777208be08edd8f297035cdfbacddb45170ad778fd643c792ee045187e39"
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
repository | Fully qualified repository name | Required |
sha | The digest of the image, minus the @sha: prefix. | Required |
anonymous | Whether to pull the image anonymously. | false |
description | A description of the target | |
tags | List of tags | [] |
oci_pull_images
Pull multiple shas for an image, generating a target for each. In the below example, we’d get the targets :python#slim and :python#buster.
oci_pull_image(
name="python",
repository="docker.io/library/python",
variants={
"slim": "f8fbb2370c6314c806b2ddbec8d94375987e16bc122379bef979c6fc5e962920",
"buster": "97c123c899c8c9ca46248f4002ec4173322e0a1086b386efefac163c64967ba2"
}
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
repository | Fully qualified repository name | Required |
variants | Dictionary with local tags to the remote sha | Required |
anonymous | Whether to pull the image anonymously | false |
description | A description of the target | |
tags | List of tags | [] |
oci_build_image
Build an image with the provided packages embedded.
oci_image_build(
name="my-server",
base=":python#slim",
repository="my-registry.example.com/a-namespace/an-image",
tag="latest",
packages=[":my_pex"]
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
base | The base image to use. Matches the FROM directive in a Dockerfile | Required |
packages | Packaged targets to include. The first element will be used as the entrypoint. | [] |
repository | Fully qualified repository name | Required when publishing |
tag | Remote tag to use | Required when publishing |
user | The user to run as: a name or uid, optionally : and a group name or gid. Not checked at build time; the runtime resolves names when the container starts. | The base image’s user |
description | A description of the target | |
tags | List of tags | [] |
oci_python_image
Build a Python image with the provided packages embedded.
oci_python_image(
name="my-server",
base=":python#slim",
repository="my-registry.example.com/a-namespace/an-image",
main="/app/server/start.py",
tag="latest",
packages=[":my_pex"]
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
base | The base image to use. Matches the FROM directive in a Dockerfile | Required |
packages | Packaged targets to include. The first element will be used as the entrypoint. | [] |
python_main | The main file to run | The last .pex in the dependency list |
repository | Fully qualified repository name | Required when publishing |
tag | Remote tag to use | Required when publishing |
description | A description of the target | |
tags | List of tags | [] |
oci_image_empty
An empty base image with no contents at all. This is declared as //:empty automatically, but you can use this to create new targets.
oci_image_empty(
name="empty",
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
description | A description of the target | |
tags | List of tags | [] |
oci_extract
Extract one or more files from another container image. Nothing is run, but the image is read through podman; see Scripted build steps.
oci_extract(
name="my-app-binary"
base=[":some-app"],
outputs=['/usr/bin/local/the-app'],
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
outputs | Paths to capture into the built layer. | [] |
exclude | Globs to not include in the output. | [] |
description | A description of the target | |
output_path | The output path during pants package | A variant generated from the target name and directory |
tags | List of tags | [] |
oci_build_layer
Run a command in an image, and capture the configured output into a layer artifact, that can be injected into other images. This matches the COPY --from workflows. The command runs through podman; see Scripted build steps.
oci_build_layer(
name="layer"
base=[":rust-1-70"],
packages=[":files"],
env=['RUSTC_OPTS=...'],
commands='cd /my-package && cargo build --release',
outputs=['/my-package/target/release/my-package'],
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
commands | The command to run in the container, through [oci].command_shell | |
packages | Packaged targets to include. The first element will be used as the entrypoint. | [] |
env | Environment variables to set. Does not support interpolation. | [] |
outputs | Paths to capture into the built layer. | [] |
exclude | Globs to not include in the output. | [] |
description | A description of the target | |
output_path | The output path during pants package | A variant generated from the target name and directory |
tags | List of tags | [] |
oci_deb_layer
Build a layer from Debian .deb packages without running dpkg, apt or any maintainer scripts. Package contents are extracted into the layer with zeroed timestamps, and each package is recorded in /var/lib/dpkg/status.d/<package> (plus <package>.md5sums), the same layout distroless images use, so scanners such as Trivy, Syft and Grype see the packages. Add it to an image through layers.
The layer gets the transitive closure of the debs targets. To have apt dependencies resolved, use deb_packages from pants-backend-distroless, which locks a package set against a snapshot and generates a target per package that brings the .deb files of its whole locked closure. Otherwise list every package the layer needs on top of the base image as file targets, pinned to a snapshot.debian.org or snapshot.ubuntu.com URL so the layer stays reproducible. Debian and Ubuntu packages are both supported; members compressed with gzip, xz or bzip2 are read directly, and zstd-compressed ones (as Ubuntu uses) are piped through the zstd tool. A path shipped by more than one package must be identical in each (type, mode, ownership and content or link target), as dpkg requires; directories keep the metadata of the first package by name. Hardlinked files are compared by name, so two packages that ship the same hardlink pair but store it the other way around (which name is the file and which is the link) are rejected. Files with extended attributes (such as file capabilities) are rejected, since the layer can’t carry them. Packages for more than one architecture are rejected; Architecture: all packages go with any.
Things to be aware of:
- No maintainer scripts run, so nothing a
postinstwould do happens: noldconfig, noupdate-alternativeslinks, no triggers, and no generated files such as/etc/ssl/certs/ca-certificates.crt(useoci_cacerts_layerfor that one). dpkgandaptinside the image read/var/lib/dpkg/status, notstatus.d, so they don’t see these packages. A package that is also in the base image is reported twice by scanners.merged_usrdefaults toTrue, which is right for Debian 12 (bookworm) and later. Set it toFalsefor older bases where/binand/libare real directories.- Moving a Python app from a
pythonbase to one built from packages changes the interpreter’s path; see “Moving from a Python base image” in thepants-backend-distrolessREADME. - A package that is also in the base image replaces the base’s files silently. If the two were built from different archive states, the image then mixes package versions.
pants lintchecks everyoci_image_buildfor this (thedeb-layer-overwriteslinter): it fails when a deb layer replaces a regular file from a lower layer with different content, and names the packages and versions on both sides. Identical files aren’t reported. Each layer, including each of the base image’s, is indexed once in its own cached process, so a pinned base is only read the first time. dpkg’s own records under/var/lib/dpkgaren’t compared. The check builds (or pulls) the base of every image with a deb layer, sopants lintneeds the same access aspants packagefor those. Skip it with--deb-layer-overwrites-skip.
file(
name="jq_deb",
source=http_source(
url="https://snapshot.debian.org/archive/debian/20241001T000000Z/pool/main/j/jq/jq_1.6-2.1_amd64.deb",
sha256="d1833fdd7a1340b59c71c223178940044fc41a8c8e28861bc03ab258d791d303",
len=64932,
filename="jq_1.6-2.1_amd64.deb",
),
)
oci_deb_layer(
name="jq",
debs=[":jq_deb", ":libjq1_deb", ":libonig5_deb"],
)
oci_image_build(
name="image",
base=[":debian#bookworm-20241016-slim"],
layers=[":jq"],
entrypoint="/usr/bin/jq",
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
debs | The .deb files to extract, with their dependencies: deb_packages targets or file targets. | Required |
merged_usr | Write contents of /bin, /sbin and /lib* under /usr, for bases where those are symlinks. | True |
description | A description of the target | |
output_path | The output path during pants package | A variant generated from the target name and directory |
tags | List of tags | [] |
oci_passwd_layer and oci_group_layer
Build layers with /etc/passwd and /etc/group, the declarative replacement for useradd and groupadd in a scripted build. oci_passwd_layer also creates each user’s home directory, owned by the user with mode 0700; homes of / and /nonexistent are never created. Lines are written in the order given, and the layers are reproducible. Set user on oci_image_build to run as one of the users.
Each file replaces the base image’s, so on a Debian base also list root and any other users and groups the base needs. Layers apply in the order of layers, so put these after any layer that ships its own /etc/passwd or /etc/group, or that one wins. Only the files and home directories are written, not their parent directories, so the base image’s /etc, /home and the like keep their ownership and modes; missing parents are created when the image is unpacked. No /etc/shadow is written; a distroless image doesn’t need one to resolve users.
The two targets are independent, so nothing checks that a user’s gid or a group’s members exist in the other.
oci_passwd_layer(
name="passwd",
users=[
{"name": "root", "uid": 0, "gid": 0, "home": "/root", "shell": "/bin/sh"},
{"name": "nonroot", "uid": 65532, "gid": 65532},
],
)
oci_group_layer(
name="group",
groups=[
{"name": "root", "gid": 0},
{"name": "nonroot", "gid": 65532, "members": ["nonroot"]},
],
)
oci_image_build(
name="image",
base=[":debian#bookworm-20241016-slim"],
layers=[":passwd", ":group"],
user="nonroot",
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
users | oci_passwd_layer: dicts with name, uid, gid, and optionally home, shell, gecos, create_home. | Required |
groups | oci_group_layer: dicts with name, gid, and optionally members, a list of user names. | Required |
description | A description of the target | |
output_path | The output path during pants package | A variant generated from the target name and directory |
tags | List of tags | [] |
A user’s home defaults to /home/<name>, shell to /sbin/nologin, gecos to empty and create_home to True. Names must match [a-z_][a-z0-9_.-]*, names and ids must be unique, and no value may contain : or a newline.
oci_cacerts_layer
Build a layer with the CA bundle /etc/ssl/certs/ca-certificates.crt from the ca-certificates package, without running update-ca-certificates: every certificate under /usr/share/ca-certificates is concatenated in path order, as the package’s postinst and update-ca-certificates would. The package’s copyright file is included too. Like distroless images, the layer has no per-certificate files or OpenSSL hash links in /etc/ssl/certs, so tools that look certificates up by hash in a directory rather than reading the bundle file don’t find them.
debs is searched for the ca-certificates package, so it can point at a target generated by deb_packages; the packages it depends on are ignored. Debian’s and Ubuntu’s packages both work.
The bundle is reproducible: certificates are concatenated in a bytewise sort of their paths, so it depends only on the package. It is not byte-for-byte what Debian would produce, though: the package’s postinst sorts the list by locale, so a scripted build can order the certificates differently, and ! deselections in /etc/ca-certificates.conf and certificates added under /usr/local/share/ca-certificates are not supported.
oci_cacerts_layer(
name="cacerts",
debs=[":bookworm#amd64/ca-certificates"],
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
debs | Targets bringing exactly one ca-certificates .deb. | Required |
description | A description of the target | |
output_path | The output path during pants package | A variant generated from the target name and directory |
tags | List of tags | [] |
oci_locale_layer
Build a layer with only the selected compiled locales under /usr/lib/locale, such as C.utf8 from libc-bin, without the rest of the package and without running locale-gen or localedef. glibc only has the ASCII C and POSIX locales built in, so a program that sets LANG=C.UTF-8 or calls setlocale with it needs these files, or it silently falls back to ASCII. Distroless images ship C.utf8 the same way.
debs is searched for packages shipping the requested locales as directories under /usr/lib/locale, so it can point at a target generated by deb_packages. Each locale must be in exactly one package, and each package a locale comes from adds its copyright file. Locales are looked up like glibc does: by the name as given, then with its codeset normalized, so C.UTF-8 selects C.utf8. C and POSIX are built into glibc and rejected. Since nothing is compiled, only locales a package ships compiled can be selected: libc-bin ships C.utf8 on Debian and Ubuntu, and the error lists the locales the packages do ship. A locale-archive file, as locale-gen writes, is not read. Hardlinks between locales become regular files. The locale directories are written with mode 0755, but not /usr/lib/locale itself, so the base image’s is kept.
oci_locale_layer(
name="locales",
debs=[":bookworm#amd64/libc-bin"],
locales=["C.UTF-8"],
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
debs | Targets bringing the .debs that ship the locales. | Required |
locales | The compiled locales to keep. | ["C.utf8"] |
description | A description of the target | |
output_path | The output path during pants package | A variant generated from the target name and directory |
tags | List of tags | [] |
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
-
Layers build much faster cold. A layer of files and packages is tarred and gzipped in one step, without capturing an uncompressed tar, and every layer is gzipped on up to 16 cores in fixed-size blocks, like
pigz. On a 1.5 GB layer, tar and gzip went from 79 s to 12 s. Layer blobs come out about 0.4% larger, and every layer and image digest changes once; layerdiff_ids don’t change. The new[oci].layer_compression_level(default 6) sets the gzip level. -
oci_image_buildstackslayers=in the order they are listed. They were stacked in address order, so where two layers wrote the same path the wrong one could win. Images whoselayersare not listed in address order get new digests. -
pants runand OCI repl work on macOS throughpodman machine, and on any platform through a named podman connection with the new[oci].podman_connectionoption. The image is loaded into the connection’s store aslocalhost/pants-oci-run:<digest>and kept for later runs; a missing or stopped machine is reported with the command to fix it. Scripted build steps still need local Linux podman. See “Running through podman machine” in the README. -
[oci].runner = "docker"runspants run, OCI repl and scripted build steps with Docker instead of podman, for teams without podman. It needs Docker 23.0 or newer and thepants.backend.dockerandpants_backend_oci.dockerbackends; Docker is found and configured by the Docker backend’s[docker]options. podman stays the default. See “Running with Docker” in the README. -
Scripted build steps rerun when the podman binary changes, instead of reusing layers cached with the old podman. They also read podman’s configuration from your home directory, like
pants run, instead of from an empty private one.pants runno longer rejectsCONTAINER_HOST/CONTAINER_CONNECTION: both it and build steps ignore them and run podman locally. Podman’s runtime state moves from private directories under/tmpto your runtime directory ($XDG_RUNTIME_DIR, else/run/user/<uid>), which build steps now see (it is part of their cache key); the store moves tooci_podman/v2to go with it, so bases are imported again once. Remove the oldoci_podman/v1store and/tmp/oci-podman-*directories withpodman unshare rm -rf <named-caches-dir>/oci_podman/v1 /tmp/oci-podman-$(id -u)-*. The README shows how to clear the store, including what an interrupted build leaves behind. -
The sandboxed builders run as one
oci-builderexecutable, a scie built in the rules with pex from the plugin’s builder scripts and a pinned python-build-standalone Python (3.11.17), instead of as scripts run by Pants’ own Python at its absolute path under the user’s home. Builder processes now name nothing on the host, so their cache keys are the same on every machine and builds share remote-cache hits; only buildingoci-builderitself, once per machine, does not. Building it downloads that Python and pex’ssciencetool from GitHub the first time. It unpacks once per machine into the newoci_buildernamed cache, so builders need no home directory. Layer compression no longer depends on the Python Pants runs on. The plugin registers Pants’ pex rules for this. Image digests are unchanged.[oci].builder_python_versionand[oci].builder_pbs_releaseselect that Python. -
New
deb-layer-overwriteslinter:pants lintfails when anoci_deb_layerin anoci_image_buildreplaces a regular file from a lower layer, such as the base image, with different content, naming the packages and versions involved. Identical files aren’t reported. Each layer is indexed once, in its own cached process. Linting builds or pulls the base of every image with a deb layer. -
oci_passwd_layerandoci_group_layerare built in a sandboxed process instead of in the rule. Their layer digests are unchanged. -
commands=onoci_image_buildandoci_build_layeralso takes a list of strings, run as that argv without[oci].command_shell, so scripted steps work on bases with no shell, such as distroless ones. A string still runs through the shell.examples/oci/multistageuses it and packages again. -
Write the packaged-files layer (
layers/image_bundle.tar) and the archivepants runhands to podman with the plugin’s Python instead of the hosttar. GNU tar before 1.35 exits 1 with “file changed as we read it” when Pants hardlinks the same large file into another sandbox while it is read, which failed cold builds of large images intermittently. macOS no longer needsgtarfor this. Files in the layer are 0755 or 0644 by their executable bit; before, files of 512KB or more came out 0555 from the Pants store. Hardlinked paths are stored as copies. The packaged-files layer’s digest changes once.[oci].unsafe_tar_ignore_file_changedis deprecated and has no effect. -
deb_layerwithmerged_usr=True(the default) writes the/bin -> usr/binstyle links for the directories it moves under/usr, so it works on an empty base. Before, an image built only from deb layers had no/lib64and its dynamic loader couldn’t be found. -
Compress each layer once, in its own cached process, so assembly only links existing blobs. Editing one layer no longer re-compresses the unchanged ones, such as a large dependencies layer.
-
Add
oci_locale_layer, which builds a reproducible layer with only the selected compiled locales, such asC.utf8fromlibc-bin, without runninglocale-genorlocaledef. -
Breaking:
pants packagewrites anoci_image_buildas a deterministic OCI archive,<name>.tar, instead of an<name>.dOCI layout directory. The archive holds only the image’s blobs, names the imagerepository:tagand includes a Dockermanifest.json, sopodman loadanddocker loadkeep the tag. It is written by the plugin’s Python, so skopeo is no longer downloaded and the[skopeo]options are removed. -
Breaking: Remove the unused
[umoci]and[runc]tools and the[oci].rootless,uid_mapandgid_mapoptions; setting them inpants.tomlor on the command line is now an error. -
Run scripted build steps (
commands=onoci_image_buildandoci_build_layer) andoci_extractthroughpodman build(podman 4.0 or newer) with--timestamp 0instead of umoci and runc. A failing command or missing output now fails the build instead of producing an empty artifact, and a scripted image step adds one layer on top of the unchanged base layers. Layer and extract outputs keep the owners and modes they have in the image, globs inoutputskeep their paths, and emptyoutputsis an error. -
Run images and OCI repl through user-installed rootless podman on Linux, using OCI archives and separate Pants-managed storage. Non-root ownership and groups are preserved. Remote podman and macOS execution are not supported yet.
-
Align the Python OCI examples with a pinned Debian 12 / Python 3.11 base.
-
Assemble images in Python instead of with umoci: adding layers, editing the config, creating the empty base image and tagging pulled images now run a small Python script in a sandboxed process. Each image is assembled in one process instead of several umoci calls per layer. Built image digests change once: layers are gzipped differently, history entries no longer carry stray quotes, and empty
User/WorkingDirfields from the base config are kept. -
oci_build_layeradds a layer for each of itsdependencies, not only the first. -
oci_deb_layerandoci_cacerts_layerread zstd-compressed packages, such as Ubuntu’s, by piping them through a new[zstd]tool: a static zstd binary of about 1 MB downloaded from aspect-build/zstd-prebuilt, not a Python dependency.oci_cacerts_layeris now built in a process instead of in the rule. -
Add
oci_passwd_layer,oci_group_layerandoci_cacerts_layer, which build/etc/passwd(with home directories),/etc/groupand a CA bundle from theca-certificatespackage as reproducible layers, without runninguseraddorupdate-ca-certificates. -
Add
usertooci_image_build. -
Add
oci_deb_layer, which builds a layer from.debpackages without running dpkg or maintainer scripts, recording each package in/var/lib/dpkg/status.d/. Package contents are streamed, files shipped identically by several packages are accepted, and extended attributes are rejected. -
oci_deb_layerincludes the transitive closure of itsdebs, so it accepts the targetsdeb_packagesfrompants-backend-distrolessgenerates from a Debian lockfile. Afiletarget indebsthat has its owndependenciesnow brings them into the layer too, and they must be.debfiles as well. Packages for more than one architecture are rejected. -
Stop setting a layer target’s entrypoint to a nested layer tarball.
-
Pull, publish and mirror images with crane instead of skopeo. Credentials come from the docker config and its credential helpers when one exists, and only otherwise from
REGISTRY_AUTH_FILEor podman/skopeo’scontainers/auth.json; apodman loginalongside a docker config is no longer used. -
Pulled images are converted from Docker v2 to OCI in the rule; an omitted
osnow defaults tolinux. -
oci_mirror_imagenow copies the whole manifest list and keeps the source digest, instead of a single re-encoded platform image. -
The README explains how rebuilding large images grows the Pants store, and suggests a lower
local_store_files_max_size_bytes.
0.10.0 - 2026-10-17
-
Target Pants
2.31.0. -
Require
pants-backend-kustomize>=0.7.0. -
Replace remaining union-dispatched
Getcalls with polymorphic call-by-name rules. -
Default to umoci
v0.6.0, using official Linux x86-64/ARM64 binaries and refreshed macOS x86-64/ARM64 builds fromtgolsson/umoci-binary. Retain the existing pinned nightly andv0.4.7entries; CI examples now use the stable default. -
Default to Skopeo
v1.24.1fromlework/skopeo-binaryfor all four platforms; remove the old version entries and provider-routing branches. -
Default to runc
v1.5.2; includev1.4.3,v1.3.6, andv1.2.9for Linux x86-64/ARM64, replacing the oldv1.1.4entries. -
Fix Kustomize OCI injection to supply the image layout/reference when publishing local bundles and default an omitted registry tag to
latest. -
Check every recorded binary download and cover local/remote Kustomize injection with regression tests that mock registry operations.
0.9.0 - 2026-10-16
- Now targets
pantsversion2.30. - Migrate rules to the Pants call-by-name API, including the OCI mirror plugin.
0.8.1 - 2025-05-15
- Add
pants_backend_oci.utility.mirrorplugin to mirror images between repositories.
0.8.0 - 2025-05-14
- New target:
oci_extract. Allows pulling out individual files from another image; without executing the image itself. - Now targets
pantsversion2.24.
0.7.0 - 2024-09-19
- Now targets
pantsversion2.22.
0.6.2 - 2024-09-19
- Adds
[oci].unsafe_tar_ignore_file_changedflag that will set--warning=no-file-changedwhen invoking tar
0.6.1 - 2024-03-28
- Fix a bug where the plugin would try to publish OCI images without a repository.
- Will now default to “latest” as a tag if none is specified.
0.6.0 - 2024-03-21
- Adding layers to an image is changing. As a preparatory step, there’s now an
oci_layertarget. Add these to thelayersfield of anoci_image_build, instead of dependencies. These will process a bit better, and gives more control over what goes into each layer. - Mac support has improved. In order to provide determinism, the
gtar(GNU tar) binary has to be available. - Both umoci and skopeo are now supported on Mac M1 and x86_64. This plugin does not support image run steps on Mac still.
- When pulling multi-arch images you can now specify
osandarchitectureon theoci_pull_imagetarget - Fix a bug where OCI layer building would fail if no dependencies were specified
0.5.0 - 2023-11-19
- Fix a crash when image pulling fails
- Add
nightlyversion forumociwith experimental support for Mac - Add
v1.13.3version forskopeowith experimental support for Mac
0.4.0 - 2023-06-18
-
Improved support for very large layers > 2GB. A lot of layers will now be compressed in transit. This adds some overhead later when injecting them into image, but fixes some crashes inside Pants.
-
Improved support for multi-stage builds (
COPY --from)There is now support for building artifacts in one container and copying them into a new container. To do this, use
oci_build_layer, and configure the output files and directories. The files and data will be inserted verbatim into the downstream container. -
Adds support for empty base images (
FROM scratch)This change enables you to use
base=["//:empty"]to start from a completely empty container. This can be useful to produce containers with statically linked binaries that require no runtime environment at all.- To change the target name, set
[oci].empty_image_targetinpants.toml.
- To change the target name, set
0.3.1 - 2023-03-16
- Handle files when building layers
0.3.0 - 2023-03-14
- [breaking] Change output format when publishing
0.2.0 - 2023-02-10
- [bugfix] Ensure layer tars has no metadata or user information
- [bugfix] Properly pass args to runc
- [bugfix] Fix log output in package_oci_image
- [feature] Add proper metadata where possible
- [feature] Add support for anonymous image pulling (
anonymous=true) - [feature] Add support for passing image SHA along to Kustomize
- [feature] Add
python_image_targetwhich will set entrypoint appropriately - [feature] Forward PATH, HOME, XDG_RUNTIME_DIR env variables to publish step to make credHelpers work
[0.1.1] - 2022-11-17
- Move to new repo
- Compatibility fixes for 2.15.0a0
[0.1.0] - 2022-11-13
Initial release.
Mirroring backend
Utility backend for mirroring images from one registry to another, or internally in one registry.
Activate with:
backend_packages = [
...,
"pants_backend_oci.utility.mirror",
]
Targets
oci_mirror_image
Mirror a single image.
oci_mirror_image(
name="base-python",
repository="docker.io/library/python",
sha="b78b777208be08edd8f297035cdfbacddb45170ad778fd643c792ee045187e39"
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | The directory name |
source_repository | Fully qualified repository name to pull from | Required |
destination_repository | Fully qualified repository name to pull from | Required |
sha | The digest of the image, minus the @sha: prefix. | Required |
tag | Optional tag to add when pushing. | |
decsription | A description of the target | |
tags | List of tags | [] |
oci_mirror_images
Mirror multiple shas for one image.
oci_mirror_images(
name="base-python",
repository="docker.io/library/python",
variants={
"some-variant": "b78b777208be08edd8f297035cdfbacddb45170ad778fd643c792ee045187e39",
"other-tag": "b78b777208be08edd8f297035cdfbacddb45170ad778fd643c792ee045187e39",
},
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | The directory name |
source_repository | Fully qualified repository name to pull from | Required |
destination_repository | Fully qualified repository name to pull from | Required |
variants | Dictionary with tags-to-sha. | Required |
decsription | A description of the target | |
tags | List of tags | [] |
Distroless backend for Pants
This release targets Pants 2.31.0. Install it with
pants-backend-distroless==0.1.0 in [GLOBAL].plugins in pants.toml, and enable
pants_backend_distroless in [GLOBAL].backend_packages.
Warning This plugin is in development. No stability is guaranteed! Contributions welcome.
This backend builds the parts of distroless-style images that come from Debian or Ubuntu packages, in the
spirit of rules_distroless: declare the
top-level packages against an apt repository snapshot, and pants generate-lockfiles resolves their
dependencies into a committed lockfile. The locked packages feed oci_deb_layer from
pants-backend-oci, which builds a layer from them
without running apt, dpkg or maintainer scripts. pants-backend-oci also has the helper layers such
an image needs: oci_passwd_layer, oci_group_layer, oci_cacerts_layer and oci_locale_layer.
Targets
deb_packages
A set of Debian packages resolved into a lockfile. Each resolve is named in [apt].resolves, which
maps it to its lockfile, and is declared by exactly one deb_packages target.
[apt.resolves]
bookworm = "3rdparty/debian/bookworm.lock.json"
deb_packages(
name="bookworm",
resolve="bookworm",
sources=["https://snapshot.debian.org/archive/debian/20241001T000000Z bookworm main"],
architectures=["amd64", "arm64"],
packages=["jq"],
# Already in the base image.
exclude=["libc6"],
# From the `debian-archive-keyring` package.
keyrings=["debian-archive-keyring.gpg"],
)
oci_deb_layer(
name="jq",
debs=[":bookworm#amd64/jq"],
)
Run pants generate-lockfiles --resolve=bookworm to write the lockfile, and again whenever the
target changes. Until then, and while the lockfile is out of date, Pants warns and generates no
packages, so anything using them fails with an error that :bookworm#amd64/jq was not generated.
The warning is only printed when the targets are generated, so with pantsd a later run may show
just that error; regenerating the lockfile fixes it.
The target generates:
- a
deb_packagetarget named<arch>/<package>for every locked package, which depends on the pinned.debof the package and of every locked package in its closure, so:bookworm#amd64/jqbringsjqand its whole closure; - a
deb_package_filetarget named<arch>/<package>/debfor every locked package, which downloads only its pinned.deb; - a
deb_package_grouptarget named<arch>, depending on all the requested packages, so:bookworm#amd64is everything.
Packages don’t depend on each other’s targets, only on .deb files, and the lockfile records each
package’s closure. Apt dependency cycles are therefore fine: libc6 and libgcc-s1 depend on each
other in Debian, and :bookworm#amd64/libc6 brings both.
A layer is for one architecture, so always pick one: debs=[":bookworm"] would bring the packages of
every architecture, which oci_deb_layer rejects.
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
resolve | The resolve name, a key of [apt].resolves. | Required |
sources | The repositories, each <uri> <suite> <component>... like a sources.list line without deb. | Required |
architectures | The Debian architectures to lock, e.g. amd64 and arm64. | Required |
packages | The top-level packages. | Required |
exclude | Packages treated as already installed, typically those the base image ships. | [] |
keyrings | OpenPGP keyrings (.gpg or .asc) to verify the repositories’ Release signatures with. | Required unless allow_unsigned=True |
allow_unsigned | Resolve without verifying signatures. Can’t be combined with keyrings. | False |
description | A description of the target | |
tags | List of tags | [] |
Sources
Pin each URI to a snapshot, such as https://snapshot.debian.org/archive/debian/<timestamp>, so
the lockfile can be regenerated and the packages stay downloadable. Several sources can be combined,
for example to add bookworm-updates, or bookworm-security from
https://snapshot.debian.org/archive/debian-security/<timestamp>. Every Packages index is checked
against the SHA256 in its suite’s signed Release file, see Signature verification.
Ubuntu works the same way, from snapshot.ubuntu.com. Its updates
and security fixes are separate suites of the same archive, and unlike the live archive, where
arm64 lives on ports.ubuntu.com, the snapshot serves arm64 packages under /ubuntu too, so one
set of sources covers both architectures. Phased-Update-Percentage is ignored: a snapshot lock
takes a phased update like any other version. Ubuntu’s packages are zstd-compressed, which
oci_deb_layer reads with its zstd tool.
deb_packages(
name="noble",
resolve="noble",
sources=[
"https://snapshot.ubuntu.com/ubuntu/20241001T000000Z noble main",
"https://snapshot.ubuntu.com/ubuntu/20241001T000000Z noble-updates main",
"https://snapshot.ubuntu.com/ubuntu/20241001T000000Z noble-security main",
],
architectures=["amd64", "arm64"],
packages=["jq", "ca-certificates"],
# Already in the ubuntu:noble base image.
exclude=["libc6", "debconf", "libssl3t64"],
# From the `ubuntu-keyring` package.
keyrings=["ubuntu-archive-keyring.gpg"],
)
Signature verification
Like rules_distroless’s gpg_keys, generate-lockfiles verifies every suite’s InRelease, or its
Release and Release.gpg when there’s no InRelease, against keyrings, and fails unless at
least one signature is good and made by a key in them. A bad signature from one of their keys fails
too. The hashes of the Packages indices are only taken from the verified file. It also fails
unless the signed file’s Suite or Codename is the suite asked for, so another suite’s validly
signed Release can’t stand in for it. This is apt’s rule, though apt only warns: a path like
debian-security’s old buster/updates matches by what comes before its last /. Either keyrings or allow_unsigned=True must be set.
Use the archive’s own keyring, from the snapshot you resolve against so it’s pinned too:
/usr/share/keyrings/debian-archive-keyring.gpg from the debian-archive-keyring package, or
/usr/share/keyrings/ubuntu-archive-keyring.gpg from ubuntu-keyring. Copy it next to the
deb_packages target, since sources can’t come from another directory.
Verification needs no gpgv or sqv: it’s built into the resolver, and supports what Debian and
Ubuntu sign with, v4 RSA and Ed25519 keys with SHA-2 hashes. Older snapshots signed only with SHA-1
need allow_unsigned=True, which turns signature verification off entirely; the suite is still
checked. Every key in the keyrings is trusted as given: expiry and revocation aren’t checked, since a
snapshot’s Release files are historical. For the same reason, freshness (Valid-Until) isn’t
checked either: the snapshot URL is what pins it. The keys aren’t recorded in the lockfile, so
changing keyrings doesn’t make it out of date.
Resolution
The Pre-Depends and Depends of the requested packages are resolved transitively, for each
architecture separately, much like rules_distroless does:
- A package resolves to its highest version, across all sources, that satisfies the constraint. When sources have the same version, the first source wins.
- For alternatives (
a | b), a package already in the closure is preferred; otherwise the first alternative that exists wins. - A virtual package (
Provides) resolves to a provider already in the closure, else the only provider, else aPriority: requiredone, else the highest version, else the first by name. - The result doesn’t depend on the order of
packages.
Recommends and Suggests are not installed, and Conflicts and Breaks are not checked.
Packages already in the base image
A layer replaces any file the base image has at the same path, and nothing warns about it. If a
locked package is also in the base, its files from the snapshot overwrite the base’s copies, which
can mix versions when the base image was built from a different archive state than the snapshot.
List such packages in exclude: they count as installed, satisfy any dependency on them, and are not
traversed, so whatever only they need is left out as well. Images built on an empty base leave
exclude empty.
Moving from a Python base image
A few things change when a Python app moves from a pulled base such as python:3.11-slim to one
built from Debian or Ubuntu packages.
The interpreter is /usr/bin/python3.X. The official Python images install it under
/usr/local/bin and have /usr/bin/env. A package’s interpreter is /usr/bin/python3.X, where
3.X is the suite’s Python: 3.11 in Debian bookworm, 3.13 in trixie, 3.12 in Ubuntu noble. Build
the pex for that version, since its interpreter constraints and wheels must match. An image built
from packages has no env unless it locks coreutils, and a pex’s default shebang is
#!/usr/bin/env python3.X, so the pex fails to start. Point the shebang at the interpreter, and
make the pex the entrypoint with oci_image_build, whose first package is the entrypoint:
pex_binary(
name="app",
entry_point="app.py:main",
shebang="/usr/bin/python3.11",
)
oci_image_build(
name="image",
base=[...],
layers=[":python"],
packages=[":app"],
)
oci_python_image doesn’t use the shebang: it runs the pex as python <pex>, and no Debian or
Ubuntu package but python-is-python3 provides /usr/bin/python. Lock that package to keep using
oci_python_image. python3-minimal, which python3 pre-depends on, adds /usr/bin/python3.
pex warns that ldd is missing. On startup a pex may print Failed to detect if this interpreter is running under musl libc: [Errno 2] No such file or directory: 'ldd'. pex runs ldd
to tell musl from glibc and, when it can’t, assumes glibc, which is right for Debian and Ubuntu.
The warning is harmless. To hide it, set emit_warnings=False on the pex_binary, or
PEX_EMIT_WARNINGS=false in the image’s environment; either hides all of pex’s warnings. Locking
libc-bin doesn’t help in an image without a shell, since Debian’s ldd is a bash script.
The Pants store grows quickly. Rebuilding multi-GB images adds new layers to the Pants store on every change. See Pants store size in the OCI README for a setting that keeps it in check.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
- Add
deb_packages, which resolves Debian packages against apt repository snapshots into a lockfile withpants generate-lockfiles, configured by[apt].resolves. It generates a target per locked package and architecture foroci_deb_layer, which depends on the.debof every package in its locked closure, so apt dependency cycles likelibc6andlibgcc-s1are fine. - Ubuntu snapshots from snapshot.ubuntu.com resolve too; the README has an example.
generate-lockfilesverifies the OpenPGP signature of every suite’sInRelease(orReleaseandRelease.gpg) against the keyrings in the newkeyringsfield ofdeb_packages. Breaking:deb_packagesneeds eitherkeyringsorallow_unsigned=True.generate-lockfilesfails unless each suite’sReleasenames it as itsSuiteorCodename, by apt’s rule (where apt only warns).- The README covers moving from a Python base image: the
/usr/bin/python3.Xpex shebang and pex’s harmlesslddwarning.
MDBook backend for Pants
This release targets Pants 2.31.0. Install it with
pants-backend-mdbook==0.8.0 in [GLOBAL].plugins in pants.toml.
Warning This plugin is in development. No stability is guaranteed! Contributions welcome.
This provides a tool for building mdbook targets with pants. There is currently a single very simple rule:
md_book(
name="my-docs",
sources=["book.toml", "src/*"],
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
sources | Files included when building the book | book.toml and the src directory |
decsription | A description of the target | "" |
tags | List of tags | [] |
Tool versions
The default is mdBook v0.5.4. The latest 0.4 release, v0.4.52,
and existing historical versions remain selectable via --mdbook-version=v0.4.52
or [mdbook].version. The new versions include native Linux/macOS binaries for
x86-64 and ARM64; retained older macOS ARM64 entries still use Rosetta.
When upgrading a book to mdBook 0.5, remove [book].multilingual from book.toml:
that obsolete setting is now rejected. Select 0.4.52 if a book or preprocessor
still requires the 0.4 series.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.8.0 - 2026-10-17
-
Target Pants
2.31.0. -
Default to mdBook
v0.5.4; add the latest 0.4 release,v0.4.52, while retaining existing historical versions. -
Use native Apple Silicon archives for the new versions, preserving the Rosetta mapping for retained older releases.
-
Check downloads for every recorded version/platform pair.
-
Remove the obsolete
[book].multilingualsetting from project documentation; mdBook 0.5 rejects this setting, so existing books may need the same migration.
0.7.0 - 2026-10-16
- Now targets
pantsversion2.30. - Migrate rules to the Pants call-by-name API.
0.6.0 - 2025-05-14
- Now targets
pantsversion2.24.
0.5.0 - 2024-09-19
- Now targets
pantsversion2.22.
[0.4.1] - 2024-04-12
- Will now use x86_64 mdbook binary on arm64, relying on Rosetta.
[0.4.0] - 2024-04-05
- Will now also include codegen sources, allowing integration with
adhoc_tooland other generators. Note that only file and direct mdbook sources are included.
[0.3.0] - 2023-11-21
- Target pants version is now 2.18.0, with support for 2.17.0.
- Updated mdbook version to 0.4.35
- Now includes support for arm64 on Linux
[0.2.0] - 2023-06-18
- Target pants version is now 2.16.0, with support for 2.15.0.
[0.1.3] - 2022-11-18
- Raise error if
book.tomlis not found - Handle all sources in dependencies
[0.1.2] - 2022-11-17
- Merge with all other backends
- Fix README links
[0.1.1] - 2022-11-12
- Fix README.
[0.1.0] - 2022-11-12
- Initial release.
Secrets backend for Pants
This release targets Pants 2.31.0. Install it with
pants-backend-secrets==0.7.0 in [GLOBAL].plugins in pants.toml.
Warning This plugin is in development. No stability is guaranteed! Contributions welcome.
This backends implements utilities for handling secrets.
Planned and missing features
- Setting and creating secrets via Pants
Targets
env_secret
A secret to be read from the environment.
env_secret(
name="bw_session_key",
key="BW_SESSION",
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
key | The environment variable to read. | Required |
decsription | A description of the target | |
tags | List of tags | [] |
Goals
decrypt
Decrypts and prints a secret.
BW_SESSION="..." pants decrypt //examples/bitwarden:pypi_token
22:29:42.17 [INFO] Completed: Decrypting examples/bitwarden:pypi_token
Secret examples/bitwarden:pypi_token from BitWarden: pypi-...
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.7.0 - 2026-10-17
- Target Pants
2.31.0. - Replace remaining union-dispatched
Getcalls with polymorphic call-by-name rules.
0.6.1 - 2026-10-16
- Replace
pdm-pep517withpdm-backendto produce correctly normalized source archive names for PyPI.
0.6.0 - 2026-10-16
- Now targets
pantsversion2.30. - Migrate rules to the Pants call-by-name API.
- Fix Twine PEX creation after the call-by-name migration.
- Support
skip_twineand distributionoutput_pathonpython_distribution_with_secrettargets.
0.5.0 - 2025-05-14
- Now targets
pantsversion2.24.
0.4.0 - 2024-09-19
- Now targets
pantsversion2.22.
[0.3.0] 2023-11-21
- Target pants version is now 2.18.0, with support for 2.17.0.
[0.2.0] - 2023-06-18
- Target pants version is now 2.16.0, with support for 2.15.0.
0.1.0 - 2023-02-10
- Initial release
BitWarden backend for Pants
This release targets Pants 2.31.0. Install it with
pants-backend-bitwarden==0.7.0 in [GLOBAL].plugins in pants.toml.
Warning This plugin is in development. No stability is guaranteed! Contributions welcome.
This backends implements targets for reading BitWarden secrets.
- bw - the BitWarden CLI client
Tool versions
The default is Bitwarden CLI 2026.10.0. Version 2026.9.1 remains
selectable with --bitwarden-version=2026.9.1 or [bitwarden].version. Both
versions include native Linux/macOS binaries for x86-64 and ARM64; historical
versions retain their existing platform coverage.
Planned and missing features
- Setting and creating secrets via Pants
Targets
bw_item
Matches one entry in your vault.
bw_item(
name="pypi",
id="386c6037-cbdd-4aa3-ba80-9ed6661f751b",
session_secret=":bw_session_key",
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
id | Item id used by this target as seen in the address bar | Required |
item_name | The name in the vault. If ambiguous this will fail. Prefer IDs. | |
session_secret | The secret to use for the BW_SESSION variable. | env["BW_SESSION"] |
decsription | A description of the target | |
tags | List of tags | [] |
bw_password
The password of an item in your vault.
bw_password(
name="pypi_password",
item=[":pypi"],
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
item | The item containing the password. | Required |
decsription | A description of the target | |
tags | List of tags | [] |
bw_field
A field from an item in your vault. These are the “Custom Fields” at the bottom of an item, not to be confused with attachments.
bw_field(
name="pypi_token",
field_name="api_token"
item=[":pypi"],
)
| Argument | Meaning | Default value |
|---|---|---|
name | The target name | Same as any other target, which is the directory name |
item | The item containing the password. | Required |
field_name | The item containing the password. | Required |
decsription | A description of the target | |
tags | List of tags | [] |
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.7.0 - 2026-10-17
-
Target Pants
2.31.0. -
Require
pants_backend_secrets>=0.7.0. -
Replace remaining union-dispatched
Getcalls with polymorphic call-by-name rules. -
Default to Bitwarden CLI
2026.10.0and retain2026.9.1and existing historical versions. -
Add native Linux ARM64 and macOS ARM64 binaries for both
2026.10.0and2026.9.1. -
Check downloads for every recorded version/platform pair.
0.6.1 - 2026-10-16
- Replace
pdm-pep517withpdm-backendto produce correctly normalized source archive names for PyPI. - Update the default Bitwarden CLI to
2026.9.1. - Require
pants_backend_secrets>=0.6.1.
0.6.0 - 2026-10-16
- Now targets
pantsversion2.30. - Migrate rules to the Pants call-by-name API.
0.5.0 - 2025-05-14
- Now targets
pantsversion2.24.
0.4.0 - 2024-09-19
- Now targets
pantsversion2.22.
[0.3.0] - 2023-11-21
- Target pants version is now 2.18.0, with support for 2.17.0.
- Default bitwarden version is now 2023.10.0
[0.2.0] - 2023-06-18
- Target pants version is now 2.16.0, with support for 2.15.0.
0.1.0 - 2023-02-10
- Initial release