Files
operation-bin/pixi_local_sync
lemke_h 8ad76f9b82 Remove baked bpy-default.sh/bpy-dev.sh; prefer system pixi
bpy-default.sh and bpy-dev.sh were saved 'pixi shell-hook' output from
another host, with PIXI_EXE and PATH pointing into a personal home that
doesn't exist on the consoles. bpy-env.sh now runs the hook at runtime
(--as-is, ~0.2 s) for its shared-env fallback instead of sourcing
bpy-default.sh; nothing else used either file.

Scripts that call pixi now prefer the system /usr/bin/pixi and fall back
to /sf/bernina/bin/pixi (e.g. on ra, which has no system pixi).
2026-10-15 17:11:39 +02:00

282 lines
12 KiB
Bash
Executable File

#!/bin/bash
# Keep a local, per-host mirror of the shared bernina pixi python
# environment in sync, so day-to-day python/pixi startup doesn't pay the
# cost of running straight off the shared project share.
#
# Unlike pixi_rebuild_local (which runs `pixi update` to produce a NEW
# lock file for testing), this script mirrors SOURCE_DIR's
# pixi.toml/pixi.lock (+pixi_dev.toml) EXACTLY into LOCAL_DIR and runs
# `pixi install --all` against that lock -- it never regenerates it. That
# keeps every host's local copy identical to whatever was last promoted
# at SOURCE_DIR.
#
# Meant to be launched once, detached, from startup_bernina_terminal (same
# nohup/disown pattern as kerberos_ticket_renew). With no flags it loops
# forever, so a single invocation covers:
# - first-time install of LOCAL_DIR if it doesn't exist yet
# - picking up any change to the shared lock file on every terminal
# restart (the loop's first iteration runs immediately)
# - noticing further upstream changes while the terminal stays open,
# checked every CHECK_INTERVAL seconds
#
# Change detection hashes SOURCE_DIR's pixi.toml/pixi.lock/pixi_dev.toml --
# cheap enough to do every minute. The (re)install itself only runs when
# that hash actually changed, or on first install. Before a first install,
# and before any (re)install if free space looks tight, the user is
# asked/warned via a zenity popup so a multi-GB download never happens
# silently without headroom.
#
# Safe to run from multiple terminals on the same host: a flock singleton
# means only one loop actually checks/installs per host at a time.
set -uo pipefail # no -e: one failed check/install must not kill the loop
SOURCE_DIR="/sf/bernina/applications/python"
PIXI_BIN="/usr/bin/pixi" # console (system) pixi first
[ -x "$PIXI_BIN" ] || PIXI_BIN="/sf/bernina/bin/pixi" # fallback, e.g. on ra (RHEL8)
LOCAL_DIR="${PIXI_LOCAL_DIR:-$HOME/bpy}"
export PIXI_CACHE_DIR="/sf/bernina/misc/pixi-cache"
CHECK_INTERVAL="${PIXI_LOCAL_SYNC_INTERVAL:-60}" # seconds between checks
EST_INSTALL_GB="${PIXI_LOCAL_SYNC_EST_GB:-15}" # estimated size of a fresh install
UPDATE_MIN_FREE_GB="${PIXI_LOCAL_SYNC_UPDATE_MIN_GB:-5}" # floor required for a resync
POPUP_THROTTLE_S=1800 # don't repeat resync error popups more often than this
ONCE=0
AUTO_YES=0
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") [-1] [-y] [-i seconds]
Mirrors $SOURCE_DIR/pixi.{toml,lock} (+pixi_dev.toml) into
\$PIXI_LOCAL_DIR (default: \$HOME/bpy) and runs 'pixi install --all'
there, so this host has its own local copy of the shared python env.
-1 check/sync once and exit, instead of looping forever
-y never prompt before the first install (assume yes)
-i seconds between checks when looping (default: 60)
EOF
exit 1
}
while getopts "1yi:h" opt; do
case "$opt" in
1) ONCE=1 ;;
y) AUTO_YES=1 ;;
i) CHECK_INTERVAL="$OPTARG" ;;
h|*) usage ;;
esac
done
command -v "$PIXI_BIN" >/dev/null 2>&1 || PIXI_BIN="pixi"
command -v "$PIXI_BIN" >/dev/null 2>&1 || { echo "error: pixi not found" >&2; exit 1; }
# Only RHEL9+ consoles are supported: the shared pixi.lock's builds
# aren't verified against RHEL8's older system libraries, and bernina
# consoles are mid-migration from RHEL8 to RHEL9. On anything older,
# clean up any local mirror a pre-this-check attempt may have left
# behind and do nothing else -- no install, no loop.
os_id="" os_major=""
if [ -r /etc/os-release ]; then
os_id=$(. /etc/os-release 2>/dev/null; echo "$ID")
os_major=$(. /etc/os-release 2>/dev/null; echo "${VERSION_ID%%.*}")
fi
if [ "$os_id" != "rhel" ] || [ -z "$os_major" ] || [ "$os_major" -lt 9 ] 2>/dev/null; then
if [ -d "$LOCAL_DIR" ]; then
echo "$(date '+%F %T') $(hostname) is not RHEL9+ (detected: ${os_id:-unknown} ${os_major:-?}) -- removing local pixi env remnant at $LOCAL_DIR"
rm -rf "$LOCAL_DIR"
command -v notify-send >/dev/null 2>&1 && notify-send -u normal "Local python env (bpy)" \
"Removed $LOCAL_DIR: this host (${os_id:-unknown} ${os_major:-?}) isn't RHEL9+, so it isn't supported." 2>/dev/null
else
echo "$(date '+%F %T') $(hostname) is not RHEL9+ (detected: ${os_id:-unknown} ${os_major:-?}) -- nothing to do"
fi
exit 0
fi
# Singleton per host+user: startup_bernina_terminal launches this on every
# terminal open, and it's also handy to run by hand -- only one loop
# should ever actually be checking/installing at a time.
LOCKFILE="${XDG_RUNTIME_DIR:-/tmp}/pixi_local_sync.lock"
exec 9>"$LOCKFILE"
flock -n 9 || { echo "already running (lock held: $LOCKFILE)" >&2; exit 0; }
STATE_DIR="$LOCAL_DIR/.pixi_local_sync"
notify() {
local urgency="$1" title="$2" body="$3"
echo "$(date '+%F %T') $title: $body"
command -v notify-send >/dev/null 2>&1 && notify-send -u "$urgency" "$title" "$body" 2>/dev/null
}
have_popup() {
[ -n "${DISPLAY:-}" ] && command -v zenity >/dev/null 2>&1
}
# free space under $HOME, in whole GB
avail_gb() {
df -Pk "$HOME" | awk 'NR==2 {printf "%d", $4/1024/1024}'
}
# marker-file throttle: true if $1 was touched less than $2 seconds ago
recently() {
local marker="$1" within_s="$2" now then
[ -f "$marker" ] || return 1
now=$(date +%s)
then=$(cat "$marker" 2>/dev/null || echo 0)
[ -n "$then" ] || then=0
(( now - then < within_s ))
}
mark_now() { date +%s > "$1"; }
# Stop trying a first install for the rest of THIS running instance:
# either the user said no, or there isn't enough space for it right now.
# Popping the same question/warning every minute forever is pointless --
# but deliberately in-memory, not a state file: a fresh invocation of
# this script (next login/terminal, i.e. a new process) should always
# check again from scratch, since free space can change and a decline
# only speaks for the moment it was given, not forever.
GAVE_UP_THIS_RUN=0
give_up() {
local reason="$1"
GAVE_UP_THIS_RUN=1
notify normal "Local python env (bpy)" \
"Not installing $LOCAL_DIR for now ($reason). Won't ask again this run -- next login/terminal will check again."
}
source_hash() {
{ cat "$SOURCE_DIR/pixi.toml" "$SOURCE_DIR/pixi.lock" 2>/dev/null
[ -f "$SOURCE_DIR/pixi_dev.toml" ] && cat "$SOURCE_DIR/pixi_dev.toml"
} | sha256sum | awk '{print $1}'
}
check_space() {
local need_gb="$1" first_install="$2" have msg
have=$(avail_gb)
if [ "$have" -lt "$need_gb" ]; then
if [ "$first_install" -eq 1 ]; then
# not enough room for the very first install -- nagging every
# minute won't change that within the same run, so stop
# asking for now; a later run (next login) checks fresh.
if have_popup; then
printf -v msg 'Not enough free space in $HOME to install the local pixi environment at\n%s\n\nAvailable now: %s GB\nNeeded (approx.): %s GB\n\nFree up space; this will be checked again next time a terminal starts.' \
"$LOCAL_DIR" "$have" "$need_gb"
zenity --error --title="Local python env (bpy): not enough space" --text="$msg" 2>/dev/null &
disown
fi
give_up "not enough space at first install: ${have}GB free, ~${need_gb}GB needed"
elif ! recently "$STATE_DIR/.space_warned" "$POPUP_THROTTLE_S"; then
notify critical "Local python env (bpy)" \
"Not enough space in \$HOME to resync $LOCAL_DIR: ${have}GB free, ~${need_gb}GB needed. Free up space -- will retry automatically."
mkdir -p "$STATE_DIR"
mark_now "$STATE_DIR/.space_warned"
fi
return 1
fi
return 0
}
ask_permission() {
local have projected msg
have=$(avail_gb)
projected=$(( have - EST_INSTALL_GB ))
[ "$projected" -lt 0 ] && projected=0
[ "$AUTO_YES" -eq 1 ] && return 0
if ! have_popup; then
echo "$(date '+%F %T') no graphical session/zenity available -- skipping first install of $LOCAL_DIR for now"
return 1
fi
printf -v msg 'No local copy of the shared bernina python environment was found at:\n%s\n\nInstalling one here speeds up python/pixi startup on this host by\navoiding the network project share.\n\nFree space in $HOME now: %s GB\nEstimated space needed: ~%s GB\nProjected free space after: ~%s GB\n\nInstall it now? (runs in the background, will not block this terminal)' \
"$LOCAL_DIR" "$have" "$EST_INSTALL_GB" "$projected"
if zenity --question --title="Set up local python env (bpy)?" --width=420 --text="$msg" 2>/dev/null; then
return 0
else
give_up "declined by user"
return 1
fi
}
do_sync() {
local first_install=0 hash
[ -d "$LOCAL_DIR/.pixi/envs" ] || first_install=1
# Already gave up this run (declined, or no space at first install) --
# stay silent for the rest of this process; a fresh invocation of the
# script starts with GAVE_UP_THIS_RUN=0 again and checks from scratch.
if [ "$first_install" -eq 1 ] && [ "$GAVE_UP_THIS_RUN" -eq 1 ]; then
return
fi
hash=$(source_hash)
[ -n "$hash" ] || { echo "$(date '+%F %T') $SOURCE_DIR not readable, skipping"; return; }
if [ "$first_install" -eq 0 ] && [ -f "$STATE_DIR/.synced_hash" ] \
&& [ "$(cat "$STATE_DIR/.synced_hash")" = "$hash" ]; then
return # nothing changed -- the common case, stay quiet and fast
fi
if [ "$first_install" -eq 1 ]; then
check_space "$EST_INSTALL_GB" 1 || return
ask_permission || return
notify normal "Local python env (bpy)" "Installing local copy in $LOCAL_DIR ..."
else
check_space "$UPDATE_MIN_FREE_GB" 0 || return
echo "$(date '+%F %T') shared env changed, resyncing $LOCAL_DIR"
fi
mkdir -p "$LOCAL_DIR" "$STATE_DIR"
if (
umask 002
# Exclusive lock for the whole copy+install window: bpy-env.sh takes
# a non-blocking SHARED lock on this same file before trusting
# the local mirror, so it falls back to the shared NFS env
# instead of activating a half-copied/half-linked one. Using
# flock (not a marker file) means a crash here still releases it
# automatically -- nothing can get permanently stuck "busy".
exec 8>"$STATE_DIR/.install.lock"
flock -x 8
mkdir -p "$PIXI_CACHE_DIR" 2>/dev/null || true
chmod 2775 "$PIXI_CACHE_DIR" 2>/dev/null || true
cp "$SOURCE_DIR/pixi.toml" "$LOCAL_DIR/" || exit 1
[ -f "$SOURCE_DIR/pixi.lock" ] && cp "$SOURCE_DIR/pixi.lock" "$LOCAL_DIR/"
[ -f "$SOURCE_DIR/pixi_dev.toml" ] && cp "$SOURCE_DIR/pixi_dev.toml" "$LOCAL_DIR/"
# --frozen: never let this mirror resolve anything itself (e.g.
# live git/PyPI access for a dependency the shared pixi.lock
# hasn't caught up with yet, as happened with the eco package) --
# only ever install exactly what's already pinned in the lock we
# just copied. Fails loudly instead of silently drifting if
# SOURCE_DIR's pixi.toml and pixi.lock are ever inconsistent;
# fix that with pixi_rebuild_local + promote, not by letting
# this script improvise.
cd "$LOCAL_DIR" && "$PIXI_BIN" install --all --frozen
); then
echo "$hash" > "$STATE_DIR/.synced_hash"
rm -f "$STATE_DIR/.space_warned"
if [ "$first_install" -eq 1 ]; then
notify normal "Local python env (bpy)" "Install finished: $LOCAL_DIR is ready ($(avail_gb)GB free in \$HOME now)."
else
notify low "Local python env (bpy)" "Resynced $LOCAL_DIR with the latest shared env."
fi
else
notify critical "Local python env (bpy)" "pixi install failed in $LOCAL_DIR -- will retry automatically."
fi
}
# small jitter on the very first check so a mass terminal restart across
# many hosts doesn't all hit the shared project dir/cache at once
[ "$ONCE" -eq 0 ] && sleep $(( RANDOM % 20 ))
if [ "$ONCE" -eq 1 ]; then
do_sync
exit 0
fi
while true; do
do_sync
sleep "$CHECK_INTERVAL"
done