docs

Prebaked repositories

Speed up agent bootstrap with precloned git repositories, and extend the repository-prebake base image.

Agent bootstrap normally clones the run repository over the network. A prebake image is an OCI image of full git clones plus a manifest.json. Point AgentRuntime.spec.repositoryImage at that image so the pod copies it into /plural/shared/repos before bootstrap. Bootstrap then moves a matching checkout into /plural/shared/repository instead of cloning over the network. Other prebaked repos stay on disk as extra context.

For field-level details, see the AgentRuntimeSpec API reference.

Use a published image

Plural publishes a Console checkout as docker.io/pluralsh/console-repos:

yaml
apiVersion: deployments.plural.sh/v1alpha1
kind: AgentRuntime
metadata:
  name: claude
spec:
  type: CLAUDE
  targetNamespace: agents
  repositoryImage: docker.io/pluralsh/console-repos:latest

Tags:

  • <YYYY-MM-DD> for each daily or manually requested build
  • latest for the newest build

Pin the date tag for reproducible runs. If the image is private, set spec.template.spec.imagePullSecrets.

Prewarm the image on cluster nodes

Pulling a large repository image when an agent starts can still add latency. Add spec.prewarm to pull repositoryImage onto eligible nodes ahead of time:

yaml
apiVersion: deployments.plural.sh/v1alpha1
kind: AgentRuntime
metadata:
  name: claude
spec:
  type: CLAUDE
  targetNamespace: agents
  repositoryImage: ghcr.io/pluralsh/console-repos:latest
  prewarm:
    cron: "0 * * * *"
    selector:
      matchLabels:
        plural.sh/agent-pool: default
    template:
      spec:
        tolerations:
          - key: plural.sh/agent
            operator: Exists
        imagePullSecrets:
          - name: repository-registry
        containers:
          - name: image-warmer
            resources:
              requests:
                cpu: 10m
                memory: 16Mi

cron is a standard five-field cron expression. The operator warms once when the configuration is first created, then follows the schedule. selector matches node labels; omit it to warm all schedulable nodes. template optionally overrides the generated warmer pod template. Container-specific overrides must target the image-warmer container by name. For a private image, repeat the required imagePullSecrets in this prewarm template; the agent pod template is configured separately.

Under the hood, the operator creates an ImageWarmer resource in its own namespace and a temporary DaemonSet with imagePullPolicy: Always. Its generated name is recorded in AgentRuntime.status.imageWarmerName. After its pod is ready on every selected node, the operator removes the DaemonSet; the pulled image remains in each node's container image cache. The default pod runs as uid/gid 65532, drops all capabilities, uses a read-only root filesystem and runtime-default seccomp, disallows privilege escalation, and does not mount a service account token. Template settings can override these defaults when required.

Changing repositoryImage updates the generated ImageWarmer. Removing prewarm removes it. Prewarming reduces image-pull latency, but node image garbage collection may evict the cached image before the next scheduled refresh.

How it works

  1. The operator starts a repository-prebake init container from repositoryImage.
  2. That container copies /data into the existing shared-context emptyDir at /plural/shared/repos. The published base image uses fcp to parallelize this small-file-heavy copy and falls back to cp -a for compatible custom images without fcp.
  3. agent-bootstrap matches the run repository URL (https and ssh forms of the same repo are equivalent) and moves that tree into /plural/shared/repository so the working copy does not duplicate disk. This is normally an instant rename because both paths use the same volume. If rename is not possible, it uses fcp when available, then deletes the source. Other prebaked repos stay under /plural/shared/repos/<path>.
  4. Fetching the requested branch is best-effort. An airgapped or stale remote keeps the prebaked copy.

This requires no extra volume and no Kubernetes image-volume feature gate. Custom images must include /bin/sh and either fcp or cp, with repos under /data, owned by uid 65532 so the non-root agent can read them.

Image layout

/data/manifest.json
/data/<path>/          # full git clone, including .git

After the init container copies that tree, the harness sees:

/plural/shared/repos/manifest.json
/plural/shared/repos/<path>/
/plural/shared/repository/     # working copy of the run's repo

manifest.json:

json
{
  "version": 1,
  "repositories": [
    {
      "url": "https://github.com/pluralsh/console.git",
      "path": "console",
      "defaultBranch": "master"
    }
  ]
}

path is relative to /data and must not contain . or .. components.

Inspect a published image:

cid="$(docker create docker.io/pluralsh/console-repos:latest unused)"
docker cp "$cid:/data/manifest.json" -
docker rm "$cid"

Extend the base image

ghcr.io/pluralsh/repository-prebake uses the same DHI Debian Trixie base as agent-harness, plus git, mise, a compile toolchain, and a prebake binary. Clone and write the manifest inside the image you push. Users build with a normal Dockerfile and docker/build-push-action. The CLI is not a host-side wrapper around docker build.

dockerfile
FROM docker.io/pluralsh/repository-prebake:latest

COPY repos.yaml /config/repos.yaml
RUN prebake --config /config/repos.yaml --chown 65532:65532

Private HTTPS remotes: leave url: without userinfo and pass the token at build time. When GIT_ACCESS_TOKEN or GIT_PASSWORD is set, prebake wires GIT_ASKPASS (GIT_USERNAME defaults to x-access-token):

dockerfile
FROM docker.io/pluralsh/repository-prebake:latest
COPY repos.yaml /config/repos.yaml
RUN --mount=type=secret,id=git_token \
    GIT_ACCESS_TOKEN="$(cat /run/secrets/git_token)" \
    prebake --config /config/repos.yaml --chown 65532:65532
docker build --secret id=git_token,env=GIT_ACCESS_TOKEN -t docker.io/org/my-repos:local .

