#!/usr/bin/env bash # blender-extensions — fv-ml1's pinned Blender extension set (stacks/blender, README "Extensions"). # # scripts/blender-extensions sync rebuild fv-ml1:/tank/blender-extensions/5.2/system from # stacks/blender/extensions.lock # scripts/blender-extensions status what is installed, against the lock # # The set lives in Blender's System extension repository, which is a plain directory. The GUI # container and `blender-run --extensions` both mount it READ-ONLY at /blender/5.2/extensions/system, # and conf/scripts/startup/fleet_extensions.py enables everything in it. Agents never install # anything: MCP safe mode blocks it, and the mount is read-only. # # sync: # 1. downloads each pinned archive into /tank/blender-extensions/zips/ (kept as a cache) and checks # its sha256 against the lock. A mismatch stops everything. # 2. installs each with Blender's own `--command extension install-file`, which validates the # manifest against this Blender and platform, into a staging directory. # 3. pre-warms the staging copy: enables everything once while it is still WRITABLE, then # byte-compiles it. 3D-Print Toolbox writes a translation cache into its own package dir on # first import, and that fails on the read-only mount (found 2026-09-28). # 4. checks that the staging copy enables READ-ONLY, then swaps it in and records the lock it was # built from as /tank/blender-extensions/5.2/installed.lock. # ⚠ It refuses while the GUI container or any blender-run job is running. They hold the old # directory through a bind mount, and the swap would pull it out from under them. set -euo pipefail HOST=${BLENDER_SSH_HOST:-infra-ops@10.251.50.54} HERE=$(cd "$(dirname "$0")" && pwd) LOCK=$HERE/../stacks/blender/extensions.lock HOOK=$HERE/../stacks/blender/conf/scripts/startup/fleet_extensions.py case "${1:-}" in sync) rows=$(grep -vE '^\s*(#|$)' "$LOCK") echo "$rows" | awk 'NF != 4 || $3 !~ /^[0-9a-f]{64}$/ || $1 !~ /^[A-Za-z0-9_]+$/ { bad=1; print "bad lock row: " $0 > "/dev/stderr" } END { exit bad }' ssh -n -o BatchMode=yes "$HOST" "mkdir -p /tank/blender-extensions/zips /tank/blender-extensions/5.2" scp -q "$LOCK" "$HOST:/tank/blender-extensions/5.2/staging.lock" scp -q "$HOOK" "$HOST:/tank/blender-extensions/5.2/staging-hook.py" ssh -o BatchMode=yes "$HOST" bash -s <<'REMOTE' set -euo pipefail cd /tank/blender-extensions if [ -n "$(docker ps -q --filter name='^blender$' --filter name='^blender-run-')" ]; then echo "blender-extensions: the GUI container or a blender-run job is running; stop it first (scripts/blender-mcp down)" >&2 exit 3 fi IMG=$(grep '^IMAGE=' /opt/docker/compose/blender/.env | cut -d= -f2) # Fixed, literal staging path: it is emptied before every build. rm -rf /tank/blender-extensions/5.2/staging mkdir -p 5.2/staging/system LOG=5.2/staging/sync.log blender() { # a throwaway Blender with the staging repo at $1 (rw|ro) and HOME in the container local mode=$1; shift docker run --rm --user 1002:1003 -e HOME=/tmp -e USER=infra-ops \ -v /tank/blender-extensions/5.2/staging/system:/blender/5.2/extensions/system:"$mode" \ -v /tank/blender-extensions/zips:/zips:ro \ -v /tank/blender-extensions/5.2/staging-hook.py:/fleet/fleet_extensions.py:ro \ --entrypoint /blender/blender "$IMG" "$@" } grep -vE '^\s*(#|$)' 5.2/staging.lock | while read -r id ver sha url; do zip=zips/$id-$ver.zip if ! echo "$sha $zip" | sha256sum -c --status 2>/dev/null; then curl -fsSL --retry 3 -o "$zip.part" "$url" mv "$zip.part" "$zip" fi echo "$sha $zip" | sha256sum -c --status || { echo "blender-extensions: sha256 MISMATCH for $id $ver; nothing installed" >&2; exit 4; } # install-file only targets user repos, so the staging dir is mounted as user_default for this step. docker run --rm --user 1002:1003 -e HOME=/tmp -e USER=infra-ops \ -v /tank/blender-extensions/5.2/staging/system:/tmp/.config/blender/5.2/extensions/user_default \ -v /tank/blender-extensions/zips:/zips:ro \ --entrypoint /blender/blender "$IMG" --factory-startup \ -c extension install-file -r user_default --no-prefs "/zips/$id-$ver.zip" >>"$LOG" 2>&1 [ -f "5.2/staging/system/$id/blender_manifest.toml" ] || { echo "blender-extensions: install of $id failed; see $PWD/$LOG" >&2; exit 5; } echo "installed $id $ver" done # Pre-warm (writable), then byte-compile with Blender's own Python. blender rw -b --factory-startup --python-exit-code 1 --python /fleet/fleet_extensions.py >>"$LOG" 2>&1 \ || { echo "blender-extensions: pre-warm failed; see $PWD/$LOG" >&2; exit 6; } docker run --rm --user 1002:1003 -v /tank/blender-extensions/5.2/staging/system:/s --entrypoint /blender/5.2/python/bin/python3.13 "$IMG" \ -m compileall -q /s >>"$LOG" 2>&1 || true # Must enable from the read-only mount before it goes live. blender ro -b --factory-startup --python-exit-code 1 --python /fleet/fleet_extensions.py >>"$LOG" 2>&1 \ || { echo "blender-extensions: read-only enable failed; see $PWD/$LOG" >&2; exit 7; } grep 'fleet_extensions: enabled' "$LOG" | tail -1 # Swap in. Literal paths only. rm -rf /tank/blender-extensions/5.2/system.prev if [ -d /tank/blender-extensions/5.2/system ]; then mv /tank/blender-extensions/5.2/system /tank/blender-extensions/5.2/system.prev; fi mv /tank/blender-extensions/5.2/staging/system /tank/blender-extensions/5.2/system mv /tank/blender-extensions/5.2/staging.lock /tank/blender-extensions/5.2/installed.lock mv /tank/blender-extensions/5.2/staging/sync.log /tank/blender-extensions/5.2/sync.log rm -rf /tank/blender-extensions/5.2/system.prev /tank/blender-extensions/5.2/staging /tank/blender-extensions/5.2/staging-hook.py echo "live: /tank/blender-extensions/5.2/system ($(du -sh /tank/blender-extensions/5.2/system | cut -f1))" REMOTE "$HERE/ops-log" record --host fv-ml1 --action extensions-sync --target blender \ --detail "rebuilt /tank/blender-extensions/5.2/system from extensions.lock ($(echo "$rows" | awk '{printf "%s %s, ", $1, $2}' | sed 's/, $//'))" ;; status) echo "lock (repo):" grep -vE '^\s*(#|$)' "$LOCK" | awk '{printf " %-16s %s\n", $1, $2}' echo "installed on fv-ml1:" ssh -n -o BatchMode=yes "$HOST" 'for m in /tank/blender-extensions/5.2/system/*/blender_manifest.toml; do [ -f "$m" ] || { echo " (nothing installed)"; break; } printf " %-16s %s\n" "$(sed -n "s/^id = \"\(.*\)\"/\1/p" "$m")" "$(sed -n "s/^version = \"\(.*\)\"/\1/p" "$m")" done' if ssh -n -o BatchMode=yes "$HOST" cat /tank/blender-extensions/5.2/installed.lock 2>/dev/null | cmp -s - "$LOCK"; then echo "installed.lock matches the repo lock" else echo "installed.lock DIFFERS from the repo lock (or is missing): run '$0 sync'" fi ;; *) sed -n 2,6p "$0" >&2; exit 2 ;; esac