Files
ServerKit/scripts/lib/pkg.sh
T
Juan DenisandClaude Opus 4.8 ff35375b50 feat(install): Phase 3 — multi-distro abstraction, init/env detection, templated unit
Hardening plan §6. Reduces the distro-specific if/else surface and handles
edge environments:

- Dependency manifest (scripts/deps/manifest.yaml): declarative per-family
  package/python/node/docker/init spec. Consumed directly by the Phase 5
  Python bootstrap; install.sh mirrors it inline (it can't parse YAML before
  Python exists in the curl|bash path).

- New sourceable libs (built in parallel, each shellcheck-clean + unit-tested):
  - scripts/lib/pkg.sh  — pkg_detect/refresh/install/is_installed across
    apt/dnf/yum/zypper/pacman/apk (PKG_MGR_OVERRIDE + PKG_DRY_RUN for tests).
  - scripts/lib/init.sh — init_detect + start/stop/enable/disable/reload/
    is_active across systemd/openrc/runit/sysvinit/none (INIT_OVERRIDE/DRY_RUN).
  - scripts/lib/env.sh  — is_container/is_wsl/has_systemd (override-friendly).

- ID_LIKE handling (G5/6.5): install.sh's os_family_from() (now a pure,
  testable function reading ${SERVERKIT_OS_RELEASE}) maps unknown IDs via
  ID_LIKE to debian/rhel/fedora/suse/arch/alpine — rhel before fedora so RHEL
  clones pick the RHEL Docker repo.

- zypper + pacman + apk (G6/6.6): choose_pkg_manager/refresh_pkg_index/pkg_add,
  provision_docker and provision_python gained suse/arch/alpine branches;
  Docker enable/start is now init-aware (systemd OR OpenRC) so it no longer
  aborts on Alpine.

- Templated systemd unit (6.7): serverkit-backend.service → templates/
  serverkit-backend.service.in, rendered (render_service_unit) at install and
  in refresh_config so a custom SERVERKIT_DIR/venv/log path is honored.
  install_service warns-and-skips enable when has_systemd is false (G5).

- nginx TLS floor as a reversible conf.d snippet (6.8): install.sh + update.sh
  prefer /etc/nginx/conf.d/serverkit-tls.conf when nginx.conf doesn't already
  declare ssl_protocols (RHEL/Fedora/SUSE), falling back to the in-place edit
  on Debian/Ubuntu to avoid a 'duplicate ssl_protocols' error. uninstall
  removes only that snippet.

Tests: new scripts/test/test_lib.sh (13) + 4 new test_install.sh cases
(os_family_from, render_service_unit, harden_global_tls A/B). 13+15+10 pass;
shellcheck error+warning clean across all new/changed shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:46:38 -04:00

163 lines
6.5 KiB
Bash

