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:
+155
-112
@@ -73,6 +73,148 @@ PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
# shellcheck source=../lib/extras-catalog
|
||||
source "$PANAMA_PATH/setup/lib/extras-catalog"
|
||||
|
||||
# Which machine this is. A server takes the short path below: core tools,
|
||||
# node, the agents -- no third-party repos, no desktop, no flatpaks.
|
||||
# shellcheck source=../lib/machine-role
|
||||
source "$PANAMA_PATH/setup/lib/machine-role"
|
||||
ROLE="$(panama_role)"
|
||||
|
||||
# One list, installed the way every list is installed: --skip-unavailable so a
|
||||
# single rotted name cannot cost the transaction, then report_missing so a
|
||||
# skipped name is a warning somebody reads.
|
||||
install_list() {
|
||||
local file="$PANAMA_PATH/setup/packages/$1" label="$2" packages
|
||||
if [[ -f "$file" ]]; then
|
||||
packages=$(packages_in "$file")
|
||||
log "Installing $label Packages"
|
||||
echo -e "Includes the following packages:"
|
||||
echo -e "$(<"$file")"
|
||||
sudo dnf install -y --skip-unavailable $packages > /dev/null
|
||||
report_missing "$file"
|
||||
log "$label packages installed!"
|
||||
else
|
||||
log "Package list was not in specified path: $file"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Node and pnpm, through nvm ----------------------------------------------
|
||||
#
|
||||
# nvm is a shell function rather than a binary, so it has to be sourced before
|
||||
# it can be used at all -- and its script reads variables that `set -u` above
|
||||
# treats as fatal, so the strictness is lifted for exactly that source and put
|
||||
# straight back.
|
||||
#
|
||||
# Deliberately not dnf's nodejs: config/bash/shell switches Node per project
|
||||
# from .nvmrc, and a system Node earlier on PATH would win every switch, leaving
|
||||
# `nvm use` looking like it did nothing.
|
||||
#
|
||||
# pnpm goes inside the nvm-managed Node rather than beside it as its own dnf
|
||||
# package, so it travels with the version it belongs to instead of outliving it.
|
||||
setup_node() {
|
||||
if [[ -s /etc/profile.d/nvm.sh ]]; then
|
||||
log "Installing the latest Node LTS through nvm"
|
||||
set +u
|
||||
# shellcheck source=/dev/null
|
||||
source /etc/profile.d/nvm.sh
|
||||
if nvm install --lts >/dev/null 2>&1; then
|
||||
nvm alias default 'lts/*' >/dev/null 2>&1 || true
|
||||
npm install -g pnpm >/dev/null 2>&1 || { log "pnpm did not install"; softly_failed+=("pnpm"); }
|
||||
log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)"
|
||||
else
|
||||
log "nvm could not install Node; skipping"; softly_failed+=("Node (nvm)")
|
||||
fi
|
||||
set -u
|
||||
else
|
||||
log "nvm is not installed, so Node was not set up"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Applications no repository packages -------------------------------------
|
||||
#
|
||||
# Everything else Panama installs comes from dnf or Flathub. These do not
|
||||
# exist in either, so each is an explicit exception with a reason, and each is
|
||||
# skipped when already present so a re-run costs nothing.
|
||||
#
|
||||
# None of them pins a version. sunhat pinned URLs -- upscayl 2.11.5, LACT 0.5.4,
|
||||
# a fedora-40 RPM -- and every one of them was a 404 within a release cycle. An
|
||||
# installer that resolves "latest" keeps working; one that names a version rots.
|
||||
#
|
||||
# A failure here is logged and stepped over rather than aborting: an
|
||||
# unreachable third-party host should not cost the rest of the run.
|
||||
|
||||
# Bun: the JavaScript runtime and package manager. No RPM, no flatpak.
|
||||
install_bun() {
|
||||
if [[ -x "$HOME/.bun/bin/bun" ]]; then
|
||||
log "Bun already installed at \"$HOME/.bun/bin/bun\""
|
||||
else
|
||||
log "Installing Bun via curl..."
|
||||
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1 || { log "Bun install failed; skipping"; softly_failed+=("Bun"); }
|
||||
fi
|
||||
}
|
||||
|
||||
# Claude Code: Anthropic's CLI. The official installer keeps itself updated
|
||||
# afterwards, so this runs once and then never needs to again.
|
||||
install_claude_code() {
|
||||
if command -v claude >/dev/null 2>&1; then
|
||||
log "Claude Code already installed at \"$(command -v claude)\""
|
||||
else
|
||||
log "Installing Claude Code via the official installer..."
|
||||
curl -fsSL https://claude.ai/install.sh | bash > /dev/null 2>&1 || { log "Claude Code install failed; skipping"; softly_failed+=("Claude Code"); }
|
||||
fi
|
||||
}
|
||||
|
||||
# Codex: OpenAI's CLI. Distributed through npm, which is why this runs after
|
||||
# setup_node -- the nvm-managed Node is the one it should land in.
|
||||
install_codex() {
|
||||
if command -v codex >/dev/null 2>&1; then
|
||||
log "Codex already installed at \"$(command -v codex)\""
|
||||
elif command -v npm >/dev/null 2>&1; then
|
||||
log "Installing Codex via npm..."
|
||||
npm install -g @openai/codex >/dev/null 2>&1 || { log "Codex install failed; skipping"; softly_failed+=("Codex"); }
|
||||
else
|
||||
log "npm is not available, so Codex was not installed"; softly_failed+=("Codex")
|
||||
fi
|
||||
}
|
||||
|
||||
# --- What was stepped over ---------------------------------------------------
|
||||
#
|
||||
# Tolerating a failure is only better than aborting on it if somebody is told.
|
||||
# The whole point of surviving a soft failure is that the rest gets installed
|
||||
# anyway -- but a machine missing something should say so once, here, rather
|
||||
# than be discovered a week later.
|
||||
report_soft_failures() {
|
||||
if (( ${#softly_failed[@]} > 0 )); then
|
||||
log "Installed, but these were stepped over:"
|
||||
printf ' - %s\n' "${softly_failed[@]}"
|
||||
log "None of them stops the machine, but this run is not recorded as"
|
||||
log "complete, so the next 'panama update' tries them again."
|
||||
# A step that did not complete has not happened. Exiting non-zero is what
|
||||
# keeps ./install from stamping the packages hash over the gaps -- stamped,
|
||||
# they would never be retried (the hash-skip would say nothing changed).
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- The server path ---------------------------------------------------------
|
||||
#
|
||||
# Everything a server runs is above this line plus the lists it installs. No
|
||||
# RPM Fusion, no Terra, no COPR, no multimedia, no flatpaks: those exist for a
|
||||
# desktop, and every one of them is a network dependency and a failure mode a
|
||||
# headless machine has no reason to carry.
|
||||
if [[ "$ROLE" == server ]]; then
|
||||
echo -e "\n--- Installing packages (server) ---"
|
||||
log "Updating all packages. This may take a while"
|
||||
sudo dnf update -y --refresh > /dev/null
|
||||
install_list core-packages "Core"
|
||||
install_list server-packages "Server"
|
||||
setup_node
|
||||
install_bun
|
||||
install_claude_code
|
||||
install_codex
|
||||
report_soft_failures
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "\n--- Installing Repositories ---"
|
||||
log "Installing RPM Fusion Free and Nonfree Repositories"
|
||||
sudo dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm > /dev/null
|
||||
@@ -105,37 +247,14 @@ echo -e "\n--- Installing relevant packages ---"
|
||||
log "Updating all packages. This may take a while"
|
||||
sudo dnf update -y --refresh > /dev/null
|
||||
|
||||
# --- Install all initial packages ---
|
||||
PACKAGES_FILE="$PANAMA_PATH/setup/packages/initial-packages"
|
||||
if [[ -f "$PACKAGES_FILE" ]]; then
|
||||
INITIAL_PACKAGES=$(packages_in "$PACKAGES_FILE")
|
||||
log "Installing Initial Packages"
|
||||
echo -e "Includes the following packages:"
|
||||
echo -e "$(<"$PACKAGES_FILE")"
|
||||
# --skip-unavailable: dnf5 refuses a whole transaction over one missing
|
||||
# name, so a single rotted entry in this list used to cost every package
|
||||
# in it -- and the desktop below never installed. The skipped names are
|
||||
# reported afterwards rather than silently dropped.
|
||||
sudo dnf install -y --skip-unavailable $INITIAL_PACKAGES > /dev/null
|
||||
report_missing "$PACKAGES_FILE"
|
||||
log "Initial packages installed!"
|
||||
else
|
||||
log "Package list was not in specified path: $PACKAGES_FILE"
|
||||
fi
|
||||
|
||||
# --- Install Desktop Packages ---
|
||||
DESKTOP_FILE="$PANAMA_PATH/setup/packages/desktop-packages"
|
||||
if [[ -f "$DESKTOP_FILE" ]]; then
|
||||
DESKTOP_PACKAGES=$(packages_in "$DESKTOP_FILE")
|
||||
log "Installing Desktop Packages"
|
||||
echo -e "Includes the following packages:"
|
||||
echo -e "$(<"$DESKTOP_FILE")"
|
||||
sudo dnf install -y --skip-unavailable $DESKTOP_PACKAGES > /dev/null
|
||||
report_missing "$DESKTOP_FILE"
|
||||
log "Desktop packages installed!"
|
||||
else
|
||||
log "Package list was not in specified path: $DESKTOP_FILE"
|
||||
fi
|
||||
# --- Install the shared core, then the desktop-only lists ---
|
||||
# --skip-unavailable throughout (inside install_list): dnf5 refuses a whole
|
||||
# transaction over one missing name, so a single rotted entry used to cost
|
||||
# every package in a list -- and the desktop below never installed. The
|
||||
# skipped names are reported afterwards rather than silently dropped.
|
||||
install_list core-packages "Core"
|
||||
install_list initial-packages "Initial"
|
||||
install_list desktop-packages "Desktop"
|
||||
|
||||
# --- Install the Hyprland desktop ---
|
||||
#
|
||||
@@ -212,70 +331,10 @@ else
|
||||
log "Package list was not in specified path: $DEV_FILE"
|
||||
fi
|
||||
|
||||
# --- Node and pnpm, through nvm ----------------------------------------------
|
||||
#
|
||||
# nvm is a shell function rather than a binary, so it has to be sourced before
|
||||
# it can be used at all -- and its script reads variables that `set -u` above
|
||||
# treats as fatal, so the strictness is lifted for exactly that source and put
|
||||
# straight back.
|
||||
#
|
||||
# Deliberately not dnf's nodejs: config/bash/shell switches Node per project
|
||||
# from .nvmrc, and a system Node earlier on PATH would win every switch, leaving
|
||||
# `nvm use` looking like it did nothing.
|
||||
#
|
||||
# pnpm goes inside the nvm-managed Node rather than beside it as its own dnf
|
||||
# package, so it travels with the version it belongs to instead of outliving it.
|
||||
if [[ -s /etc/profile.d/nvm.sh ]]; then
|
||||
log "Installing the latest Node LTS through nvm"
|
||||
set +u
|
||||
# shellcheck source=/dev/null
|
||||
source /etc/profile.d/nvm.sh
|
||||
if nvm install --lts >/dev/null 2>&1; then
|
||||
nvm alias default 'lts/*' >/dev/null 2>&1 || true
|
||||
npm install -g pnpm >/dev/null 2>&1 || { log "pnpm did not install"; softly_failed+=("pnpm"); }
|
||||
log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)"
|
||||
else
|
||||
log "nvm could not install Node; skipping"; softly_failed+=("Node (nvm)")
|
||||
fi
|
||||
set -u
|
||||
else
|
||||
log "nvm is not installed, so Node was not set up"
|
||||
fi
|
||||
|
||||
# --- Applications no repository packages -------------------------------------
|
||||
#
|
||||
# Everything else Panama installs comes from dnf or Flathub. These four do not
|
||||
# exist in either, so each is an explicit exception with a reason, and each is
|
||||
# skipped when already present so a re-run costs nothing.
|
||||
#
|
||||
# Claude Desktop is the half-exception: a repository does carry it, just not one
|
||||
# Fedora or Flathub knows about, so what is exceptional there is adding the
|
||||
# repository rather than installing around one.
|
||||
#
|
||||
# None of them pins a version. sunhat pinned URLs -- upscayl 2.11.5, LACT 0.5.4,
|
||||
# a fedora-40 RPM -- and every one of them was a 404 within a release cycle. An
|
||||
# installer that resolves "latest" keeps working; one that names a version rots.
|
||||
#
|
||||
# A failure here is logged and stepped over rather than aborting: this stage has
|
||||
# already installed the desktop by this point, and an unreachable third-party
|
||||
# host should not cost you that.
|
||||
|
||||
# Bun: the JavaScript runtime and package manager. No RPM, no flatpak.
|
||||
if [[ -x "$HOME/.bun/bin/bun" ]]; then
|
||||
log "Bun already installed at \"$HOME/.bun/bin/bun\""
|
||||
else
|
||||
log "Installing Bun via curl..."
|
||||
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1 || { log "Bun install failed; skipping"; softly_failed+=("Bun"); }
|
||||
fi
|
||||
|
||||
# Claude Code: Anthropic's CLI. The official installer keeps itself updated
|
||||
# afterwards, so this runs once and then never needs to again.
|
||||
if command -v claude >/dev/null 2>&1; then
|
||||
log "Claude Code already installed at \"$(command -v claude)\""
|
||||
else
|
||||
log "Installing Claude Code via the official installer..."
|
||||
curl -fsSL https://claude.ai/install.sh | bash > /dev/null 2>&1 || { log "Claude Code install failed; skipping"; softly_failed+=("Claude Code"); }
|
||||
fi
|
||||
setup_node
|
||||
install_bun
|
||||
install_claude_code
|
||||
install_codex
|
||||
|
||||
# Claude Desktop: Anthropic ships macOS and Windows only, so this is a community
|
||||
# RPM built from the official release. Panama used to build it from source -- it
|
||||
@@ -406,20 +465,4 @@ for extra in ${PANAMA_EXTRAS:-}; do
|
||||
fi
|
||||
done
|
||||
|
||||
# --- What was stepped over ---------------------------------------------------
|
||||
#
|
||||
# Tolerating a failure is only better than aborting on it if somebody is told.
|
||||
# This stage now survives a codec swap that finds nothing to swap, and the whole
|
||||
# point of surviving it is that the desktop gets installed anyway -- but a
|
||||
# machine missing its video codecs should say so once, here, rather than be
|
||||
# discovered a week later by a video that will not play.
|
||||
if (( ${#softly_failed[@]} > 0 )); then
|
||||
log "Installed, but these were stepped over:"
|
||||
printf ' - %s\n' "${softly_failed[@]}"
|
||||
log "None of them stops the desktop, but this run is not recorded as"
|
||||
log "complete, so the next 'panama update' tries them again."
|
||||
# A step that did not complete has not happened. Exiting non-zero is what
|
||||
# keeps ./install from stamping the packages hash over the gaps -- stamped,
|
||||
# they would never be retried (the hash-skip would say nothing changed).
|
||||
exit 1
|
||||
fi
|
||||
report_soft_failures
|
||||
|
||||
+53
-14
@@ -42,6 +42,24 @@ yes_no() { gum confirm --default=false "$1"; }
|
||||
# ── Machine ──────────────────────────────────────────────────────────────────
|
||||
|
||||
heading "This machine"
|
||||
|
||||
# The role decides most of what follows: a server is never asked about NVIDIA
|
||||
# drivers or Steam, and a desktop is never asked about compose services. It is
|
||||
# also the one answer that outlives the run -- install records it durably,
|
||||
# because `panama update` asks nothing and still has to know which machine it
|
||||
# is updating. See setup/lib/machine-role.
|
||||
#
|
||||
# PANAMA_ROLE_PRESET is how `./install --server` answers this without a prompt,
|
||||
# for the curl-onto-a-fresh-VPS path where the caller already said what the
|
||||
# machine is. An empty or escaped choice falls back to desktop, which is what
|
||||
# every Panama machine was before roles existed.
|
||||
role="${PANAMA_ROLE_PRESET:-}"
|
||||
if [[ -z "$role" ]]; then
|
||||
role="$(gum choose --header "What is this machine?" "desktop" "server")" || role=""
|
||||
fi
|
||||
[[ "$role" == server ]] || role=desktop
|
||||
record PANAMA_ROLE "$role"
|
||||
|
||||
current_hostname="$(hostname)"
|
||||
printf 'Current hostname: %s\n' "$current_hostname"
|
||||
new_hostname=""
|
||||
@@ -94,11 +112,23 @@ record PANAMA_SSH_KEY "$ssh_key"
|
||||
# machine rather than an answer to a hypothetical. A machine with no NVIDIA card
|
||||
# is never asked about drivers, and one with nothing to remove is never asked
|
||||
# about removing it.
|
||||
|
||||
heading "Hardware"
|
||||
#
|
||||
# A server is asked none of it. The stages these answers feed --
|
||||
# install-hardware, the debloat removal -- do not run on the server path at
|
||||
# all, and a question whose answer nothing consumes is a control that lies.
|
||||
# The defaults are still recorded so the answers file has the same shape
|
||||
# either way.
|
||||
|
||||
nvidia=no
|
||||
mok_hash=""
|
||||
installed=()
|
||||
firmware=no
|
||||
debloat=no
|
||||
|
||||
if [[ "$role" != server ]]; then
|
||||
|
||||
heading "Hardware"
|
||||
|
||||
nvidia_card="$(lspci 2>/dev/null | grep -iE 'vga compatible|3d controller' | grep -i nvidia | sed 's/.*: //' | head -1)"
|
||||
|
||||
if [[ -n "$nvidia_card" ]]; then
|
||||
@@ -139,13 +169,9 @@ if [[ -n "$nvidia_card" ]]; then
|
||||
else
|
||||
printf 'No NVIDIA card found.\n'
|
||||
fi
|
||||
record PANAMA_NVIDIA "$nvidia"
|
||||
record PANAMA_MOK_HASH "$mok_hash"
|
||||
|
||||
# The stage that removes them owns the list, so there is one copy of it.
|
||||
debloat=no
|
||||
mapfile -t removable < <("$(dirname "${BASH_SOURCE[0]}")/install-hardware" --debloat-list)
|
||||
installed=()
|
||||
for package in "${removable[@]}"; do
|
||||
rpm -q "$package" >/dev/null 2>&1 && installed+=("$package")
|
||||
done
|
||||
@@ -154,14 +180,18 @@ if (( ${#installed[@]} > 0 )); then
|
||||
debloat=yes
|
||||
fi
|
||||
fi
|
||||
record PANAMA_DEBLOAT "$debloat"
|
||||
|
||||
firmware=no
|
||||
if command -v fwupdmgr >/dev/null 2>&1; then
|
||||
if yes_no "Update firmware with fwupdmgr?"; then
|
||||
firmware=yes
|
||||
fi
|
||||
fi
|
||||
|
||||
fi # role != server
|
||||
|
||||
record PANAMA_NVIDIA "$nvidia"
|
||||
record PANAMA_MOK_HASH "$mok_hash"
|
||||
record PANAMA_DEBLOAT "$debloat"
|
||||
record PANAMA_FIRMWARE "$firmware"
|
||||
|
||||
# ── Applications ─────────────────────────────────────────────────────────────
|
||||
@@ -175,10 +205,12 @@ record PANAMA_FIRMWARE "$firmware"
|
||||
# applications nobody chose, on a machine whose owner answered a question they
|
||||
# thought was about something else.
|
||||
|
||||
extras=""
|
||||
if [[ "$role" != server ]]; then
|
||||
|
||||
heading "Applications"
|
||||
|
||||
extras_dir="$(dirname "${BASH_SOURCE[0]}")/../packages/extras"
|
||||
extras=""
|
||||
if [[ -d "$extras_dir" ]]; then
|
||||
mapfile -t categories < <(for file in "$extras_dir"/*; do
|
||||
[[ -f "$file" ]] && basename "$file"
|
||||
@@ -189,6 +221,8 @@ if [[ -d "$extras_dir" ]]; then
|
||||
extras="${extras% }"
|
||||
fi
|
||||
fi
|
||||
|
||||
fi # role != server
|
||||
record PANAMA_EXTRAS "$extras"
|
||||
|
||||
# ── Personal content ─────────────────────────────────────────────────────────
|
||||
@@ -227,17 +261,22 @@ shown() { [[ -n "$1" ]] && printf '%s' "$1" || printf 'unchanged'; }
|
||||
|
||||
heading "Ready"
|
||||
gum style --border rounded --padding "0 1" "$(
|
||||
printf 'Role %s\n' "$role"
|
||||
printf 'Hostname %s\n' "${new_hostname:-"$current_hostname (unchanged)"}"
|
||||
printf 'Git name %s\n' "$(shown "$git_name")"
|
||||
printf 'Git email %s\n' "$(shown "$git_email")"
|
||||
printf 'Git editor %s\n' "$(shown "$git_editor")"
|
||||
printf 'GitHub %s\n' "$([[ "$gh_login" == yes ]] && echo "sign in" || echo "no change")"
|
||||
printf 'SSH key %s\n' "$([[ "$ssh_key" == yes ]] && echo "generate" || echo "no change")"
|
||||
printf 'NVIDIA %s\n' "$([[ "$nvidia" == yes ]] && echo "install driver" || echo "no")"
|
||||
printf 'Secure Boot %s\n' "$([[ -n "$mok_hash" ]] && echo "enroll a key" || echo "no change")"
|
||||
printf 'Fedora apps %s\n' "$([[ "$debloat" == yes ]] && echo "remove ${installed[*]}" || echo "keep")"
|
||||
printf 'Firmware %s\n' "$([[ "$firmware" == yes ]] && echo "update" || echo "no")"
|
||||
printf 'Extras %s\n' "${extras:-none}"
|
||||
# Hardware and extras were never asked on a server, and a summary line for
|
||||
# a question that was not asked reads as a decision that was not made.
|
||||
if [[ "$role" != server ]]; then
|
||||
printf 'NVIDIA %s\n' "$([[ "$nvidia" == yes ]] && echo "install driver" || echo "no")"
|
||||
printf 'Secure Boot %s\n' "$([[ -n "$mok_hash" ]] && echo "enroll a key" || echo "no change")"
|
||||
printf 'Fedora apps %s\n' "$([[ "$debloat" == yes ]] && echo "remove ${installed[*]}" || echo "keep")"
|
||||
printf 'Firmware %s\n' "$([[ "$firmware" == yes ]] && echo "update" || echo "no")"
|
||||
printf 'Extras %s\n' "${extras:-none}"
|
||||
fi
|
||||
printf 'Personal %s' "$([[ "$user_content" == yes ]] && echo "link user/ into home" || echo "not linked")"
|
||||
)"
|
||||
|
||||
|
||||
+70
-42
@@ -20,6 +20,13 @@ CONFIG="$HOME/.config"
|
||||
# every symlink below fail silently while the stage still reported success.
|
||||
mkdir -p "$PANAMA_OLD" "$CONFIG"
|
||||
|
||||
# Which machine this is. A server links the shell environment -- bash, vim,
|
||||
# the universal dirs below, the tmux and btop theming, the hook samples --
|
||||
# and then stops: everything after the early exit assumes a screen.
|
||||
# shellcheck source=../lib/machine-role
|
||||
source "$PANAMA_PATH/setup/lib/machine-role"
|
||||
ROLE="$(panama_role)"
|
||||
|
||||
# --- Bashrc ---
|
||||
echo -e "\n--- Replacing .bashrc ---"
|
||||
# Backup existing .bashrc if it's a regular file
|
||||
@@ -45,8 +52,21 @@ ln -s "$PANAMA_BASH/.bashrc" "$HOME/.bashrc"
|
||||
#
|
||||
# gnome-control-center is a separate matter and stays declared: Panama's own
|
||||
# Settings hands off to it for the panels it deliberately does not own.
|
||||
dirs=("espanso" "ghostty" "gtk-3.0" "gtk-4.0" "hypr" "kitty" "nvim" \
|
||||
"quickshell" "tmux" "uwsm" "vicinae" "wofi" "xdg-desktop-portal")
|
||||
#
|
||||
# Split by role, and every directory under config/dot must be claimed by
|
||||
# exactly one of these three lists -- universal, desktop, or handled (linked
|
||||
# or consumed some other way below: btop exposes only themes, ohmyposh is
|
||||
# read in place by config/bash/shell, panama's hook samples are copied, vim
|
||||
# links a single file). tests/setup/dotfile-classification-contract fails
|
||||
# when a new directory appears in none of them, because unclassified means
|
||||
# silently absent from every server.
|
||||
universal_dirs=("nvim" "tmux")
|
||||
desktop_dirs=("espanso" "ghostty" "gtk-3.0" "gtk-4.0" "hypr" "kitty" \
|
||||
"quickshell" "uwsm" "vicinae" "wofi" "xdg-desktop-portal")
|
||||
handled_dirs=("btop" "ohmyposh" "panama" "vim")
|
||||
|
||||
dirs=("${universal_dirs[@]}")
|
||||
[[ "$ROLE" == server ]] || dirs+=("${desktop_dirs[@]}")
|
||||
|
||||
# --- Vim vimrc ---
|
||||
echo -e "\n--- Setting up vim ---"
|
||||
@@ -107,6 +127,54 @@ elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
|
||||
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
|
||||
fi
|
||||
|
||||
# btop reads themes from its own config directory, but OWNS btop.conf -- it
|
||||
# rewrites that file on exit -- so only the theme files are exposed, per file,
|
||||
# and the config itself is left to btop. panama-theme-apps edits the single
|
||||
# color_theme line in place.
|
||||
BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes"
|
||||
mkdir -p "$BTOP_THEME_DIR"
|
||||
for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do
|
||||
[ -e "$btop_theme_src" ] || continue
|
||||
btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")"
|
||||
if [ -L "$btop_theme_dst" ]; then
|
||||
rm "$btop_theme_dst"
|
||||
fi
|
||||
if [ -e "$btop_theme_dst" ]; then
|
||||
log "Keeping existing btop theme at $btop_theme_dst"
|
||||
else
|
||||
ln -s "$btop_theme_src" "$btop_theme_dst"
|
||||
log "Linked btop theme → $btop_theme_dst"
|
||||
fi
|
||||
done
|
||||
|
||||
# Hook samples. Copied rather than symlinked, and only when absent: hooks are
|
||||
# the user's own scripts, and ~/.config/panama is theirs too -- settings.json
|
||||
# lives there. A symlinked directory would put their scripts in the repository
|
||||
# working tree, which is the mistake the gtk bookmarks made.
|
||||
PANAMA_HOOK_SAMPLES="$PANAMA_DOT/panama/hooks"
|
||||
USER_HOOK_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/panama/hooks"
|
||||
if [ -d "$PANAMA_HOOK_SAMPLES" ]; then
|
||||
mkdir -p "$USER_HOOK_DIR"
|
||||
for sample in "$PANAMA_HOOK_SAMPLES"/*.sample; do
|
||||
[ -e "$sample" ] || continue
|
||||
sample_dst="$USER_HOOK_DIR/$(basename "$sample")"
|
||||
if [ -e "$sample_dst" ]; then
|
||||
log "Keeping existing hook sample at $sample_dst"
|
||||
else
|
||||
cp "$sample" "$sample_dst"
|
||||
log "Copied hook sample → $sample_dst"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# A server's dotfiles end here. Everything below assumes a session: lock
|
||||
# screens, GTK, launcher themes, icons, wallpapers, Firefox chrome, desktop
|
||||
# entries, quadlets for the desktop's own containers, file associations.
|
||||
if [ "$ROLE" = server ]; then
|
||||
log "Server role: desktop dotfiles skipped"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# hyprlock.conf is generated from a template on every color scheme change and
|
||||
# is not committed. Seed it so the FIRST lock of a fresh install is themed --
|
||||
# without it hyprlock falls back to its own defaults, which is a bare gray
|
||||
@@ -139,26 +207,6 @@ elif [ -r "$HYPRLOCK_TEMPLATE" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# btop reads themes from its own config directory, but OWNS btop.conf -- it
|
||||
# rewrites that file on exit -- so only the theme files are exposed, per file,
|
||||
# and the config itself is left to btop. panama-theme-apps edits the single
|
||||
# color_theme line in place.
|
||||
BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes"
|
||||
mkdir -p "$BTOP_THEME_DIR"
|
||||
for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do
|
||||
[ -e "$btop_theme_src" ] || continue
|
||||
btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")"
|
||||
if [ -L "$btop_theme_dst" ]; then
|
||||
rm "$btop_theme_dst"
|
||||
fi
|
||||
if [ -e "$btop_theme_dst" ]; then
|
||||
log "Keeping existing btop theme at $btop_theme_dst"
|
||||
else
|
||||
ln -s "$btop_theme_src" "$btop_theme_dst"
|
||||
log "Linked btop theme → $btop_theme_dst"
|
||||
fi
|
||||
done
|
||||
|
||||
# GTK3 has no include mechanism, so its settings.ini is generated whole from a
|
||||
# template rather than layered. Without this, a fresh checkout has a template
|
||||
# and no settings.ini, and GTK3 applications fall back to their built-in theme.
|
||||
@@ -454,26 +502,6 @@ fi
|
||||
# than by symlinking the directory itself, the same way the quadlets and
|
||||
# desktop entries are: Nautilus writes nothing here today, but a directory
|
||||
# symlink into the repository is how machine state ends up in a tracked path.
|
||||
# Hook samples. Copied rather than symlinked, and only when absent: hooks are
|
||||
# the user's own scripts, and ~/.config/panama is theirs too -- settings.json
|
||||
# lives there. A symlinked directory would put their scripts in the repository
|
||||
# working tree, which is the mistake the gtk bookmarks made.
|
||||
PANAMA_HOOK_SAMPLES="$PANAMA_DOT/panama/hooks"
|
||||
USER_HOOK_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/panama/hooks"
|
||||
if [ -d "$PANAMA_HOOK_SAMPLES" ]; then
|
||||
mkdir -p "$USER_HOOK_DIR"
|
||||
for sample in "$PANAMA_HOOK_SAMPLES"/*.sample; do
|
||||
[ -e "$sample" ] || continue
|
||||
sample_dst="$USER_HOOK_DIR/$(basename "$sample")"
|
||||
if [ -e "$sample_dst" ]; then
|
||||
log "Keeping existing hook sample at $sample_dst"
|
||||
else
|
||||
cp "$sample" "$sample_dst"
|
||||
log "Copied hook sample → $sample_dst"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
PANAMA_NAUTILUS_DIR="$PANAMA_PATH/config/local/share/nautilus-python/extensions"
|
||||
USER_NAUTILUS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/nautilus-python/extensions"
|
||||
if [ -d "$PANAMA_NAUTILUS_DIR" ]; then
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The server counterpart of link-dotfiles, and deliberately thin: ~/Server is
|
||||
# created here, and everything about which services are linked into it is
|
||||
# panama-server's job -- relink refreshes the symlinks of whatever this
|
||||
# machine has enabled, and enabling something new is a decision a person makes
|
||||
# with `panama server enable`, not something an installer infers.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
|
||||
mkdir -p "$HOME/Server"
|
||||
|
||||
exec "$PANAMA_PATH/bin/panama-server" relink
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# What makes a Fedora machine able to run rootless compose services. Server
|
||||
# role only -- ./install never runs this on a desktop. Idempotent throughout:
|
||||
# every step checks the machine before touching it, so a re-run on a machine
|
||||
# that already has all of this changes nothing and says so.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
|
||||
# ── Linger ───────────────────────────────────────────────────────────────────
|
||||
# Without it every user unit -- which is every service -- stops at logout and
|
||||
# starts only at login, which on a server means "runs while somebody is SSHed
|
||||
# in". Linger is what makes the user session a real init.
|
||||
if loginctl show-user "$USER" 2>/dev/null | grep -q '^Linger=yes'; then
|
||||
log "Linger already enabled for $USER"
|
||||
else
|
||||
log "Enabling linger for $USER"
|
||||
sudo loginctl enable-linger "$USER"
|
||||
fi
|
||||
|
||||
# ── Unprivileged ports from 80 ───────────────────────────────────────────────
|
||||
# Rootless containers cannot bind 80/443 while the kernel reserves everything
|
||||
# below 1024 for root. Lowering the floor to 80 is what lets the reverse proxy
|
||||
# be a rootless container like everything else. A file in /etc/sysctl.d so it
|
||||
# survives reboots; sysctl --system so it applies now.
|
||||
SYSCTL_FILE=/etc/sysctl.d/99-rootless-ports.conf
|
||||
SYSCTL_WANT='net.ipv4.ip_unprivileged_port_start=80'
|
||||
if [[ -r "$SYSCTL_FILE" ]] && grep -qx "$SYSCTL_WANT" "$SYSCTL_FILE"; then
|
||||
log "Unprivileged ports already start at 80 ($SYSCTL_FILE)"
|
||||
else
|
||||
log "Allowing unprivileged binds from port 80"
|
||||
printf '%s\n' "$SYSCTL_WANT" | sudo tee "$SYSCTL_FILE" >/dev/null
|
||||
sudo sysctl --system >/dev/null
|
||||
fi
|
||||
|
||||
# ── Firewall ─────────────────────────────────────────────────────────────────
|
||||
# 80 and 443 because everything is reverse-proxied; 81 for the proxy's own
|
||||
# admin portal. Deliberately nothing else: a service needing another port open
|
||||
# documents that in its own folder and it is opened by hand, because a list of
|
||||
# per-service firewall holes maintained by an installer is a list nobody
|
||||
# audits.
|
||||
if systemctl is-active firewalld >/dev/null 2>&1; then
|
||||
reload_needed=0
|
||||
for port in 80 443 81; do
|
||||
if sudo firewall-cmd --permanent --query-port="${port}/tcp" >/dev/null 2>&1; then
|
||||
log "Port ${port}/tcp already open"
|
||||
else
|
||||
log "Opening port ${port}/tcp"
|
||||
sudo firewall-cmd --permanent --add-port="${port}/tcp" >/dev/null
|
||||
reload_needed=1
|
||||
fi
|
||||
done
|
||||
(( reload_needed )) && sudo firewall-cmd --reload >/dev/null
|
||||
else
|
||||
log "firewalld is not active; no ports to open"
|
||||
fi
|
||||
|
||||
# ── The shared container network ─────────────────────────────────────────────
|
||||
# Every compose file expects nginx-bridge as an external network: the reverse
|
||||
# proxy reaches each service by container name across it, and no service needs
|
||||
# a published port of its own. External means compose will not create it, so
|
||||
# somebody has to -- this is that somebody.
|
||||
if podman network exists nginx-bridge 2>/dev/null; then
|
||||
log "podman network nginx-bridge already exists"
|
||||
else
|
||||
log "Creating podman network nginx-bridge"
|
||||
podman network create nginx-bridge >/dev/null
|
||||
fi
|
||||
|
||||
# ── Nightly image updates ────────────────────────────────────────────────────
|
||||
# server/scripts/update-containers, on a midnight timer. Linked rather than
|
||||
# copied so a pull updates the machinery with everything else; see the script
|
||||
# header for why this replaced watchtower.
|
||||
UNIT_SRC="$PANAMA_PATH/server/systemd"
|
||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
mkdir -p "$UNIT_DIR" "$HOME/Server/logs"
|
||||
for unit in podman-update.service podman-update.timer; do
|
||||
src="$UNIT_SRC/$unit"
|
||||
dst="$UNIT_DIR/$unit"
|
||||
[[ -e "$src" ]] || { log "Missing $src; skipping the update timer"; continue; }
|
||||
if [[ -L "$dst" ]]; then
|
||||
rm "$dst"
|
||||
elif [[ -e "$dst" ]]; then
|
||||
mv "$dst" "$dst.pre-panama"
|
||||
log "Kept the existing $unit as $unit.pre-panama"
|
||||
fi
|
||||
ln -s "$src" "$dst"
|
||||
log "Linked $unit"
|
||||
done
|
||||
systemctl --user daemon-reload
|
||||
if systemctl --user is-enabled podman-update.timer >/dev/null 2>&1; then
|
||||
log "podman-update.timer already enabled"
|
||||
else
|
||||
log "Enabling podman-update.timer (nightly image updates)"
|
||||
systemctl --user enable --now podman-update.timer
|
||||
fi
|
||||
Reference in New Issue
Block a user