Help us shape the future of Go! Take the 2026 Go Developer Survey

declarative-image-factory

module
v0.0.0-...-89f5746 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 5, 2026 License: Apache-2.0

README

declarative-image-factory

factory turns a short YAML manifest into a purpose-built OCI image that is reproducible bit-for-bit, signed, and shipped with SBOMs, vulnerability reports, and a signed recipe (the exact Containerfile, lockfile, and build settings) that anyone can use to rebuild it and get the same digest.

Distro packages come from the base image's own package manager — apk (Wolfi, Chainguard, Alpine), apt (Debian, Ubuntu), or dnf (Fedora, RHEL/UBI, Amazon Linux, Rocky, Alma) — with every package pinned by version and hash. Everything else — release binaries, Nix packages, tools inside other images, Go programs, arbitrary build steps — is declared inline as a tool, so there is no package recipe to write for each third-party binary.

image.yaml ──factory lock──▶ image.lock.yaml + image.Containerfile ──factory build──▶ image + attestations
 (intent)                     (every input pinned by hash)                           SBOM · vulns · recipe · signature

Applications build the same way on stacks you define: a stack names the toolchain image, the build steps, and the run image apps ship on, so an app manifest is little more than its source directory (like Cloud Native Buildpacks, but the buildpack is yours). When the run image is rebuilt, factory rebase moves each app onto it in seconds without rebuilding the app, after checking that this is safe.

Quick start

Requirements: Go, and Docker or Podman (BuildKit runs inside a container, pinned by digest). syft, grype, and cosign on PATH for scanning and signing.

go install github.com/northcutted/declarative-image-factory/cmd/factory@latest

factory lock   -f examples/platform-tools/image.yaml   # pin everything; writes lock + Containerfile
factory build  -f examples/platform-tools/image.yaml   # build, SBOM, scan, gate (local only)
factory build  -f examples/platform-tools/image.yaml --push   # …and push, sign, attest
factory verify -f examples/platform-tools/image.yaml --digest sha256:…   # rebuild from scratch, compare
factory verify --image ghcr.io/acme/platform-tools@sha256:…              # rebuild from the signed recipe

factory build  -f examples/hello-app/app.yaml           # an app, built on examples/stacks/go.yaml
factory rebase -f examples/hello-app/app.yaml --push --update-lock   # move it onto the newest run image, test, re-pin

Commit image.yaml, image.lock.yaml, and image.Containerfile. Reviewers see exact version and hash changes in the lock diff and the resulting build steps in the Containerfile diff.

The manifest

apiVersion: factory.dev/v1alpha1
kind: Image
extends: ../base.yaml            # optional: layer on a team/org base manifest
metadata:
  name: platform-tools           # ref defaults to <org registry>/<name>
