additionas
Autodeploy / autodeploy (push) Successful in 9s

This commit is contained in:
lemke_h committed 2026-09-05 13:08:27 +02:00
1 parent da4f047c37
commit f677ec82cf
21 files changed
+1746 -1226

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
{
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#2f7c47",
"activityBar.background": "#2f7c47",
"activityBar.foreground": "#e7e7e7",
"activityBar.inactiveForeground": "#e7e7e799",
"activityBarBadge.background": "#422c74",
"activityBarBadge.foreground": "#e7e7e7",
"activityBarTop.activeBackground": "#2f7c47",
"activityBarTop.background": "#2f7c47",
"activityBarTop.foreground": "#e7e7e7",
"activityBarTop.inactiveForeground": "#e7e7e799",
"commandCenter.border": "#e7e7e799",
"commandCenter.foreground": "#e7e7e7",
"sash.hoverBorder": "#2f7c47",
"statusBar.background": "#215732",
"statusBar.debuggingBackground": "#215732",
"statusBar.debuggingForeground": "#e7e7e7",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#2f7c47",
"statusBarItem.remoteBackground": "#1b122e",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#215732",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#21573299",
"titleBar.inactiveForeground": "#e7e7e799"
},
"peacock.remoteColor": "#215732"
}
Binary file not shown.
Binary file not shown.
+232
View File
@@ -19,6 +19,7 @@ to quit.
- [Window tiling (gTile)](#window-tiling-gtile)
- [Compose key — accents & Greek letters](#compose-key--accents--greek-letters)
- [Screen & session behavior](#screen--session-behavior)
- [Scripts in this directory](#scripts-in-this-directory)
(Links above are for viewing this file in an editor/browser that
supports Markdown anchors, e.g. VS Code or GitHub/GitLab — the
@@ -132,3 +133,234 @@ missing or accidentally edited); defaults come from
for the session (`kerberos_ticket_renew.sh`).
- The local pixi Python env in `~/bpy` is kept in sync with the
shared env in the background (`pixi_local_sync.sh`).
<!-- BEGIN SCRIPT INDEX (generated by list_bin_commands) -->
## Scripts in this directory
Every script in `/sf/bernina/bin`, grouped by topic. Generated by `list_bin_commands` from the actual directory contents -- re-run it after adding/removing scripts to keep this in sync.
### Cheat sheet & help
| Command | Description |
|---|---|
| `cheatsheet` | Show this cheat sheet in a terminal (glow + less, full detail) |
| `cheatsheet_overlay` | Show this cheat sheet as a floating overlay panel (bound to Super+F1) |
| `cheatsheet_to_shortcuts_json` | Regenerate the overlay's JSON from this file's headings/tables |
| `check_kerberos_renewal` | Diagnose why the Kerberos ticket renewer might be renewing/prompting too often |
| `useful_commands` | Show the older, plain-text useful-commands reference |
### EPICS / eco environment
| Command | Description |
|---|---|
| `eco` | Start eco, the beamline's Python control environment |
| `eco-dev` | Start eco from the development checkout (symlink into the eco repo) |
| `eco-in` | Start eco inside a chosen environment (arg, default bpy-default) |
| `eco-notlazy` | Start eco with lazy-loading disabled (default env bernina37) |
| `eco-status-server` | Start/stop/check the eco status server (namespace mode) |
| `eco-status-server-install-user-service` | Install the eco status server as a systemd --user service |
| `eco-test` | Start eco against the sfb312 test micromamba environment |
| `eco_bams_bpms` | Start eco configured for BAM/BPM work |
| `eco_historic` | Start eco against historic/legacy config |
| `eco_mm_env` | Start eco in a chosen micromamba environment |
| `eco_new` | Start eco using bpy-default.sh directly |
| `eco_noenv` | Start eco's startup.py directly, no environment activation step |
| `eco_old` | Start eco against the older sfb312 micromamba environment |
| `eco_roman` | Start eco configured for the bernina_robot setup |
| `ecotest` | Start eco in the jungfrau_client conda environment |
### Python, conda & pixi environments
| Command | Description |
|---|---|
| `anaconda_bernina_env` | Conda init hook for the bernina anaconda env (source this) |
| `anaconda_env` | Activate a named anaconda environment |
| `anaconda_manual` | Show the anaconda environment manual (rendered markdown) |
| `anaconda_testenv` | Activate the anaconda test environment |
| `bpy` | Activate a named bpy (pixi) environment |
| `bpy-default.sh` | Set PATH for the default shared bpy312 pixi environment (source this) |
| `bpy-dev.sh` | Set PATH for the bpy312-dev pixi environment (source this) |
| `bpy-env` | Activate the Bernina pixi Python env -- local per-host mirror if in sync, else shared (source this) |
| `bpy-select` | Interactively pick/switch the active bpy pixi environment |
| `micromamba` | micromamba package manager |
| `mm_bernina` | Micromamba shell-init hook for bernina (source this) |
| `mm_bernina_env` | Activate a named micromamba environment |
| `pixi` | pixi package manager |
| `pixi_local_sync.sh` | Keep the local per-host pixi env mirror in sync (runs in background at login) |
| `pixi_rebuild_local.sh` | Rebuild the bernina pixi env in an isolated local sandbox, for testing |
### Jupyter & notebooks
| Command | Description |
|---|---|
| `bernina-jupyter` | Start JupyterLab in the bernina-scripts config dir (bpy env) |
| `ipylab` | Start an ipylab session in a chosen anaconda environment |
| `jupyter-notebook-on-ra` | Open a Jupyter Notebook on a remote-access host, for a given user |
| `jupyterlab-on-ra` | Open JupyterLab on a remote-access host, for a given user |
### Browsers
| Command | Description |
|---|---|
| `chrome` | Open Chrome with a per-host profile |
| `chromeAs` | Open another user's Chrome profile from your own desktop (older variant) |
| `chrome_as` | Open another user's Chrome profile from your own desktop |
| `firefox` | Open Firefox with a per-host profile |
| `firefox-create-hostprofile` | Create a fresh per-host Firefox profile |
| `firefox-hostprofile` | Open Firefox with the per-host profile (alt entrypoint) |
| `firefox_as` | Open another user's default Firefox profile from your own desktop |
| `firefox_default` | Default Firefox profile launcher (symlink to firefox_shared_profile) |
| `firefox_shared_profile` | Open Firefox using a shared/synced profile |
| `google-chrome` | Open Chrome with a per-host profile (alt entrypoint) |
### Displays & control panels
| Command | Description |
|---|---|
| `Aramis` | Open the Aramis overview display (mydm/medm) |
| `alarms_caqtdm` | Open the EPICS alarms caQtDM panel |
| `aramis-home` | Open the Aramis photonics overview caQtDM panel |
| `bernina-home` | Open the esbMaster caQtDM panel |
| `cams_sf` | Open the SwissFEL cameras caQtDM panel |
| `diffractometer-config` | Open the diffractometer motor IOC configuration tool |
| `motion-home` | Open the ZamofingMaster motion caQtDM panel |
| `screen_panel` | Open the SF controls screen panel |
| `screenpanel` | Open the screen panel (uses screenpanel-sf if available) |
| `sf_archiver_panel` | Open the SwissFEL archiver viewer panel |
| `strip_chart` | Open the PShell strip chart viewer |
| `swissfelStatus` | Open the SwissFEL OP overview caQtDM panel |
| `thc_angles` | Open the THC angles bokeh app in Firefox |
| `thc_stripchart` | Open the THC temperature strip chart |
| `timetool_online_plot` | Open the timetool online plot (voila notebook) |
### Cameras
| Command | Description |
|---|---|
| `bernina-cam-mobile1` | View the mobile1 camera feed (VLC) |
| `bernina-cam-n` | View the north camera feed (VLC) |
| `bernina-cam-s` | View the south camera feed (VLC) |
| `bernina-cam-w` | View the west camera feed (VLC) |
| `bernina-cam-wideangle1` | View wide-angle camera 1 feed (VLC) |
| `bernina-cam-wideangle2` | View wide-angle camera 2 feed (VLC) |
| `bernina-cam-wideangle3` | View wide-angle camera 3 feed (VLC) |
| `bernina-cam-wideangle4` | View wide-angle camera 4 feed (VLC) |
| `bernina-cams-fix` | Launch the fixed multi-camera mosaic view (VLC) |
| `monitoring_cameras` | Open the monitoring cameras panel (pshell-based) |
| `record_camera` | Record a camera feed to file |
### FEL & laser control
| Command | Description |
|---|---|
| `feloff` | Stop/block the FEL beam and announce it |
| `felon` | Open/enable the FEL beam and announce it |
| `laseroff` | Block the laser shutter and announce it |
| `laseron` | Open the laser shutter and announce it |
### pshell & robot control
| Command | Description |
|---|---|
| `pshell_be` | Start the PShell Workbench for the main Bernina beamline environment |
| `pshell_correlation` | Open the correlation panel |
| `pshell_default` | Default pshell entrypoint (symlink to pshell_be) |
| `robot_server` | Start the PShell Workbench for the Bernina robot -- needs a new binary, /opt/gfa/pshell is gone (see chat history: /sf/controls/bin/pshell -wb is the likely replacement, blocked on a dataFormat=null config issue) |
### Desktop & window management
| Command | Description |
|---|---|
| `code` | Launch VS Code |
| `darkscreens` | Blank/turn off the screens (xset dpms) |
| `flameshot` | Take a screenshot |
| `gnome-terminal_as` | Open a GNOME Terminal as another user |
| `launcher` | Main Bernina application launcher |
| `launcher_dev` | Application launcher (dev/legacy python 3.5 environment) |
| `launcher_lca` | Open the launcher on the machine/LCA network, for a given user |
| `launcher_legacy` | Legacy application launcher (python 3.5 environment) |
| `rdpWater` | RDP into the WaterLab Windows machine |
| `remoteDesktop` | RDP into an arbitrary PSI Windows host |
| `sf-launcher` | Open the launcher on the sf network, for a given user |
| `termAs` | Open a terminal as another user |
| `terminalAs` | Open a terminal as another user (alt entrypoint) |
| `window_arrange.py` | Arrange/tile windows helper script |
| `winterm` | Open a Windows-side terminal session |
### Applications & viewers
| Command | Description |
|---|---|
| `hdfview` | Open HDFView (HDF5 file viewer) |
| `jungfrau_console` | Open the Jungfrau detector console |
| `ncdu` | Disk usage analyzer |
| `paraview` | Open ParaView |
| `pycharm` | Open PyCharm Community |
| `pylote` | Open Pylote |
| `vesta` | Open VESTA (crystal structure viewer) |
### Login session & keybinding setup
| Command | Description |
|---|---|
| `create-links-in-home` | Force-apply PSI's Partially Shared Home symlinks now |
| `gogh` | Gogh terminal color-scheme installer (legacy -- upstream download is broken, see install_terminal_profiles) |
| `install_gtile` | Install/update and enable the gTile gnome-shell extension (runs at login) |
| `install_terminal_profiles` | Install Solarized/Spacedust GNOME Terminal profiles, set Spacedust default (runs at login) |
| `kerberos_ticket_renew.sh` | Keep the Kerberos ticket cache alive for the login session (runs in background) |
| `migrate_to_network_drive.sh` | Move a local dotfile onto PSI's Partially Shared Home and register it for sync |
| `set_gnome_keyboard_shortcut` | Register one GNOME custom keyboard shortcut (used by startup_bernina_terminal) |
| `startup_bernina_terminal` | Full per-login setup for a Bernina console -- runs automatically at login |
| `sync_personal_config.sh` | Seed .ipython/.condarc onto PSI's Partially Shared Home for cross-host sync |
### gopass secrets & git
| Command | Description |
|---|---|
| `git-push` | git push, forcing the origin remote to SSH first |
| `gopass` | Bernina gopass secrets store CLI |
| `gopass.py` | Minimal Python interface to the Bernina gopass store |
| `gopass_bernina_service_setup.sh` | One-shot setup: give a service account (gac-bernina) access to the gopass store |
| `gopass_bernina_setup.sh` | One-shot onboarding for a human PSI account joining the gopass store |
### Personal dirs & data
| Command | Description |
|---|---|
| `create_data_symlinks` | (Re)create the ~/bernina_data convenience symlink |
| `create_personal_dir.sh` | Create/verify a personal working directory under /sf/bernina/config/personal/ (your username) |
| `name2pgroups` | Look up p-group(s) for a given user name |
| `pgroup2name` | Look up the user name for a given p-group |
| `quotas-bernina` | Show Bernina's storage quota usage |
### Beamline status & monitoring
| Command | Description |
|---|---|
| `chiller_flowrate` | Query the JF16 chiller flowrate over telnet |
| `feedbacks_bernina` | Open the undulator/feedbacks control tool, for a given user |
| `get_bernina_PVs_alive` | Check whether a set of Bernina PVs are alive |
| `go2daq` | SSH to the SwissFEL DAQ host (sf-daq-3) |
| `ioc-control-bernina` | Open the IOC process monitor (PBMonitor) |
| `ioc-finder` | Find/locate an EPICS IOC |
| `is_control` | Check whether a given hostname is a primary controls terminal |
| `phonebook` | Show the Bernina phone list |
| `remaining-slurm-job-time` | Show remaining time for a SLURM job |
| `restart_service` | Restart a systemd service as the dbe account (sudo) |
| `run-and-position.py` | Run-and-position helper script |
| `saythis` | Speak given text aloud |
| `set_bs_pvs` | Configure the SLAAR21 beam-synchronous PVs |
| `slurm_resources` | Show SLURM partition resource usage |
| `undulator-pointing` | Open the undulator pointing control tool, for a given user |
### Desktop entries (.desktop files)
| Command | Description |
|---|---|
| `Bernina.desktop` | Bernina launcher desktop icon (runs launcher) |
| `bernifox.desktop` | Bernina-branded Firefox launcher (per-host profile), set as default browser |
| `kerberos-ticket-renew.desktop` | Autostart entry for the Kerberos ticket renewer |
<!-- END SCRIPT INDEX -->
+3 -2
View File
@@ -95,8 +95,9 @@ def build_shortcuts(header, rows):
if row[0]
]
# Shortcut | Action [| Runs] (everything else: app shortcuts, gTile, primary-controls)
if ncols >= 2 and header[0].lower().startswith("shortcut"):
# Shortcut | Action [| Runs] (app shortcuts, gTile, primary-controls)
# Command | Description (the bin/ script index)
if ncols >= 2 and header[0].lower().startswith(("shortcut", "command")):
return [
{"key": row[0], "description": row[1]}
for row in rows
+15 -28
View File
@@ -1,11 +1,11 @@
# Personal directories on `/sf/bernina`
`/sf/bernina/config/personal/<username>` is the Bernina beamline
convention for a per-user directory on real, git-friendly NFS storage —
as opposed to `~/network_drives`, which is SMB and can't hold a git
repository at all (see `migrate_to_network_drive.md`). It's the location
the gopass guide (`gopass.md`) uses for a personal gopass clone that's
automatically the same on every Bernina host.
`/sf/bernina/config/personal/<username>` is the Bernina convention for a
per-user directory on real, git-friendly NFS storage — unlike
`~/network_drives` (SMB), which can't hold a git repo (see
`migrate_to_network_drive.md`). The gopass guide (`gopass.md`) uses it for
a personal store clone that's automatically the same on every Bernina
host.
## Usage
@@ -14,29 +14,16 @@ create_personal_dir.sh # create/verify your own directory
create_personal_dir.sh someuser # provision on someone else's behalf (needs root, or ask them to self-serve)
```
Run it again any time — it's safe to re-run, and only changes anything
that doesn't already match.
Safe to re-run — only changes what doesn't already match.
## What it does
1. **Checks the shared parent directory (`/sf/bernina/config/personal/`)
allows self-service.** It needs to be group-writable (so anyone in
`unx-sf_bernina_bs` can create their own subdirectory) with the sticky
bit set (so nobody can rename or delete someone *else's* directory —
the same principle `/tmp` uses). If it isn't set up that way yet and
you own it, the script offers to fix it (`chmod 3775`,
`chgrp unx-sf_bernina_bs`) — this only needs doing once, ever, since
1. Makes sure the shared parent (`/sf/bernina/config/personal/`) is
group-writable with the sticky bit set — like `/tmp`: anyone in
`unx-sf_bernina_bs` can add their own subdirectory, but nobody can
touch anyone else's. Offers to fix it (`chmod 3775`) if you own it and
it isn't set up that way yet; only ever needs doing once, since
`/sf/bernina` is one NFS export shared by every Bernina host.
2. **Creates (or verifies) `<username>`'s own subdirectory**, locked to
`2700` — owner-only, nobody else (not even other team members) can
list or read into it, despite the parent being browsable.
## Why this layout
- Parent `rwxrwxr-x` + sticky: anyone can see who has a personal
directory and add their own; nobody can touch anyone else's entry.
- Each user's own directory `rwx------`: totally private once inside.
This is the standard "shared drop-in directory, private subdirectories"
pattern — structurally the same idea as `/tmp`, scoped to the beamline
group instead of the whole system.
2. Creates (or verifies) `<username>`'s own subdirectory, locked to
`2700` — owner-only, nobody else can list or read into it even though
the parent is browsable.
+3
View File
@@ -0,0 +1,3 @@
{
"value": 0
}
+3
View File
@@ -0,0 +1,3 @@
{
"value": 0
}
+3
View File
@@ -0,0 +1,3 @@
{
"value": 0
}
+183
View File
@@ -0,0 +1,183 @@
#!/bin/bash
# eco status server (namespace mode) - start/stop/status wrapper.
#
# Canonical copy lives in the eco repo at eco/status_server/bin/; the copy in
# /sf/bernina/bin is installed from there (see install-user-service).
#
# Runs in the FOREGROUND by default, which is what systemd's Type=simple
# wants and what the accompanying user unit calls. Use `-b` to detach for
# interactive use.
#
# Everything is overridable by environment, so the same script serves a
# personal checkout, the shared checkout and the systemd unit unchanged:
#
# ECO_STATUS_SERVER_CHECKOUT eco checkout to run (prepended to PYTHONPATH)
# ECO_STATUS_SERVER_CONFIG server config JSON
# ECO_STATUS_SERVER_PYTHON interpreter (needs eco's full dependency set)
# ECO_STATUS_SERVER_URL what `status`/`stop` talk to
# ECO_STATUS_SERVER_LOG log file used by -b
#
# A file at /sf/bernina/config/eco_status_server/env (or
# $ECO_STATUS_SERVER_ENV) is sourced first if it exists, so a site can set
# those in one place without editing this script.
set -euo pipefail
ENV_FILE="${ECO_STATUS_SERVER_ENV:-/sf/bernina/config/eco_status_server/env}"
# shellcheck source=/dev/null
[ -r "$ENV_FILE" ] && . "$ENV_FILE"
CHECKOUT="${ECO_STATUS_SERVER_CHECKOUT:-/sf/bernina/code/gac-bernina/eco}"
CONFIG="${ECO_STATUS_SERVER_CONFIG:-/sf/bernina/config/eco_status_server/bernina_namespace.json}"
PYTHON="${ECO_STATUS_SERVER_PYTHON:-/sf/bernina/applications/python/.pixi/envs/bpy312/bin/python}"
URL="${ECO_STATUS_SERVER_URL:-http://$(hostname -s):8091}"
LOG="${ECO_STATUS_SERVER_LOG:-$HOME/.eco/status_server_$(hostname -s).log}"
# Matches only the running server, never this script or an ssh command line
# that mentions it (the bracket keeps the pattern from matching itself).
PGREP_PATTERN='[e]co\.status_server --mode'
usage() {
cat <<EOF
usage: $(basename "$0") <command> [options]
start [-b] [-- <extra args>] run the server (foreground; -b to detach)
stop stop the running server on this host
restart stop, then start detached
status query $URL/health
wait [timeout] block until the server reports ready
logs [-f] show the detached-mode log
config print the resolved configuration
Environment (current values):
ECO_STATUS_SERVER_CHECKOUT=$CHECKOUT
ECO_STATUS_SERVER_CONFIG=$CONFIG
ECO_STATUS_SERVER_PYTHON=$PYTHON
ECO_STATUS_SERVER_URL=$URL
ECO_STATUS_SERVER_LOG=$LOG
EOF
}
die() { echo "$(basename "$0"): $*" >&2; exit 1; }
check_prereqs() {
[ -d "$CHECKOUT/eco" ] || die "no eco checkout at $CHECKOUT (set ECO_STATUS_SERVER_CHECKOUT)"
# Not fatal, but almost always the explanation when half the namespace
# fails to initialize: without the gateway list, Channel Access falls
# back to the local broadcast domain. A systemd unit inherits nothing,
# so this is the first thing to check there.
if [ -z "${EPICS_CA_ADDR_LIST:-}" ]; then
echo "$(basename "$0"): warning: EPICS_CA_ADDR_LIST is not set;" >&2
echo " most components will fail to connect. See the EnvironmentFile" >&2
echo " written by eco-status-server-install-user-service." >&2
fi
[ -r "$CONFIG" ] || die "no server config at $CONFIG (set ECO_STATUS_SERVER_CONFIG)"
[ -x "$PYTHON" ] || die "no interpreter at $PYTHON (set ECO_STATUS_SERVER_PYTHON)"
}
server_pid() { pgrep -f "$PGREP_PATTERN" | head -1; }
cmd_start() {
local background=0
if [ "${1:-}" = "-b" ] || [ "${1:-}" = "--background" ]; then background=1; shift; fi
[ "${1:-}" = "--" ] && shift
check_prereqs
local pid; pid="$(server_pid || true)"
[ -n "$pid" ] && die "already running (pid $pid); use 'restart' or 'stop' first"
export PYTHONPATH="$CHECKOUT${PYTHONPATH:+:$PYTHONPATH}"
if [ "$background" -eq 1 ]; then
mkdir -p "$(dirname "$LOG")"
# </dev/null matters: several eco components prompt for credentials
# on stdin during __init__, and a headless server must get EOF
# rather than block forever waiting for an answer nobody will type.
nohup "$PYTHON" -u -m eco.status_server --mode namespace \
--config "$CONFIG" "$@" </dev/null >>"$LOG" 2>&1 &
echo "started pid $! -> $LOG"
echo "the namespace init takes minutes; watch it with: $(basename "$0") wait"
else
exec "$PYTHON" -u -m eco.status_server --mode namespace \
--config "$CONFIG" "$@" </dev/null
fi
}
cmd_stop() {
local pid; pid="$(server_pid || true)"
[ -z "$pid" ] && { echo "not running"; return 0; }
kill "$pid"
for _ in $(seq 1 30); do
sleep 1
pgrep -f "$PGREP_PATTERN" >/dev/null || { echo "stopped (was pid $pid)"; return 0; }
done
die "pid $pid did not exit after 30 s"
}
cmd_status() {
local pid; pid="$(server_pid || true)"
if [ -n "$pid" ]; then echo "process: running, pid $pid"; else echo "process: not running on $(hostname -s)"; fi
# urllib rather than curl|python: the health formatting is a real
# program, and quoting it through a shell pipeline is how it silently
# turns into "no answer" the first time someone edits it.
# `|| true`: a server that is not up yet is a normal state for this
# command, and under `set -e` a non-zero exit here would abort the
# `wait` loop that calls it.
"$PYTHON" - "$URL" <<'PYEOF' || true
import json, sys, urllib.request
url = sys.argv[1].rstrip("/") + "/health"
try:
with urllib.request.urlopen(url, timeout=15) as r:
h = json.load(r)
except Exception as exc:
print("health: no answer from %s (%s)" % (url, exc))
raise SystemExit(1)
print("state: {state} (generation {generation}, up {up:.1f} min)".format(
state=h["state"], generation=h["generation"], up=h["uptime_s"] / 60))
print("init: {i}/{t} components, {f} failed".format(
i=h["n_initialized"], t=h["n_target_names"], f=h["n_failed"]))
print("serving: {d} status detectors, {m} monitorable".format(
d=h["n_direct_read"], m=h.get("n_monitorable")))
print("process: {r:.0f} MB, {t} threads, {c:.0f} s cpu".format(
r=h.get("rss_mb") or 0, t=h.get("n_threads"), c=h.get("cpu_seconds") or 0))
if h.get("failed_names"):
print("failed: " + ", ".join(h["failed_names"]))
for rec in h.get("recordings", []):
if rec.get("running"):
print("recording {id}: {u} updates, {s} stored".format(
id=rec["recording_id"], u=rec["n_updates"], s=rec["n_stored"]))
PYEOF
}
is_ready() {
"$PYTHON" - "$URL" <<'PYEOF' >/dev/null 2>&1
import json, sys, urllib.request
with urllib.request.urlopen(sys.argv[1].rstrip("/") + "/health", timeout=15) as r:
raise SystemExit(0 if json.load(r).get("ready") else 1)
PYEOF
}
cmd_wait() {
# split, not `local a=.. b=$((a))`: under `set -u` bash evaluates the
# arithmetic before the first assignment is visible, and the command
# fails with "timeout: unbound variable".
local timeout="${1:-1800}"
local end=$((SECONDS + timeout))
while [ $SECONDS -lt $end ]; do
if is_ready; then cmd_status; return 0; fi
cmd_status | sed -n '2p'
sleep 15
done
die "not ready after ${timeout}s"
}
case "${1:-}" in
start) shift; cmd_start "$@" ;;
stop) cmd_stop ;;
restart) cmd_stop; shift; cmd_start -b "$@" ;;
status) cmd_status ;;
wait) shift; cmd_wait "$@" ;;
logs) shift; [ "${1:-}" = "-f" ] && tail -f "$LOG" || tail -n 100 "$LOG" ;;
config) usage ;;
""|-h|--help|help) usage ;;
*) usage; exit 2 ;;
esac
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
# Install the eco status server as a *user* systemd service on this host.
#
# User-level (`systemctl --user`) rather than system-level on purpose: it
# needs no root, and the service then runs as the invoking account - which
# matters twice for this server. It writes status.json into the pgroup's
# res/ tree, so it needs that account's pgroup membership; and several
# bernina components authenticate to auxiliary machines over SSH during
# __init__, so it needs that account's SSH credentials.
#
# The unit just calls `eco-status-server start` in the foreground, so the
# interactive command and the service run exactly the same thing.
set -euo pipefail
SELF_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
LAUNCHER="${ECO_STATUS_SERVER_LAUNCHER:-$SELF_DIR/eco-status-server}"
UNIT_DIR="$HOME/.config/systemd/user"
UNIT="$UNIT_DIR/eco-status-server.service"
ENV_FILE="${ECO_STATUS_SERVER_ENV:-$HOME/.config/eco-status-server.env}"
usage() {
cat <<EOF
usage: $(basename "$0") [--launcher PATH] [--env-file PATH] [--force]
Writes:
$UNIT
and, if it does not exist yet, a template environment file at
$ENV_FILE
Then prints the systemctl commands to enable and start it.
EOF
}
FORCE=0
while [ $# -gt 0 ]; do
case "$1" in
--launcher) LAUNCHER="$2"; shift 2 ;;
--env-file) ENV_FILE="$2"; shift 2 ;;
--force) FORCE=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&2; usage; exit 2 ;;
esac
done
[ -x "$LAUNCHER" ] || { echo "launcher not executable: $LAUNCHER" >&2; exit 1; }
mkdir -p "$UNIT_DIR" "$(dirname "$ENV_FILE")"
if [ ! -e "$ENV_FILE" ]; then
{
echo "# Environment for the eco status server (systemd EnvironmentFile"
echo "# syntax: KEY=value, no export, no shell expansion). Edit to taste."
echo "ECO_STATUS_SERVER_CHECKOUT=${ECO_STATUS_SERVER_CHECKOUT:-/sf/bernina/code/gac-bernina/eco}"
echo "ECO_STATUS_SERVER_CONFIG=${ECO_STATUS_SERVER_CONFIG:-/sf/bernina/config/eco_status_server/bernina_namespace.json}"
echo "ECO_STATUS_SERVER_PYTHON=${ECO_STATUS_SERVER_PYTHON:-/sf/bernina/applications/python/.pixi/envs/bpy312/bin/python}"
echo
echo "# Channel Access settings, captured from the shell that ran the"
echo "# installer. A systemd user service inherits almost nothing, and"
echo "# without EPICS_CA_ADDR_LIST the server talks to the local"
echo "# broadcast domain instead of the beamline gateway: measured on"
echo "# saresb-cons-04, that is the difference between 77 of 87"
echo "# components initializing and 49 of 87."
env | grep '^EPICS_' | sort || true
} > "$ENV_FILE"
echo "wrote template $ENV_FILE - check it before starting the service"
if ! grep -q '^EPICS_CA_ADDR_LIST=' "$ENV_FILE"; then
echo
echo "WARNING: EPICS_CA_ADDR_LIST was not set in this shell, so it is" >&2
echo " not in $ENV_FILE either. Re-run this from a shell where" >&2
echo " Channel Access works, or add it by hand - otherwise the" >&2
echo " service will start but most components will not connect." >&2
fi
else
echo "keeping existing $ENV_FILE"
fi
if [ -e "$UNIT" ] && [ "$FORCE" -eq 0 ]; then
echo "$UNIT already exists; re-run with --force to overwrite" >&2
exit 1
fi
cat > "$UNIT" <<EOF
[Unit]
Description=eco status/monitor server (namespace mode)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=$ENV_FILE
ExecStart=$LAUNCHER start
# The port is bound immediately and /health answers from the first second;
# namespace.init_all() then runs on a background thread for several minutes.
# So "started" here means "answering /health with state=initializing", and
# clients wait for state=ready themselves - no long TimeoutStartSec needed.
Restart=on-failure
RestartSec=10
# A restart that keeps failing should back off rather than reinitialize the
# whole namespace in a tight loop.
StartLimitIntervalSec=600
StartLimitBurst=5
[Install]
WantedBy=default.target
EOF
echo "wrote $UNIT"
cat <<EOF
Next steps:
# let the service keep running when you are not logged in
loginctl enable-linger $USER
systemctl --user daemon-reload
systemctl --user enable --now eco-status-server
systemctl --user status eco-status-server
journalctl --user -u eco-status-server -f
# kill -USR1 <mainpid> dumps every thread's stack into the journal -
# the way to see which component a stuck initialization is sitting in.
EOF
+1 -1
View File
@@ -11,4 +11,4 @@ FLAMESHOT_DIR="/sf/bernina/applications/Flameshot/Flameshot-12.1.0-extracted"
LC_ALL=C \
QT_QPA_PLATFORM_PLUGIN_PATH=/usr/lib64/qt5/plugins/platforms \
LD_LIBRARY_PATH=/usr/lib64 \
"$FLAMESHOT_DIR/usr/bin/flameshot" "$@"
"$FLAMESHOT_DIR/usr/bin/flameshot" "$@" "&"
+265 -778
View File
File diff suppressed because it is too large. Load diff
+140 -55
View File
@@ -2,28 +2,33 @@
#
# gopass_bernina_service_setup.sh — make a *service/automation* account's
# (e.g. gac-bernina) access to the "bernina" gopass store independent of
# any particular host's $HOME. See gopass.md §11.
# any particular host's $HOME. See gopass.md.
#
# Why this exists, and why it's different from gopass_bernina_setup.sh
# (the script for human accounts): a human account's $HOME is made to look
# the same on every PSI Linux host via "Partially Shared Home" (specific
# files symlinked/copied in from ~/network_drives at login - see
# migrate_to_network_drive.md). A service account may not have that, and
# even if it did, ~/network_drives itself can't hold a git repo (confirmed,
# gopass.md §10.3) - so a shared $HOME for this kind of account is a
# single point of failure the moment it isn't actually shared anymore.
# (the script for human accounts): this account needs a passphrase-less
# GPG key (unattended use) and a repo-scoped SSH Deploy Key instead of a
# personal SSH key - everything else (storage backend, the network_drives
# chmod workaround) is the same idea as the human script.
#
# The fix: put EVERYTHING this account needs for gopass - GPG keyring, SSH
# deploy key, gopass's own config, and the store clone itself - on
# /sf/bernina (confirmed git-safe NFS4, gopass.md §10.2), and repoint the
# relevant tools at it with three environment variables (all individually
# confirmed to work: GOPASS_HOMEDIR relocates gopass's config/mounts,
# GNUPGHOME relocates GPG's keyring, GIT_SSH_COMMAND controls which key
# gopass's own git-based sync uses - gopass shells out to the real `git`
# binary, so this is respected). Those three lines live in one file on
# /sf/bernina; each host then only needs ONE line in its own LOCAL
# ~/.bashrc sourcing it - a one-time, per-host, low-maintenance step,
# unlike trying to keep a whole $HOME in sync.
# Storage backend: prefers ~/network_drives (SMB) - the account's GPG
# keyring, gopass config, and store clone all end up there. Falls back to
# /sf/bernina/config/personal/<account> (NFS4) only if network_drives
# isn't reachable AND the account has group access to create a directory
# there (unlike a human's /sf/bernina/config/personal, this needs
# `unx-sf_bernina_bs` group membership, which a service account often
# doesn't have - confirmed for gac-bernina: `create_personal_dir.sh`
# fails with "Permission denied"). Check which of the two actually works
# for THIS account before relying on either - see gopass.md.
#
# THE WORKAROUND THIS SCRIPT USES for network_drives (confirmed by hand -
# see gopass.md): network_drives (SMB) rejects the chmod calls that
# `git init`/`git clone`/`git config`/`gpg --gen-key`/`ssh-keygen` make
# internally, so those must never run directly there. An
# ALREADY-initialized repo, keyring, or key pair, copied on with `cp -r`
# (rsync also works on some shares but confirmed to fail outright on at
# least one - see copy_tree() below), keeps working normally afterward.
# So: do the actual init/clone/keygen in a local scratch directory first,
# then copy the result onto network_drives and use it from there.
#
# Run this AS the service account (e.g. via `become <account>`, or however
# you get a shell as it). Safe to re-run - every step checks current state
@@ -37,19 +42,37 @@
#
set -euo pipefail
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
cat <<'USAGE'
gopass_bernina_service_setup.sh [account-email]
For: the non-interactive SERVICE/AUTOMATION account (e.g. gac-bernina) -
NOT a human. This account can READ secrets (its GPG key is authorized as
a recipient) but does not need write/admin access - see gopass.md for the
3-role split (admin / normal user / gac-bernina). Its git access is a
repo-scoped Deploy Key, which should be added READ-ONLY.
Run this AS the service account. Puts everything (passphrase-less GPG
key, SSH deploy key, gopass config, store clone) under
~/network_drives/home/gopass/ if reachable, else
/sf/bernina/config/personal/<account>/ - same auto-detection as
gopass_bernina_setup.sh, the human onboarding script (use that one
instead for a human, including the admin).
Note: /sf/bernina/config/personal needs unx-sf_bernina_bs group
membership, which a service account often doesn't have - if
create_personal_dir.sh fails with "Permission denied" for this account,
network_drives is your only option; check it's reachable on every host
this account needs to run from first.
USAGE
exit 0
fi
GITEA_REMOTE="git@gitea.psi.ch:Bernina/bernina-secrets.git"
STORE_ALIAS="bernina"
PERSONAL_ROOT="/sf/bernina/config/personal"
BIN_DIR="/sf/bernina/bin"
ACCOUNT="$(whoami)"
EMAIL="${1:-${ACCOUNT}@psi.ch}"
PERSONAL_DIR="/sf/bernina/config/personal/${ACCOUNT}"
GOPASS_HOME_DIR="${PERSONAL_DIR}/gopass-home"
GNUPG_DIR="${PERSONAL_DIR}/gnupg"
SSH_DIR="${PERSONAL_DIR}/ssh"
STORE_DIR="${PERSONAL_DIR}/gopass-${STORE_ALIAS}"
ENV_FILE="${PERSONAL_DIR}/env.sh"
log() { echo "==> $*"; }
warn() { echo "!! $*" >&2; }
ask() {
@@ -57,36 +80,72 @@ ask() {
read -r -p "$1 [y/N] " reply
[[ "$reply" =~ ^[Yy]$ ]]
}
copy_tree() {
# `rsync -rlt` (no -p/-a - permission preservation doesn't work on
# network_drives) works on some network_drives/SMB shares but
# confirmed to fail on at least one (gac-bernina's: exit 3, "Errors
# selecting input/output files, dirs", zero output even with -v and
# stderr combined - an rsync/gvfs-fuse incompatibility specific to
# that share). `cp -r` confirmed to work there, so use it everywhere.
mkdir -p "$2"
cp -r "$1/." "$2/"
}
ACCOUNT="$(whoami)"
EMAIL="${1:-${ACCOUNT}@psi.ch}"
# --- 0. pick a storage backend ----------------------------------------------
if [[ -d "${HOME}/network_drives/home" ]]; then
BACKEND="network_drives"
ROOT_DIR="${HOME}/network_drives/home/gopass"
NEEDS_SCRATCH=1
else
BACKEND="sf_bernina"
ROOT_DIR="${PERSONAL_ROOT}/${ACCOUNT}"
NEEDS_SCRATCH=0
log "~/network_drives/home not reachable on this host - falling back to ${ROOT_DIR}"
fi
GOPASS_HOME_DIR="${ROOT_DIR}/gopass-home"
GNUPG_DIR="${ROOT_DIR}/gnupg"
SSH_DIR="${ROOT_DIR}/ssh"
STORE_DIR="${ROOT_DIR}/gopass-${STORE_ALIAS}"
ENV_FILE="${ROOT_DIR}/env.sh"
SCRATCH="$(mktemp -d)"
cleanup() { rm -rf "${SCRATCH}"; }
trap cleanup EXIT
echo "Setting up host-independent gopass access for ${ACCOUNT} (${EMAIL})"
echo "Everything will live under: ${PERSONAL_DIR}"
echo "Backend: ${BACKEND} (everything will live under ${ROOT_DIR})"
echo
# --- 1. personal directory on the shared NFS mount --------------------------
log "Step 1/5: personal directory"
"${BIN_DIR}/create_personal_dir.sh" "${ACCOUNT}"
# --- 1. target directory -----------------------------------------------------
log "Step 1/5: target directory (${ROOT_DIR})"
if [[ "${BACKEND}" == "sf_bernina" ]]; then
"${BIN_DIR}/create_personal_dir.sh" "${ACCOUNT}"
fi
mkdir -p "${GOPASS_HOME_DIR}"
mkdir -m 700 -p "${GNUPG_DIR}"
mkdir -m 700 -p "${SSH_DIR}"
# --- 2. GPG key: migrate existing if present, else offer to generate --------
# --- 2. GPG key: migrate existing if present, else generate passphrase-less -
log "Step 2/5: GPG key for ${EMAIL}"
if GNUPGHOME="${GNUPG_DIR}" gpg --list-secret-keys "${EMAIL}" >/dev/null 2>&1; then
log " already present under ${GNUPG_DIR}"
elif gpg --list-secret-keys "${EMAIL}" >/dev/null 2>&1; then
log " found an existing key for ${EMAIL} in this shell's current \$HOME/.gnupg"
if ask " Copy it to ${GNUPG_DIR} (keeps the SAME key/recipient — no re-authorization needed)?"; then
cp -r "${HOME}/.gnupg/." "${GNUPG_DIR}/"
find "${GNUPG_DIR}" -type d -exec chmod 700 {} \;
find "${GNUPG_DIR}" -type f -exec chmod 600 {} \;
copy_tree "${HOME}/.gnupg" "${GNUPG_DIR}"
log " copied"
else
warn " without this, decrypt calls will fail until a key exists under ${GNUPG_DIR}"
fi
else
warn " no GPG key found anywhere for ${EMAIL}"
if ask " Generate a new passphrase-less one now (needed for unattended use — see gopass.md §11 for the security trade-off)?"; then
GNUPGHOME="${GNUPG_DIR}" gpg --batch --gen-key <<EOF
if ask " Generate a new passphrase-less one now (needed for unattended use — see gopass.md for the security trade-off)?"; then
KEYGEN_HOME="${GNUPG_DIR}"
[[ "${NEEDS_SCRATCH}" -eq 1 ]] && KEYGEN_HOME="${SCRATCH}/gnupg" && mkdir -m 700 -p "${KEYGEN_HOME}"
GNUPGHOME="${KEYGEN_HOME}" gpg --batch --gen-key <<EOF
%no-protection
Key-Type: RSA
Key-Length: 4096
@@ -95,10 +154,11 @@ Name-Email: ${EMAIL}
Expire-Date: 1y
%commit
EOF
PUBKEY_FILE="${PERSONAL_DIR}/${ACCOUNT}-gopass-pubkey.asc"
[[ "${NEEDS_SCRATCH}" -eq 1 ]] && copy_tree "${KEYGEN_HOME}" "${GNUPG_DIR}"
PUBKEY_FILE="${ROOT_DIR}/${ACCOUNT}-gopass-pubkey.asc"
GNUPGHOME="${GNUPG_DIR}" gpg --armor --export "${EMAIL}" > "${PUBKEY_FILE}"
log " generated, exported public key to ${PUBKEY_FILE}"
log " send it to the store admin — they run 'gopass recipients add --store ${STORE_ALIAS} ${EMAIL}' (gopass.md §6)"
log " send it to the store admin — they run 'gopass recipients add --store ${STORE_ALIAS} ${EMAIL}' (gopass.md)"
fi
fi
@@ -111,7 +171,6 @@ elif [[ -f "${HOME}/.ssh/id_ed25519" ]]; then
if ask " Copy it to ${SSH_DIR} (keeps the SAME key — no re-registering the deploy key needed)?"; then
cp "${HOME}/.ssh/id_ed25519" "${SSH_DIR}/id_ed25519"
[[ -f "${HOME}/.ssh/id_ed25519.pub" ]] && cp "${HOME}/.ssh/id_ed25519.pub" "${SSH_DIR}/id_ed25519.pub"
chmod 600 "${SSH_DIR}/id_ed25519"
log " copied"
else
warn " without this, git/gitea access will fail until a key exists under ${SSH_DIR}"
@@ -119,12 +178,19 @@ elif [[ -f "${HOME}/.ssh/id_ed25519" ]]; then
else
warn " no SSH key found anywhere"
if ask " Generate a new passphrase-less deploy key now?"; then
ssh-keygen -t ed25519 -f "${SSH_DIR}/id_ed25519" -N "" -C "${ACCOUNT} deploy key"
log " generated. Register the PUBLIC half as a Deploy Key on the repo (gopass.md §11):"
if [[ "${NEEDS_SCRATCH}" -eq 1 ]]; then
# ssh-keygen chmods the private key file after writing it -
# fails directly on network_drives like git/gpg do.
ssh-keygen -t ed25519 -f "${SCRATCH}/id_ed25519" -N "" -C "${ACCOUNT} deploy key"
cp "${SCRATCH}/id_ed25519" "${SCRATCH}/id_ed25519.pub" "${SSH_DIR}/"
else
ssh-keygen -t ed25519 -f "${SSH_DIR}/id_ed25519" -N "" -C "${ACCOUNT} deploy key"
fi
log " generated. Register the PUBLIC half as a READ-ONLY Deploy Key on the repo (gopass.md):"
echo
cat "${SSH_DIR}/id_ed25519.pub"
echo
log " https://gitea.psi.ch/Bernina/bernina-secrets/settings/keys -> Add Deploy Key"
log " https://gitea.psi.ch/Bernina/bernina-secrets/settings/keys -> Add Deploy Key (leave 'Allow write access' UNCHECKED)"
fi
fi
@@ -132,22 +198,30 @@ fi
log "Step 4/5: writing ${ENV_FILE}"
cat > "${ENV_FILE}" <<EOF
# Source this from ~/.bashrc (LOCAL to each host - not shared) to make
# ${ACCOUNT}'s gopass access work the same everywhere, regardless of
# whether \$HOME is shared. Generated by gopass_bernina_service_setup.sh -
# see gopass.md §11 for what each of these does and why.
# ${ACCOUNT}'s gopass access work the same everywhere this account runs,
# regardless of whether \$HOME is shared. Generated by
# gopass_bernina_service_setup.sh - see gopass.md for what each of these
# does and why.
export GOPASS_HOMEDIR="${GOPASS_HOME_DIR}"
export GNUPGHOME="${GNUPG_DIR}"
export GIT_SSH_COMMAND="ssh -i ${SSH_DIR}/id_ed25519 -o IdentitiesOnly=yes"
EOF
log " done"
# --- 5. clone/mount the store using the new env ------------------------------
log "Step 5/5: cloning '${STORE_ALIAS}' to ${STORE_DIR}"
# --- 5. root store + clone/mount the store using the new env -----------------
log "Step 5/5: '${STORE_ALIAS}' store at ${STORE_DIR}"
export GOPASS_HOMEDIR="${GOPASS_HOME_DIR}" GNUPGHOME="${GNUPG_DIR}"
export GIT_SSH_COMMAND="ssh -i ${SSH_DIR}/id_ed25519 -o IdentitiesOnly=yes"
if ! gopass mounts >/dev/null 2>&1; then
gopass init "${EMAIL}"
if [[ "${NEEDS_SCRATCH}" -eq 1 ]]; then
GNUPGHOME="${GNUPG_DIR}" GOPASS_HOMEDIR="${SCRATCH}/gopass-home" gopass init "${EMAIL}"
(cd "${SCRATCH}/gopass-home/.local/share/gopass/stores/root" && git config core.fileMode false)
copy_tree "${SCRATCH}/gopass-home" "${GOPASS_HOME_DIR}"
sed -i "s|${SCRATCH}/gopass-home|${GOPASS_HOME_DIR}|g" "${GOPASS_HOME_DIR}/.config/gopass/config"
else
gopass init "${EMAIL}"
fi
fi
if gopass mounts 2>/dev/null | grep -q "${STORE_ALIAS} ("; then
@@ -155,21 +229,32 @@ if gopass mounts 2>/dev/null | grep -q "${STORE_ALIAS} ("; then
elif [[ -d "${STORE_DIR}/.git" ]]; then
gopass mounts add "${STORE_ALIAS}" "${STORE_DIR}"
elif [[ -d "${STORE_DIR}" ]]; then
warn " ${STORE_DIR} exists but has no .git — see gopass.md §12 Troubleshooting"
warn " ${STORE_DIR} exists but has no .git — see gopass.md Troubleshooting"
exit 1
else
# Plain `git clone`, deliberately NOT `gopass clone` - confirmed by
# hand that `gopass clone` can hang indefinitely (Go-level deadlock at
# its post-clone "Gathering information for the git repository"
# bookkeeping, unrelated to repo size or network) - see gopass.md.
CLONE_DEST="${STORE_DIR}"
[[ "${NEEDS_SCRATCH}" -eq 1 ]] && CLONE_DEST="${SCRATCH}/${STORE_ALIAS}"
CLONE_ERR="$(mktemp)"
if ! gopass clone --path "${STORE_DIR}" "${GITEA_REMOTE}" "${STORE_ALIAS}" 2>"${CLONE_ERR}"; then
if ! git clone "${GITEA_REMOTE}" "${CLONE_DEST}" 2>"${CLONE_ERR}"; then
cat "${CLONE_ERR}" >&2
rm -f "${CLONE_ERR}"
warn " clone failed — check the deploy key is registered and has repo access (gopass.md §11)"
warn " clone failed — check the deploy key is registered and has repo access (gopass.md)"
exit 1
fi
rm -f "${CLONE_ERR}"
(cd "${CLONE_DEST}" && git config core.fileMode false)
if [[ "${NEEDS_SCRATCH}" -eq 1 ]]; then
copy_tree "${CLONE_DEST}" "${STORE_DIR}"
fi
gopass mounts add "${STORE_ALIAS}" "${STORE_DIR}"
fi
if ! gopass mounts 2>/dev/null | grep -q "${STORE_ALIAS} ("; then
warn " mount verification failed — see gopass.md §12 Troubleshooting"
warn " mount verification failed — see gopass.md Troubleshooting"
exit 1
fi
log " mounted and verified OK"
@@ -178,7 +263,7 @@ echo
echo "======================================================================"
echo "Done for THIS shell. To make it stick on every host this account runs"
echo "on, add ONE line to that host's LOCAL ~/.bashrc (not shared — do this"
echo "once per host):"
echo "once per host, and only where ${BACKEND} is reachable for this account):"
echo
echo " echo 'source ${ENV_FILE}' >> ~/.bashrc"
echo
+236 -49
View File
@@ -2,9 +2,27 @@
#
# gopass_bernina_setup.sh — one-shot onboarding for a *human* PSI account
# joining the shared "bernina" gopass secrets store. See gopass.md (same
# directory) for the full picture; this automates §5 (Onboarding additional
# users) using the §10.2 NFS-shared store location, which is recommended
# for anyone working from Bernina beamline hosts.
# directory) for the full picture; this automates the "Onboarding someone
# new" section.
#
# Storage backend: prefers ~/network_drives (SMB, Partially Shared Home) -
# the account's GPG keyring, gopass config, and the store clone all end up
# there, so they're already the same on every host without any separate
# provisioning step. Falls back to /sf/bernina/config/personal/<you> (NFS4)
# if network_drives isn't reachable on this host - see gopass.md
# "Store location" for the trade-off.
#
# THE WORKAROUND THIS SCRIPT USES for network_drives (confirmed by hand -
# see gopass.md): network_drives (SMB) rejects the chmod calls that
# `git init`/`git clone`/`git config`/`gpg --gen-key` make internally, so
# those must never run directly there ("Operation not supported"). An
# ALREADY-initialized repo or keyring, copied on with `cp -r` (rsync also
# works on some shares but confirmed to fail outright on at least one -
# see copy_tree() below), keeps working normally afterward:
# add/commit/push/pull, encrypt/decrypt, and `gopass insert`/`show`/`sync`
# all confirmed. So: do the actual init/clone/gen-key/config in a local
# scratch directory first, then copy the result onto network_drives and
# use it from there.
#
# What this script CANNOT do for you — gitea has no API for these, and
# they're each an action only another human/account can take:
@@ -14,7 +32,7 @@
# Bernina/bernina-secrets repo
# 3. Getting your GPG key authorized as a recipient - an existing
# recipient (currently: lemke_h) has to run `gopass recipients add`
# for you (see gopass.md §6) after you send them your exported
# for you (see gopass.md) after you send them your exported
# public key
# The script detects each of these and tells you exactly what's missing
# and what to do, then stops - re-run it after fixing that one thing.
@@ -23,7 +41,11 @@
# Defaults to <your-username>@psi.ch.
#
# Safe to re-run: every step checks current state first and skips work
# that's already done.
# that's already done. If you already have a working GPG key/store in an
# old location (e.g. $HOME/.gnupg, or a previous /sf/bernina-only setup),
# this OFFERS to copy it into the new location rather than generating a
# fresh one - keeps the same recipient registration, no re-authorization
# needed.
#
set -euo pipefail
@@ -39,36 +61,84 @@ ask() {
read -r -p "$1 [y/N] " reply
[[ "$reply" =~ ^[Yy]$ ]]
}
copy_tree() {
# `rsync -rlt` was the first choice here (no -p/-a - permission
# preservation doesn't work on network_drives) and works fine on some
# network_drives/SMB shares, but confirmed to fail on at least one
# (gac-bernina's: exit 3, "Errors selecting input/output files, dirs",
# with -v producing zero output even with stderr combined - looks
# like an rsync/gvfs-fuse incompatibility specific to that share).
# `cp -r` confirmed to work where that rsync failed, so use it
# everywhere instead for consistency.
mkdir -p "$2"
cp -r "$1/." "$2/"
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
cat <<'USAGE'
gopass_bernina_setup.sh [your-psi-email]
For: any HUMAN PSI account that needs to read/write the shared "bernina"
gopass secrets store - this is the SAME script and procedure whether
you're the admin (the one who also runs `gopass recipients add` for
others) or a normal group user. There is no separate "admin mode".
NOT for the gac-bernina service account - see
gopass_bernina_service_setup.sh for that (different: passphrase-less key,
deploy key instead of a personal SSH key, non-interactive).
Defaults to <your-username>@psi.ch. Safe to re-run.
USAGE
exit 0
fi
USERNAME="$(whoami)"
EMAIL="${1:-${USERNAME}@psi.ch}"
PERSONAL_DIR="${PERSONAL_ROOT}/${USERNAME}"
STORE_PATH="${PERSONAL_DIR}/gopass-${STORE_ALIAS}"
# --- 0. pick a storage backend ----------------------------------------------
if [[ -d "${HOME}/network_drives/home" ]]; then
BACKEND="network_drives"
ROOT_DIR="${HOME}/network_drives/home/gopass"
NEEDS_SCRATCH=1
else
BACKEND="sf_bernina"
ROOT_DIR="${PERSONAL_ROOT}/${USERNAME}"
NEEDS_SCRATCH=0
log "~/network_drives/home not reachable on this host - falling back to ${ROOT_DIR}"
fi
GNUPG_DIR="${ROOT_DIR}/gnupg"
GOPASS_HOME_DIR="${ROOT_DIR}/gopass-home"
STORE_DIR="${ROOT_DIR}/gopass-${STORE_ALIAS}"
ENV_FILE="${ROOT_DIR}/env.sh"
SCRATCH="$(mktemp -d)"
cleanup() { rm -rf "${SCRATCH}"; }
trap cleanup EXIT
echo "Setting up gopass access to the '${STORE_ALIAS}' store for ${EMAIL}"
echo "(local store path will be: ${STORE_PATH})"
echo "Backend: ${BACKEND} (everything will live under ${ROOT_DIR})"
echo
# --- 1. personal directory on the shared NFS mount -------------------------
log "Step 1/6: personal directory (${PERSONAL_DIR})"
if [[ -d "${PERSONAL_DIR}" ]]; then
log " already exists"
else
# --- 1. target directory -----------------------------------------------------
log "Step 1/6: target directory (${ROOT_DIR})"
if [[ "${BACKEND}" == "sf_bernina" ]]; then
"${BIN_DIR}/create_personal_dir.sh"
fi
mkdir -p "${GOPASS_HOME_DIR}"
mkdir -m 700 -p "${GNUPG_DIR}"
# --- 2. SSH key + gitea reachability ---------------------------------------
# --- 2. SSH key + gitea reachability -----------------------------------------
log "Step 2/6: SSH key for gitea.psi.ch"
if [[ ! -f ~/.ssh/id_ed25519 && ! -f ~/.ssh/id_rsa ]]; then
if ask " No SSH key found. Generate one now (ssh-keygen -t ed25519)?"; then
ssh-keygen -t ed25519 -C "${EMAIL}"
else
warn " Can't continue without an SSH key - see gopass.md §3.2"
warn " Can't continue without an SSH key - see gopass.md"
exit 1
fi
fi
if ssh -T -o BatchMode=yes -o ConnectTimeout=8 git@gitea.psi.ch 2>&1 | grep -qi "successfully authenticated"; then
if ssh -T -o BatchMode=yes -o ConnectTimeout=8 git@gitea.psi.ch </dev/null 2>&1 | grep -qi "successfully authenticated"; then
log " SSH to gitea.psi.ch: OK"
else
warn " SSH to gitea.psi.ch did not authenticate."
@@ -78,44 +148,127 @@ else
exit 1
fi
# --- 3. GPG key --------------------------------------------------------------
log "Step 3/6: GPG key for ${EMAIL}"
if gpg --list-secret-keys "${EMAIL}" >/dev/null 2>&1; then
log " usable GPG key already exists for ${EMAIL}"
# --- 3. GPG key: already in place / migrate from an old location / generate -
# NOTE: we detect "is there a usable key" by presence, not by matching
# ${EMAIL} - your real PSI email often isn't <username>@psi.ch (e.g.
# lemke_h's is henrik.lemke@psi.ch), so a literal-match check would miss
# an existing key and offer to generate a redundant, unauthorized one.
# Once a key is found/generated, EMAIL is corrected to that key's own
# uid email for every step from here on (gopass init, the final
# instructions, etc).
log "Step 3/6: GPG key"
key_email_in() {
GNUPGHOME="$1" gpg --list-secret-keys --with-colons 2>/dev/null \
| awk -F: '/^uid/{print $10; exit}' | sed -n 's/.*<\(.*\)>.*/\1/p'
}
has_secret_key_in() {
GNUPGHOME="$1" gpg --list-secret-keys --with-colons 2>/dev/null | grep -q '^sec'
}
if has_secret_key_in "${GNUPG_DIR}"; then
EMAIL="$(key_email_in "${GNUPG_DIR}")"
log " already present under ${GNUPG_DIR} (${EMAIL})"
else
if ask " No GPG key found for ${EMAIL}. Generate one now (gpg --full-generate-key)?"; then
gpg --full-generate-key
OLD_GNUPGHOME=""
for candidate in "${GNUPGHOME:-}" "${HOME}/.gnupg" "${PERSONAL_ROOT}/${USERNAME}/gnupg"; do
[[ -z "${candidate}" || "${candidate}" == "${GNUPG_DIR}" ]] && continue
if has_secret_key_in "${candidate}"; then
OLD_GNUPGHOME="${candidate}"
break
fi
done
if [[ -n "${OLD_GNUPGHOME}" ]]; then
FOUND_EMAIL="$(key_email_in "${OLD_GNUPGHOME}")"
log " found an existing key (${FOUND_EMAIL}) under ${OLD_GNUPGHOME}"
if ask " Copy it to ${GNUPG_DIR} (keeps the SAME key/recipient - no re-authorization needed)?"; then
copy_tree "${OLD_GNUPGHOME}" "${GNUPG_DIR}"
EMAIL="${FOUND_EMAIL}"
log " copied - using ${EMAIL} as your identity from here on"
else
warn " without this, decrypt calls will fail until a key exists under ${GNUPG_DIR}"
fi
else
warn " Can't continue without a GPG key - see gopass.md §3.3"
exit 1
warn " no GPG key found anywhere (checked ${GNUPG_DIR}, \$GNUPGHOME, ~/.gnupg, ${PERSONAL_ROOT}/${USERNAME}/gnupg)"
if ask " Generate one now (gpg --full-generate-key - use your REAL psi.ch email, set a real passphrase)?"; then
if [[ "${NEEDS_SCRATCH}" -eq 1 ]]; then
# gpg --gen-key does chmods internally that fail directly on
# network_drives - generate locally, then copy the result.
mkdir -m 700 -p "${SCRATCH}/gnupg"
GNUPGHOME="${SCRATCH}/gnupg" gpg --full-generate-key
copy_tree "${SCRATCH}/gnupg" "${GNUPG_DIR}"
else
GNUPGHOME="${GNUPG_DIR}" gpg --full-generate-key
# safety net in case this gpg version generated into the
# default homedir despite the GNUPGHOME override above
if ! has_secret_key_in "${GNUPG_DIR}" && has_secret_key_in "${HOME}/.gnupg"; then
copy_tree "${HOME}/.gnupg" "${GNUPG_DIR}"
fi
fi
if has_secret_key_in "${GNUPG_DIR}"; then
EMAIL="$(key_email_in "${GNUPG_DIR}")"
log " generated - using ${EMAIL} as your identity from here on"
fi
else
warn " Can't continue without a GPG key"
exit 1
fi
fi
fi
# --- 4. root store -----------------------------------------------------------
if ! has_secret_key_in "${GNUPG_DIR}"; then
warn " no usable key under ${GNUPG_DIR} - can't continue. Re-run and"
warn " agree to the copy/generate step above, or place a key there by hand first."
exit 1
fi
log " using GPG identity: ${EMAIL}"
export GNUPGHOME="${GNUPG_DIR}"
# --- 4. root store -------------------------------------------------------------
log "Step 4/6: gopass root store"
export GOPASS_HOMEDIR="${GOPASS_HOME_DIR}"
if gopass mounts >/dev/null 2>&1; then
log " already initialized"
elif [[ "${NEEDS_SCRATCH}" -eq 1 ]]; then
GNUPGHOME="${GNUPG_DIR}" GOPASS_HOMEDIR="${SCRATCH}/gopass-home" gopass init "${EMAIL}"
(cd "${SCRATCH}/gopass-home/.local/share/gopass/stores/root" && git config core.fileMode false)
copy_tree "${SCRATCH}/gopass-home" "${GOPASS_HOME_DIR}"
sed -i "s|${SCRATCH}/gopass-home|${GOPASS_HOME_DIR}|g" "${GOPASS_HOME_DIR}/.config/gopass/config"
else
gopass init "${EMAIL}"
fi
# --- 5. clone/mount the bernina store ---------------------------------------
log "Step 5/6: cloning '${STORE_ALIAS}' to ${STORE_PATH}"
# --- 5. clone/mount the bernina store ----------------------------------------
log "Step 5/6: '${STORE_ALIAS}' store at ${STORE_DIR}"
if gopass mounts 2>/dev/null | grep -q "${STORE_ALIAS} ("; then
log " already mounted"
elif [[ -d "${STORE_PATH}/.git" ]]; then
elif [[ -d "${STORE_DIR}/.git" ]]; then
log " directory already looks cloned, mounting it"
gopass mounts add "${STORE_ALIAS}" "${STORE_PATH}"
elif [[ -d "${STORE_PATH}" ]]; then
warn " ${STORE_PATH} exists but has no .git - a previous attempt likely"
warn " failed partway. See gopass.md §12 Troubleshooting before removing"
gopass mounts add "${STORE_ALIAS}" "${STORE_DIR}"
elif [[ -d "${STORE_DIR}" ]]; then
warn " ${STORE_DIR} exists but has no .git - a previous attempt likely"
warn " failed partway. See gopass.md Troubleshooting before removing"
warn " it by hand (confirm it really lacks .git first)."
exit 1
else
# Plain `git clone` + `gopass mounts add`, deliberately NOT `gopass
# clone`: confirmed by hand that `gopass clone` can hang indefinitely
# (Go-level deadlock, wchan futex_do_wait, zero subprocesses/network
# activity - not a slow/big-repo thing, reproduced even against the
# tiny real bernina-secrets repo) at its post-clone "Gathering
# information for the git repository" bookkeeping step. A plain git
# clone plus `gopass mounts add` (the same call already used just
# above when a store directory is pre-existing) reaches the same end
# state without going through that codepath - confirmed instant
# against the real repo.
CLONE_DEST="${STORE_DIR}"
if [[ "${NEEDS_SCRATCH}" -eq 1 ]]; then
CLONE_DEST="${SCRATCH}/${STORE_ALIAS}"
fi
CLONE_ERR="$(mktemp)"
if ! gopass clone --path "${STORE_PATH}" "${GITEA_REMOTE}" "${STORE_ALIAS}" 2>"${CLONE_ERR}"; then
if ! git clone "${GITEA_REMOTE}" "${CLONE_DEST}" 2>"${CLONE_ERR}"; then
cat "${CLONE_ERR}" >&2
if grep -qi "not authorized\|access rights\|could not read from remote" "${CLONE_ERR}"; then
if grep -qi "not authorized\|access rights\|could not read from remote\|permission denied" "${CLONE_ERR}"; then
warn " Repo access not granted yet. Ask the admin (lemke_h) to add you"
warn " as a Collaborator (Write access) on Bernina/bernina-secrets:"
warn " https://gitea.psi.ch/Bernina/bernina-secrets/settings/collaboration"
@@ -125,35 +278,69 @@ else
exit 1
fi
rm -f "${CLONE_ERR}"
(cd "${CLONE_DEST}" && git config core.fileMode false)
if [[ "${NEEDS_SCRATCH}" -eq 1 ]]; then
copy_tree "${CLONE_DEST}" "${STORE_DIR}"
fi
gopass mounts add "${STORE_ALIAS}" "${STORE_DIR}"
fi
# verify (gopass.md §5.3 - don't skip this, a broken mount fails confusingly later)
# verify (don't skip this, a broken mount fails confusingly later)
if ! gopass mounts 2>/dev/null | grep -q "${STORE_ALIAS} ("; then
warn " mount verification failed - see gopass.md §5.3/§12 Troubleshooting"
warn " mount verification failed - see gopass.md Troubleshooting"
exit 1
fi
if [[ ! -d "${STORE_PATH}/.git" ]]; then
warn " ${STORE_PATH} has no .git - clone did not fully succeed, see gopass.md §5.3"
if [[ ! -d "${STORE_DIR}/.git" ]]; then
warn " ${STORE_DIR} has no .git - clone did not fully succeed"
exit 1
fi
log " mounted and verified OK"
# --- 6. export pubkey, tell them what's left --------------------------------
log "Step 6/6: exporting your GPG public key"
PUBKEY_FILE="${HOME}/${USERNAME}-gopass-pubkey.asc"
# --- 6. env.sh + export pubkey + tell them what's left -----------------------
log "Step 6/6: writing ${ENV_FILE} and exporting your GPG public key"
cat > "${ENV_FILE}" <<EOF
# Source this from ~/.bashrc (LOCAL to each host) to make your gopass
# access to '${STORE_ALIAS}' work the same on every host where ${ROOT_DIR}
# is reachable. Generated by gopass_bernina_setup.sh - see gopass.md.
export GNUPGHOME="${GNUPG_DIR}"
export GOPASS_HOMEDIR="${GOPASS_HOME_DIR}"
EOF
PUBKEY_FILE="${ROOT_DIR}/${USERNAME}-gopass-pubkey.asc"
gpg --armor --export "${EMAIL}" > "${PUBKEY_FILE}"
log " wrote ${PUBKEY_FILE}"
# is this identity already an authorized recipient? (true for anyone
# re-running this script after migrating an already-authorized key to a
# new location - e.g. the admin's own re-run - not just for brand-new
# people)
ALREADY_RECIPIENT=0
if gopass recipients 2>/dev/null | grep -qi "<${EMAIL}>"; then
ALREADY_RECIPIENT=1
fi
echo
echo "======================================================================"
echo "Almost done. One step only an existing recipient can do for you:"
echo "Add ONE line to this host's LOCAL ~/.bashrc (not shared - do this once"
echo "per host, or replace any older GNUPGHOME/GOPASS_HOMEDIR exports there):"
echo
echo " Send ${PUBKEY_FILE} to the store admin (lemke_h) - internal email,"
echo " Mattermost/Slack DM, or in person. They will run:"
echo " echo 'source ${ENV_FILE}' >> ~/.bashrc"
echo
echo " gpg --import ${USERNAME}-gopass-pubkey.asc"
echo " gopass recipients add --store ${STORE_ALIAS} ${EMAIL}"
echo
echo "Until then, 'gopass show ${STORE_ALIAS}/...' will fail with a"
echo "'not a recipient' error - that's expected, not a bug (see gopass.md §1)."
if [[ "${ALREADY_RECIPIENT}" -eq 1 ]]; then
echo "You're already an authorized recipient of '${STORE_ALIAS}' (${EMAIL}) -"
echo "nothing else to do. Open a fresh shell (or source ~/.bashrc above),"
echo "then 'gopass show ${STORE_ALIAS}/...' should work (it'll ask for your"
echo "GPG passphrase once, then cache it)."
else
echo "One step only an existing recipient can do for you:"
echo
echo " Send ${PUBKEY_FILE} to the store admin (lemke_h) - internal email,"
echo " Mattermost/Slack DM, or in person. They will run:"
echo
echo " gpg --import ${USERNAME}-gopass-pubkey.asc"
echo " gopass recipients add --store ${STORE_ALIAS} ${EMAIL}"
echo
echo "Until then, 'gopass show ${STORE_ALIAS}/...' will fail with a"
echo "'not a recipient' error - that's expected, not a bug (see gopass.md)."
fi
echo "======================================================================"
+22
View File
@@ -23,15 +23,37 @@ if [ -L "$EXT_DIR" ] && [ ! -e "$EXT_DIR" ]; then
rm -f "$EXT_DIR"
fi
# Clear anything GNOME Shell's own extensions.gnome.org auto-update check
# may have already staged here before allow-extension-installation (set by
# the main startup script) had a chance to stop it -- a stale staged build
# would otherwise still be sitting here ready to silently swap in on some
# future shell restart, superseding the pinned version reinstalled below.
rm -rf "${HOME}/.local/share/gnome-shell/extension-updates/${UUID}"
src_version=$(sed -n 's/.*"version": *\([0-9]*\).*/\1/p' "$SRC/metadata.json")
dest_version=""
[ -f "$DEST/metadata.json" ] && dest_version=$(sed -n 's/.*"version": *\([0-9]*\).*/\1/p' "$DEST/metadata.json")
reinstalled=false
if [ ! -d "$DEST" ] || [ "$src_version" != "$dest_version" ]; then
mkdir -p "$(dirname "$DEST")"
rsync -a --delete "$SRC/" "$DEST/"
glib-compile-schemas "$DEST/schemas" 2>/dev/null
echo "install_gtile: installed gTile version $src_version to $DEST"
reinstalled=true
fi
# A plain `enable` is a no-op if the extension is already enabled -- the
# normal case every login -- so it does NOT make gnome-shell pick up a
# version this script just rsynced onto disk above (extensions aren't
# hot-reloaded on file change). Force a disable/enable cycle whenever we
# actually changed what's on disk, so the running session matches disk
# immediately instead of only from the next full login onward (confirmed:
# this exact gap is what left saresb-cons-05 running a stale in-memory
# version even after the rsync above had already put the correct one back
# on disk).
if $reinstalled && gnome-extensions list --enabled 2>/dev/null | grep -qx "$UUID"; then
gnome-extensions disable "$UUID" 2>/dev/null
fi
# enable the extension (needs a live gnome-shell D-Bus session)
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
source /sf/bernina/bin/bpy-env
python -c "from eco import ioc_finder; ioc_finder()"
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env python3
"""Generate the "Scripts in this directory" section of cheatsheet.md from
the actual contents of /sf/bernina/bin -- a full index of every script
here, grouped by topic, as a set of "Command | Description" tables.
Descriptions are hand-curated below (COMMANDS dict), not auto-derived --
most of these scripts have no usable header comment (many are a single
command line), so a heuristic would mostly just echo the command back.
Any file present in the directory but missing from COMMANDS lands in an
"Uncategorized" bucket at the end instead of being silently dropped, so
new scripts get noticed next time this runs rather than staying invisible
forever. Anything in COMMANDS that no longer exists on disk is silently
skipped (self-heals when scripts are removed).
These tables are headed "Command | Description" -- cheatsheet_to_shortcuts_json
recognizes that header (alongside "Shortcut") so this section also shows up
in the Super+F1 overlay, not just the full document.
To keep one script out of this listing entirely, put a comment containing
HIDE_MARKER (see below) anywhere in its first HEADER_SCAN_LINES lines --
no need to touch this generator. Takes priority over everything else,
including an entry already in COMMANDS.
Usage: list_bin_commands [bin-dir] [cheatsheet.md path]
Rewrites the content between the BEGIN/END markers in-place; everything
else in the file is left untouched.
"""
import subprocess
import sys
from pathlib import Path
HIDE_MARKER = "cheatsheet: hide"
HEADER_SCAN_LINES = 20
def is_hidden(path):
try:
with path.open(encoding="utf-8", errors="replace") as f:
for _ in range(HEADER_SCAN_LINES):
line = f.readline()
if not line:
break
if HIDE_MARKER in line:
return True
except OSError:
pass
return False
BEGIN_MARKER = "<!-- BEGIN SCRIPT INDEX (generated by list_bin_commands) -->"
END_MARKER = "<!-- END SCRIPT INDEX -->"
# Files that aren't really commands: docs, backups, empty placeholders,
# data files consumed by another script, the cheat sheet doc itself, and
# this generator's own sibling infra (already covered in its own section
# above in cheatsheet.md).
EXCLUDE = {
"cheatsheet.md", "anaconda_env.bak", "bpy-select_bak",
"jungfrau_console_nox~", "me", "mosaic01.vlm",
"bs_pvs_SLAAR21-CVME-LAS6991", "create_personal_dir.md", "gopass.md",
"migrate_to_network_drive.md", "list_bin_commands",
}
# (category, description) per command. Category order below is the
# display order; commands are sorted alphabetically within each.
COMMANDS = {
# --- Cheat sheet & help ---
"cheatsheet": ("Cheat sheet & help", "Show this cheat sheet in a terminal (glow + less, full detail)"),
"cheatsheet_overlay": ("Cheat sheet & help", "Show this cheat sheet as a floating overlay panel (bound to Super+F1)"),
"cheatsheet_to_shortcuts_json": ("Cheat sheet & help", "Regenerate the overlay's JSON from this file's headings/tables"),
"check_kerberos_renewal": ("Cheat sheet & help", "Diagnose why the Kerberos ticket renewer might be renewing/prompting too often"),
"useful_commands": ("Cheat sheet & help", "Show the older, plain-text useful-commands reference"),
# --- EPICS / eco environment ---
"eco": ("EPICS / eco environment", "Start eco, the beamline's Python control environment"),
"eco_bams_bpms": ("EPICS / eco environment", "Start eco configured for BAM/BPM work"),
"eco-dev": ("EPICS / eco environment", "Start eco from the development checkout (symlink into the eco repo)"),
"eco_historic": ("EPICS / eco environment", "Start eco against historic/legacy config"),
"eco-in": ("EPICS / eco environment", "Start eco inside a chosen environment (arg, default bpy-default)"),
"eco_mm_env": ("EPICS / eco environment", "Start eco in a chosen micromamba environment"),
"eco_new": ("EPICS / eco environment", "Start eco using bpy-default.sh directly"),
"eco_noenv": ("EPICS / eco environment", "Start eco's startup.py directly, no environment activation step"),
"eco-notlazy": ("EPICS / eco environment", "Start eco with lazy-loading disabled (default env bernina37)"),
"eco_old": ("EPICS / eco environment", "Start eco against the older sfb312 micromamba environment"),
"eco_roman": ("EPICS / eco environment", "Start eco configured for the bernina_robot setup"),
"eco-status-server": ("EPICS / eco environment", "Start/stop/check the eco status server (namespace mode)"),
"eco-status-server-install-user-service": ("EPICS / eco environment", "Install the eco status server as a systemd --user service"),
"eco-test": ("EPICS / eco environment", "Start eco against the sfb312 test micromamba environment"),
"ecotest": ("EPICS / eco environment", "Start eco in the jungfrau_client conda environment"),
# --- Python, conda & pixi environments ---
"anaconda_bernina_env": ("Python, conda & pixi environments", "Conda init hook for the bernina anaconda env (source this)"),
"anaconda_env": ("Python, conda & pixi environments", "Activate a named anaconda environment"),
"anaconda_manual": ("Python, conda & pixi environments", "Show the anaconda environment manual (rendered markdown)"),
"anaconda_testenv": ("Python, conda & pixi environments", "Activate the anaconda test environment"),
"bipython": ("Python, conda & pixi environments", "ipython from the shared bernina_default anaconda env"),
"bjupyter": ("Python, conda & pixi environments", "jupyter from the shared bernina_default anaconda env"),
"bpy": ("Python, conda & pixi environments", "Activate a named bpy (pixi) environment"),
"bpy-default.sh": ("Python, conda & pixi environments", "Set PATH for the default shared bpy312 pixi environment (source this)"),
"bpy-dev.sh": ("Python, conda & pixi environments", "Set PATH for the bpy312-dev pixi environment (source this)"),
"bpy-env": ("Python, conda & pixi environments", "Activate the Bernina pixi Python env -- local per-host mirror if in sync, else shared (source this)"),
"bpy-select": ("Python, conda & pixi environments", "Interactively pick/switch the active bpy pixi environment"),
"bpython": ("Python, conda & pixi environments", "python from the shared bernina_default anaconda env"),
"micromamba": ("Python, conda & pixi environments", "micromamba package manager"),
"mm_bernina": ("Python, conda & pixi environments", "Micromamba shell-init hook for bernina (source this)"),
"mm_bernina_env": ("Python, conda & pixi environments", "Activate a named micromamba environment"),
"pixi": ("Python, conda & pixi environments", "pixi package manager"),
"pixi_local_sync.sh": ("Python, conda & pixi environments", "Keep the local per-host pixi env mirror in sync (runs in background at login)"),
"pixi_rebuild_local.sh": ("Python, conda & pixi environments", "Rebuild the bernina pixi env in an isolated local sandbox, for testing"),
# --- Jupyter & notebooks ---
"bernina-jupyter": ("Jupyter & notebooks", "Start JupyterLab in the bernina-scripts config dir (bpy env)"),
"ipylab": ("Jupyter & notebooks", "Start an ipylab session in a chosen anaconda environment"),
"jupyterlab-on-ra": ("Jupyter & notebooks", "Open JupyterLab on a remote-access host, for a given user"),
"jupyter-notebook-on-ra": ("Jupyter & notebooks", "Open a Jupyter Notebook on a remote-access host, for a given user"),
# --- Browsers ---
"chrome": ("Browsers", "Open Chrome with a per-host profile"),
"chrome_as": ("Browsers", "Open another user's Chrome profile from your own desktop"),
"chromeAs": ("Browsers", "Open another user's Chrome profile from your own desktop (older variant)"),
"firefox": ("Browsers", "Open Firefox with a per-host profile"),
"firefox_as": ("Browsers", "Open another user's default Firefox profile from your own desktop"),
"firefox-create-hostprofile": ("Browsers", "Create a fresh per-host Firefox profile"),
"firefox_default": ("Browsers", "Default Firefox profile launcher (symlink to firefox_shared_profile)"),
"firefox-hostprofile": ("Browsers", "Open Firefox with the per-host profile (alt entrypoint)"),
"firefox_shared_profile": ("Browsers", "Open Firefox using a shared/synced profile"),
"google-chrome": ("Browsers", "Open Chrome with a per-host profile (alt entrypoint)"),
# --- Displays & control panels ---
"alarms_caqtdm": ("Displays & control panels", "Open the EPICS alarms caQtDM panel"),
"Aramis": ("Displays & control panels", "Open the Aramis overview display (mydm/medm)"),
"aramis-home": ("Displays & control panels", "Open the Aramis photonics overview caQtDM panel"),
"bernina-home": ("Displays & control panels", "Open the esbMaster caQtDM panel"),
"cams_sf": ("Displays & control panels", "Open the SwissFEL cameras caQtDM panel"),
"diffractometer-config": ("Displays & control panels", "Open the diffractometer motor IOC configuration tool"),
"motion-home": ("Displays & control panels", "Open the ZamofingMaster motion caQtDM panel"),
"screen_panel": ("Displays & control panels", "Open the SF controls screen panel"),
"screenpanel": ("Displays & control panels", "Open the screen panel (uses screenpanel-sf if available)"),
"sf_archiver_panel": ("Displays & control panels", "Open the SwissFEL archiver viewer panel"),
"strip_chart": ("Displays & control panels", "Open the PShell strip chart viewer"),
"swissfelStatus": ("Displays & control panels", "Open the SwissFEL OP overview caQtDM panel"),
"thc_angles": ("Displays & control panels", "Open the THC angles bokeh app in Firefox"),
"thc_stripchart": ("Displays & control panels", "Open the THC temperature strip chart"),
"timetool_online_plot": ("Displays & control panels", "Open the timetool online plot (voila notebook)"),
# --- Cameras ---
"bernina-cam-mobile1": ("Cameras", "View the mobile1 camera feed (VLC)"),
"bernina-cam-n": ("Cameras", "View the north camera feed (VLC)"),
"bernina-cam-s": ("Cameras", "View the south camera feed (VLC)"),
"bernina-cams-fix": ("Cameras", "Launch the fixed multi-camera mosaic view (VLC)"),
"bernina-cam-w": ("Cameras", "View the west camera feed (VLC)"),
"bernina-cam-wideangle1": ("Cameras", "View wide-angle camera 1 feed (VLC)"),
"bernina-cam-wideangle2": ("Cameras", "View wide-angle camera 2 feed (VLC)"),
"bernina-cam-wideangle3": ("Cameras", "View wide-angle camera 3 feed (VLC)"),
"bernina-cam-wideangle4": ("Cameras", "View wide-angle camera 4 feed (VLC)"),
"monitoring_cameras": ("Cameras", "Open the monitoring cameras panel (pshell-based)"),
"record_camera": ("Cameras", "Record a camera feed to file"),
# --- FEL & laser control ---
"feloff": ("FEL & laser control", "Stop/block the FEL beam and announce it"),
"felon": ("FEL & laser control", "Open/enable the FEL beam and announce it"),
"laseroff": ("FEL & laser control", "Block the laser shutter and announce it"),
"laseron": ("FEL & laser control", "Open the laser shutter and announce it"),
# --- pshell & robot control ---
"pshell_be": ("pshell & robot control", "Start the PShell Workbench for the main Bernina beamline environment"),
"pshell_correlation": ("pshell & robot control", "Open the correlation panel"),
"pshell_default": ("pshell & robot control", "Default pshell entrypoint (symlink to pshell_be)"),
"robot_server": ("pshell & robot control", "Start the PShell Workbench for the Bernina robot -- needs a new binary, /opt/gfa/pshell is gone (see chat history: /sf/controls/bin/pshell -wb is the likely replacement, blocked on a dataFormat=null config issue)"),
# --- Desktop & window management ---
"code": ("Desktop & window management", "Launch VS Code"),
"darkscreens": ("Desktop & window management", "Blank/turn off the screens (xset dpms)"),
"flameshot": ("Desktop & window management", "Take a screenshot"),
"gnome-terminal_as": ("Desktop & window management", "Open a GNOME Terminal as another user"),
"launcher": ("Desktop & window management", "Main Bernina application launcher"),
"launcher_dev": ("Desktop & window management", "Application launcher (dev/legacy python 3.5 environment)"),
"launcher_lca": ("Desktop & window management", "Open the launcher on the machine/LCA network, for a given user"),
"launcher_legacy": ("Desktop & window management", "Legacy application launcher (python 3.5 environment)"),
"rdpWater": ("Desktop & window management", "RDP into the WaterLab Windows machine"),
"remoteDesktop": ("Desktop & window management", "RDP into an arbitrary PSI Windows host"),
"sf-launcher": ("Desktop & window management", "Open the launcher on the sf network, for a given user"),
"termAs": ("Desktop & window management", "Open a terminal as another user"),
"terminalAs": ("Desktop & window management", "Open a terminal as another user (alt entrypoint)"),
"window_arrange.py": ("Desktop & window management", "Arrange/tile windows helper script"),
"winterm": ("Desktop & window management", "Open a Windows-side terminal session"),
# --- Applications & viewers ---
"hdfview": ("Applications & viewers", "Open HDFView (HDF5 file viewer)"),
"jungfrau_console": ("Applications & viewers", "Open the Jungfrau detector console"),
"ncdu": ("Applications & viewers", "Disk usage analyzer"),
"paraview": ("Applications & viewers", "Open ParaView"),
"pycharm": ("Applications & viewers", "Open PyCharm Community"),
"pylote": ("Applications & viewers", "Open Pylote"),
"vesta": ("Applications & viewers", "Open VESTA (crystal structure viewer)"),
# --- Login session & keybinding setup ---
"create-links-in-home": ("Login session & keybinding setup", "Force-apply PSI's Partially Shared Home symlinks now"),
"gogh": ("Login session & keybinding setup", "Gogh terminal color-scheme installer (legacy -- upstream download is broken, see install_terminal_profiles)"),
"install_gtile": ("Login session & keybinding setup", "Install/update and enable the gTile gnome-shell extension (runs at login)"),
"install_terminal_profiles": ("Login session & keybinding setup", "Install Solarized/Spacedust GNOME Terminal profiles, set Spacedust default (runs at login)"),
"kerberos_ticket_renew.sh": ("Login session & keybinding setup", "Keep the Kerberos ticket cache alive for the login session (runs in background)"),
"migrate_to_network_drive.sh": ("Login session & keybinding setup", "Move a local dotfile onto PSI's Partially Shared Home and register it for sync"),
"set_gnome_keyboard_shortcut": ("Login session & keybinding setup", "Register one GNOME custom keyboard shortcut (used by startup_bernina_terminal)"),
"startup_bernina_terminal": ("Login session & keybinding setup", "Full per-login setup for a Bernina console -- runs automatically at login"),
"sync_personal_config.sh": ("Login session & keybinding setup", "Seed .ipython/.condarc onto PSI's Partially Shared Home for cross-host sync"),
# --- gopass secrets & git ---
"git-push": ("gopass secrets & git", "git push, forcing the origin remote to SSH first"),
"gopass": ("gopass secrets & git", "Bernina gopass secrets store CLI"),
"gopass_bernina_service_setup.sh": ("gopass secrets & git", "One-shot setup: give a service account (gac-bernina) access to the gopass store"),
"gopass_bernina_setup.sh": ("gopass secrets & git", "One-shot onboarding for a human PSI account joining the gopass store"),
"gopass.py": ("gopass secrets & git", "Minimal Python interface to the Bernina gopass store"),
# --- Personal dirs & data ---
"create_data_symlinks": ("Personal dirs & data", "(Re)create the ~/bernina_data convenience symlink"),
"create_personal_dir.sh": ("Personal dirs & data", "Create/verify a personal working directory under /sf/bernina/config/personal/ (your username)"),
"name2pgroups": ("Personal dirs & data", "Look up p-group(s) for a given user name"),
"pgroup2name": ("Personal dirs & data", "Look up the user name for a given p-group"),
"quotas-bernina": ("Personal dirs & data", "Show Bernina's storage quota usage"),
# --- Beamline status & monitoring ---
"chiller_flowrate": ("Beamline status & monitoring", "Query the JF16 chiller flowrate over telnet"),
"feedbacks_bernina": ("Beamline status & monitoring", "Open the undulator/feedbacks control tool, for a given user"),
"get_bernina_PVs_alive": ("Beamline status & monitoring", "Check whether a set of Bernina PVs are alive"),
"go2daq": ("Beamline status & monitoring", "SSH to the SwissFEL DAQ host (sf-daq-3)"),
"ioc-control-bernina": ("Beamline status & monitoring", "Open the IOC process monitor (PBMonitor)"),
"ioc-finder": ("Beamline status & monitoring", "Find/locate an EPICS IOC"),
"is_control": ("Beamline status & monitoring", "Check whether a given hostname is a primary controls terminal"),
"phonebook": ("Beamline status & monitoring", "Show the Bernina phone list"),
"remaining-slurm-job-time": ("Beamline status & monitoring", "Show remaining time for a SLURM job"),
"restart_service": ("Beamline status & monitoring", "Restart a systemd service as the dbe account (sudo)"),
"run-and-position.py": ("Beamline status & monitoring", "Run-and-position helper script"),
"saythis": ("Beamline status & monitoring", "Speak given text aloud"),
"set_bs_pvs": ("Beamline status & monitoring", "Configure the SLAAR21 beam-synchronous PVs"),
"slurm_resources": ("Beamline status & monitoring", "Show SLURM partition resource usage"),
"undulator-pointing": ("Beamline status & monitoring", "Open the undulator pointing control tool, for a given user"),
# --- Desktop entries ---
"bernifox.desktop": ("Desktop entries (.desktop files)", "Bernina-branded Firefox launcher (per-host profile), set as default browser"),
"Bernina.desktop": ("Desktop entries (.desktop files)", "Bernina launcher desktop icon (runs launcher)"),
"kerberos-ticket-renew.desktop": ("Desktop entries (.desktop files)", "Autostart entry for the Kerberos ticket renewer"),
}
CATEGORY_ORDER = [
"Cheat sheet & help",
"EPICS / eco environment",
"Python, conda & pixi environments",
"Jupyter & notebooks",
"Browsers",
"Displays & control panels",
"Cameras",
"FEL & laser control",
"pshell & robot control",
"Desktop & window management",
"Applications & viewers",
"Login session & keybinding setup",
"gopass secrets & git",
"Personal dirs & data",
"Beamline status & monitoring",
"Desktop entries (.desktop files)",
"Uncategorized (new, needs review)",
]
def build_section(bin_dir):
present = {p.name for p in bin_dir.iterdir() if p.is_file() and not p.name.startswith(".")}
by_category = {cat: [] for cat in CATEGORY_ORDER}
for name in sorted(present):
if name in EXCLUDE:
continue
if is_hidden(bin_dir / name):
continue
if name in COMMANDS:
cat, desc = COMMANDS[name]
else:
cat, desc = "Uncategorized (new, needs review)", "(no description yet -- add this to list_bin_commands)"
by_category[cat].append((name, desc))
lines = ["## Scripts in this directory", ""]
lines.append(
"Every script in `/sf/bernina/bin`, grouped by topic. Generated by "
"`list_bin_commands` from the actual directory contents -- re-run it "
"after adding/removing scripts to keep this in sync."
)
lines.append("")
for cat in CATEGORY_ORDER:
entries = by_category[cat]
if not entries:
continue
lines.append(f"### {cat}")
lines.append("")
lines.append("| Command | Description |")
lines.append("|---|---|")
for name, desc in entries:
lines.append(f"| `{name}` | {desc} |")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def main():
bin_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/sf/bernina/bin")
md_path = Path(sys.argv[2]) if len(sys.argv) > 2 else bin_dir / "cheatsheet.md"
section = build_section(bin_dir)
text = md_path.read_text(encoding="utf-8")
if BEGIN_MARKER in text and END_MARKER in text:
before = text.split(BEGIN_MARKER)[0]
after = text.split(END_MARKER)[1]
new_text = f"{before}{BEGIN_MARKER}\n\n{section}\n{END_MARKER}{after}"
else:
sep = "\n" if text.endswith("\n") else "\n\n"
new_text = f"{text}{sep}\n{BEGIN_MARKER}\n\n{section}\n{END_MARKER}\n"
md_path.write_text(new_text, encoding="utf-8")
present = {p.name for p in bin_dir.iterdir() if p.is_file() and not p.name.startswith(".")}
missing = sorted(
n for n in present
if n not in COMMANDS and n not in EXCLUDE and not is_hidden(bin_dir / n)
)
if missing:
print("Uncategorized (added to the doc under 'needs review', but worth "
"adding real descriptions to list_bin_commands):", file=sys.stderr)
for n in missing:
print(f" {n}", file=sys.stderr)
if __name__ == "__main__":
main()
+115 -312
View File
@@ -1,338 +1,159 @@
# Syncing dotfiles/config across hosts with PSI's Partially Shared Home
How `~/network_drives`, `linux_home_links.yaml`, and the two helper
scripts in your home directory (`sync_personal_config.sh`,
`migrate_to_network_drive.sh`) fit together to keep personal config
(`.vimrc`, `.condarc`, SSH keys, ...) the same across every PSI Linux
host you log into.
How `~/network_drives`, `linux_home_links.yaml`, and two helper scripts
(`sync_personal_config.sh`, `migrate_to_network_drive.sh`) keep personal
config (`.vimrc`, `.condarc`, SSH keys, ...) the same across every PSI
Linux host you log into. This is PSI's official
[Partially Shared Home](https://linux.psi.ch/documentation/user-guide/personal_drive/)
feature.
This is PSI's official "Partially Shared Home" feature
(<https://linux.psi.ch/documentation/user-guide/personal_drive/>); this
doc explains the mechanics and the gotchas found while wiring up a shared
gopass secrets store (see the companion
See also: the
**[gopass + gitea guide](https://claude.ai/code/artifact/ee455a99-fc83-44d8-952d-e85f4bcf4cbb)**
for why that one secret store ended up **not** using this mechanism), and
while tracking down why SSH key-based login kept silently falling back to
a password on some hosts (§7).
for why the gopass secrets store deliberately does *not* use this
mechanism (§3 below).
---
## 1. The three pieces
## The pieces
```
~/network_drives/home/ same network location on EVERY host
├── linux_config/ (SMB share, auto-mounted at login)
│ ├── ssh/ <- real files live here
│ ├── vimrc
│ ├── condarc
│ └── ...
└── linux_home_links.yaml <- the manifest: what goes where
│ ├── ssh/, vimrc, condarc, ...
└── linux_home_links.yaml <- manifest: what goes where
~/ (LOCAL home, different content per host, NOT shared)
├── .ssh/ <- real local directory (see §7 - NOT
│ ├── config -> ... a whole-directory symlink anymore)
│ ├── known_hosts -> ...
│ ├── id_ed25519(.pub) -> ...
│ ├── id_rsa(.pub) -> ...
│ └── authorized_keys <- real local file, copied in, NOT linked
├── .vimrc -> network_drives/home/linux_config/vimrc (symlink)
├── .bashrc (real local file,
│ refreshed by copy)
~/ (LOCAL home, different per host, NOT shared)
├── .vimrc -> network_drives/home/linux_config/vimrc (symlink)
├── .bashrc (real local file, refreshed by copy)
└── ...
create-automatic-links-in-home.service (systemd --user, runs at login/
ssh/`become`) reads linux_home_links.yaml and makes the local side match it.
create-automatic-links-in-home.service (systemd --user) reads the
manifest and makes the local side match it, at login/ssh/`become`.
```
1. **`~/network_drives`** is your personal PSI network drive (the "U:"
drive on Windows), auto-mounted via SMB at the same path on every
Linux client. Nothing needs to be copied between hosts for anything
that lives here — it's already the same storage everywhere.
2. **Your local `$HOME`** is per-host. Files here do **not** follow you
between machines unless something explicitly puts them there.
3. **`linux_home_links.yaml`** (at `~/network_drives/home/linux_home_links.yaml`)
is the manifest bridging the two: it says which local-home paths should
be a symlink into `network_drives`, or should be refreshed by copying
from it.
4. **`create-automatic-links-in-home.service`** (a systemd `--user`
service backed by `/opt/pli/bin/create-automatic-links-in-home`, a PSI-
provided Python script) reads that manifest and creates the symlinks /
performs the copies. It runs automatically on login, `ssh`, and
`become`; you can also force it:
```bash
systemctl --user restart create-automatic-links-in-home.service
```
**Caveat (see §7):** "runs on ssh" means it runs as part of PAM
*session* setup for that login — which happens only *after* sshd has
already decided whether pubkey authentication succeeded. For anything
sshd itself needs to read during authentication (`authorized_keys`,
above all), this service is structurally too late to help.
- **`~/network_drives`**: your personal PSI network drive (SMB), same
path on every Linux client — nothing to sync for what lives here.
- **Local `$HOME`**: per-host. Nothing follows you between machines
unless something explicitly puts it there.
- **`linux_home_links.yaml`**: the manifest bridging the two.
- **The service** (`/opt/pli/bin/create-automatic-links-in-home`) applies
it. Force it manually: `systemctl --user restart create-automatic-links-in-home.service`.
**Caveat:** it runs via PAM *session* setup, which happens *after*
`sshd` has already decided on pubkey auth — so it's structurally too
late for anything `sshd` itself needs during authentication, notably
`authorized_keys` (see the `.ssh` gotcha below).
## 2. `link:` vs `copy:` vs `download:`
`linux_home_links.yaml` has up to three top-level sections:
## `link:` / `copy:` / `download:`
```yaml
link:
'.ssh':
to: 'network_drives/home/linux_config/ssh'
'.vimrc':
to: 'network_drives/home/linux_config/vimrc'
copy:
'.bashrc':
from: 'network_drives/home/linux_config/bashrc'
download:
'.config/tool.config':
from: 'https://token@gitea.psi.ch/you/linux_config/tool.config'
```
- **`link:`** — the local path becomes an actual symlink pointing into
`network_drives`. The real file lives only on the network drive; the
local path is just a pointer. Use this for anything you might edit
locally (SSH keys, `.vimrc`, `.gitconfig`) — edits go straight to the
shared copy.
- **`copy:`** — the local path is overwritten with a fresh copy from
`network_drives` **every time the service runs** (login/ssh/become, or
a manual restart). This is one-directional: network → local. **Edit the
network_drives copy, not the local file** — local edits get silently
clobbered on the next apply. This exists for files read before the
network mount is ready (e.g. `.bashrc` at shell startup, or
`authorized_keys` at SSH auth time — see §7).
- **`download:`** — like `copy:`, but the source is an HTTP(S) URL
instead of a network-drive path (e.g. pulling a token-authenticated
config from a private git host).
- **`link:`** — local path becomes a symlink into `network_drives`. Edits
go straight to the shared copy.
- **`copy:`** — local path is overwritten from `network_drives` **every
time the service runs** (one-directional). Edit the network-drive
source, not the local file, or your edit gets clobbered on next apply.
Used for anything needed before the mount is ready (`.bashrc` at shell
startup, `authorized_keys` at SSH auth time).
- **`download:`** — like `copy:`, from an HTTP(S) URL instead.
## 3. The nesting-depth gotcha (read this before adding anything nested)
## Nesting-depth bug
This is a real, confirmed bug/limitation in the linking tool, not a
hypothetical. The tool takes the `to:` value and creates the symlink with
that exact string as the target — with **no adjustment for how deep the
local path is**:
The tool creates each symlink using the literal `to:` string, with **no
adjustment for how deep the local path is** — a relative target resolves
relative to the symlink's *own* directory, not `$HOME`. So it only works
out of the box for paths directly under `$HOME` (one segment). A nested
path (e.g. `.local/share/gnome-shell/extensions`, 3 segments) needs that
many `../` prepended to `to:`:
```python
# from /opt/pli/bin/create-automatic-links-in-home
target = Path(link_config["to"])
link_path.symlink_to(target)
```
A relative symlink target is resolved relative to the symlink's *own*
directory, not `$HOME`. So this works correctly only when the local path
is directly under `$HOME` (one path segment) — every currently-working
example in this account's config (`.gitconfig`, `.vimrc`, `.condarc`,
`.scilog_psi`) is exactly that.
**Proof it breaks for nested paths:** this account's own
`linux_home_links.yaml` has
```yaml
'.local/share/gnome-shell/extensions':
to: 'network_drives/home/gnome-ext'
```
which is 3 segments deep (`.local/share/gnome-shell/` is the symlink's
parent), so the tool creates `.local/share/gnome-shell/extensions ->
network_drives/home/gnome-ext` — which resolves to
`~/.local/share/gnome-shell/network_drives/home/gnome-ext`, a path that
doesn't exist. **This symlink is dangling right now.** Confirmed with:
```bash
$ ls ~/.local/share/gnome-shell/extensions
ls: cannot access '...': No such file or directory
```
**The fix** for a nested path is to prefix `to:` with one `../` per path
segment below `$HOME`. For the example above (3 segments:
`.local`, `share`, `gnome-shell`), that's:
```yaml
'.local/share/gnome-shell/extensions':
to: '../../../network_drives/home/gnome-ext'
```
The `.ssh/*` entries in §7 are a live example of this at depth 1 (one
`../`). `migrate_to_network_drive.sh` (§5) computes this automatically —
it's the reason to prefer the script over hand-editing the yaml for
anything not directly under `$HOME`.
## 4. Filesystem limitation: no git repositories on `network_drives`
`migrate_to_network_drive.sh` computes this automatically — prefer it
over hand-editing the yaml for anything not directly under `$HOME`.
Confirmed by hand while setting up a team gopass store (see the gopass
guide's "Limitations & workarounds" section for the full story):
`network_drives` is an SMB mount (via `gvfs`) that does **not** support
the atomic rename / `mkstemp`-then-rename writes git needs for its object
store, and doesn't support setting file permissions either:
## No git repos on `network_drives` (SMB) — use `/sf/bernina` (NFS) instead
`network_drives` is an SMB/`gvfs` mount that doesn't support the atomic
rename/`mkstemp` writes git needs, or plain permission-preservation:
```
rsync: [receiver] mkstemp "...git/objects/.../abc123.XXXXXX" failed: Operation not supported (95)
cp: preserving permissions for '...': Operation not supported
```
Plain files copy there fine (that's what `link:`/`copy:` are for) — it's
specifically git's internals, and permission-preservation, that fail.
**Don't put a live git working tree here**, directly or via a `link:`
symlink pointing at one. That includes: the gopass store, any other git
clone, or generally anything that needs real POSIX file-locking/atomic-
write semantics (databases, etc.). Those should stay in local home and
sync via their own network protocol (git+a remote, in gopass's case —
see the [gopass + gitea guide](https://claude.ai/code/artifact/ee455a99-fc83-44d8-952d-e85f4bcf4cbb)).
Plain files are fine there (that's what `link:`/`copy:` are for) — just
never `git init`/`clone`/`config` (or `gpg --gen-key`) directly against a
path under `network_drives`; those all fail the same way, trying to
`chmod` an internal lock file. `/sf/bernina` (NFS4) doesn't have this
problem — `git init`/`add`/`commit` work cleanly on it. Rule of thumb:
check the mount type (`df -T <path>`) — `nfs`/`nfs4` is generally fine,
`cifs`/`smb`/`gvfs` is not.
**Not every network mount has this problem, though.** `~/network_drives`
specifically is SMB; `/sf/bernina` (Bernina beamline group storage,
`lx-bl-fs.psi.ch:/export/bernina`, NFS4) is a different, real POSIX
filesystem, and `git init`/`add`/`commit` and the `mkstemp`+rename pattern
both work on it cleanly — tested the same way as the failure above. The
gopass guide (§10.2) uses exactly this: one shared clone at
`/sf/bernina/code/<you>/gopass-bernina`, visible identically on every
Bernina host with no `linux_home_links.yaml` entry needed at all, since
`/sf/bernina` is already mounted at the same path everywhere. The
takeaway isn't "no network filesystem can hold a git repo" — it's "check
what kind of mount it actually is" (`df -T <path>` — `nfs`/`nfs4` is
generally fine, `cifs`/`smb`/a `gvfs` FUSE mount usually is not).
That said, an *already-initialized* repo (or GPG keyring), copied on
afterward with `rsync -rlt` (no `-p`/`-a`), keeps working normally from
`network_drives` — `add`/`commit`/`push`/`pull` and `gpg` encrypt/decrypt
all confirmed. `gopass.md` ("Store location") uses exactly this
init-locally-then-copy workaround as the *preferred* location for a
human's gopass setup, precisely because `network_drives` is already the
same mount on every host. Do this by hand only if you understand the
gotcha above; otherwise use the gopass scripts, which handle it for you.
## 5. The two helper scripts
## The scripts
### `~/sync_personal_config.sh` (pre-existing)
**`~/sync_personal_config.sh`** (pre-existing) — seeds two hardcoded
items (`.ipython`, `.condarc`). Never edits an existing
`linux_home_links.yaml`, only prints the snippet to add by hand.
Seeds two hardcoded items (`.ipython`, `.condarc`) onto the network
drive. Deliberately conservative: if `linux_home_links.yaml` already
exists, it **never edits it** — it only prints the snippet you'd need to
add, to avoid clobbering your comments or other entries. Good for a quick
look at how the seeding step works; limited to those two items.
### `/sf/bernina/bin/migrate_to_network_drive.sh` (general-purpose)
Handles any local file or directory, for either `link:` or `copy:`, and
optionally pulls the source from another host first:
**`/sf/bernina/bin/migrate_to_network_drive.sh`** (general-purpose) —
handles any file/directory, `link:` or `copy:`:
```bash
migrate_to_network_drive.sh <path-relative-to-HOME> [link|copy] [dest-name] [from-host]
# e.g.
migrate_to_network_drive.sh .npmrc
migrate_to_network_drive.sh .config/htop copy htop-config
migrate_to_network_drive.sh .npmrc link "" saresb-cons-07
migrate_to_network_drive.sh .npmrc link "" saresb-cons-07 # pull from another host first
```
What it does, in order:
1. **Optionally fetches the source via `scp` from another host first**
(§5.1 below) — either because you asked explicitly (`[from-host]`), or
because it offers to when the local path doesn't exist on this host at
all.
2. **Refuses outright** if the (local or freshly-fetched) source contains
a `.git` directory (§4).
3. **Skips the seed-copy step entirely if local and network are already
identical** (added while wiring up §7's `.ssh/*` split — re-running
this to register one more already-symlinked file, or a file whose
content already matches, just registers the yaml entry without
prompting to overwrite anything).
4. Otherwise, if something already exists at the destination on
`network_drives`, shows a diff and asks before overwriting anything.
5. Copies the content over (without trying to preserve permissions —
see §4's second `Operation not supported` case).
6. For `link:` mode: after you confirm (skipped if `$HOME` path is
already a symlink resolving to the right place), removes the local
copy and replaces it with a symlink — with the `../` depth computed
correctly for wherever the path sits (§3), then verifies the symlink
actually resolves before touching the yaml.
7. If `linux_home_links.yaml` already has a `link:`/`copy:` section,
inserts the new entry into it directly (after showing a diff and a
timestamped backup path, and asking for confirmation) rather than just
printing a snippet.
8. Offers to apply immediately via
`systemctl --user restart create-automatic-links-in-home.service`.
It refuses anything containing a `.git` directory, diffs before
overwriting anything already on the network drive, computes the correct
`../` depth, and inserts the yaml entry itself (with a backup and a
confirmation prompt) rather than just printing a snippet. `[from-host]`
lets it `scp` the source from another host first — explicitly, or
offered automatically when the local path doesn't exist yet (defaults to
`saresb-vcons-01`).
**Note on `dest-name` for a file that already lives inside a shared
subdirectory** (e.g. adding another file under the existing
`linux_config/ssh/` alongside `config`/`known_hosts`/the key files from
§7): the script's *default* `dest-name` flattens `/` to `-`
(`.ssh/foo` → `ssh-foo`), which does **not** match paths already nested
under `linux_config/ssh/`. Pass the real relative path explicitly, e.g.:
For a file that belongs inside an existing shared subdirectory (e.g.
another key under `linux_config/ssh/`), pass the real relative path as
`dest-name` explicitly — the script's automatic flattening (`.ssh/foo` →
`ssh-foo`) won't match a path that should nest under `ssh/`:
```bash
migrate_to_network_drive.sh .ssh/some_new_key link ssh/some_new_key
```
Tested end-to-end: a flat `copy:` entry, a nested `link:` entry (verified
the computed `../` prefix actually resolves, unlike the pre-existing
gnome-ext one), the git-repo refusal, the scp fetch (§5.1), and the
already-in-sync skip added for §7.
## The `.ssh` gotcha: a whole-directory `link:` breaks SSH key login
#### 5.1 Fetching from another host
**Symptom:** your key is genuinely in the shared `authorized_keys`, but a
fresh `ssh` to some host still falls back to a password — inconsistently.
Consolidating several hosts' independently-drifted copies of the same
dotfile onto one canonical, network-synced version is exactly what
`sync_personal_config.sh`'s own header comment describes doing by hand
("Run this on host_old ... Run on host_new to pick up"). The script
automates the "pull from host_old" half:
**Cause:** if `.ssh` is linked as a single whole-directory entry, it
resolves through the same GVFS/SMB mount as `network_drives` generally —
which only exists while a graphical session for your uid is already
active on that host. `sshd` reads `authorized_keys` during authentication
itself, *before* the linking service has run (see the caveat above), so
a fresh connection only succeeds if some earlier session happened to
leave the mount warm.
- **`[from-host]` given explicitly** (4th argument): always fetches a
fresh copy via `scp host:<path>` (same relative-to-`$HOME` path on the
remote side) and, if a local copy also exists, shows a diff and asks
before replacing it.
- **`[from-host]` omitted, local path missing:** offers the fetch
interactively, defaulting the hostname to `saresb-vcons-01` (just hit
enter to accept, or type a different host).
Either way, PSI accounts are the same username on every host, so a plain
`host:path` works — no `user@` needed. The fetch requires that host to be
SSH-reachable from wherever you're running the script; `saresb-vcons-01`
didn't answer from this particular session's network path when tested
(DNS resolved fine — `129.129.243.41` — the connection itself timed out),
so this was validated against a loopback self-fetch instead (same code
path, `host:path` resolved via SSH to the local machine's own hostname).
Worth a quick manual check the first time you rely on the default host
from wherever you're actually running this.
## 6. Troubleshooting
- **A `link:` entry resolves to "No such file or directory":** almost
always the nesting-depth bug (§3). Check how many `../` the `to:`
value has versus how deep the local path is.
- **Changes to a `copy:` file disappear:** you edited the local copy
instead of the network-drive source. Edit
`~/network_drives/home/linux_config/<name>` instead, then re-apply.
- **Nothing updates after editing `linux_home_links.yaml`:** the service
only runs at login/ssh/become; force it with
`systemctl --user restart create-automatic-links-in-home.service`.
- **`~/network_drives/home` missing entirely:** run `setup-network-drives`
(both helper scripts do this automatically first).
- **Trying to sync a git repo (gopass or otherwise) this way:** don't —
see §4 and the [gopass + gitea guide](https://claude.ai/code/artifact/ee455a99-fc83-44d8-952d-e85f4bcf4cbb).
- **SSH key-based login silently falls back to a password on some
hosts:** see §7 — almost certainly a `link:`-ed `authorized_keys`.
## 7. `.ssh` as a whole-directory `link:` breaks SSH key login (confirmed)
**Symptom:** `ssh-copy-id`/manually-appended keys are genuinely present in
the shared `authorized_keys`, yet a fresh `ssh` to some host still prompts
for a password instead of using the key — inconsistently, seemingly at
random.
**Root cause, confirmed on `saresb-cons-05` (2026-08-27):** the stock
setup has one whole-directory entry,
```yaml
link:
'.ssh':
to: 'network_drives/home/linux_config/ssh'
```
`~/.ssh` is therefore a symlink resolving through a `gvfsd-fuse`/
`gvfsd-smb` mount under `/run/user/<uid>/gvfs/...` — a mount that only
exists while some **graphical desktop session** for that uid is active on
that specific host (confirmed via `findmnt`/`mount`; it's the exact same
mechanism behind `~/network_drives` generally).
`sshd` reads `~/.ssh/authorized_keys` as part of the authentication
handshake itself — **before** any PAM session setup for the incoming
connection runs. `create-automatic-links-in-home.service` is only started
via `pam_systemd`, i.e. as part of that same PAM *session* setup (`WantedBy=
default.target` inside the user's `systemd --user` instance) — confirmed
via `systemctl --user cat`/`status`. So the service is structurally
**always too late** to make a `link:`-ed `.ssh` resolve for pubkey auth on
a genuinely fresh connection; it only ever appeared to work because some
earlier session for the same uid had left the GVFS mount warm already.
This is the identical root-cause class the `.bashrc` `copy:` entry already
exists to work around ("linking does not work as the shell is started
before the mounts are ready") — it just hadn't been extended to
`authorized_keys`, likely because that failure is silent (falls back to
password) rather than a visibly broken shell.
**Fix applied and verified end-to-end:** split the single whole-directory
`link:` entry into per-file entries, with `authorized_keys` specifically
made a `copy:` (real local file, refreshed from the network master, never
a symlink):
**Fix:** split `.ssh` into per-file entries, with `authorized_keys`
specifically as `copy:` (a real local file, never a symlink):
```yaml
link:
@@ -344,47 +165,29 @@ link:
to: '../network_drives/home/linux_config/ssh/id_ed25519'
'.ssh/id_ed25519.pub':
to: '../network_drives/home/linux_config/ssh/id_ed25519.pub'
'.ssh/id_rsa':
to: '../network_drives/home/linux_config/ssh/id_rsa'
'.ssh/id_rsa.pub':
to: '../network_drives/home/linux_config/ssh/id_rsa.pub'
copy:
'.ssh/authorized_keys':
from: 'network_drives/home/linux_config/ssh/authorized_keys'
```
Each `link:` entry needs exactly one `../` (§3's rule: one segment below
`$HOME`, since the symlink now sits at `~/.ssh/<file>` rather than
`~/.ssh` itself).
Each `link:` entry needs exactly one `../` (one segment below `$HOME` now
that the symlink is `~/.ssh/<file>`, not `~/.ssh` itself). Verified fix on
a real host: pubkey auth failed outright before the split, succeeded
immediately after, and survived a service restart with `authorized_keys`
re-copied identically. Apply the same split on any other host that hits
this, or use `migrate_to_network_drive.sh` per file (remember the
explicit `dest-name`, e.g. `ssh/config`).
**Verified, in this order, on `saresb-cons-05`:**
1. Fresh non-multiplexed connection *before* the fix:
`debug1: Offering public key ...` followed by outright rejection — no
`Server accepts key` line, straight to `Permission denied
(publickey,gssapi-keyex,gssapi-with-mic,password)` — even though the
key was already correctly present in `authorized_keys` (confirmed
present, correct owner, correct `700`/`600` permissions the whole
time — this was never a permissions/`StrictModes` problem).
2. Applied the split above, both on disk and in `linux_home_links.yaml`.
3. Fresh non-multiplexed connection *after* the fix:
`Authenticated to saresb-cons-05 (via proxy) using "publickey".`
4. Ran `systemctl --user restart create-automatic-links-in-home.service`
(simulating the service actually firing on a future login) — the
per-file symlinks were left untouched, and `authorized_keys` was
re-copied from the network master with **identical** content
(`md5sum` matched before/after).
5. Re-ran the fresh non-multiplexed connection test once more —
`Authenticated to saresb-cons-05 (via proxy) using "publickey".` again.
## Troubleshooting
**Old backup:** the original whole-directory symlink is preserved at
`~/.ssh.netlink` (not referenced by anything, safe to remove once you've
confirmed things stay stable for a while).
**If you hit this on another host:** either hand-edit that host's
`linux_home_links.yaml` the same way, or use
`migrate_to_network_drive.sh` (§5) once per file — it already computes
the right `../` depth and registers the yaml entry; just remember to pass
an explicit `dest-name` (e.g. `ssh/config`) since the files already live
under `linux_config/ssh/` and the script's automatic flattening wouldn't
match that path by default.
- **A `link:` entry resolves to "No such file or directory"**: the
nesting-depth bug — check the `../` count against the path's depth.
- **Changes to a `copy:` file disappear**: you edited the local copy
instead of `~/network_drives/home/linux_config/<name>`.
- **Nothing updates after editing the yaml**: force
`systemctl --user restart create-automatic-links-in-home.service`.
- **`~/network_drives/home` missing**: run `setup-network-drives`.
- **Trying to sync a git repo this way**: don't — see above.
- **SSH key login silently falls back to a password on some hosts**: the
`.ssh` gotcha above.
+39 -1
View File
@@ -9,6 +9,12 @@ gsettings set org.gnome.settings-daemon.plugins.media-keys email []
gsettings set org.gnome.settings-daemon.plugins.media-keys home []
gsettings set org.gnome.settings-daemon.plugins.media-keys screenshot []
gsettings set org.gnome.settings-daemon.plugins.media-keys help []
# Note: org.gnome.mutter has its own copy of this key that stays at its
# default (false) and looks unmanaged/inconsistent -- it isn't. The
# shell.overrides schema documents itself as overriding the mutter key
# "when running GNOME Shell" (confirmed via `gsettings describe`), which is
# always the case here, so this is the authoritative key and the mutter one
# is inert. No need to set both.
gsettings set org.gnome.shell.overrides workspaces-only-on-primary true
# use Caps Lock as a compose key, so accented/special characters (and
@@ -142,15 +148,30 @@ if gnome-extensions list --enabled 2>/dev/null | grep -qx "Shortcuts@kyle.aims.a
echo "Disabled leftover Shortcuts@kyle.aims.ac.za gnome-shell extension (superseded by cheatsheet_overlay)"
fi
rm -rf "$HOME/.local/share/gnome-shell/extensions/Shortcuts@kyle.aims.ac.za"
# The extension's own settings subtree is a separate relocatable schema and
# survives disabling it or deleting its directory -- reset it too so it
# doesn't linger as orphaned dconf state (found still present on hosts that
# ran the extension long after it was disabled above).
dconf reset -f /org/gnome/shell/extensions/shortcuts/
#custom bernina keyboard shortcuts
gsettings reset org.gnome.settings-daemon.plugins.media-keys custom-keybindings
# Full subtree wipe, not just the list: set_gnome_keyboard_shortcut always
# appends to the next free customN slot and never reuses/cleans up old
# ones, so a `gsettings reset ... custom-keybindings` here only clears which
# paths are active -- the individual customN/{name,command,binding} nodes
# below are a relocatable schema and persist forever once written. Any
# future change to the number/order of the set_gnome_keyboard_shortcut
# calls below would otherwise orphan whatever slots existed under the old
# call count (found on saresb-cons-05: 11 stray custom18-custom28 nodes
# from a since-removed shortcut).
dconf reset -f /org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/
if [ "$(hostname)" = "saresb-cons-03.psi.ch" ] || [ "$(hostname)" = "saresb-cons-04.psi.ch" ] || [ "$(hostname)" = "saresb-cons-05.psi.ch" ] || [ "$(hostname)" = "saresb-cons-08.psi.ch" ] || [ "$(hostname)" = "saresb-cons-09.psi.ch" ] || [ "$(hostname)" = "saresb-cons-10.psi.ch" ]; then
echo "This is a primary controls terminal"
set_gnome_keyboard_shortcut 'stop_fel' 'feloff' 'Calculator'
set_gnome_keyboard_shortcut 'open_fel' 'felon' '<Super>Calculator'
set_gnome_keyboard_shortcut 'stop_laser' 'laseroff' 'Mail'
set_gnome_keyboard_shortcut 'open_laser' 'laseron' '<Super>Mail'
echo "done"
else
echo "no primary controls terminal"
fi
@@ -170,6 +191,16 @@ set_gnome_keyboard_shortcut 'dark screens' 'xset dpms force off' '<Ctrl><Alt>d'
#install/update and enable the gtile extension, then set its keyboard
#shortcuts for easier use of 4K screens
# GNOME Shell's own extensions.gnome.org auto-update check doesn't know or
# care that gTile's UUID is pinned/managed here by install_gtile plus the
# shared /sf/bernina/applications directory -- it silently fetched and
# swapped in a newer upstream build once already (confirmed: v52 replacing
# our pinned v44, with a different settings/keybinding schema, breaking
# every gTile shortcut on saresb-cons-05). allow-extension-installation
# gates that whole background fetch/stage/swap mechanism; there's no
# reason for it to be on when every extension this desktop uses is already
# managed by hand.
gsettings set org.gnome.shell allow-extension-installation false
install_gtile
dconf write /org/gnome/shell/extensions/gtile/resize21 "'3x3 0:2 0:2, 0:1 0:2, 0:2 1:2, 0:1 1:2'"
dconf write /org/gnome/shell/extensions/gtile/resize22 "'3x3 1:2 1:2, 1:1 1:2, 0:2 2:2, 0:1 2:2'"
@@ -180,6 +211,13 @@ dconf write /org/gnome/shell/extensions/gtile/resize26 "'3x3 2:1 2:1, 2:0 2:2, 1
dconf write /org/gnome/shell/extensions/gtile/resize27 "'3x3 0:0 0:0, 0:0 0:1, 0:0 1:0, 0:0 1:1'"
dconf write /org/gnome/shell/extensions/gtile/resize28 "'3x3 1:0 1:0, 1:0 1:1, 0:0 2:0, 0:0 2:1'"
dconf write /org/gnome/shell/extensions/gtile/resize29 "'3x3 2:0 2:0, 2:0 2:1, 1:0 2:0, 1:0 2:1'"
# preset-resize-21 (numpad-1's keybinding for the resize21 preset above)
# had a stray override missing its Shift modifier on saresb-cons-04 (bound
# to plain <Super>KP_1 instead of gTile's own default <Super><Shift>KP_1;
# every other preset-resize-2N sibling was untouched and still works fine)
# -- reset it back to gTile's own default rather than hardcoding a value
# here, so this self-heals regardless of what that default actually is.
gsettings reset org.gnome.shell.extensions.gtile preset-resize-21
# gnome terminal color profiles: install Solarized Dark/Light and Spacedust
# (plus a "1-Spacedust" copy that sorts to the top of the profile menu) if