Do not put tokens in repos.yaml or a Docker ARG. prebake strips URL userinfo from origin and manifest.json.

yaml
# repos.yaml
repositories:
  - url: https://github.com/org/app.git
    path: app                  # optional, defaults to the repo name
    branch: main               # optional, defaults to the remote default branch
  - https://github.com/org/lib.git

String entries and mappings can be mixed. repos: is accepted as an alias for repositories:.

CI publishes repository-prebake:<YYYY-MM-DD> and :latest to Docker Hub, GHCR, and GCR daily and on manual runs. Pin a date tag in production; :latest moves.

prebake CLI

text
prebake --config repos.yaml [--dest /data] [--recurse-submodules] [--lfs] [--chown uid:gid]
FlagDefaultPurpose
--configrequiredYAML listing repositories
--dest/dataClones and manifest.json. The init container copies this tree into the pod.
--recurse-submodulesoffPass --recurse-submodules to git clone
--lfsoffFetch Git LFS objects
--chownunsetuid:gid (or uid) applied recursively to --dest

HTTPS auth (env, not flags): GIT_ACCESS_TOKEN or GIT_PASSWORD, optional GIT_USERNAME (default x-access-token).

If /data/<path> already contains a .git directory, prebake keeps that checkout instead of cloning. Use that to bake the CI checkout SHA:

dockerfile
FROM docker.io/pluralsh/repository-prebake:latest
COPY repos.yaml /config/repos.yaml
COPY . /data/app
RUN git config --global --add safe.directory /data/app \
 && prebake --config /config/repos.yaml \
 && chown -R 65532:65532 /data

prebake strips URL userinfo from origin and manifest.json. Do not leave tokens in repos.yaml.

Build with GitHub Actions

yaml
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    context: .
    file: Dockerfile
    push: true
    tags: docker.io/org/my-repos:sha-${{ github.sha }}
    secrets: |
      git_token=${{ secrets.GIT_ACCESS_TOKEN }}

Precompile after prebake

prebake only clones and writes manifest.json. Compile artifacts belong in later RUN steps in your Dockerfile, and must land under /data/<path> (_build, deps, node_modules, in-tree Go caches). Anything outside /data is not copied into the pod.

Go caches must live inside the copied repository. If GOPATH / GOBIN / GOCACHE / GOMODCACHE point outside that tree, they will not survive the copy into /plural/shared/repository:

dockerfile
FROM docker.io/pluralsh/repository-prebake:latest

COPY repos.yaml /config/repos.yaml
RUN prebake --config /config/repos.yaml

WORKDIR /data/app
ENV GOPATH=/data/app/.gopath \
    GOBIN=/data/app/.gopath/bin \
    GOCACHE=/data/app/.cache/go-build \
    GOMODCACHE=/data/app/.cache/pkg/mod
RUN mix deps.get && MIX_ENV=test mix compile \
 && chown -R 65532:65532 /data

Console image

The in-tree repository-prebake/console Dockerfile extends the published base: COPY this checkout to /data/console, RUN prebake (Console plus plural-cli, plural, and authed plrl-up-demos extra context), then runs the ordered repository/language scripts in console/precompile/, including Plural's Elixir and JavaScript dependency trees. CI builds it as docker.io/pluralsh/console-repos. Pass GIT_ACCESS_TOKEN as a BuildKit secret so prebake can clone the private repo.

Locally, from the Console repository root, build the base image first:

docker build -f repository-prebake/base/Dockerfile \
  -t docker.io/pluralsh/repository-prebake:local \
  go/repository-prebake
cp repository-prebake/console/.dockerignore .dockerignore
docker build -f repository-prebake/console/Dockerfile \
  --build-arg PREBAKE_IMAGE=docker.io/pluralsh/repository-prebake:local \
  --secret id=git_token,env=GIT_ACCESS_TOKEN \
  -t docker.io/pluralsh/console-repos:local \
  .

Mise toolchains at agent boot

To install language tools in the agent container without wrapping compiles in DinD, supply a mise config and keep the default container writable. mise is already in the agent-harness image (and in repository-prebake for image builds). If it is missing at run time, the harness logs an error and continues without toolchain bootstrap.

yaml
spec:
  repositoryImage: docker.io/pluralsh/console-repos:latest
  readOnlyRootFilesystem: false
  mise:
    config: |
      [tools]
      elixir = "1.19.4"
      go = "1.27.1"
      node = "24.11.1"
      [env]
      MIX_HOME = "/plural/shared/repository/.mix"
      MIX_ARCHIVES = "/plural/shared/repository/.mix/archives"
      HEX_HOME = "/plural/shared/repository/.hex"
      GOPATH = "/plural/shared/repository/.gopath"
      GOBIN = "/plural/shared/repository/.gopath/bin"
      GOCACHE = "/plural/shared/repository/.cache/go-build"
      GOMODCACHE = "/plural/shared/repository/.cache/pkg/mod"
      GOWORK = "/plural/shared/repository/go/go.work"

The harness runs mise trust and mise bootstrap --yes before the coding agent starts. Point Mix home, MIX_ARCHIVES, GOWORK, and Go caches at /plural/shared/repository so those directories survive from the prebake copy.

If you extend a finished image that already ran mise bootstrap at build time, set readOnlyRootFilesystem: true. The same mise.config is still mounted so mise exec sees [tools] and [env], but bootstrap is skipped.

DinD requires a writable root filesystem. If both dind: true and readOnlyRootFilesystem: true are set, the operator clears read-only on the default container so Podman can start.

A template securityContext that omits readOnlyRootFilesystem inherits the CRD value. An explicit template value still wins (except when DinD is enabled).