spec:
  base: cgr.dev/chainguard/wolfi-base:latest   # or debian:trixie-slim, amazonlinux:2023, …
  platforms: [linux/amd64, linux/arm64]
  packages: [bash, git, jq]      # installed with the base's package manager (apk, apt, or dnf)
  tools:
    - name: kubectl              # plain download
      from: url
      version: 1.37.1
      url: https://dl.k8s.io/release/v{{version}}/bin/{{os}}/{{arch}}/kubectl
      checksum: "{{url}}.sha256"
    - name: gh                   # GitHub release asset, binary pulled from the archive
      from: github-release
      repo: cli/cli
      version: 2.102.0
      asset: gh_{{version}}_{{os}}_{{arch}}.tar.gz
      checksums: gh_{{version}}_checksums.txt
      extract: gh_{{version}}_{{os}}_{{arch}}/bin/gh
    - name: crane                # copied out of another image
      from: oci
      image: gcr.io/go-containerregistry/crane:latest
      path: /ko-app/crane
    - name: yq                   # compiled with a pinned Go toolchain
      from: go
      package: github.com/mikefarah/yq/v4
      version: v4.54.1
    - name: ripgrep              # from nixpkgs, on any base (even distroless)
      from: nix
      package: ripgrep           # attribute path, e.g. python3Packages.black
      nixpkgs: nixos-26.05       # branch, tag, commit, or github:owner/repo/ref
    - name: awscli               # escape hatch: any script; write outputs to /out
      from: build
      image: python:3.13
      run: pip install --prefix=/out/usr/local awscli==2.0.0
  files:
    - {src: config/, dst: /etc/platform-tools/}
  env: {KUBECONFIG: /home/nonroot/.kube/config}
  labels: {org.opencontainers.image.source: https://github.com/acme/images}
  user: "65532"
  entrypoint: [/bin/bash]
  vex: [vex/triaged.openvex.json] # OpenVEX statements applied to scans
  test:                          # smoke test, run in every platform image before pushing
    command: [/usr/local/bin/kubectl, version, --client]   # must exit 0
    # http: {port: 8080, path: /healthz}   # or: start the image, expect a non-error answer
    # timeout: 60s
Tool source Pinned by Verified against
url sha256 per platform sha256: in the manifest, or a checksum: file URL
github-release sha256 per platform checksums: asset, else the digest GitHub reports for the asset
oci image digest content addressing
go module version + toolchain image digest the Go checksum database
nix nixpkgs commit + store path and closure per platform cache.nixos.org's ed25519 signature on every store path, checked at lock and again by nix copy at build
build builder image digest nothing — the lock marks it unverified

Placeholders: {{version}}, {{os}}, {{arch}} (renamed per tool with archMap: {amd64: x86_64}), {{name}}, plus {{tag}} and {{asset}} for GitHub releases and {{url}} in checksum:. Unknown placeholders are errors. Tools with no checksum source are hashed on first download ("TOFU") and are rejected unless the org profile sets policy.allowTOFU: true.

Tools install to /usr/local/bin/<name> with mode 0755 unless dest: and mode: say otherwise.

Packages

factory lock resolves packages with the distribution's own solver, run inside the digest-pinned base image (apt and dnf run in a container; apk is resolved in Go from the signed APKINDEX). The lock records every package that will be installed — for apt and dnf, each file's URL and sha256 — and the build downloads exactly those files with ADD --checksum and installs them offline. The build never asks a repository what is current.

Manager Detected from Resolution Pinned download URL Signatures checked at lock
apk /lib/apk/db/installed Go solver over the base's repositories; base packages are pinned too apk fetches by exact version APKINDEX signed by a key in the base's /etc/apk/keys
apt os-release debian/ubuntu apt-get in the base; Debian sources point at snapshot.debian.org Debian: snapshot.debian.org. Ubuntu: Launchpad, which keeps every published file apt verifies the signed indexes
dnf os-release fedora/rhel/amzn/… dnf --downloadonly in the base Fedora: Koji's signed copy of the build (byte-identical to the repository's). Amazon Linux: its content-addressed CDN. Others: the repository (mirror it) rpm -K against the distribution's keys

Override detection with spec.packageManager. Each manager's leftovers that differ between runs (logs, caches, ldconfig's aux-cache, apt's binary caches, SQLite's shared-memory file) are removed in the install layer. Amazon Linux's rpm records the wall-clock install time, so factory rewrites those header fields to SOURCE_DATE_EPOCH, as newer rpm does natively.

Apps and stacks

A stack is a platform team's recipe for building one kind of app. It is the "buildpack", written in YAML and owned by you:

apiVersion: factory.dev/v1alpha1
kind: Stack
metadata: {name: go}
spec:
  build: golang:1                          # toolchain the steps run in
  run: ../go-runtime/image.yaml            # what apps ship on: an image ref, or a factory manifest
  platforms: [linux/amd64, linux/arm64]
  crossCompile: true                       # run steps on the build machine with TARGETOS/TARGETARCH
  buildEnv: {CGO_ENABLED: "0", MAIN: .}    # apps may override
  caches: [/root/.cache/go-build, /go/pkg/mod]
  steps:                                   # run in the app's source (/src); write the app to /out
    - run: go mod download
    - run: GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w -buildid=" -o "/out/$APP_NAME" "$MAIN"
  appDir: /app                             # where /out lands (default /app)
  user: "65532"                            # defaults for apps; {{name}} is the app's name
  entrypoint: ["/app/{{name}}"]

An app names a stack and adds little else:

apiVersion: factory.dev/v1alpha1
kind: App
metadata: {name: hello-app}
spec:
  stack: ../stacks/go.yaml
  source: .                 # default: the manifest's directory
  exclude: [docs/]          # .gitignore-style; .git and /out are always left out
  buildEnv: {MAIN: ./cmd/server}
  env: {PORT: "8080"}       # env, labels, user, workdir, entrypoint, cmd override the stack's

