# contain

Simple declarative container builds from local artifacts.

`contain` is a unix philosophy CLI that does the following thing well:
Produces a container image from a local directory structure and a base image.

It runs nicely with [Skaffold](https://skaffold.dev/) as [custom](https://skaffold.dev/docs/builders/builder-types/custom/) `buildCommand`, as it picks up the `IMAGE` and `PLATFORMS` envs.

## commands

The CLI now has sub-commands (migrated to [cobra](https://github.com/spf13/cobra)):

- `contain build` – existing build/append functionality (also the default when you invoke just `contain ...` for backwards compatibility).
- `contain sbom` – experimental stub that will generate a Software Bill of Materials from a build metadata file in future versions. Currently it just echoes the provided `--build-metadata` path.
- `contain push` – push an OCI image layout to a registry with digests preserved verbatim (see below).
- `contain registry-proxy` – localhost registry endpoint forwarding to an upstream registry, so stock docker tooling can push any layer size (see below).
- `contain mirror` – copy an image between registries preserving digests, like `crane cp` (see below).

Examples (old style still works):

```
contain -x -b busybox:latest --file-output out.json
contain build -x -b busybox:latest --file-output out.json
contain sbom --build-metadata out/localdir.buildctl.json
contain push out/oci-layout registry.example.com/app/name:tag
contain mirror ghcr.io/org/app:v1 registry.example.com/app/name:v1
```

## push subcommand

`contain push` pushes an OCI image layout directory, e.g. from
`docker buildx build -o type=oci,tar=false,dest=DIR`, to a registry.
Manifests and blobs are transferred as raw bytes so all digests are
preserved (unlike `remote.WriteIndex` from a layout, see pkg/testcases
caveats). Credentials resolve like docker/crane.

Registries advertising the `_directpush/v1` extension via OCI extensions
discovery (`GET /v2/_oci/ext/discover`; spec: PROTOCOL.md (attached) —
e.g. Yolean's Cloudflare R2 registry where the proxy caps request
bodies) receive blobs at or above `--direct-threshold`
(default 8 MiB) via presigned URLs uploaded in parallel straight to
object storage, with any session-prescribed headers (e.g.
`x-amz-checksum-sha256` so storage verifies content itself). Detection is
discovery-only: regular OCI registries are the primary destination and
get standard OCI uploads exclusively, for all layer sizes — never
extension-path probes.

## registry-proxy subcommand

`contain registry-proxy` serves an unauthenticated OCI registry on
localhost that forwards to an upstream registry, for docker contexts
where builds should push with stock tooling regardless of upstream
upload limits:

```
docker run -d -v $HOME/.docker:/dockercfg:ro -e DOCKER_CONFIG=/dockercfg \
  -p 127.0.0.1:5000:5000 ghcr.io/yolean/contain \
  registry-proxy --upstream registry.example.com --addr 0.0.0.0:5000
docker push localhost:5000/app/name:tag
```

Blob uploads are staged to disk (unbounded size), digest-verified, then
re-uploaded like `contain push` (direct-to-storage for large blobs).
Staging goes to `--staging-dir`, else `$CONTAIN_STAGING_DIR`, else
`$CONTAIN_CACHE_DIR/staging`, else the system temp dir. Set one of these
when running in a container: `/tmp` is frequently tmpfs, so staging a
multi-GB layer there is charged to memory and gets the process
OOM-killed.
Pulls and manifest reads pass through with upstream credentials from the
docker keychain. Only bind to localhost or trusted networks — the proxy
itself does not authenticate clients. Note that docker configs using
`credsStore` (Docker Desktop) resolve via the host keychain, which is not
available inside a container; mount a plain `config.json` with `auths`.

## mirror subcommand

`contain mirror` copies an image, single manifest or full index, from one
registry to another. Manifests and blobs transfer as raw bytes and every
node is digest-verified before it is pushed, so even a plain-http source
cannot inject content under a wrong digest. The source may pin a digest
(`repo:tag@sha256:...` or `repo@sha256:...`), which is then checked
against what the source actually served.

```
contain mirror ghcr.io/org/app:v1 registry.example.com/app/name:v1
contain mirror --src-plain-http registry.internal:5000/app:v1 registry.example.com/app/name:v1
```

The push side uses the destination's direct-to-storage extension for
large blobs when the destination advertises it, exactly as `contain push`
does. Blobs already present at the destination are skipped, so
re-mirroring is cheap. Each blob is staged to disk while it is verified;
see `--staging-dir` under registry-proxy for where that goes and why it
matters in a container.

## sbom subcommand

`contain sbom` is a CLI to produces an _application_ SBOM in SPDX format from:

1. an SBOM file from CI such as

  - [npm sbom](https://docs.npmjs.com/cli/v10/commands/npm-sbom) output
  - [mvn spdx:createSPDX](https://github.com/spdx/spdx-maven-plugin) output

2. a container image build result such as

  - [skaffold build](https://skaffold.dev/docs/references/cli/#skaffold-build) `--file-output`
  - [buildctl build](https://github.com/moby/buildkit/blob/master/docs/reference/buildctl.md) `--metadata-file`
  - [contain build]() `--file-output` or `--metadata-file`

3. and optionally a base image ref

  - otherwise autodetected from the `org.opencontainers.image.base.name` annotation (also used by for example [go-containeregistry](https://github.com/google/go-containerregistry/blob/v0.20.6/cmd/crane/rebase.md#base-image-annotation-hints))

The resulting SPDX fully describes your deliverabe,
if your image builds only deal with appending artifacts to base images.
That's the principle of [contain]().

## SBOM output and provenance

contain focuses on builds that append local artifacts to existing multi-architecture base images. This shapes our SPDX output and relationships so that downstream tooling understands the provenance correctly while remaining compatible with a variety of input SBOMs (npm, Maven, etc.).

What we emit when you run `contain sbom`:

- Preserve the input SBOM verbatim (application packages, dependencies, relationships) and add a creator entry: `Tool: contain-<version>`.
- Add two container packages under `packages` with `primaryPackagePurpose: CONTAINER`:
  - Base image package: name equals the base ref without digest (e.g. `example.net/misc/base-image:abc`). If the base digest is known, we add a SHA256 checksum to the package. We discover this primarily from the built image’s OCI annotations `org.opencontainers.image.base.name` and `org.opencontainers.image.base.digest` and allow env overrides (`CONTAIN_SPDX_BASE_NAME`, `CONTAIN_SPDX_BASE_DIGEST`) to avoid registry access in CI/tests.
  - Result image package: name equals `imageName@sha256:<digest>` using the pushed digest from the build metadata. This is the immutable deliverable that identifies the published container image.
- Set `documentDescribes` to include the result image package’s SPDXID so that the top-level described artifact is your deliverable image, not only the application package from the tool SBOM.
- Add a provenance relationship: `RESULT DESCENDANT_OF BASE`. This expresses that the result container is derived from the base image, aligning with SPDX 2.3 relationship semantics for lineage.

Why these choices matter for append-style builds:

- Multi-arch parity: The same application file tree is appended to each platform manifest; there is a single top-level deliverable (the manifest list digest). Using the index digest as the result package name communicates this cross-platform deliverable clearly.
- Minimal coupling: We do not rewrite or duplicate application dependencies from the input SBOM. contain only adds the container provenance (base/result) that traditional language-package SBOMs lack.
- Compatibility: We operate on generic SPDX JSON fields (`packages`, `relationships`, `documentDescribes`) and avoid tool-specific extensions so inputs from npm/Maven/other producers continue to validate. If a container package already exists, we update it in-place (e.g., add a SHA256 checksum) without reordering non-container packages. New container packages are appended.

Notes and fallbacks:

- Base autodiscovery is best-effort. If the registry is unreachable, set `CONTAIN_SPDX_BASE_NAME` and `CONTAIN_SPDX_BASE_DIGEST` to ensure deterministic enrichment.
- We only add platform-agnostic provenance. contain builds are intentionally platform-agnostic at the layer level; provenance is expressed once at the result (manifest list) level.
- Output stays pretty-printed JSON with two-space indentation for readability and easy diffing in CI.

## libs

https://pkg.go.dev/github.com/spdx/tools-golang
https://pkg.go.dev/sigs.k8s.io/bom
https://pkg.go.dev/github.com/ibm/sbom-utility


## basics

Contain is designed to take platform-agnostic layers and append to multi-platform bases.
Nodejs and Java are examples of runtime environments that work well with such images.

Future versions might add support for:
- Single platform base images (can be auto detected)
- Configuring platform per layer, i.e. omit a layer on non-matching platforms

To leave room for single platform images, Contain requires that you set platforms to `all`,
the same value you'd use for [ko](https://github.com/ko-build/ko/) multi-platform images.

There are many image manifests formats but Contain supports only OCI.
By validating manifest types Contain helps keeping your images consistent.
Future versions could add support for other formats, but that would be opt-in by config.

### platform matching

Platforms are compared after normalization, using
[containerd/platforms](https://github.com/containerd/platforms), so the
spellings the ecosystem uses for one platform are one platform to Contain:
`linux/arm64` and `linux/arm64/v8` match each other in both directions, as do
`linux/arm` and `linux/arm/v7`, and `linux/amd64` and `linux/amd64/v1`. This is
what Skaffold does with the same `PLATFORMS` value it passes to Contain.

Normalization does not widen. Platforms that genuinely differ stay different,
including `linux/arm/v6` against `linux/arm/v7`, the arm64 feature levels
`v8.1` and `v9`, and the amd64 microarchitecture levels `v2` to `v4`. One
configured platform selects at most one manifest from the base index, so
`linux/arm` against a base declaring v5, v6 and v7 builds one image, not three.

Every configured platform must match a manifest in the base index. Building a
subset of what was asked for is an error, naming both what matched nothing and
what the base offers:

```
platforms [linux/s390x] matched no manifest in base <ref>, which has [linux/amd64 linux/arm64]
```

`localFile.pathPerPlatform` keys are matched the same way, so a key and a base
child cannot disagree about which file serves which platform. A key naming no
variant, for example `linux/amd64`, additionally serves any variant of that
architecture.

The platform reported in `--file-output` is the platform of the image that was
pushed, taken from the base image it was appended to. Contain does not rewrite
the base's spelling, so that value can be compared against the registry
manifest as-is.

### execution

Here's an example of a base image manifest, with optional attestation:

```json
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:2e2643ac2745067b637a4e1c4d5a3936b27a430cf0d989562c04fb7d7c53e69c",
      "size": 475,
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:e34c5ca17d295d5873f451ab094fb5b5515a0a5ec433d8613276baeb8f1c7741",
      "size": 475,
      "platform": {
        "architecture": "arm64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:897bf5e232d9c5a72947462cc13072e988428e0ff80f4441c7a238e4892afc00",
      "size": 566,
      "annotations": {
        "vnd.docker.reference.digest": "sha256:2e2643ac2745067b637a4e1c4d5a3936b27a430cf0d989562c04fb7d7c53e69c",
        "vnd.docker.reference.type": "attestation-manifest"
      },
      "platform": {
        "architecture": "unknown",
        "os": "unknown"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:a7f5930278c418d53dc56bfcd22f7332fbda225006a1875fbc673df454929a49",
      "size": 566,
      "annotations": {
        "vnd.docker.reference.digest": "sha256:e34c5ca17d295d5873f451ab094fb5b5515a0a5ec433d8613276baeb8f1c7741",
        "vnd.docker.reference.type": "attestation-manifest"
      },
      "platform": {
        "architecture": "unknown",
        "os": "unknown"
      }
    }
  ]
}
```

This means that to get the actual base layer references, Contain will have to pull both of the `application/vnd.oci.image.manifest.v1+json` manifests. They're also pretty-printed json, like

```json
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:0d0715737c21d4dc2a49af26ef780241ad5d6ab1a0e1133364e40d002ca16722",
    "size": 575
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:c61587a79a418fb6188de8add2e9f694b012acde27abefd27dedaff5f02de71e",
      "size": 93
    }
  ]
}
```

Running `sha256sum` on the above you get `2e2643ac2745067b637a4e1c4d5a3936b27a430cf0d989562c04fb7d7c53e69c` which is the digest in the index manifest.
Using this manifest you can retrieve the layers,
but because Contain is agnostic to what the base image contains there's no need to spend the bandwidth of pulling them.

Later on if the resulting image is pushed to a different registry than where the base image lives,
[go-containerregistry](https://github.com/google/go-containerregistry) will handle the copying of all layers.

Contain does not support nested indexes. It will bail if any manifest in the index has mediaType `application/vnd.oci.image.index.v1+json`.

Upon successful retrieval of indexes, contain can start the actual build.

Layers are just tarballs. The task for Contain is to produce these `tar+gzip`s from your config,
hash them and append _each layer_ in order to _each platform_'s index.
That creates a new index per platform, each one having a new digest (checksum).
With those checksums Contain can produce a new index.

In practice Contain will run append and push to the first platform,
then derive the layers to append from that one.

Currently Contain can't update attestations. Those index entries are therefore dropped.

## Configuration

Contain supports template variables in config yaml using the framework from [Skaffold](https://skaffold.dev/docs/environment/templating/).

## Reproducible Builds

Contain implements reproducible builds using deterministic layer creation:

- **Timestamps**: All files and directories in layers use `SOURCE_DATE_EPOCH` (1970-01-01T00:00:00Z) for reproducible timestamps
- **File Modes**: File permissions are normalized to 0644 for regular files and 0755 for directories by default
- **Executable Preservation**: The executable bit (0111) is preserved from source files when present
- **Mode Override**: Layer attributes can override the default file and directory modes
- **Symlink Support**: Symbolic links pointing within the source tree are preserved with their target paths
- **Directory Inclusion**: Directory entries are explicitly included in layers for complete filesystem representation

### Mode Configuration

You can override the default file and directory modes using layer attributes:

```yaml
layers:
  - localDir:
      path: ./build
      containerPath: /app
    layerAttributes:
      mode: 0600        # Override file mode
      dirMode: 0700     # Override directory mode
      uid: 1000
      gid: 1000
```

### Reproducible Layer Content

The reproducible build implementation ensures that:

1. **Identical source** produces **identical layers** regardless of build time or environment
2. **File metadata** is normalized to prevent variations from filesystem differences
3. **Directory structure** is completely captured including empty directories
4. **Symlinks** are preserved when they point within the source tree
5. **Build timestamps** do not affect layer checksums

This enables reliable caching, content-addressable storage, and deterministic container image builds.

## Migration to Reproducible Builds

This version introduces major changes for reproducible builds that affect layer digests:

- **Breaking Change**: All layer digests will change due to normalized timestamps and file modes
- **Test Updates Required**: ExpectDigest values in integration tests need updating
- **Backward Compatibility**: Configuration remains compatible, only layer content changes
- **Benefits**: Builds are now deterministic across environments and time

To update existing configurations, simply rebuild your images - the functionality remains the same but with improved reproducibility guarantees.
