← PROJECT
#go#oci#kubernetes#tooling#oras

Pulling Any OCI Artifact: Building unpacker in Go

OCI registries store more than container images. Helm charts, Flux manifests, and custom artifacts all live there too, but pulling any of them back out in a CI pipeline requires different tools for each type. unpacker wraps all of that into a single Go binary.

The Problem

A Kubernetes platform team typically pushes several artifact types to the same OCI registry. A Helm chart lands there via helm push. A Flux OCI source (a tarball of Kubernetes manifests) arrives via the Flux CLI. You push a custom config bundle or binary with oras. Same registry, same wire protocol, three different push tools.

Pulling them back out mirrors that split. Charts come down with helm pull. Flux sources need flux pull artifact. Custom artifacts require oras pull. Each tool has its own flag conventions, output paths, and authentication behavior.

In a CI pipeline that needs to inspect or unpack any of these, you end up with per-type glue scripts and a growing list of binary dependencies to install and version-pin. The pipeline knows the artifact’s OCI reference — it should not need to know which tool fetches it.

That gap is what unpacker fills: a single command that accepts any OCI reference and writes the artifact’s files to disk.

OCI Primer

An OCI artifact is anything stored in an OCI registry with an OCI manifest. The bytes inside do not have to form a runnable container image. You can push a Helm chart, a Flux source tarball, or a raw binary to an OCI registry and store them alongside images in the same registry.

The manifest that describes each artifact contains a list of layers. Every layer carries a mediaType field that names the content format. A Helm chart layer uses application/vnd.cncf.helm.chart.content.v1.tar+gzip. A standard image layer uses application/vnd.oci.image.layer.v1.tar+gzip. A Docker image manifest carries application/vnd.docker.image.manifest.v2+json. The consumer reads the mediaType to decide how to handle the bytes.

Two Go libraries cover most OCI registry work, and they solve different problems. oras-go implements the OCI Distribution Spec for arbitrary artifact pull and push. It understands OCI manifests natively and handles non-image artifact types without special casing. go-containerregistry (crane) implements the Docker V2 image API and produces an OCI image layout that tools like umoci can unpack. Choosing between them depends on what the artifact is. unpacker uses both, because the artifact types it handles require both.

Architecture

IMAGE REF


Stage 1: oras-go (pullWithOras)
  - Fetch manifest from registry
  - Reject Docker mediatypes → Stage 2
  - Download layer blobs to tmp/

    │  success: OCI artifact (Helm, Flux, custom)
    │  failure: Docker manifest or auth error

Stage 2: crane (pullWithCrane)
  - Pull Docker image
  - Write OCI image layout to tmp/


Unpack (Unpack)
  - Read manifest.json
  - Path 1: tar extract  (tmp/ has a .tar.gz, mediatype in allowed list)
  - Path 2: umoci unpack (tmp/ has blobs/sha256/ OCI layout)
  - Path 3: file copy    (tmp/ has plain files, no recognised structure)


output-dir/image/   <- unpacked artifact files

Resolve() in internal/unpacker/auth.go runs before either stage and determines which credentials, if any, the pull uses. It checks in this order:

  1. --public flag — no credentials
  2. --config path — a docker config file (crane sets DOCKER_CONFIG to the containing directory)
  3. USERNAME + PASSWORD env vars — basic auth
  4. None of the above — error

Unpack() inspects tmp/ after the pull completes. If the first regular file has a gzip magic header (0x1f 0x8b) and the mediatype matches the allowed list, it extracts the tar. If blobs/sha256/ exists, the directory holds an OCI image layout written by crane, and Unpack() calls umoci. If neither condition holds, it copies files from tmp/ directly to the output directory.

Usage

Install

Two options: build from source, or use the Docker image.

Build from source (requires Go 1.22+ and umoci on $PATH):

git clone https://github.com/Sifungurux/unpacker
cd unpacker
go build -o unpacker ./cmd/unpacker
sudo mv unpacker /usr/local/bin/

Docker image (bundles umoci, no local install needed):

docker run --rm -v "$(pwd)/output:/output" \
  ghcr.io/Sifungurux/unpacker:latest \
  --public --output-dir /output \
  ghcr.io/stefanprodan/charts/podinfo:6.7.1

Pull a public Helm OCI chart

unpacker --public --output-dir ./output ghcr.io/stefanprodan/charts/podinfo:6.7.1

After unpacking, output/image/ contains the chart files:

output/
├── image/
│   ├── Chart.yaml
│   ├── values.yaml
│   └── templates/
├── manifest.json
└── tmp/

Pull a public Flux OCI source

unpacker --public --output-dir ./output ghcr.io/fluxcd/flux-manifests:v2.0.0

output/image/ contains the Kubernetes manifests from the Flux release tarball.

Pull from a private registry

Using a docker config file:

unpacker \
  --config ~/.docker/config.json \
  --output-dir ./output \
  registry.example.com/myteam/myartifact:v1.0.0

Using environment variables:

export USERNAME=myuser
export PASSWORD=mypassword
unpacker --output-dir ./output registry.example.com/myteam/myartifact:v1.0.0

Pull from a local insecure registry

For a local registry without TLS (such as the registry:2 container):

unpacker --insecure --public --output-dir ./output localhost:5000/myartifact:latest

--insecure enables plain HTTP and skips TLS verification for both pull stages.

Design Decisions

Shelling out to skopeo means carrying a binary that varies in version and path across CI containers and base images. oras-go and go-containerregistry are the upstream Go libraries that OCI tooling is built on; pulling them in as dependencies keeps unpacker self-contained and removes the version-pinning problem from the caller.

The --insecure flag covers two distinct failure modes: plain HTTP registries and registries with self-signed certificates. oras-go handles plain HTTP through PlainHTTP = true on the repository client. When the oras pull fails because TLS verification rejects a self-signed certificate, the request falls through to crane, which sets InsecureSkipVerify: true on its transport. The flag’s behavior follows the two-stage pull sequence rather than requiring the caller to know which library handles which registry type.

umoci handles the layer extraction from OCI image layouts. Rootless unpacking means preserving file ownership metadata without root access, and umoci is the reference implementation for that. Bundling it in the Docker image at a verified SHA-256 makes the container image portable; running it as a local binary requires it on $PATH. Keeping the extraction logic outside unpacker means the tool focuses on pull and dispatch, while umoci owns the complexity of OCI layer unpacking.

What’s Next

The most immediate use is CI integration: a build step calls unpacker to pull a deployment artifact before kubectl apply, replacing per-type fetch scripts with one binary. The pipeline knows the OCI reference; unpacker handles the rest regardless of artifact type.

The --mediatype flag already accepts repeated values to extend the allowed list beyond the defaults, which cover Flux and Helm types. Registries that push SBOMs, attestations, or config bundles need entries added per deployment, which gets repetitive. A configurable allow-list in a config file would let teams define the registry’s artifact types once and reference the file in every pipeline run.