lock, render, build, and verify work on apps exactly as on images (-f app.yaml, which is also the default when there is no image.yaml). The lock pins the build and run images and records the stack's digest, so a stack change makes every app's lock stale. The generated app.Containerfile runs the steps in the pinned build image and copies only /out onto the pinned run image with COPY --link. The app's layer therefore never touches the run image's files, and the image records its base in the standard org.opencontainers.image.base.name/.base.digest annotations. Those two properties are what make it safe to rebase.

Apps can't declare packages, tools, or files: anything an app needs from the OS belongs in the stack's run image, which is itself a factory image. Quote flow-style values that contain {{name}} (["/app/{{name}}"]).

Rebasing

factory rebase --image repo:tag swaps the base layers under an image's own layers for a newer build of its base, without rebuilding the image. By default it follows the base the image names in its annotations (the stack's run image tag), so after the platform team pushes a patched run image, rebasing every app takes seconds and needs no source. --onto picks another base; --from names the old one for images without annotations (built with plain Dockerfiles, say). With a tag, --push moves that tag to the result.

For apps, factory rebase -f app.yaml rebases the app's published image (metadata.ref and its first tag) onto its stack's run image and runs the app's smoke test on the result before pushing. --update-lock then pins app.lock.yaml to that base (factory lock --update-base), so the next build from source doesn't quietly go back to the unpatched one. If the rebase is refused, the lock still moves and the command fails: rebuilding is the fix.

Before rebasing, factory checks each platform (--check stops there):

  • The image's own layers only add files. If they replace, delete, or hide a base file, write through a base symlink, or change package-manager state (an apt-get install in a Dockerfile), the image's copies would win over the new base's, so a patched library could stay unpatched. These images are refused; rebuild them.
  • The bases are compatible. The distribution release (os-release) must match, every shared library and ELF interpreter the image's own programs load must still be there, and so must the entrypoint. Otherwise rebuild, or pass --force.

How the image runs never changes: user, entrypoint, command, working directory, ports, and volumes stay as built, and factory notes where the new base would differ. Environment variables and labels the image inherited unchanged from the old base follow the new one (a changed PATH, say).

A rebase is a pure function of its inputs. The result carries a signed rebase record (the image, old and new base digests) instead of a recipe, so factory verify --image repeats the rebase and compares digests in seconds. SBOM and vulnerability attestations are made for the result as for builds.

Running a fleet

The intended split, and the automation that keeps it current:

Who Owns Changes it when
Platform team, with security factory.org.yaml: registry, signer identity, vulnerability gate, policy, mirrors, pinned BuildKit Rarely; every lock goes stale
Platform team Run and base images (kind: Image) and their locks Weekly updates, CVE fixes
Platform team, one owner per language Stacks Toolchain bumps; every app on the stack rebuilds
App teams app.yaml, the source, app.lock.yaml Their own releases
Security VEX statements; fleet-wide audit (e.g. ClearCutt) When triaged
  • Weekly updates (update-locks.yml). factory lock --update on every manifest, one pull request each, listing exactly what moved (image digests, package and tool versions). CI on the pull request builds, smoke-tests, scans, and gates; merging publishes. Auto-merging these when the gates pass is reasonable.
  • Base patches reach apps without app teams (fleet.yml). Daily and after every publish on main, factory rebase -f <app> --push --update-lock for each app: rebase if safe, smoke-test, scan, push, sign, repeat from the signed record, then a pull request pinning the app's lock to the same base. An app that can't be rebased gets the same pull request (merging rebuilds it) and a failed job.
  • CVE response is the same path on demand: fix the run image (re-lock, merge), and the fleet job moves every app onto it in minutes.
  • Toolchain and stack changes rebuild rather than rebase (the app's own layer changes): the weekly job's pull request per app does it, tested by each app's CI.
  • Distribution upgrades (Debian 13 to 14, say) are refused by rebase on purpose. Publish a new run image and stack version beside the old ones; apps move with a one-line change and a rebuild.

Pull requests opened with the default GITHUB_TOKEN don't start other workflows, so set a FACTORY_BOT_TOKEN secret (a GitHub App token, or a fine-grained token with contents and pull-requests write) for CI to run on them. Deploy by digest and let GitOps follow the tags; keep old run images (a rebase needs the old base's manifest) and old app digests (rollback).

The org profile

Everything that differs between organizations lives in factory.org.yaml, found by walking up from the manifest (or --org). See examples/factory.org.yaml.

