feat(blender): pinned extension set in a read-only System repo, for the GUI and blender-run --extensions
Blender is now a mandatory stage in draupnir's pipeline (Prime, 2026-09-28), and draupnir asked for eight add-ons from extensions.blender.org: SurfacePsycho 0.10.4, CAD Sketcher 0.32.1, 3D-Print Toolbox 1.4.1, STEP Importer 1.2.1, Bool Tool 2.1.0, LoopTools 4.7.7, MeasureIt 1.8.4, 3MF Import/Export 2.7.7. - stacks/blender/extensions.lock pins each by version and archive sha256. - scripts/blender-extensions sync builds fv-ml1:/tank/blender-extensions/5.2/system with Blender's own install-file, pre-warms and byte-compiles it, checks a read-only enable, then swaps it in. It refuses while the GUI or a blender-run job holds the old directory. - conf/scripts/startup/fleet_extensions.py enables every package in the System repo: in a timer in the GUI (after the prefs load), and as --python ahead of the caller's args in blender-run --extensions (a failed enable exits 1 before the caller's script). - It also patches SurfacePsycho's sp_overwrite_segment_selection from eval() to literal_eval(): the eval walked past MCP safe mode (control: unpatched ran code, patched refuses). - blender-run: --extensions (bind mounts via --mount so a missing source fails instead of being created); USER/LOGNAME set, which CAD Sketcher's getpass needs. - compose.yaml mounts the repo read-only and the hook into the GUI container. NOT yet deployed. - scripts/blender-probes/extensions_acceptance.py: one operator run per add-on, safe-mode compliant. Headless 8/9 online and with --network none; CAD Sketcher sketching is GUI-only. A Python audit hook saw no network/process events (positive control fired).
This commit is contained in:
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user