Panama learns what a server is: from a root login to running containers
A machine's role is now the interview's first question and the one answer Panama records. Servers get the same shell minus the screen: core packages, nvm, Bun, Claude Code and Codex (desktops get Codex too), linger, rootless ports from 80, firewalld, the nginx-bridge network, and a nightly image updater that replaced watchtower for cause. server/containers/ carries junior's 23 compose services -- secrets moved to per-machine .env files that never enter this public repo, every transformed compose proven to render byte-identical to what is live. 'panama server' enables, disables and relinks them; nothing here restarts a running service. 'boot --server' walks a fresh VPS from its root login to a normal install. Five new contracts pin the secrets rule, the catalog's shape, panama-server's behavior, the role plumbing, and the dotfile classification. Claude-Session: https://claude.ai/code/session_01NU5JGiN3JfzqrLQB6wmJ1E
This commit is contained in:
@@ -202,7 +202,9 @@ done
|
||||
# the GNOME excision was caught by nothing until the array entry was removed too,
|
||||
# which was luck rather than a test.
|
||||
|
||||
mapfile -t linked_dirs < <(sed -n '/^dirs=(/,/)/p' "$repo_dir/setup/scripts/link-dotfiles" \
|
||||
# The array is assembled per role from universal_dirs and desktop_dirs, so
|
||||
# those are what get read -- the assembly line itself carries no names.
|
||||
mapfile -t linked_dirs < <(sed -n '/^universal_dirs=(/,/^handled_dirs=(.*)$/p' "$repo_dir/setup/scripts/link-dotfiles" \
|
||||
| grep -oE '"[^"]+"' | tr -d '"')
|
||||
|
||||
(( ${#linked_dirs[@]} > 0 )) \
|
||||
|
||||
@@ -36,7 +36,7 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio
|
||||
# authselect is on the list for the same reason: it manages Fedora's PAM and
|
||||
# nsswitch profiles and arrives with fprintd-pam, realmd and nss-mdns, so the
|
||||
# fingerprint aliases in config/bash can rely on it without declaring it.
|
||||
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig)$'
|
||||
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig|sysctl)$'
|
||||
|
||||
# bootctl and coredumpctl ship in systemd-udev, which every Fedora install
|
||||
# carries -- it is the udev half of systemd, not an optional tool. Declaring
|
||||
@@ -45,11 +45,12 @@ BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|fi
|
||||
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|bootctl|coredumpctl|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
|
||||
|
||||
# Installed by install-packages itself rather than by a package list. Two
|
||||
# reasons, both deliberate: bun and claude have no RPM or flatpak at all, and
|
||||
# node, npm and pnpm come from nvm on purpose -- a dnf nodejs earlier on PATH
|
||||
# would win every per-project `nvm use`, which is the whole point of having nvm.
|
||||
# reasons, both deliberate: bun, claude and codex have no RPM or flatpak at
|
||||
# all (codex comes through npm), and node, npm and pnpm come from nvm on
|
||||
# purpose -- a dnf nodejs earlier on PATH would win every per-project
|
||||
# `nvm use`, which is the whole point of having nvm.
|
||||
# Anything added here needs a matching install block and a stated reason.
|
||||
SELF_INSTALLED='^(bun|claude|node|npm|pnpm)$'
|
||||
SELF_INSTALLED='^(bun|claude|codex|node|npm|pnpm)$'
|
||||
|
||||
# Tools an alias may lean on without Panama installing them anywhere. The
|
||||
# docker aliases serve the machines that run Docker by deliberate choice;
|
||||
@@ -92,6 +93,7 @@ package_for() {
|
||||
nvim) printf 'neovim' ;;
|
||||
fwupdmgr) printf 'fwupd' ;;
|
||||
dnf4) printf 'python3-dnf' ;;
|
||||
firewall-cmd) printf 'firewalld' ;;
|
||||
notify-send) printf 'libnotify' ;;
|
||||
wl-copy|wl-paste) printf 'wl-clipboard' ;;
|
||||
ssh-keygen) printf 'openssh' ;;
|
||||
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This repository is public, and server/ describes real infrastructure. The
|
||||
# rule that makes that safe has three parts, and each is pinned here because
|
||||
# each fails silently:
|
||||
#
|
||||
# 1. No tracked file under server/ carries a secret. Compose files reference
|
||||
# secrets as ${VAR} interpolations resolved from the .env beside them on
|
||||
# the machine; .env.example names the variables with CHANGE_ME in place
|
||||
# of every value that matters.
|
||||
# 2. .gitignore keeps .env and data/ out of server/containers/ even when a
|
||||
# cutover or a mistake puts one there. The live files belong in
|
||||
# ~/Server/<Name>/, outside the checkout entirely -- the ignore is a
|
||||
# seatbelt, and a seatbelt that got deleted should fail loudly.
|
||||
# 3. Nothing named .env is tracked, full stop.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
server_dir="$repo_dir/server"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
[[ -d "$server_dir" ]] || { printf 'compose secrets contract: no server/ directory\n' >&2; exit 1; }
|
||||
|
||||
# ── 1. Tracked content is clean ──────────────────────────────────────────────
|
||||
#
|
||||
# Only tracked files: the live .env a cutover briefly leaves in a service
|
||||
# directory is exactly what the gitignore exists for, and flagging it here
|
||||
# would punish the ignore for working.
|
||||
|
||||
while IFS= read -r file; do
|
||||
path="$repo_dir/$file"
|
||||
[[ -f "$path" ]] || continue
|
||||
|
||||
# A secret-bearing key with a literal value. ${VAR} interpolations, empty
|
||||
# values, the CHANGE_ME placeholder, and booleans (ALLOW_EMPTY_PASSWORD=yes
|
||||
# is a switch, not a credential) are the allowed shapes; anything else
|
||||
# after PASSWORD/SECRET/TOKEN/KEY is treated as a leak. Keys that merely
|
||||
# configure where a secret lives (a *_FILE path, a key NAME) are not
|
||||
# values.
|
||||
while IFS= read -r hit; do
|
||||
note "$file looks like it carries a secret: ${hit%%[=:]*}"
|
||||
done < <(grep -inE '(password|secret|token|api_key|private_key|access_key)[a-z0-9_]*[[:space:]]*[:=]' "$path" 2>/dev/null \
|
||||
| grep -vE '[:=][[:space:]]*["'"'"']?(\$\{|CHANGE_ME|(true|false|yes|no|[01])["'"'"']?[[:space:]]*$|["'"'"']?[[:space:]]*$)' \
|
||||
| grep -viE '(_file|_path|_name|_key_name)[[:space:]]*[:=]' \
|
||||
| grep -vE '^[0-9]+:[[:space:]]*#')
|
||||
|
||||
if grep -qE 'BEGIN [A-Z ]*PRIVATE KEY' "$path" 2>/dev/null; then
|
||||
note "$file contains a private key"
|
||||
fi
|
||||
if grep -qE 'sk-ant-[A-Za-z0-9]|ghp_[A-Za-z0-9]{20}|xox[baprs]-[A-Za-z0-9]' "$path" 2>/dev/null; then
|
||||
note "$file contains something that looks like an API token"
|
||||
fi
|
||||
done < <(git -C "$repo_dir" ls-files 'server/')
|
||||
|
||||
# ── 2. The ignore still stands ───────────────────────────────────────────────
|
||||
#
|
||||
# check-ignore consults the real gitignore for a path that need not exist, so
|
||||
# this asserts the rule rather than the current absence of violations.
|
||||
|
||||
git -C "$repo_dir" check-ignore -q 'server/containers/AnyService/.env' \
|
||||
|| note '.gitignore no longer covers .env under server/containers/'
|
||||
git -C "$repo_dir" check-ignore -q 'server/containers/AnyService/data/dump.sql' \
|
||||
|| note '.gitignore no longer covers data/ under server/containers/'
|
||||
|
||||
# ── 3. No .env is tracked ────────────────────────────────────────────────────
|
||||
|
||||
while IFS= read -r tracked; do
|
||||
note "a live .env is tracked: $tracked"
|
||||
done < <(git -C "$repo_dir" ls-files 'server/**/.env' 'server/.env')
|
||||
|
||||
# ── Report ───────────────────────────────────────────────────────────────────
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
|
||||
printf 'compose secrets contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'compose secrets contract: PASS\n'
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Every service in the catalog has the same shape, because panama-server and
|
||||
# the update timer both depend on it blindly:
|
||||
#
|
||||
# * a compose.yml, which is the only file enable symlinks into ~/Server
|
||||
# * exactly one podman-*.service unit -- its filename is the unit's
|
||||
# identity, so two would be an ambiguity and zero an unenableable service
|
||||
# * a WorkingDirectory of %h/Server/<Name>, matching the directory enable
|
||||
# creates -- a unit pointing anywhere else starts compose against a
|
||||
# directory that has no .env and no data
|
||||
# * every ${VAR} the compose interpolates without a default is named in
|
||||
# .env.example, or the first enable renders a compose full of empty
|
||||
# strings and the service runs misconfigured rather than failing
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
containers_dir="$repo_dir/server/containers"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
[[ -d "$containers_dir" ]] || { printf 'containers shape contract: PASS (no services yet)\n'; exit 0; }
|
||||
|
||||
shopt -s nullglob
|
||||
for dir in "$containers_dir"/*/; do
|
||||
name="$(basename "$dir")"
|
||||
|
||||
[[ -f "$dir/compose.yml" ]] || note "$name has no compose.yml"
|
||||
|
||||
units=("$dir"/*.service)
|
||||
if (( ${#units[@]} == 0 )); then
|
||||
note "$name has no unit file"
|
||||
continue
|
||||
elif (( ${#units[@]} > 1 )); then
|
||||
note "$name has ${#units[@]} unit files; its identity is ambiguous"
|
||||
continue
|
||||
fi
|
||||
unit="${units[0]}"
|
||||
|
||||
case "$(basename "$unit")" in
|
||||
podman-*.service) ;;
|
||||
*) note "$name's unit is not named podman-<name>.service: $(basename "$unit")" ;;
|
||||
esac
|
||||
|
||||
grep -q "^WorkingDirectory=%h/Server/$name\$" "$unit" \
|
||||
|| note "$name's unit does not work in %h/Server/$name"
|
||||
grep -q 'podman compose' "$unit" \
|
||||
|| note "$name's unit does not run podman compose"
|
||||
|
||||
# ${VAR} without a :- default has nowhere to come from but the .env, and
|
||||
# the .env is seeded from .env.example -- so a variable the example does
|
||||
# not name is one the first enable silently renders empty.
|
||||
[[ -f "$dir/compose.yml" ]] || continue
|
||||
while IFS= read -r var; do
|
||||
[[ -n "$var" ]] || continue
|
||||
if [[ ! -f "$dir/.env.example" ]]; then
|
||||
note "$name interpolates \${$var} but has no .env.example"
|
||||
continue
|
||||
fi
|
||||
grep -qE "^${var}=" "$dir/.env.example" \
|
||||
|| note "$name interpolates \${$var}, which .env.example does not name"
|
||||
done < <(grep -vE '^[[:space:]]*#' "$dir/compose.yml" 2>/dev/null \
|
||||
| grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\}' \
|
||||
| sed 's/^\${//; s/}$//' | sort -u)
|
||||
done
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
|
||||
printf 'containers shape contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'containers shape contract: PASS\n'
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# panama-server against a throwaway HOME, a fixture catalog, and a stubbed
|
||||
# systemctl -- the behaviors that protect a machine's live services:
|
||||
#
|
||||
# * enable seeds .env from .env.example and REFUSES to start while a
|
||||
# CHANGE_ME placeholder survives -- a service brought up with the
|
||||
# placeholder as its database password runs, wrongly, forever
|
||||
# * a completed enable links exactly the tracked files (compose.yml and the
|
||||
# unit), reloads systemd, and enables the unit
|
||||
# * disable removes the unit and nothing else: the .env and data stay
|
||||
# * relink refreshes links for enabled services only, and names a service
|
||||
# whose definition changed since the machine last looked -- without
|
||||
# restarting anything
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
panama_server="$repo_dir/bin/panama-server"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
[[ -x "$panama_server" ]] || { printf 'panama-server contract: %s is not executable\n' "$panama_server" >&2; exit 1; }
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
home="$work/home"
|
||||
catalog="$work/catalog"
|
||||
stub_dir="$work/bin"
|
||||
calls="$work/calls"
|
||||
mkdir -p "$home" "$stub_dir" "$catalog/Example"
|
||||
: >"$calls"
|
||||
|
||||
cat >"$catalog/Example/compose.yml" <<'YML'
|
||||
services:
|
||||
example:
|
||||
image: docker.io/library/nginx:latest
|
||||
environment:
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
YML
|
||||
cat >"$catalog/Example/.env.example" <<'ENV'
|
||||
DB_PASSWORD=CHANGE_ME
|
||||
ENV
|
||||
cat >"$catalog/Example/podman-example.service" <<'UNIT'
|
||||
[Unit]
|
||||
Description=Podman Compose: Example
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=%h/Server/Example
|
||||
ExecStart=/usr/bin/podman compose up -d
|
||||
ExecStop=/usr/bin/podman compose down
|
||||
RemainAfterExit=yes
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
UNIT
|
||||
|
||||
# The stub records every invocation; is-active answers 'inactive' so nothing
|
||||
# here depends on a real systemd.
|
||||
cat >"$stub_dir/systemctl" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf 'systemctl %s\n' "\$*" >>"$calls"
|
||||
case "\$*" in
|
||||
*is-active*) echo inactive; exit 3 ;;
|
||||
*is-enabled*) exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
cat >"$stub_dir/podman" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf 'podman %s\n' "\$*" >>"$calls"
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$stub_dir/systemctl" "$stub_dir/podman"
|
||||
|
||||
run() {
|
||||
HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_STATE_HOME="$home/.local/state" \
|
||||
PANAMA_SERVER_DIR="$catalog" PANAMA_SERVER_TARGET="$home/Server" \
|
||||
PATH="$stub_dir:$PATH" "$panama_server" "$@"
|
||||
}
|
||||
|
||||
unit_link="$home/.config/systemd/user/podman-example.service"
|
||||
|
||||
# ── enable stops on placeholders ─────────────────────────────────────────────
|
||||
|
||||
run enable Example >/dev/null 2>&1 \
|
||||
&& note 'enable exited zero with CHANGE_ME still in the .env'
|
||||
|
||||
[[ -f "$home/Server/Example/.env" ]] \
|
||||
|| note 'enable did not seed .env from .env.example'
|
||||
[[ -L "$home/Server/Example/compose.yml" ]] \
|
||||
|| note 'enable did not link compose.yml into ~/Server'
|
||||
[[ -e "$unit_link" ]] \
|
||||
&& note 'enable installed the unit despite the placeholder refusal'
|
||||
grep -q 'systemctl .*enable' "$calls" \
|
||||
&& note 'enable reached systemctl despite the placeholder refusal'
|
||||
|
||||
# ── a filled .env enables ────────────────────────────────────────────────────
|
||||
|
||||
printf 'DB_PASSWORD=s3cret\n' >"$home/Server/Example/.env"
|
||||
: >"$calls"
|
||||
run enable Example >/dev/null 2>&1 \
|
||||
|| note 'enable failed with a completed .env'
|
||||
|
||||
[[ -L "$unit_link" ]] \
|
||||
|| note 'enable did not link the unit into the systemd user directory'
|
||||
grep -q 'systemctl --user daemon-reload' "$calls" \
|
||||
|| note 'enable did not daemon-reload'
|
||||
grep -q 'systemctl --user enable --now podman-example.service' "$calls" \
|
||||
|| note 'enable did not enable --now the unit'
|
||||
[[ "$(cat "$home/Server/Example/.env")" == "DB_PASSWORD=s3cret" ]] \
|
||||
|| note 'enable rewrote an .env that already existed'
|
||||
|
||||
# ── relink names a changed definition, restarts nothing ──────────────────────
|
||||
|
||||
printf ' # a changed line\n' >>"$catalog/Example/compose.yml"
|
||||
: >"$calls"
|
||||
relink_out="$(run relink 2>&1)" || note 'relink failed'
|
||||
grep -q 'Example' <<<"$relink_out" \
|
||||
|| note 'relink did not name the service whose definition changed'
|
||||
grep -q 'restart' "$calls" \
|
||||
&& note 'relink restarted something; the restart is deliberately manual'
|
||||
|
||||
# A second relink with nothing new must not cry wolf.
|
||||
relink_out="$(run relink 2>&1)" || note 'a repeat relink failed'
|
||||
grep -q 'restart podman-example' <<<"$relink_out" \
|
||||
&& note 'relink reports the same change twice'
|
||||
|
||||
# ── disable removes the unit and only the unit ───────────────────────────────
|
||||
|
||||
mkdir -p "$home/Server/Example/data"
|
||||
printf 'precious\n' >"$home/Server/Example/data/keep"
|
||||
: >"$calls"
|
||||
run disable Example >/dev/null 2>&1 || note 'disable failed'
|
||||
|
||||
[[ -e "$unit_link" ]] \
|
||||
&& note 'disable left the unit installed'
|
||||
grep -q 'systemctl --user disable --now podman-example.service' "$calls" \
|
||||
|| note 'disable did not stop the unit'
|
||||
[[ -f "$home/Server/Example/.env" ]] \
|
||||
|| note 'disable removed the .env'
|
||||
[[ -f "$home/Server/Example/data/keep" ]] \
|
||||
|| note 'disable removed data'
|
||||
|
||||
# ── an unknown service is a real answer ──────────────────────────────────────
|
||||
|
||||
run enable NoSuchThing >/dev/null 2>&1 \
|
||||
&& note 'enabling an unknown service exited zero'
|
||||
|
||||
# ── Report ───────────────────────────────────────────────────────────────────
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
|
||||
printf 'panama-server contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'panama-server contract: PASS\n'
|
||||
@@ -34,16 +34,61 @@ hyprland_at="$(line_of '^HYPR_FILE=')"
|
||||
# ── Nothing fragile above the desktop ───────────────────────────────────────
|
||||
#
|
||||
# Named individually rather than by pattern: each is a command whose failure is
|
||||
# survivable, and each one above the Hyprland block is a machine that boots to
|
||||
# nothing.
|
||||
# survivable, and each one that EXECUTES above the Hyprland block is a machine
|
||||
# that boots to nothing.
|
||||
#
|
||||
# Executes, not appears: the fragile steps live in functions defined near the
|
||||
# top (the server role calls them without ever reaching a desktop section),
|
||||
# and a definition runs nothing. So function bodies are excluded from the
|
||||
# position scan, and the desktop path's calls to those functions are required
|
||||
# to sit below the desktop instead.
|
||||
|
||||
for fragile in 'dnf swap' 'groupupdate' 'group upgrade' 'gstreamer1-plugins' \
|
||||
'flatpak install' 'nvm install' 'curl -fsSL'; do
|
||||
while read -r at; do
|
||||
(( at < hyprland_at )) \
|
||||
&& note "'$fragile' runs at line $at, above the desktop at line $hyprland_at"
|
||||
done < <(grep -n -- "$fragile" "$installer" | grep -v '^\s*#' | cut -d: -f1)
|
||||
done
|
||||
while IFS= read -r finding; do
|
||||
[[ -n "$finding" ]] && note "$finding"
|
||||
done < <(python3 - "$installer" "$hyprland_at" <<'PY'
|
||||
import re, sys
|
||||
|
||||
path, hypr = sys.argv[1], int(sys.argv[2])
|
||||
lines = open(path, encoding="utf-8").read().splitlines()
|
||||
|
||||
in_body = False
|
||||
body = set()
|
||||
for i, line in enumerate(lines, 1):
|
||||
if not in_body and re.match(r'^[a-z_]+\(\)\s*\{', line):
|
||||
in_body = True
|
||||
body.add(i)
|
||||
continue
|
||||
if in_body:
|
||||
body.add(i)
|
||||
if line == '}':
|
||||
in_body = False
|
||||
|
||||
fragile = ['dnf swap', 'groupupdate', 'group upgrade', 'gstreamer1-plugins',
|
||||
'flatpak install', 'nvm install', 'curl -fsSL']
|
||||
for i, line in enumerate(lines, 1):
|
||||
if i >= hypr or i in body or line.strip().startswith('#'):
|
||||
continue
|
||||
for needle in fragile:
|
||||
if needle in line:
|
||||
print(f"'{needle}' runs at line {i}, above the desktop at line {hypr}")
|
||||
|
||||
# The desktop path still has to run the fragile helpers -- below the desktop.
|
||||
# (The server path calls them above, inside a branch that exits before the
|
||||
# desktop section; the exit is asserted back in bash.)
|
||||
for call in ('setup_node', 'install_bun', 'install_claude_code', 'install_codex'):
|
||||
calls = [i for i, line in enumerate(lines, 1)
|
||||
if re.match(r'^\s*' + call + r'\s*$', line) and i not in body]
|
||||
if not calls:
|
||||
print(f"{call} is never called, so the desktop path skips it")
|
||||
elif not any(i > hypr for i in calls):
|
||||
print(f"{call} is only called above the desktop")
|
||||
PY
|
||||
)
|
||||
|
||||
# The server branch is what excuses fragile calls above the desktop, and only
|
||||
# because it never falls through into the desktop section.
|
||||
sed -n '/^if \[\[ "\$ROLE" == server \]\]; then/,/^fi/p' "$installer" | grep -q '^\s*exit 0' \
|
||||
|| note 'the server branch does not exit before the desktop section'
|
||||
|
||||
# ── Everything fragile is actually tolerated ────────────────────────────────
|
||||
#
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Every directory under config/dot is claimed by exactly one of
|
||||
# link-dotfiles' three lists -- universal (both roles), desktop, or handled
|
||||
# (linked or consumed some other way in that script). The failure this
|
||||
# prevents is quiet: a new dot directory added to the repo but classified
|
||||
# nowhere links on no server, or on every server, depending on which list the
|
||||
# author forgot -- and nothing says so until a machine is missing its config.
|
||||
#
|
||||
# The lists are lifted from the script itself rather than restated here, so
|
||||
# renaming them fails loudly instead of freezing a stale copy.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
link_dotfiles="$repo_dir/setup/scripts/link-dotfiles"
|
||||
dot_dir="$repo_dir/config/dot"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
lists="$(sed -n '/^universal_dirs=(/,/^handled_dirs=(.*)$/p' "$link_dotfiles")"
|
||||
if [[ -z "$lists" ]]; then
|
||||
printf 'dotfile classification contract: link-dotfiles no longer defines the three lists\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
eval "$lists"
|
||||
|
||||
claimed=("${universal_dirs[@]}" "${desktop_dirs[@]}" "${handled_dirs[@]}")
|
||||
|
||||
for dir in "$dot_dir"/*/; do
|
||||
name="$(basename "$dir")"
|
||||
hits=0
|
||||
for entry in "${claimed[@]}"; do
|
||||
[[ "$entry" == "$name" ]] && hits=$((hits + 1))
|
||||
done
|
||||
if (( hits == 0 )); then
|
||||
note "config/dot/$name is claimed by no list, so servers silently skip or acquire it"
|
||||
elif (( hits > 1 )); then
|
||||
note "config/dot/$name is claimed by $hits lists"
|
||||
fi
|
||||
done
|
||||
|
||||
for entry in "${claimed[@]}"; do
|
||||
[[ -d "$dot_dir/$entry" ]] \
|
||||
|| note "link-dotfiles classifies '$entry', which config/dot does not contain"
|
||||
done
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
|
||||
printf 'dotfile classification contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'dotfile classification contract: PASS (%d directories classified)\n' "${#claimed[@]}"
|
||||
@@ -35,7 +35,9 @@ consumed="$(grep -rhoE '\$\{PANAMA_[A-Z_]+' "$repo_dir"/setup/scripts/* "$instal
|
||||
# which is the akmods certificate path with an override on it. Nothing sets that
|
||||
# override outside the hardware contract, which needs a certificate it is allowed
|
||||
# to create -- enrolling the real one to find out what happens is not a test.
|
||||
INFRASTRUCTURE='^(PANAMA_PATH|PANAMA_ANSWERS|PANAMA_BASH|PANAMA_DOT|PANAMA_OLD|PANAMA_APPLICATION_DIR|PANAMA_ICON_DIR|PANAMA_UNIT_DIR|PANAMA_CURSOR_DIR|PANAMA_WALLPAPER_DIR|PANAMA_MOK_CERT)$'
|
||||
# PANAMA_ROLE_PRESET is the --server flag on its way INTO the interview, not an
|
||||
# answer out of it -- the answer it produces is PANAMA_ROLE, which is checked.
|
||||
INFRASTRUCTURE='^(PANAMA_PATH|PANAMA_ANSWERS|PANAMA_BASH|PANAMA_DOT|PANAMA_OLD|PANAMA_APPLICATION_DIR|PANAMA_ICON_DIR|PANAMA_UNIT_DIR|PANAMA_CURSOR_DIR|PANAMA_WALLPAPER_DIR|PANAMA_MOK_CERT|PANAMA_ROLE_PRESET)$'
|
||||
|
||||
while read -r key; do
|
||||
[[ -n "$key" ]] || continue
|
||||
@@ -67,7 +69,7 @@ grep -qE 'mktemp' "$install_script" \
|
||||
|| note 'install does not create the answers file with mktemp'
|
||||
|
||||
# Declining must stop the run rather than count as one failed stage among five.
|
||||
grep -qE 'if ! "\$PANAMA_PATH/setup/scripts/interview"; then' "$install_script" \
|
||||
grep -qE 'if ! [A-Z_="$ ]*"\$PANAMA_PATH/setup/scripts/interview"; then' "$install_script" \
|
||||
|| note 'install does not treat a declined interview as fatal'
|
||||
|
||||
# ── 4. A real run, with gum stubbed ──────────────────────────────────────────
|
||||
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The machine role: the one interview answer Panama keeps.
|
||||
#
|
||||
# What is pinned, and why each half matters:
|
||||
#
|
||||
# * The interview asks the role first and a server is never asked the
|
||||
# desktop's questions -- hardware and extras feed stages the server path
|
||||
# does not run, and a question nothing consumes is a control that lies.
|
||||
# * `--server` presets the answer without a prompt, because the fresh-VPS
|
||||
# path runs from a curl with nobody to interview twice.
|
||||
# * setup/lib/machine-role reads env over file over default, and records
|
||||
# only values it can read back -- an unreadable role file must degrade to
|
||||
# desktop, never to an error, because every pre-role machine has none.
|
||||
# * install runs different stages per role, and the server list must never
|
||||
# silently reacquire a desktop stage (or the reverse).
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
interview="$repo_dir/setup/scripts/interview"
|
||||
install_script="$repo_dir/install"
|
||||
machine_role="$repo_dir/setup/lib/machine-role"
|
||||
boot="$repo_dir/boot"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
# ── The helper, sandboxed ────────────────────────────────────────────────────
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
role_of() {
|
||||
env -u PANAMA_ROLE XDG_STATE_HOME="$work/state" ${1:+PANAMA_ROLE="$1"} \
|
||||
bash -c "source '$machine_role'; panama_role"
|
||||
}
|
||||
|
||||
[[ "$(role_of "")" == desktop ]] \
|
||||
|| note 'with no file and no env, the role is not desktop'
|
||||
|
||||
env XDG_STATE_HOME="$work/state" \
|
||||
bash -c "source '$machine_role'; panama_role_record server"
|
||||
[[ "$(role_of "")" == server ]] \
|
||||
|| note 'a recorded server role does not read back'
|
||||
|
||||
[[ "$(role_of desktop)" == desktop ]] \
|
||||
|| note 'an exported PANAMA_ROLE does not win over the recorded file'
|
||||
|
||||
printf 'gibberish\n' >"$work/state/panama/role"
|
||||
[[ "$(role_of "")" == desktop ]] \
|
||||
|| note 'a role file with an unknown value does not degrade to desktop'
|
||||
|
||||
env XDG_STATE_HOME="$work/state" \
|
||||
bash -c "source '$machine_role'; panama_role_record nonsense"
|
||||
[[ "$(role_of "")" == desktop ]] \
|
||||
|| note 'recording an unknown value does not degrade to desktop'
|
||||
|
||||
# ── The interview, per role ──────────────────────────────────────────────────
|
||||
#
|
||||
# The same stub the interview contract stands up, plus `choose` so the role
|
||||
# question is answerable. GUM_STUB_CHOOSE also feeds the extras checklist on
|
||||
# a desktop run, which is why the desktop case leaves it empty.
|
||||
|
||||
stub_dir="$work/bin"
|
||||
mkdir -p "$stub_dir"
|
||||
cat >"$stub_dir/gum" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
case "$1" in
|
||||
input) printf '%s\n' "$GUM_STUB_INPUT" ;;
|
||||
confirm) [[ "$GUM_STUB_CONFIRM" == yes ]] ;;
|
||||
choose) printf '%s\n' "$GUM_STUB_CHOOSE" ;;
|
||||
style) shift; printf '%s\n' "${@: -1}" ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$stub_dir/gum"
|
||||
|
||||
answers="$work/answers"
|
||||
|
||||
ask_interview() {
|
||||
: >"$answers"
|
||||
GUM_STUB_INPUT="x" GUM_STUB_CONFIRM=yes GUM_STUB_CHOOSE="${2:-}" \
|
||||
PANAMA_ANSWERS="$answers" PANAMA_ROLE_PRESET="${1:-}" \
|
||||
PATH="$stub_dir:$PATH" bash "$interview" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
answered() {
|
||||
( # shellcheck source=/dev/null
|
||||
source "$answers"; printf '%s' "${!1:-}" )
|
||||
}
|
||||
|
||||
ask_interview "" server \
|
||||
|| note 'the interview fails when the role question answers server'
|
||||
[[ "$(answered PANAMA_ROLE)" == server ]] \
|
||||
|| note 'choosing server does not record PANAMA_ROLE=server'
|
||||
[[ "$(answered PANAMA_EXTRAS)" == "" ]] \
|
||||
|| note 'a server was asked the extras question'
|
||||
[[ "$(answered PANAMA_NVIDIA)" == no ]] \
|
||||
|| note 'a server run does not record the hardware defaults'
|
||||
|
||||
ask_interview "" "" \
|
||||
|| note 'the interview fails when the role question is escaped'
|
||||
[[ "$(answered PANAMA_ROLE)" == desktop ]] \
|
||||
|| note 'an escaped role question does not default to desktop'
|
||||
|
||||
ask_interview server desktop \
|
||||
|| note 'the interview fails under a --server preset'
|
||||
[[ "$(answered PANAMA_ROLE)" == server ]] \
|
||||
|| note 'PANAMA_ROLE_PRESET=server does not win: the prompt was asked anyway'
|
||||
|
||||
# ── install runs the right stages ────────────────────────────────────────────
|
||||
#
|
||||
# Read from the source rather than run: the stage lists are data, and what
|
||||
# matters is which names each role's list carries.
|
||||
|
||||
# Only the two literal lists: the upgrade filter reassigns STAGES from a
|
||||
# variable, which is not a role's stage list.
|
||||
server_stages="$(sed -n 's/^ STAGES=(\(.*\))$/\1/p' "$install_script" | grep '^install-packages' | head -1)"
|
||||
desktop_stages="$(sed -n 's/^ STAGES=(\(.*\))$/\1/p' "$install_script" | grep '^install-packages' | tail -1)"
|
||||
|
||||
for stage in install-packages link-dotfiles link-user setup-server link-server setup-identity; do
|
||||
grep -qw "$stage" <<<"$server_stages" \
|
||||
|| note "the server stage list is missing $stage"
|
||||
done
|
||||
for stage in install-hardware change-settings link-skills link-vicinae-scripts; do
|
||||
grep -qw "$stage" <<<"$server_stages" \
|
||||
&& note "the server stage list acquired the desktop stage $stage"
|
||||
done
|
||||
for stage in install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware; do
|
||||
grep -qw "$stage" <<<"$desktop_stages" \
|
||||
|| note "the desktop stage list lost $stage"
|
||||
done
|
||||
|
||||
grep -q -- '--server)' "$install_script" \
|
||||
|| note 'install does not accept --server'
|
||||
grep -q 'panama_role_record' "$install_script" \
|
||||
|| note 'install never records the role, so panama update cannot know it'
|
||||
|
||||
# ── boot's front door ────────────────────────────────────────────────────────
|
||||
|
||||
grep -q -- '--server) SERVER=1' "$boot" \
|
||||
|| note 'boot does not accept --server'
|
||||
grep -q 'not root' "$boot" \
|
||||
|| note 'boot no longer refuses a root run without --server'
|
||||
grep -q 'runuser' "$boot" \
|
||||
|| note 'the root bootstrap does not hand off to the created user'
|
||||
|
||||
# ── Report ───────────────────────────────────────────────────────────────────
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
|
||||
printf 'role contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'role contract: PASS\n'
|
||||
@@ -257,14 +257,23 @@ fi
|
||||
|
||||
# ── The stage runs, in the one order that gives personal skills precedence ──
|
||||
|
||||
# Checked against every literal per-role stage list. The server list carries
|
||||
# no link-skills at all (its three skills operate the desktop), so the demand
|
||||
# is: at least one list runs it, and any list that runs it runs it between
|
||||
# link-dotfiles and link-user.
|
||||
python3 - "$installer" <<'PY' || note 'link-skills is not in STAGES between link-dotfiles and link-user'
|
||||
import re, sys
|
||||
line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES="))
|
||||
stages = re.findall(r"[\w-]+", line)
|
||||
for name in ("link-dotfiles", "link-skills", "link-user"):
|
||||
if name not in stages:
|
||||
lines = [l.strip() for l in open(sys.argv[1], encoding="utf-8")
|
||||
if l.strip().startswith("STAGES=(") and "upgrade_stages" not in l]
|
||||
anywhere = False
|
||||
for line in lines:
|
||||
stages = re.findall(r"[\w-]+", line)
|
||||
if "link-skills" not in stages:
|
||||
continue
|
||||
anywhere = True
|
||||
if not stages.index("link-dotfiles") < stages.index("link-skills") < stages.index("link-user"):
|
||||
raise SystemExit(1)
|
||||
if not stages.index("link-dotfiles") < stages.index("link-skills") < stages.index("link-user"):
|
||||
if not anywhere:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
|
||||
@@ -166,13 +166,20 @@ grep -q 'PANAMA_USER_CONTENT' "$interview" \
|
||||
|| note 'the interview never asks about personal content'
|
||||
|
||||
# Order matters: link-user must land the tracked espanso identity before
|
||||
# setup-identity would seed one from the interview answers.
|
||||
# setup-identity would seed one from the interview answers. Checked in every
|
||||
# role's literal stage list (the upgrade filter reassigns from a variable and
|
||||
# is not a list).
|
||||
python3 - "$installer" <<'PY' || note 'link-user does not run before setup-identity'
|
||||
import re, sys
|
||||
line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES="))
|
||||
stages = re.findall(r"[\w-]+", line)
|
||||
if stages.index("link-user") > stages.index("setup-identity"):
|
||||
lines = [l.strip() for l in open(sys.argv[1], encoding="utf-8")
|
||||
if l.strip().startswith("STAGES=(") and "upgrade_stages" not in l]
|
||||
if not lines:
|
||||
raise SystemExit(1)
|
||||
for line in lines:
|
||||
stages = re.findall(r"[\w-]+", line)
|
||||
if "link-user" in stages and "setup-identity" in stages:
|
||||
if stages.index("link-user") > stages.index("setup-identity"):
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
|
||||
Reference in New Issue
Block a user