owpengram-server/scripts/install-prereqs.sh
2026-09-13 02:02:19 +03:00

352 lines
14 KiB
Bash
Executable file

#!/usr/bin/env bash
# Installs everything owpengram-server.sh checks for, so a fresh machine needs
# one command instead of a shopping list: Go, Python 3 (+ the panel's packages
# in a venv), Docker and OpenSSL.
#
# ./scripts/install-prereqs.sh install whatever is missing
# ./scripts/install-prereqs.sh --dry-run only report what it would install
# ./scripts/install-prereqs.sh --yes no confirmation prompt
#
# Supported: Arch (pacman) and Ubuntu/Debian (apt). Anything else is reported
# rather than guessed at -- a wrong package manager on a production box is a
# worse outcome than a clear "install these yourself".
#
# Root is taken once, up front, and held for the whole run: a sudo prompt
# appearing ten minutes in, after the user walked away, is how half-installed
# machines happen.
set -euo pipefail
cd "$(dirname "$0")/.."
REPO_ROOT="$PWD"
ok() { printf '[ok] %s\n' "$*"; }
info() { printf '[..] %s\n' "$*"; }
warn() { printf '[WARN] %s\n' "$*"; }
die() { printf '[ERROR] %s\n' "$*" >&2; exit 1; }
DRY_RUN=0
ASSUME_YES=0
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
--yes|-y) ASSUME_YES=1 ;;
-h|--help) sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown argument: $arg" ;;
esac
done
# --- distro ------------------------------------------------------------------
# ID_LIKE is what makes derivatives work without listing every one of them:
# EndeavourOS says ID_LIKE=arch, Pop!_OS and Mint say ID_LIKE=ubuntu/debian.
FAMILY=""
DISTRO_NAME="unknown"
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
DISTRO_NAME="${PRETTY_NAME:-$ID}"
case " ${ID:-} ${ID_LIKE:-} " in
*" arch "*) FAMILY="arch" ;;
*" ubuntu "*|*" debian "*) FAMILY="debian" ;;
esac
fi
[[ -n "$FAMILY" ]] || die "unsupported distribution: ${DISTRO_NAME}. Supported: Arch and Ubuntu/Debian. Install Go 1.25+, Python 3, Docker and OpenSSL by hand, then run ./owpengram-server.sh"
# --- what is missing ---------------------------------------------------------
have() { command -v "$1" >/dev/null 2>&1; }
# Go's own version gate: go.mod asks for 1.25, and a too-old toolchain fails at
# build time with a message that does not obviously point back here.
GO_MIN_MINOR=25
go_is_recent_enough() {
have go || return 1
local v minor
v="$(go env GOVERSION 2>/dev/null || true)" # e.g. go1.25.7
minor="${v#go1.}"
minor="${minor%%.*}"
[[ "$minor" =~ ^[0-9]+$ ]] && (( minor >= GO_MIN_MINOR ))
}
venv_python() { printf '%s/.venv/bin/python' "$REPO_ROOT"; }
# Same precedence owpengram-server.sh uses when it picks an interpreter: the
# venv when one exists, otherwise whatever python3 is on PATH. Checking only the
# venv would report the packages missing on a machine that already has them
# installed system-wide, and build a venv nobody asked for.
pydeps_satisfied() {
local py
if [[ -x "$(venv_python)" ]]; then
py="$(venv_python)"
elif have python3; then
py="python3"
else
return 1
fi
[[ -z "$("$py" tui-panel/check_deps.py 2>/dev/null)" ]]
}
NEEDED=()
go_is_recent_enough || NEEDED+=("go")
have python3 || NEEDED+=("python")
have docker || NEEDED+=("docker")
have openssl || NEEDED+=("openssl")
# The venv is built with python3, so it can only be settled after Python is.
pydeps_satisfied || NEEDED+=("pydeps")
if [[ ${#NEEDED[@]} -eq 0 ]]; then
ok "All prerequisites are already installed."
exit 0
fi
echo "== Missing prerequisites on ${DISTRO_NAME} =="
for item in "${NEEDED[@]}"; do
case "$item" in
go) echo " - Go 1.${GO_MIN_MINOR}+ (builds owpengram-server and the admin panel)" ;;
python) echo " - Python 3 (runs the server-panel TUI)" ;;
pydeps) echo " - Python packages: textual, psutil, cryptography (into ./.venv)" ;;
docker) echo " - Docker (runs PostgreSQL, Redis and MinIO)" ;;
openssl) echo " - OpenSSL (exports the server's RSA public key for clients)" ;;
esac
done
echo
if [[ "$DRY_RUN" -eq 1 ]]; then
ok "Dry run -- nothing was installed."
exit 0
fi
if [[ "$ASSUME_YES" -ne 1 ]]; then
# Without a terminal there is nobody to answer, and `read` would block for as
# long as the caller is willing to wait -- which for a CI job or a wrapping
# script is forever. Say so instead of hanging.
if [[ ! -t 0 ]]; then
die "no terminal to confirm on -- re-run with --yes to install without asking, or --dry-run to only look"
fi
read -r -p "Install these now? This needs root. [Y/n] " answer
case "${answer:-y}" in
[Yy]|[Yy][Ee][Ss]|"") ;;
*) die "cancelled" ;;
esac
fi
# Ask for the password once, then refresh the timestamp in the background so a
# long Go download or apt run does not hit a second prompt mid-way.
if [[ $EUID -ne 0 ]]; then
have sudo || die "sudo is not installed and this is not running as root"
sudo -v || die "could not acquire root"
while true; do sudo -n true 2>/dev/null; sleep 50; done &
SUDO_KEEPALIVE=$!
trap 'kill "$SUDO_KEEPALIVE" 2>/dev/null || true' EXIT
SUDO="sudo"
else
SUDO=""
fi
needs() { local x; for x in "${NEEDED[@]}"; do [[ "$x" == "$1" ]] && return 0; done; return 1; }
# Tools this script itself leans on, installed before anything tries to use
# them. A minimal Ubuntu has no curl, and the Go tarball download would die on
# its first call -- after root was already taken and the first package was
# already installed, which is the worst place to stop. Arch's base always has
# curl (pacman links against it), so in practice this is the Debian path.
ensure_installer_tools() {
local wanted=()
have curl || wanted+=("curl")
# Without the CA bundle every https download fails certificate verification.
if [[ "$FAMILY" == "debian" && ! -e /etc/ssl/certs/ca-certificates.crt ]]; then
wanted+=("ca-certificates")
fi
[[ ${#wanted[@]} -eq 0 ]] && return 0
info "Installing what this script needs first: ${wanted[*]}"
pkg_install "${wanted[@]}"
}
# --- package manager ---------------------------------------------------------
APT_UPDATED=0
apt_update_once() {
if [[ "$APT_UPDATED" -eq 0 ]]; then
$SUDO apt-get update -qq
APT_UPDATED=1
fi
}
pkg_install() {
case "$FAMILY" in
arch) $SUDO pacman -S --needed --noconfirm "$@" ;;
debian) apt_update_once; $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" ;;
esac
}
ensure_installer_tools
# --- Python + OpenSSL --------------------------------------------------------
if needs python; then
info "Installing Python 3"
case "$FAMILY" in
arch) pkg_install python ;;
# python3-venv is separate on Debian/Ubuntu and is what the panel's
# dependencies go into: both distros mark the system Python
# externally-managed, so pip into it is refused by design.
debian) pkg_install python3 python3-venv python3-pip ;;
esac
ok "Python installed: $(python3 --version)"
fi
if needs openssl; then
info "Installing OpenSSL"
pkg_install openssl
ok "OpenSSL installed: $(openssl version)"
fi
# --- Go ----------------------------------------------------------------------
# Arch ships a current Go; Debian/Ubuntu do not (24.04 is still on 1.22, below
# what go.mod needs), so there the official tarball is the only sane source.
install_go_tarball() {
local version arch tarball url tmp expected actual
version="$(curl -fsSL 'https://go.dev/VERSION?m=text' | head -n1)"
[[ "$version" == go* ]] || die "could not determine the latest Go version"
case "$(uname -m)" in
x86_64) arch="amd64" ;;
aarch64) arch="arm64" ;;
armv7l) arch="armv6l" ;;
*) die "unsupported CPU architecture for the Go tarball: $(uname -m)" ;;
esac
tarball="${version}.linux-${arch}.tar.gz"
url="https://go.dev/dl/${tarball}"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
info "Downloading ${tarball}"
curl -fsSL "$url" -o "$tmp/$tarball"
# The published checksum lives in go.dev's release index, so the download is
# verified against the site's metadata rather than trusted on arrival.
expected="$(curl -fsSL 'https://go.dev/dl/?mode=json&include=all' \
| python3 -c 'import json,sys
want = sys.argv[1]
for release in json.load(sys.stdin):
for f in release.get("files", []):
if f.get("filename") == want:
print(f.get("sha256", ""))
raise SystemExit
' "$tarball")"
[[ -n "$expected" ]] || die "no published checksum for ${tarball}"
actual="$(sha256sum "$tmp/$tarball" | cut -d' ' -f1)"
[[ "$actual" == "$expected" ]] || die "checksum mismatch for ${tarball}: expected ${expected}, got ${actual}"
ok "Checksum verified"
$SUDO rm -rf /usr/local/go
$SUDO tar -C /usr/local -xzf "$tmp/$tarball"
# /usr/local/go/bin is not on a default PATH; drop a profile snippet so new
# shells find it. The current shell still will not, hence the note at the end.
printf 'export PATH=$PATH:/usr/local/go/bin\n' | $SUDO tee /etc/profile.d/go.sh >/dev/null
$SUDO chmod 0644 /etc/profile.d/go.sh
export PATH="$PATH:/usr/local/go/bin"
}
GO_NEEDS_NEW_SHELL=0
if needs go; then
info "Installing Go"
case "$FAMILY" in
arch) pkg_install go ;;
debian) install_go_tarball; GO_NEEDS_NEW_SHELL=1 ;;
esac
ok "Go installed: $(go version)"
fi
# --- Docker ------------------------------------------------------------------
DOCKER_NEEDS_RELOGIN=0
install_docker_debian() {
local codename repo_distro
# Derivatives (Mint, Pop!_OS) carry their own codename that Docker's repo
# does not publish; UBUNTU_CODENAME is the upstream one it does.
# shellcheck disable=SC1091
. /etc/os-release
codename="${UBUNTU_CODENAME:-${VERSION_CODENAME:-}}"
[[ -n "$codename" ]] || die "could not determine the distribution codename for Docker's repository"
case " ${ID:-} ${ID_LIKE:-} " in
*" ubuntu "*) repo_distro="ubuntu" ;;
*) repo_distro="debian" ;;
esac
pkg_install ca-certificates curl gnupg
$SUDO install -m 0755 -d /etc/apt/keyrings
$SUDO curl -fsSL "https://download.docker.com/linux/${repo_distro}/gpg" -o /etc/apt/keyrings/docker.asc
$SUDO chmod a+r /etc/apt/keyrings/docker.asc
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' \
"$(dpkg --print-architecture)" "$repo_distro" "$codename" \
| $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null
APT_UPDATED=0 # the new repository has to be picked up
pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
}
if needs docker; then
info "Installing Docker"
case "$FAMILY" in
arch) pkg_install docker docker-compose ;;
debian) install_docker_debian ;;
esac
if have systemctl; then
$SUDO systemctl enable --now docker
ok "Docker service started"
else
warn "systemd not found -- start the Docker daemon yourself before running ./owpengram-server.sh"
fi
# Without this every docker call needs sudo, which the panel does not use.
if [[ $EUID -ne 0 ]] && ! id -nG "$USER" | tr ' ' '\n' | grep -qx docker; then
$SUDO usermod -aG docker "$USER"
DOCKER_NEEDS_RELOGIN=1
# The new group only reaches a *new* login, so owpengram-server.sh re-enters
# itself with `sg docker` to make this run work without one. Debian split sg
# out of util-linux into util-linux-extra, and recent Ubuntu images ship
# neither it nor newgrp -- on those the launcher had no way back in and could
# only tell the user to log out. Pulling it in here is the difference between
# install-then-start working in one go and not.
if [[ "$FAMILY" == "debian" ]] && ! have sg; then
info "Installing util-linux-extra (provides sg)"
pkg_install util-linux-extra || warn "could not install util-linux-extra"
fi
fi
ok "Docker installed: $(docker --version)"
fi
# --- the panel's Python packages ---------------------------------------------
# Always a venv, never the system interpreter: Arch and Debian both refuse pip
# into it (PEP 668), and owpengram-server.sh already prefers ./.venv when present.
if needs pydeps; then
info "Installing the panel's Python packages into ./.venv"
# Debian/Ubuntu ship venv separately from python3, so an interpreter that was
# already installed (and therefore skipped the Python step above) can still be
# missing ensurepip -- `python3 -m venv` then fails halfway through, leaving a
# broken .venv behind. The package is named for the interpreter's version;
# the unversioned metapackage is the fallback for Debian releases that have it.
if [[ "$FAMILY" == "debian" ]] && ! python3 -c 'import ensurepip' 2>/dev/null; then
PYVER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
info "Installing python${PYVER}-venv"
pkg_install "python${PYVER}-venv" 2>/dev/null || pkg_install python3-venv
fi
# A venv built while ensurepip was missing still has a working interpreter --
# it is pip that is absent, so "is there a python in there" answers yes and
# the repair never happens. Usable means pip runs; anything else gets thrown
# away and rebuilt, which costs nothing when there was nothing there.
venv_ok() {
local py
py="$(venv_python)"
[[ -x "$py" ]] && "$py" -m pip --version >/dev/null 2>&1
}
venv_ok || rm -rf "$REPO_ROOT/.venv"
venv_ok || python3 -m venv "$REPO_ROOT/.venv"
"$(venv_python)" -m pip install --quiet --upgrade pip
"$(venv_python)" -m pip install --quiet -r tui-panel/requirements-panel.txt
ok "Python packages installed"
fi
echo
ok "Prerequisites are ready."
if [[ "$GO_NEEDS_NEW_SHELL" -eq 1 ]]; then
echo " Go was installed to /usr/local/go. Open a new shell, or run:"
echo " export PATH=\$PATH:/usr/local/go/bin"
fi
if [[ "$DOCKER_NEEDS_RELOGIN" -eq 1 ]]; then
echo " You were added to the 'docker' group. Already-running shells keep the"
echo " old one -- owpengram-server.sh re-enters itself so this run works"
echo " anyway, but log out and back in before using docker elsewhere."
fi
echo " Then start the server with: ./owpengram-server.sh"