build-tpu-image-iris
SkillMediaBuild + push the datagen/eval TPU image (`ghcr.io/open-thoughts/openthoughts-agent:tpu`) — the Iris TPU runtime (vLLM-TPU 0.20.0 PyTorch/XLA+JAX + Harbor + the Daytona sandbox backend) — AS AN IRIS KANIKO JOB on CoreWeave `cw-us-east-02a` (a CPU-only amd64 build; NO CUDA/nvcc, so it's minutes not hours, and the Mac CANNOT build it — arm64 + linux/amd64-only). Covers WHY kaniko not buildkit (shared with the gpu-rl skill), the crane-export-over-ubuntu recipe, and — the load-bearing lesson this skill exists for — how to make a canonical fast-moving dep (Harbor, our `marin-community/harbor` fork on `main`) ACTUALLY reach the worker: the RUNTIME gate is OT-Agent's `uv.lock` (the iris worker `uv sync --frozen --reinstall`s from it, OVERWRITING the image bake) — so deploying a harbor change is a `uv lock --upgrade-package harbor` + commit, NOT (only) an image rebuild; the Dockerfile `HARBOR_COMMIT` + `--force-reinstall` pin is a secondary image-consistency measure. VERIFY by grepping the INSTALLED file (never trust a build-log line). Also: PINNED-first push (`:tpu-<gitsha>` immutable, promote floating `:tpu` via `crane tag` only after a live smoke), capturing the digest, monitoring, and WHEN a rebuild is required vs a runtime `/app` bundle sync. Use when asked to build / rebuild / push the `:tpu` image, or after bumping harbor / the [datagen-tpu] resolve / vLLM-TPU. Reference: docker/Dockerfile.tpu, docker/build_tpu_kaniko.sh, the sibling build-gpu-rl-image-iris skill, .agents/ops/iris/ops.md.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the build-tpu-image-iris skill
What this skill tells your AI
The instructions your AI receives, as published by open-thoughts/openthoughts-agent in .agents/skills/build-tpu-image-iris/SKILL.md and read by ahel’s review.
📍 Iris orientation — read first. Before acting on anything in this skill, read the Iris tools catalog (
.agents/ops/iris/ops.md) and the Iris ops directory (.agents/ops/iris/— the CoreWeave GPU particulars inops.md, the TPUmarinparticulars inops.md). They carry the binding access/preamble/gotchas and the helper-script inventory the steps below rely on.
⚠ Local clone = ground truth. ALL
Dockerfile.tpu/build_tpu_kaniko.shedits go in the local Mac checkout onpenfever/working→ commit → push. The iris job bundles the LOCAL workspace to/appviagit ls-files --cached --others --exclude-standard(respects.gitignore; reads WORKING-TREE content, so uncommitted tracked edits ARE included — you do NOT have to commit/push a Dockerfile edit before a build). Never hand-edit on a remote / patch-by-rsync.
The :tpu image is the Iris TPU datagen + eval runtime — linux/amd64 (TPU host VMs are x86_64),
single-stage python:3.12-slim + uv pip install -e ".[datagen-tpu,cloud]" (vLLM-TPU 0.20.0 PyTorch/XLA+JAX,
tpu-inference 0.20.0, transformers 5.5.3 for Qwen3.5 hybrid GDN) + Harbor with the Daytona sandbox backend. It
is consumed by every datagen/eval launch (data/cloud/launch_tracegen_iris.py, eval/cloud/launch_eval_iris.py
— see the datagen-launch-iris skill). This skill is the how-to to BUILD + PUSH it.
This image is MUCH simpler than gpu-rl. No CUDA / nvcc / vLLM-fork wheels /
WHEEL_SOURCE/ wheelhouse —Dockerfile.tpuis single-stage and TPU slice size is a submit-time iris arg (--tpu v5p-8), not a build choice. So this skill omits all the gpu-rl wheel-cache machinery. For the shared kaniko mechanics (WHY kaniko not buildkit, the crane-export trick, ghcr creds,SINGLE_SNAPSHOT=0layering), the siblingbuild-gpu-rl-image-irisskill is the deep reference — read its §1/§2 once; they apply verbatim here. (The gpu-rl build files themselves are canonical in MarinSkyRL —docker/Dockerfile.gpu-rl+docker/build_gpu_rl_kaniko.sh— not OT-Agent; only the TPU builddocker/Dockerfile.tpu+docker/build_tpu_kaniko.shis OT-Agent-local.)
0. Where the build runs, and why it can't run elsewhere
- The Mac CANNOT build it. It's arm64; the image is
linux/amd64-only. Build in-cluster. - There is NO in-cluster image-build primitive in iris (
iris build= LOCALdocker buildx). So the build runs AS AN IRIS JOB — a CPU-only task that does the Docker build itself, via kaniko (the cluster deniesCAP_SYS_ADMIN/bind-mounts + runs gVisor, so buildkit fails; kaniko snapshots its own rootfs). Same decisive reasoning as the gpu-rl skill §1. - CPU-only, minutes not hours. Unlike gpu-rl (~3 h of nvcc), the
:tpubuild is justapt+ auvresolve/install — dominated by the dependency download/install (~15–30 min). No GPU, no big-memory wheel-packaging step. - Cluster: run on
cw-us-east-02a(proven kaniko + ghcr-egress + kubeconfig path; the TPU image is a plain amd64 CPU build so it does not need the marin TPU cluster).KUBECONFIG=~/.kube/coreweave-iris-gpuand the otagent-env iris binary are hard prereqs (see.agents/ops/iris/ops.md).
1. Mechanism (crane-export kaniko) — what build_tpu_kaniko.sh does
The task image is docker.io/library/ubuntu:22.04 (kaniko's executor is distroless / no bash, so it can't be
the task image directly). docker/build_tpu_kaniko.sh runs, in order:
apt-get installcrane →crane export gcr.io/kaniko-project/executor:latestover/.- Write
$DOCKER_CONFIG/config.jsonAFTER the overlay (kaniko clobbers/kanikootherwise), with the ghcr auth{"auths":{"ghcr.io":{"auth":"<base64 penfever:$DOCKER_TOKEN>"}}}andexport DOCKER_CONFIG=/kaniko/.docker. exec /kaniko/executor --context dir:///app --dockerfile docker/Dockerfile.tpu --skip-unused-stages --compressed-caching=false --cache=true --cache-repo=…/cache-tpu --destination …:tpu-<GITSHA>.
Load-bearing flags (already in the script — recognize them):
SINGLE_SNAPSHOT=0(default) = per-instruction layers. Dodges the un-pullable ~16 GB single-blob layer (containerd EOFs the single-blob GET over ghcr egress →ImagePullBackOff). Keep the default; do NOT setSINGLE_SNAPSHOT=1unless you accept that pull risk.--compressed-caching=false— writes layers to disk so multi-layer snapshotting doesn't blow memory.--skip-unused-stages— harmless here (single-stage) but kept for parity.
ghcr push creds (the load-bearing cred gotcha — shared with gpu-rl)
secrets.env DOCKER_TOKENis a Docker Hub PAT (dckr_pat_…) — WRONG for ghcr.io. Pass the GitHub PAT as-e DOCKER_TOKEN "$(gh auth token)"(userpenfever, scopewrite:packages). The script'sDOCKER_TOKENenv is what it base64s into the ghcr auth — so feed it the GH token, not the Docker Hub one.
2. ⚑ THE CORE LESSON — the RUNTIME dep gate is OT-Agent's uv.lock, NOT the image bake
⚠⚠ READ THIS FIRST — the image is NOT how a harbor (or any locked dep) version reaches an iris worker. The iris worker bootstrap re-syncs the venv from the bundled OT-Agent
uv.lockat startup —hpc/iris/bootstrap.py:34runsuv sync --frozen --reinstall --link-mode=copy, which REINSTALLS harbor fromuv.lockand OVERWRITES whatever the image baked. So ifuv.lockpins the old harbor, the worker runs the old harbor no matter what--task-imageyou launch with. Proven live 2026-07-08: job…070443-resume-095302, launched on the freshly-built:tpu-abd6dc86(which bakes harbor 0.8.1), still died atjob.py:263 raise= harbor 0.8.0 — becauseuv.lockstill pinned0.8.0(d9504d19).THE ACTUAL DEPLOY LEVER for a canonical locked dep (harbor):
cd /Users/benjaminfeuer/Documents/OpenThoughts-Agent uv lock --upgrade-package harbor # re-resolves harbor from main HEAD → rewrites uv.lock git add uv.lock && git commit -m "chore(deps): re-lock harbor -> <ver> (<sha>)" && git push # penfever/working # verify: grep -A2 'name = "harbor"' uv.lock → version + the intended revEvery future iris launch bundles the updated lock → the worker
uv sync --frozeninstalls the new harbor at runtime, regardless of the image. This is what actually shipped the fix (OT-Agent1bac810f:uv.lockharbor0.8.0 d9504d19→0.8.1 2dde0bbf). Rebuilding/promoting the image does NOT deploy a harbor change to iris workers — do the lock bump.
The rest of this section (the Dockerfile HARBOR_COMMIT pin + --force-reinstall cache-bust) is still worth
keeping — it keeps the image self-consistent (a direct docker run of the image, or any consumer that does
NOT re-sync from the lock, gets the right harbor) and keeps the worker's uv sync fast (image already at the
locked version = fewer reinstalls). But it is a secondary consistency measure, not the runtime gate. Keep
the two in lockstep: when you bump the lock, bump HARBOR_COMMIT to the same sha.
This skill's build machinery matters because the image still bakes the base + the [datagen-tpu] resolve + the
first-party code; just don't mistake a green image build (or a crane promote) for a deployed dep change.
Historical footnote — why the image ALSO looked stale (the cached-wheel layer)
Before the lock finding, the image itself was baking a stale harbor: c49064a8 did NOT bump harbor's version
(still 0.8.0), so kaniko's cached uv pip install harbor layer (keyed on the unchanged COPYed
pyproject.toml + RUN string) was reused, baking a stale 0.8.0 wheel — the "Built harbor @ c49064a8"
log line was misleading. That's now fixed in the Dockerfile (below), but it was only HALF the problem; the
runtime lock-sync above was the other half and the decisive one.
Principle (user directive, 2026-07-08): for our frequently-updated canonical repos, install FRESH FROM
GITHUB (from-source at the intended commit), never a stale cached wheel — and, decisively for iris,
keep the SAME commit in uv.lock (the runtime authority). Harbor is exactly such a repo — our
marin-community/harbor fork, canonical branch main, changed almost every campaign.
Two mechanisms that guarantee freshness (the Dockerfile now does BOTH for harbor):
- Pin
ENV HARBOR_COMMIT=<sha>and bump it every harbor change. Changing the env line changes THIS RUN-layer's cache key → kaniko is forced to rebuild the layer (this is the cache-bust). uv pip install --no-deps --force-reinstall "harbor[daytona] @ git+https://github.com/marin-community/harbor.git@${HARBOR_COMMIT}".--force-reinstallmakesuvrebuild harbor from the exact pinned commit regardless of any cached wheel;--no-depskeeps it a surgical swap that won't refloat the pinned resolve. Then a build-time assert prints the baked version + commit.
Belt-and-suspenders that ALSO helped: bump harbor's package version onENV HARBOR_COMMIT=2dde0bbf11d48a4e44df6ad0a56cee31e7ab609e # 0.8.1, carries c49064a8 (resume/export-push fix) RUN . /opt/openthoughts/.venv/bin/activate && \ uv pip install --no-deps --force-reinstall \ "harbor[daytona] @ git+https://github.com/marin-community/harbor.git@${HARBOR_COMMIT}" && \ python -c "import harbor, importlib.metadata as m; print('baked harbor', m.version('harbor'))"main(0.8.0 → 0.8.1, commit2dde0bbf) souv's resolver treats it as a new package and won't reuse a same-version cached wheel even on the base[datagen-tpu]resolve layer.
Do NOT rely on a same-commit version-only bump alone, and do NOT rely on
--no-cachealone either. The durable, reproducible fix is theHARBOR_COMMITenv-line bump (cache-bust) +--force-reinstall(source rebuild) + a version bump (resolver-level freshness). All three are cheap; use them together.
Applying the principle beyond harbor. For any other canonical fast-moving repo baked into this image, mirror this shape: a pinned
ENV <REPO>_COMMITon the canonical branch +--force-reinstallfromgit+https://…@${COMMIT}for image self-consistency — AND bumpuv.lockto the same commit (uv lock --upgrade-package <name>), because the iris worker'suv sync --frozeninstalls from the lock at runtime (§2). Never let such a dep ride a cached wheel keyed on an unchangedpyproject.toml, and never assume the image bake wins over the lock. (First-party OT-Agent code is already editable-e+ synced to/appat launch — never stale; this lesson is for third-party-installed canonical deps like harbor.)
3. The launch command (verbatim shape)
source "${DC_AGENT_SECRET_ENV:?set DC_AGENT_SECRET_ENV to the secrets file first}"
export KUBECONFIG=~/.kube/coreweave-iris-gpu # HARD prereq (see ops doc)
IRIS=/Users/benjaminfeuer/miniconda3/envs/otagent/bin/iris # the cw-capable (otagent-env) iris binary
GHCR_TOKEN=$(gh auth token) # GitHub PAT, NOT the Docker Hub DOCKER_TOKEN
GITSHA=$(git rev-parse --short HEAD) # REQUIRED — build_tpu_kaniko.sh has `: "${GITSHA:?}"` (the immutable :tpu-<gitsha> tag)
B64=$(base64 -i docker/build_tpu_kaniko.sh | tr -d '\n') # the kaniko build script, base64'd in
$IRIS --cluster=cw-us-east-02a job run \
--task-image docker.io/library/ubuntu:22.04 --no-sync --enable-extra-resources \
--cpu 32 --memory 256GB --disk 300GB \
--job-name tpu-kaniko-$GITSHA \
--max-retries 0 --timeout 10800 \
-e DOCKER_USER_ID penfever -e DOCKER_TOKEN "$GHCR_TOKEN" -e BUILD_B64 "$B64" \
-e GITSHA "$GITSHA" --no-wait \
-- bash -lc 'echo "$BUILD_B64" | base64 -d > /tmp/build.sh && exec bash /tmp/build.sh'
-e GITSHAis MANDATORY (: "${GITSHA:?}"— the immutable:tpu-<gitsha>tag; the job dies without it).- These are the verbatim flags that built
:tpu-abd6dc86(2026-07-08, ~8.5 min, 9.8 MB bundle). They're over-provisioned — a CPU-onlyuvresolve + layered snapshot needs nowhere near 256 GB / 300 GB (gpu-rl's sizing was inherited). Trimming toward ~16 CPU / 64 GB / 120 GB is safe if you want faster scheduling; bump back only if the snapshot step reports pressure.SINGLE_SNAPSHOTandDOCKERFILEwere NOT set → they takebuild_tpu_kaniko.shdefaults (SINGLE_SNAPSHOT=0= per-instruction pullable layers,DOCKERFILE=docker/Dockerfile.tpu). Leave them.--timeout 10800(3 h) is generous headroom for a ~8–30 min build.- The build pushes ONLY the immutable
:tpu-<GITSHA>tag (PINNED-first). The floating:tpuis promoted separately in §5, so a botched build cannot break other users':tpujobs.
4. ⚑ VERIFY THE BUILT IMAGE — grep the INSTALLED file, never trust a build-log line
This is the whole point of the skill — the last build was declared good on a build-log line while the deployed file was stale. After the build SUCCEEDS + pushes, pull the image and grep the ACTUAL installed source:
Use the venv's python explicitly — a bare bash -lc python … resolves a non-venv interpreter and
import harbor fails. Either call /opt/openthoughts/.venv/bin/python directly or
. /opt/openthoughts/.venv/bin/activate first.
NEW=ghcr.io/open-thoughts/openthoughts-agent:tpu-<gitsha>
# 1. installed harbor version is the bumped one:
docker run --rm "$NEW" /opt/openthoughts/.venv/bin/python -c \
"import importlib.metadata as m; print('harbor', m.version('harbor'))" # expect 0.8.1 (not 0.8.0)
# 2. the FIX is in the installed job.py (logger.warning, NOT a raise) at the guard:
docker run --rm "$NEW" sh -lc \
"grep -n -A4 -B2 'existing_config != self.config' /opt/openthoughts/.venv/lib/python3.12/site-packages/harbor/job.py"
# → the `if existing_config != self.config:` guard must be followed by logger.warning(...), NOT
# raise FileExistsError(...). (On :tpu-abd6dc86 this is job.py:275-276.)
The file has TWO guards — check the RIGHT one. The job-config-drift guard (the resume killer) is the
existing_config != self.configone, nowlogger.warning. Araise FileExistsErrorstill legitimately remains FAR AWAY (job.py:841 on abd6dc86) in the per-trial reconciler the fix intentionally defers to — so a plaingrep raise FileExistsErrorwill show a hit; that's expected, NOT a regression. Grep the specific guard line as above. Confirm the site-packages path withdocker run --rm "$NEW" /opt/openthoughts/.venv/bin/python -c "import harbor,os;print(os.path.dirname(harbor.__file__))"if the venv layout has moved. Do not declare success on the build log alone — grep the file. That a pull-and-run verify job even executes ALSO proves the image is pullable (SINGLE_SNAPSHOT=0); optionally confirm layering withdocker buildx imagetools inspect "$NEW" --raw→ many layers, max well under 8 GB.
5. Capture the digest + PROMOTE the floating :tpu (PINNED-first)
The build pushed only :tpu-<gitsha>. Promote the floating :tpu to it only AFTER a live smoke (a real
datagen job on :tpu-<gitsha> serves and — for a resume/export-push fix — SURVIVES a preempt-resume and
populates literals end-to-end). Record the pre-promote digest first so rollback is a one-liner.
# resolve the immutable digest of the pinned tag:
crane digest ghcr.io/open-thoughts/openthoughts-agent:tpu-<gitsha> # sha256:… (record it)
# record the CURRENT floating :tpu digest BEFORE promoting (rollback target):
crane digest ghcr.io/open-thoughts/openthoughts-agent:tpu
# PROMOTE (re-point floating :tpu → the validated pinned digest):
crane tag ghcr.io/open-thoughts/openthoughts-agent@sha256:<new-digest> tpu
# ROLLBACK (if the smoke regresses): re-point :tpu back to the recorded pre-promote digest:
crane tag ghcr.io/open-thoughts/openthoughts-agent@sha256:<old-digest> tpu
Then update the campaign tracker's Runtime block with the new :tpu-<gitsha> + digest + what it bakes
(harbor commit, OT-Agent bundle commit) and the recorded rollback digest.
Rollback to a digest that PREDATES the fix is a no-op — it dies identically. When rolling back, roll back to the last digest that actually WORKED for your purpose, not just "the previous tag." (2026-07-08: the tempting
879ebabarollback was a strict no-op because it, too, predatedc49064a8.)
6. Monitoring the build job
⚠ State-poll, NEVER a log-string watch. A clean kill / OOM-reap emits no terminal log line — poll the authoritative iris lifecycle state.
PY=/Users/benjaminfeuer/miniconda3/envs/otagent/bin/python # otagent env: iris + a WORKING kubernetes client
export KUBECONFIG=~/.kube/coreweave-iris-gpu # HARD prereq for every cw call
$PY scripts/iris/iris_ops.py /benjaminfeuer/tpu-kaniko-<gitsha> --once --json # authoritative state now
$PY scripts/iris/iris_ops.py /benjaminfeuer/tpu-kaniko-<gitsha> --interval 60 # watch until terminal
$IRIS --cluster=cw-us-east-02a job summary /benjaminfeuer/tpu-kaniko-<gitsha> --json # state+error+exit+finished_at
# full log by time-window (NOT --tail, which under-samples): --since-ms <submit_ms> --no-tail
- Use the otagent iris binary for cw (the bare
marin/.venv/bin/irishas a brokenkubernetesimport and cannot drive cw). Alliris/kubectlcalls synchronous — never background them. - Kaniko's
--destinationpush is the TERMINAL step → SUCCEEDED = pushed. Still run §4 verification.
7. WHEN a rebuild is actually required (vs a runtime /app bundle sync)
⚠ For iris datagen/eval workers, a rebuild rarely deploys anything — the worker
uv sync --frozen --reinstalls from the bundleduv.lock(§2), so DEPS come from the lock and first-party CODE comes from the/appbundle. Both bypass the image bake. So the honest list of "must rebuild the image" cases is SHORT.
Rebuild the :tpu image ONLY for something neither the lock-sync nor the /app bundle can carry:
- base-layer changes — apt packages, the python base image, the
uvvenv layout / bootstrap assumptions. - first-serve latency — you want the image already AT the locked dep set so the worker's
uv syncis a fast no-op instead of a big reinstall. (Optimization, not correctness.)
Do NOT rebuild the image to deploy:
- A harbor (or any locked third-party dep) change → that's a
uv.lockbump (§2:uv lock --upgrade-package harbor+ commit). The image bake is overwritten at runtime by the lock-sync. KeepHARBOR_COMMITin step with the lock for image self-consistency, but the lock is what ships it. - A
[datagen-tpu,cloud]resolve change (vLLM-TPU / tpu-inference / jax/libtpu / transformers) → same: updatepyproject.toml+uv lock, and the worker installs it from the lock. (Rebuild only to keep the image current for fast sync.) - A first-party OT-Agent source change (
hpc/,data/,scripts/,eval/, the launchers, the RecordProxy/correlator) → editable-e; the worker syncs the local working-tree at launch. Ships next launch.
8. Standing constraints
- Canonical branches: OT-Agent =
penfever/working; harbormarin-community/harbor=main(penfever/workingRETIRED — worktree→PR→main).git fetch+ FF-check before any push; leave the editable harbor checkout at/Users/benjaminfeuer/Documents/harborclean onmain(datagen rescues depend on it). - PINNED-first, promote-after-smoke. Never push straight to floating
:tpu; other users' live datagen/eval jobs pull it. source "$DC_AGENT_SECRET_ENV"; NEVER echo secret values. ghcr =gh auth token, not Docker HubDOCKER_TOKEN.- Never
iris cluster restart/stop/bounce (kills every running job). The build job is its own iris job;iris job kill /benjaminfeuer/tpu-kaniko-<gitsha>is job-scoped (with permission). --cache-repo=…/cache-tpuis shared — don't delete it.
Cross-reference
- Consumes this image:
datagen-launch-iris+eval-agentic-launch-iris(the:tpuruntime). Update the campaign tracker Runtime block + rollback digest after a promote. - Shared kaniko mechanics (deep reference):
build-gpu-rl-image-irisSKILL §1 (kaniko-not-buildkit), §2 (crane-export + ghcr creds +SINGLE_SNAPSHOT=0), §6 (build-asserts-as-validation). - Cluster access / kubeconfig / iris-binary:
.agents/ops/iris/ops.md. - TPU datagen lifecycle / preemption / resume:
.agents/ops/iris/ops.md. - Code:
docker/Dockerfile.tpu,docker/build_tpu_kaniko.sh,scripts/iris/iris_ops.py.
Signals
- GitHub stars
- 289
- Forks
- 40
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
build-tpu-image-iris- Source
- github.com/open-thoughts/openthoughts-agent