# shellcheck shell=bash
#
# scripts/lib/pkg.sh — sourceable cross-distro package-management abstraction.
#
# The ServerKit installer/updater needs to refresh the package index, install a
# handful of OS packages, and check whether something is already present — and it
# has to do this identically on Debian/Ubuntu (apt), RHEL/Rocky/Alma/Fedora
# (dnf, or yum on older boxes), openSUSE (zypper), Arch (pacman) and Alpine
# (apk). Sprinkling per-distro `if command -v apt` branches through every script
# rots fast and is impossible to unit-test, so we centralise the dispatch here.
#
# This bash helper mirrors the semantics of the Python PackageManager class in
# backend/app/utils/system.py (same detect → install/is_installed contract,
# same sudo-only-when-needed rule) so the host and the running panel agree on
# what "installed" means. It covers the three managers the Python side knows
# (apt/dnf/yum) plus the three install-time-only ones (zypper/pacman/apk) that
# the shell installer can hit before the panel is even up.
#
# Contract (these names/behaviours are depended on by callers and unit tests —
# do not rename them or add extra public functions):
# pkg_detect → echoes: apt|dnf|yum|zypper|pacman|apk|"" (none)
# pkg_refresh → refresh the package index (best-effort)
# pkg_install <pkg> [pkg...] → install packages non-interactively
# pkg_is_installed <pkg> → return 0 if installed, 1 otherwise
#
# Honors:
# PKG_DRY_RUN=1 print intended commands instead of running them
# PKG_MGR_OVERRIDE=... force the detected manager (used by unit tests)
#
# Everything mutating is best-effort and privilege-aware: a refresh/install we
# can't run cleanly degrades to a non-zero return rather than aborting the
# caller, and sudo is prepended only when we are not already root.
# Prepend sudo for a mutating command when we are not root AND sudo exists;
# otherwise run the command as-is. Mirrors _needs_sudo() in system.py — minimal
# containers run as root without sudo installed, and Windows dev boxes have no
# sudo at all, so blindly prefixing it would break those.
_pkg_sudo() {
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
sudo "$@"
return $?
fi
"$@"
}
# Run a (privileged) mutating package command, or just print it under dry-run.
# Kept tiny so the dry-run output is exactly the command that would otherwise
# execute — which is what the installer unit tests assert against.
_pkg_run() {
if [ "${PKG_DRY_RUN:-0}" = "1" ]; then
printf ' [pkg] would run: %s\n' "$*"
return 0
fi
# Discard the tool's own chatter (progress bars, "Nothing to do" noise) here
# so callers don't redirect — that redirection would also swallow the
# dry-run line above, which the installer unit tests assert on.
_pkg_sudo "$@" >/dev/null 2>&1
}
# Detect the active package manager. Order matters and is fixed (apt first):
# a box can have several of these binaries installed, so we pick the first that
# is present in the documented precedence. An explicit override short-circuits
# detection entirely — that is how the unit tests force a given manager.
pkg_detect() {
if [ -n "${PKG_MGR_OVERRIDE:-}" ]; then
printf '%s\n' "$PKG_MGR_OVERRIDE"
return 0
fi
local mgr
for mgr in apt dnf yum zypper pacman apk; do
if command -v "$mgr" >/dev/null 2>&1; then
printf '%s\n' "$mgr"
return 0
fi
done
# No supported manager found: echo nothing (empty string).
printf '\n'
return 0
}
# Refresh the package index for the detected manager. Best-effort: index
# refreshes routinely fail on a flaky mirror and that should not abort an
# install, so we never propagate the manager's exit here.
pkg_refresh() {
local mgr
mgr="$(pkg_detect)"
case "$mgr" in
apt) _pkg_run apt-get update ;;
dnf) _pkg_run dnf makecache --refresh ;;
yum) _pkg_run yum makecache ;;
zypper) _pkg_run zypper --non-interactive refresh ;;
pacman) _pkg_run pacman -Sy --noconfirm ;;
apk) _pkg_run apk update ;;
*) return 0 ;;
esac
}
# Install one or more packages non-interactively for the detected manager.
# Propagates the manager's non-zero exit on failure (callers want to know an
# install failed); returns 1 with a stderr note when no manager was detected.
pkg_install() {
if [ "$#" -eq 0 ]; then
return 0
fi
local mgr
mgr="$(pkg_detect)"
case "$mgr" in
# Deliberate word-splitting of the package list via "$@" — each argument
# is already a separate, individually-quoted package name.
apt) _pkg_run apt-get install -y "$@" ;;
dnf) _pkg_run dnf install -y "$@" ;;
yum) _pkg_run yum install -y "$@" ;;
zypper) _pkg_run zypper --non-interactive install "$@" ;;
pacman) _pkg_run pacman -S --noconfirm "$@" ;;
apk) _pkg_run apk add "$@" ;;
*)
printf ' [pkg] no supported package manager found — cannot install: %s\n' "$*" >&2
return 1
;;
esac
}
# Return 0 if <pkg> is installed, 1 otherwise. Query-only (no sudo): we use the
# distro's low-level package database rather than the high-level manager so the
# answer matches what an install would see. Each branch tolerates the query tool
# being absent (it returns non-zero / 1 rather than aborting the caller).
pkg_is_installed() {
local pkg="$1"
local mgr
mgr="$(pkg_detect)"
case "$mgr" in
apt)
# dpkg can report a removed-but-configured package with returncode 0,
# so additionally require the "install ok installed" status line —
# matching PackageManager.is_installed() in system.py.
local out
if ! command -v dpkg >/dev/null 2>&1; then
return 1
fi
out="$(dpkg -s "$pkg" 2>/dev/null)" || return 1
case "$out" in
*'install ok installed'*) return 0 ;;
*) return 1 ;;
esac
;;
dnf|yum|zypper)
command -v rpm >/dev/null 2>&1 || return 1
rpm -q "$pkg" >/dev/null 2>&1
;;
pacman)
command -v pacman >/dev/null 2>&1 || return 1
pacman -Q "$pkg" >/dev/null 2>&1
;;
apk)
command -v apk >/dev/null 2>&1 || return 1
apk info -e "$pkg" >/dev/null 2>&1
;;
*)
return 1
;;
esac
}