From 7484714c908fe4eadf1248c0ae9a1698bb7758c8 Mon Sep 17 00:00:00 2001 From: gabriel engvall Date: Wed, 24 Jun 2026 10:27:17 +0200 Subject: [PATCH 1/7] fix(cluster-templates): derive recipe base from slot-derived build VMID cf now builds each VM under a slot-derived id (recipe base * 100 + slot index) so parallel builds don't collide. The post-processor passes that large id as CF_BUILT_VMID, but this script used it directly as the per-node template base and its `>= OFFSET` guard then aborted with exit 1 -- which, under `set -e` in vzdump-and-cleanup.sh, killed the post-processor before the sidecar was written, so the whole build was reported as "no artifacts created" despite a good .vma.zst. Recover the recipe base (RAW / 100 when >= OFFSET) before computing per-node VMIDs. A plain non-slot build still passes the base directly. --- scripts/cf-cluster-templates.sh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/cf-cluster-templates.sh b/scripts/cf-cluster-templates.sh index 4675c22..95beb95 100755 --- a/scripts/cf-cluster-templates.sh +++ b/scripts/cf-cluster-templates.sh @@ -21,7 +21,7 @@ set -uo pipefail ARTIFACT="${1:?usage: cf-cluster-templates.sh }" -BASE_VMID="${CF_BUILT_VMID:?CF_BUILT_VMID not set}" +RAW_BUILT_VMID="${CF_BUILT_VMID:?CF_BUILT_VMID not set}" DUMP_DIR="${PVE_DUMP_DIR:-/var/lib/vz/dump}" # --- knobs (edit to taste) ------------------------------------------------- @@ -32,9 +32,19 @@ STORAGE="${CF_TEMPLATE_STORAGE:-local-lvm}" OFFSET="${CF_TEMPLATE_VMID_OFFSET:-10000}" # per-node VMID spacing # --------------------------------------------------------------------------- +# cf builds the VM under a slot-derived id (recipe base * 100 + slot index, +# slot index 0..99) so parallel builds on a node don't collide on a fixed VMID. +# The per-node template numbering needs the recipe BASE, so recover it. A plain +# (non-slot) build passes the base directly, which is already < OFFSET. +if [ "$RAW_BUILT_VMID" -ge "$OFFSET" ]; then + BASE_VMID=$(( RAW_BUILT_VMID / 100 )) +else + BASE_VMID="$RAW_BUILT_VMID" +fi + # Adjacent nodes collide if BASE_VMID >= OFFSET (e.g. node1+14001 == node2+4001). if [ "$BASE_VMID" -ge "$OFFSET" ]; then - echo "cf-cluster-templates: CF_BUILT_VMID ($BASE_VMID) must be < CF_TEMPLATE_VMID_OFFSET ($OFFSET)" >&2 + echo "cf-cluster-templates: derived base VMID ($BASE_VMID) must be < CF_TEMPLATE_VMID_OFFSET ($OFFSET)" >&2 exit 1 fi From 1e38315509cb2c2e61bae687f7301d0c8c27b61b Mon Sep 17 00:00:00 2001 From: Eric Wang <37554696+ericwang401@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:33:00 -0400 Subject: [PATCH 2/7] docs: slim down claude instructions --- CLAUDE.md | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0450402..eef4bd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,17 +1 @@ -# Claude Notes - -See [AGENTS.md](AGENTS.md) for agent-facing guidance on disk sizing, Windows Server 2025 requirements, and preseed key injection behavior. - -## Project overview - -This repo builds Proxmox VM templates via Packer and exports them as vzdump `.vma.zst` artifacts. Packer runs **on the Proxmox node** (not locally) — `src/build.ts` rsyncs the repo to the node and invokes packer over SSH. - -## Key files - -- `src/cli.ts` — CLI entry point (`tb build`, `tb list`, etc.) -- `src/build.ts` — rsync + remote packer orchestration -- `src/config.ts` — recipe metadata parsing (reads HCL comment headers) -- `builds/.pkr.hcl` — one file per recipe -- `builds/_shared/post/vzdump-and-cleanup.sh` — post-processor: vzdump, move artifact, write sidecar JSON -- `scripts/inject-placeholders.sh` — generates ephemeral SSH keypair, injects into preseed -- `.env` — local secrets (never commit) +@AGENTS.md \ No newline at end of file From 6a1f5f3b83be74cd598d889204fa06b4cab0070d Mon Sep 17 00:00:00 2001 From: Eric Wang <37554696+ericwang401@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:41:23 -0400 Subject: [PATCH 3/7] docs(cluster-templates): clarify CF_BUILT_VMID may be slot-derived Header comment still described CF_BUILT_VMID as the per-node base and example (build_vmid 4001 -> node1=14001). Post-merge, CF_BUILT_VMID is the slot-derived id and BASE_VMID is recovered from it; update the comment to match. --- scripts/cf-cluster-templates.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/cf-cluster-templates.sh b/scripts/cf-cluster-templates.sh index 95beb95..10ce0bf 100755 --- a/scripts/cf-cluster-templates.sh +++ b/scripts/cf-cluster-templates.sh @@ -11,8 +11,11 @@ # Reads from the environment (set by the recipe's post-processor): # CF_BUILT_VMID (required), CF_RECIPE_NAME, CF_ARCH # -# Per-node VMID = node_id * OFFSET + CF_BUILT_VMID (OFFSET default 10000) -# build_vmid 4001 -> node1=14001, node2=24001, node3=34001 +# CF_BUILT_VMID may be slot-derived (recipe base * 100 + slot index) for +# parallel builds; we recover the recipe BASE_VMID from it below. +# +# Per-node VMID = node_id * OFFSET + BASE_VMID (OFFSET default 10000) +# base 4001 -> node1=14001, node2=24001, node3=34001 # # LOCAL/cluster convenience — not part of the upstream recipes. From fdf27666270b84507a4c85550086fcf9c5da91a4 Mon Sep 17 00:00:00 2001 From: Eric Wang <37554696+ericwang401@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:48:03 -0400 Subject: [PATCH 4/7] fix(cluster-templates): export recipe base VMID instead of decoding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build VMID is slot-derived (recipe base * 100 + slot index) so parallel builds don't collide. cf-cluster-templates.sh then needed the recipe base back, and recovered it by dividing by 100 — gated on a >= OFFSET heuristic that coupled the slot multiplier (100) to an unrelated, user-tunable knob (CF_TEMPLATE_VMID_OFFSET). Raising the offset above a recipe's slot-derived id silently skipped the divide. The base is known at the source, so stop throwing it away: cf now exports CF_RECIPE_BASE_VMID (recipe.buildVmid) via buildRemoteEnv, which the shell-local post-processor and CF_UPLOAD_CMD inherit. The script reads it directly and falls back to CF_BUILT_VMID for plain non-slot builds (where the two are equal). Removes the /100 + OFFSET decode. --- scripts/cf-cluster-templates.sh | 23 +++++++++-------------- src/build.ts | 3 ++- src/build/packer.ts | 9 ++++++++- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/scripts/cf-cluster-templates.sh b/scripts/cf-cluster-templates.sh index 10ce0bf..218029e 100755 --- a/scripts/cf-cluster-templates.sh +++ b/scripts/cf-cluster-templates.sh @@ -9,10 +9,11 @@ # CF_UPLOAD_CMD=bash $PVE_DUMP_DIR/cofoundry-work/scripts/cf-cluster-templates.sh {{file}} # # Reads from the environment (set by the recipe's post-processor): -# CF_BUILT_VMID (required), CF_RECIPE_NAME, CF_ARCH +# CF_RECIPE_BASE_VMID or CF_BUILT_VMID (required), CF_RECIPE_NAME, CF_ARCH # -# CF_BUILT_VMID may be slot-derived (recipe base * 100 + slot index) for -# parallel builds; we recover the recipe BASE_VMID from it below. +# CF_BUILT_VMID is the slot-derived build id (recipe base * 100 + slot index) +# for parallel builds; CF_RECIPE_BASE_VMID is the recipe base cf exports for +# the per-node template numbering. Plain builds set only CF_BUILT_VMID = base. # # Per-node VMID = node_id * OFFSET + BASE_VMID (OFFSET default 10000) # base 4001 -> node1=14001, node2=24001, node3=34001 @@ -24,7 +25,11 @@ set -uo pipefail ARTIFACT="${1:?usage: cf-cluster-templates.sh }" -RAW_BUILT_VMID="${CF_BUILT_VMID:?CF_BUILT_VMID not set}" +# cf exports the recipe BASE directly. CF_BUILT_VMID is the slot-derived build +# id (recipe base * 100 + slot index) for parallel builds; the per-node template +# numbering needs the base, so prefer CF_RECIPE_BASE_VMID. A plain (non-slot) +# build doesn't set it — CF_BUILT_VMID is then the base itself. +BASE_VMID="${CF_RECIPE_BASE_VMID:-${CF_BUILT_VMID:?CF_BUILT_VMID or CF_RECIPE_BASE_VMID not set}}" DUMP_DIR="${PVE_DUMP_DIR:-/var/lib/vz/dump}" # --- knobs (edit to taste) ------------------------------------------------- @@ -35,16 +40,6 @@ STORAGE="${CF_TEMPLATE_STORAGE:-local-lvm}" OFFSET="${CF_TEMPLATE_VMID_OFFSET:-10000}" # per-node VMID spacing # --------------------------------------------------------------------------- -# cf builds the VM under a slot-derived id (recipe base * 100 + slot index, -# slot index 0..99) so parallel builds on a node don't collide on a fixed VMID. -# The per-node template numbering needs the recipe BASE, so recover it. A plain -# (non-slot) build passes the base directly, which is already < OFFSET. -if [ "$RAW_BUILT_VMID" -ge "$OFFSET" ]; then - BASE_VMID=$(( RAW_BUILT_VMID / 100 )) -else - BASE_VMID="$RAW_BUILT_VMID" -fi - # Adjacent nodes collide if BASE_VMID >= OFFSET (e.g. node1+14001 == node2+4001). if [ "$BASE_VMID" -ge "$OFFSET" ]; then echo "cf-cluster-templates: derived base VMID ($BASE_VMID) must be < CF_TEMPLATE_VMID_OFFSET ($OFFSET)" >&2 diff --git a/src/build.ts b/src/build.ts index abb6d1e..873a294 100644 --- a/src/build.ts +++ b/src/build.ts @@ -382,7 +382,8 @@ export const buildPhase = async ( remoteTmpDir, recipe.arch, recipe.group ?? '', - recipe.finalDiskSize + recipe.finalDiskSize, + recipe.buildVmid ) const unregisterVmCleanup = diff --git a/src/build/packer.ts b/src/build/packer.ts index dc11e50..e2513ec 100644 --- a/src/build/packer.ts +++ b/src/build/packer.ts @@ -65,7 +65,8 @@ export const buildRemoteEnv = ( remoteTmpDir: string, arch: string, group: string, - finalDiskSize?: string + finalDiskSize?: string, + baseVmid?: number ): string => { // Packer runs on the PVE node, so SSH_TARGET=local tells the post-processor // to run vzdump directly instead of SSHing back to itself. @@ -78,6 +79,12 @@ export const buildRemoteEnv = ( TMPDIR: remoteTmpDir, PACKER_CACHE_DIR: '/var/lib/vz/template/iso', } + // The post-processor's CF_BUILT_VMID is the slot-derived id (base*100+slot) + // for parallel builds; export the recipe BASE so downstream consumers + // (e.g. cf-cluster-templates.sh) get it directly instead of reverse- + // engineering it. Inherited by the shell-local post-processor and any + // CF_UPLOAD_CMD it spawns. + if (baseVmid !== undefined) pairs.CF_RECIPE_BASE_VMID = String(baseVmid) // Opt-in: when set, the post-processor shrinks the OS disk to this size // before vzdump (see builds/_shared/post/shrink-disk.sh). if (finalDiskSize) pairs.CF_FINAL_DISK_SIZE = finalDiskSize From e3b85ca180a25b1e60f36b02367abb7ad5610d58 Mon Sep 17 00:00:00 2001 From: Eric Wang <37554696+ericwang401@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:50:08 -0400 Subject: [PATCH 5/7] fix(post): advertise recipe base as sidecar suggested_vmid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit suggested_vmid was set to CF_BUILT_VMID, which is the slot-derived build id (base*100+slot) for parallel builds — so the published sidecar suggested an absurd clone target (e.g. 400100) instead of the recipe base. Use CF_RECIPE_BASE_VMID (falling back to CF_BUILT_VMID for plain builds), matching the base values the manifest fixtures expect. VM ops (vzdump/qm) keep using CF_BUILT_VMID — the actual built VM. --- builds/_shared/post/vzdump-and-cleanup.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/builds/_shared/post/vzdump-and-cleanup.sh b/builds/_shared/post/vzdump-and-cleanup.sh index b65f0ae..d90e6c6 100755 --- a/builds/_shared/post/vzdump-and-cleanup.sh +++ b/builds/_shared/post/vzdump-and-cleanup.sh @@ -28,6 +28,12 @@ set -euo pipefail : "${CF_ARCH:?}" : "${CF_GROUP:?}" +# CF_BUILT_VMID is the slot-derived build id (recipe base * 100 + slot index) +# for parallel builds. The sidecar's suggested_vmid should advertise the recipe +# BASE, which cf exports as CF_RECIPE_BASE_VMID. Plain non-slot builds set only +# CF_BUILT_VMID, where the two are equal. +BASE_VMID="${CF_RECIPE_BASE_VMID:-$CF_BUILT_VMID}" + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOCAL_FILE="$CF_OUT_DIR/${CF_RECIPE_NAME}-${CF_ARCH}.vma.zst" @@ -142,7 +148,7 @@ cat >"$SIDECAR.tmp" < Date: Sun, 28 Jun 2026 17:04:49 -0400 Subject: [PATCH 6/7] ci: use registry app token for generated file pushes --- .github/workflows/build.yml | 19 +++++++++++++------ .github/workflows/check-upstream.yml | 12 ++++++++++-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a77963c..d240d87 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,7 +49,15 @@ jobs: R2_ENDPOINT: ${{ vars.R2_ENDPOINT || secrets.R2_ENDPOINT }} R2_BUCKET: ${{ vars.R2_BUCKET || secrets.R2_BUCKET }} steps: + - uses: actions/create-github-app-token@v2 + id: registry-app-token + with: + app-id: ${{ secrets.REGISTRY_APP_ID }} + private-key: ${{ secrets.REGISTRY_APP_PRIVATE_KEY }} + - uses: actions/checkout@v7 + with: + token: ${{ steps.registry-app-token.outputs.token }} - uses: oven-sh/setup-bun@v2 @@ -144,12 +152,11 @@ jobs: # Canonical history: `git log registry.json` is the audit log of every # published version. Rollback = revert the commit. - name: Commit registry.json - uses: stefanzweifel/git-auto-commit-action@v7 - with: - file_pattern: registry.json - commit_message: "chore: publish ${{ inputs.recipe }} build" - commit_user_name: github-actions[bot] - commit_user_email: 41898282+github-actions[bot]@users.noreply.github.com + run: | + git config user.name "cofoundry-registry-writer[bot]" + git config user.email "cofoundry-registry-writer[bot]@users.noreply.github.com" + git add registry.json + git diff --cached --quiet || (git commit -m "chore: publish ${{ inputs.recipe }} build" && git push) # ── Mirror registry.json to R2 ───────────────────────────────────────── # Single fetch origin for consumers. Short TTL so rollbacks propagate diff --git a/.github/workflows/check-upstream.yml b/.github/workflows/check-upstream.yml index f034ff3..43c6d37 100644 --- a/.github/workflows/check-upstream.yml +++ b/.github/workflows/check-upstream.yml @@ -20,7 +20,15 @@ jobs: matrix: ${{ steps.check.outputs.matrix }} any_changed: ${{ steps.check.outputs.any_changed }} steps: + - uses: actions/create-github-app-token@v2 + id: registry-app-token + with: + app-id: ${{ secrets.REGISTRY_APP_ID }} + private-key: ${{ secrets.REGISTRY_APP_PRIVATE_KEY }} + - uses: actions/checkout@v7 + with: + token: ${{ steps.registry-app-token.outputs.token }} - uses: oven-sh/setup-bun@v2 @@ -44,8 +52,8 @@ jobs: - name: Commit updated checksums run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "cofoundry-registry-writer[bot]" + git config user.email "cofoundry-registry-writer[bot]@users.noreply.github.com" git add upstream-checksums.json git diff --staged --quiet || (git commit -m "chore: update upstream checksums" && git push) From 1d7a285bb6d236fbbf7d3b89af8e2b89b87985e2 Mon Sep 17 00:00:00 2001 From: Eric Wang <37554696+ericwang401@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:05:34 -0400 Subject: [PATCH 7/7] docs: remove ai handoff doc --- docs/phase-3-verification.md | 55 ------------------------------------ 1 file changed, 55 deletions(-) delete mode 100644 docs/phase-3-verification.md diff --git a/docs/phase-3-verification.md b/docs/phase-3-verification.md deleted file mode 100644 index 509e2d2..0000000 --- a/docs/phase-3-verification.md +++ /dev/null @@ -1,55 +0,0 @@ -# Phase 3 verification - -Phase 3b/3c/3d retunes the recipes; none of it can be verified from a dev -laptop. Run these checks on the Proxmox node before merging. - -## Disk size before/after (record in PR) - -Build at least `debian-12` and `windows-server-2022` and note the -`vzdump-*.vma.zst` size. After this branch lands, `discard = true` lets -sparse zero regions stay compressed; the artifact should shrink. - -| Recipe | Pre-3b size | Post-3b size | -| ------------------- | ----------- | ------------ | -| debian-12 | _fill in_ | _fill in_ | -| windows-server-2022 | _fill in_ | _fill in_ | - -## Per-recipe boot smoke - -```sh -bun run cf build -# wait for completion, then on the PVE node: -qm clone 9999 --full && qm start 9999 && qm terminal 9999 -``` - -For each recipe, confirm: - -- Build completes without bootloop (the bigger risk for Windows — DriverPath - letter or vioscsi dir name). -- Cloned VM boots and reaches login. -- Linux: `lsblk` shows `/dev/sda*` (not vda), `systemctl status qemu-guest-agent` - is active, `qm terminal` produces output (serial console works). -- Windows: Device Manager shows "Red Hat VirtIO SCSI controller" and "Red Hat - VirtIO Ethernet Adapter"; VirtIO tools service running. - -## Known risks - -- **windows-server-2025 driver path**: virtio-win-0.1.248 may not ship a - `2k25` directory. The recipe currently uses `2k22` as a fallback per - Microsoft's compat guidance. Verify the directory exists in the mounted - ISO; if 2k25 is present, change `E:\vioscsi\2k22\amd64` → - `E:\vioscsi\2k25\amd64` in `builds/windows-server-2025/autounattend.xml` - (same for NetKVM). -- **CD drive letter (E:)**: Packer attaches the virtio ISO via - `additional_iso_files` (sata) and the answerfiles CD via the second - `additional_iso_files` (ide). Windows typically assigns the SATA CD-ROM - first (D:) and the IDE CD second (E:) — but the plan and autounattend - reference `E:\vioscsi`. If installation can't find a disk to install on, - the driver path is wrong; try `D:\vioscsi\...` instead. -- **Linux LVM on /dev/sda**: Debian preseed now installs to `/dev/sda` and - grub goes to `/dev/sda`. If `grub-install` fails, check that the boot disk - is the first SCSI disk (`scsi0`); the build VM should not have extra - attached disks. -- **Serial console**: kernel cmdline is `console=tty0 console=ttyS0,115200` - — last `console=` wins for the kernel's primary console, so logs go to - the serial port. noVNC still works because `console=tty0` is listed first.