Section Controls
registry, tags Where images go and how they are tagged
defaults Base image, platforms, and labels for every manifest
builder Pinned BuildKit, Dockerfile frontend, toolbox, Go and Nix images, default nixpkgs branch; runtime: docker|podman; addr: for a remote buildkitd (e.g. in Kubernetes)
signing keyless (Sigstore OIDC), key (file or any cosign KMS URI), or none; extra cosign args (private Sigstore, no tlog); the identity verifiers expect
sbom Whether to add a syft scan to the declared SBOM
vulnerabilities Scanner and the failOn severity gate, optionally onlyFixed
policy Non-root, required labels, TOFU, allowed download hosts (tools and packages) and registries
mirrors URL prefix rewrites for every download (from: https://snapshot.debian.org/, to: https://artifactory.acme/debian-snapshot/); checksums still pin the content

What makes it reproducible

  • Every input is content-addressed in the lock: base, BuildKit, frontend, and helper images by digest; every package including transitive dependencies by exact version, and for apt/dnf by file sha256; every download by sha256; Go modules by version (sumdb-checked); Nix store paths by their signed hashes.
  • Pinned BuildKit: builds run moby/buildkit@sha256:… in a container, so every machine uses the same builder.
  • Clamped timestamps: SOURCE_DATE_EPOCH comes from the lock (midnight UTC the day before the pins last changed, so files created during any build are newer and get clamped even on a builder whose clock lags) and rewrite-timestamp=true clamps them. Every stage that runs commands declares the epoch, so layers cached under a different epoch are never reused. Downloads and local files are copied through a toolbox stage so upstream mtimes never reach the image.
  • Known nondeterminism removed: glibc's ldconfig aux-cache (which records inode numbers) is deleted in the same layer it is created.
  • One layer per tool (COPY --link), so a change to one tool doesn't disturb the others.

factory verify rebuilds without any cache and compares digests. On a mismatch it names the first differing layer and the files that differ.

What gets attached to a pushed image

Attestation Subject Content
Signature index and each platform image cosign (sign --recursive)
Recipe (…/recipe/v1) index Containerfile, effective manifest, lock, staged context files, BuildKit digest, epoch, platforms, digests
SBOM (CycloneDX) each platform image syft scan merged with components declared by the lock (purls, hashes, download URLs, verification method)
Vulnerabilities (cosign …/vuln/v1) each platform image grype results with VEX applied
Rebase record (…/rebase/v1) index of a rebased image (instead of a recipe) the image that was rebased, each platform's old base, the new base; enough to repeat the rebase
SLSA provenance index from the CI platform (see images.yml)

Unsigned copies of all of these are written to out/<name>/attestations/, and out/<name>/result.json summarizes the build for CI.

CI

.github/workflows/images.yml is a complete pipeline. On pull requests it builds, scans, and gates. On main it also pushes, signs keylessly, adds SLSA provenance, and then rebuilds on a second runner from the signed recipe alone. factory emits image, repository, and digest to $GITHUB_OUTPUT. update-locks.yml and fleet.yml keep the fleet current (see Running a fleet). Other CI systems call the same commands; nothing is GitHub-specific.

Limitations

  • Repositories that drop old versions break old locks: Alpine (apk) and RHEL/UBI, Rocky, Alma, and CentOS (dnf). Wolfi, Chainguard, Debian (snapshot), Ubuntu (Launchpad), Fedora (Koji), and Amazon Linux keep history (Ubuntu has no snapshot for arm64, so it resolves against the live archive at lock time; the pinned files still never change). For the others, set mirrors: to a caching proxy (Artifactory, Nexus, Pulp).
  • dnf locking needs dnf in the base; microdnf-only images (ubi-minimal) can't be locked yet.
  • Nix packages must be in the binary cache; anything that would need building from source is rejected at lock time. They bring their own libraries (larger images), and scanners cover Nix less thoroughly than distro packages.
  • from: build steps are only as reproducible as the script; verify will tell you.
  • SLSA provenance must come from the build platform, not from factory.
  • Vulnerability reports describe the day they were made. Rescanning and re-attesting on a schedule is not built in yet.
  • The local BuildKit cache volume is shared, so run one build at a time per machine.
  • Stacks are referenced by path, so apps in other repositories vendor them (a git submodule, say); fetching stacks from a registry is not built in yet.
  • Rebase checks are file-level: they catch replaced files, missing libraries and interpreters, and a changed distribution release, not every behavior change in the new base (a different default config file, say). Within one distribution release, as the stack's run image is rebuilt, that is the usual contract; test rebased images like any other.
  • Rebasing needs the old base's manifest, so keep old run images in the registry.
  • The fleet workflow finds apps by their manifests in this repository. Apps in other repositories run the same job on their own, or a registry-wide inventory (ClearCutt's base graph) drives factory rebase --image.
  • Smoke tests run images on the build machine, other architectures through emulation (QEMU in CI); --no-test skips them.

Development

go test ./...
go test ./internal/render -update   # refresh the golden Containerfiles
FACTORY_E2E=1 go test ./internal/factory   # real lock + build + no-cache rebuild per package manager,
                                           # and an app built on a stack, then rebased (needs docker or podman)

License

Apache-2.0; see LICENSE.

Directories

Path Synopsis
cmd
factory command
Command factory builds signed, attested, reproducible OCI images from a declarative manifest.
Command factory builds signed, attested, reproducible OCI images from a declarative manifest.
internal
apk
Package apk reads APK repository indexes and resolves a fully pinned package set, so `apk add` installs the same versions on every build.
Package apk reads APK repository indexes and resolves a fully pinned package set, so `apk add` installs the same versions on every build.
attest
Package attest builds in-toto statements (including factory's recipe predicate) and drives cosign to sign and attach them.
Package attest builds in-toto statements (including factory's recipe predicate) and drives cosign to sign and attach them.
builder
Package builder runs BuildKit with the settings that make output reproducible: a pinned BuildKit, SOURCE_DATE_EPOCH, rewrite-timestamp, and an OCI layout output whose index digest is the image digest.
Package builder runs BuildKit with the settings that make output reproducible: a pinned BuildKit, SOURCE_DATE_EPOCH, rewrite-timestamp, and an OCI layout output whose index digest is the image digest.
container
Package container runs one-off containers through docker or podman, for steps that need a distribution's own tools (apt, dnf, nix) at lock time.
Package container runs one-off containers through docker or podman, for steps that need a distribution's own tools (apt, dnf, nix) at lock time.
diff
Package diff explains why two builds of the same recipe differ, down to the files inside the first differing layer.
Package diff explains why two builds of the same recipe differ, down to the files inside the first differing layer.
factory
Package factory implements the CLI commands: lock, render, build, verify.
Package factory implements the CLI commands: lock, render, build, verify.
lock
Package lock defines the lockfile: every input of an image, content-addressed.
Package lock defines the lockfile: every input of an image, content-addressed.
manifest
Package manifest defines the user-facing manifests (image.yaml for images, app.yaml for apps built on a stack), stacks, and the org profile (factory.org.yaml), and loads them with extends/default merging.
Package manifest defines the user-facing manifests (image.yaml for images, app.yaml for apps built on a stack), stacks, and the org profile (factory.org.yaml), and loads them with extends/default merging.
native
Package native resolves apt and dnf packages with the distribution's own tools, run inside the pinned base image at lock time.
Package native resolves apt and dnf packages with the distribution's own tools, run inside the pinned base image at lock time.
nix
Package nix locks nixpkgs packages: it evaluates store paths with a pinned nix container and walks their closures through a binary cache, checking every path's signature.
Package nix locks nixpkgs packages: it evaluates store paths with a pinned nix container and walks their closures through a binary cache, checking every path's signature.
policy
Package policy enforces the org profile's rules on a manifest and its lock.
Package policy enforces the org profile's rules on a manifest and its lock.
rebase
Package rebase moves an application's layers from the base image they were built on to a newer build of that base, without rebuilding the application, and checks first that the move is safe (see Analyze).
Package rebase moves an application's layers from the base image they were built on to a newer build of that base, without rebuilding the application, and checks first that the move is safe (see Analyze).
registry
Package registry wraps go-containerregistry for the few operations factory needs: resolve digests, read files out of images, and move OCI layouts.
Package registry wraps go-containerregistry for the few operations factory needs: resolve digests, read files out of images, and move OCI layouts.
render
Package render generates a Containerfile from a manifest and its lock.
Package render generates a Containerfile from a manifest and its lock.
resolve
Package resolve turns a manifest into a lockfile by pinning every input.
Package resolve turns a manifest into a lockfile by pinning every input.
sbom
Package sbom builds CycloneDX SBOMs.
Package sbom builds CycloneDX SBOMs.
scan
Package scan runs the vulnerability scanner and evaluates the org's gate.
Package scan runs the vulnerability scanner and evaluates the org's gate.
smoke
Package smoke runs a manifest's smoke test in a built image, through docker or podman, before the image is pushed.
Package smoke runs a manifest's smoke test in a built image, through docker or podman, before the image is pushed.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL