Files
pic/install.sh
T
roof 5cb8ebe430
Unit Tests / test (push) Successful in 12m18s
fix: quiet installer output for non-technical users; Makefile/compose cleanup
The installer dumped ~200 lines of docker layer spam, a leaked apt error,
and obsolete version warnings, alarming for non-technical users.

install.sh:
- Clean, progress-only default output; full log to /var/log/pic-install.log
- Admin password still surfaced on stdout at the end
- PIC_DEBUG=1 / --debug flag restores verbose output
- On error, prints the last 20 lines from the log file

Makefile:
- start / update / start-core compose invocations get @ prefix to suppress
  command echo, plus --quiet-pull to kill layer-download spam

docker-compose.yml + docker-compose.services.yml:
- Removed obsolete `version: '3.3'` top-level key (triggers deprecation
  warning with current Docker Compose)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:01:48 -04:00

462 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================================
# Personal Internet Cell (PIC) — Bash Installer
# =============================================================================
#
# SECURITY NOTICE
# ---------------
# You are about to execute this script with elevated privileges.
# ALWAYS review a script before running it:
#
# curl -fsSL https://git.pic.ngo/roof/pic/raw/branch/main/install.sh | less
#
# SHA256 checksum (verify before running):
# PLACEHOLDER — updated when script is published at git.pic.ngo
#
# Verify with:
# sha256sum install.sh
# # or, via curl before piping:
# curl -fsSL https://git.pic.ngo/roof/pic/raw/branch/main/install.sh \
# | sha256sum
#
# =============================================================================
#
# Usage:
# bash install.sh # Standard install (uses sudo internally for packages)
# bash install.sh --force # Bypass idempotency check
# PIC_DIR=/srv/pic bash install.sh # Custom install directory
#
# Supported OS: Debian/Ubuntu (apt), Fedora/RHEL (dnf), Alpine Linux (apk)
#
# =============================================================================
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
PIC_DIR="${PIC_DIR:-/opt/pic}"
PIC_REPO="${PIC_REPO:-https://git.pic.ngo/roof/pic.git}"
PIC_USER="${PIC_USER:-pic}"
API_HEALTH_URL="http://127.0.0.1:3000/health"
API_HEALTH_TIMEOUT=60
WEBUI_PORT=8081
FORCE=0
PIC_DEBUG="${PIC_DEBUG:-0}"
# Parse flags
for arg in "$@"; do
case "$arg" in
--force) FORCE=1 ;;
--debug) PIC_DEBUG=1 ;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: $0 [--force] [--debug]" >&2
exit 1
;;
esac
done
# ---------------------------------------------------------------------------
# Log file — /var/log/pic-install.log when writable (root via sudo), else /tmp
# ---------------------------------------------------------------------------
if touch /var/log/pic-install.log 2>/dev/null; then
LOGFILE="/var/log/pic-install.log"
else
LOGFILE="${TMPDIR:-/tmp}/pic-install.log"
fi
: > "$LOGFILE" # truncate / create
# ---------------------------------------------------------------------------
# Color output
# ---------------------------------------------------------------------------
if [ -t 1 ] && command -v tput >/dev/null 2>&1 && tput setaf 1 >/dev/null 2>&1; then
RED="$(tput setaf 1)"
GREEN="$(tput setaf 2)"
YELLOW="$(tput setaf 3)"
BOLD="$(tput bold)"
RESET="$(tput sgr0)"
else
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BOLD='\033[1m'
RESET='\033[0m'
fi
log_step() { printf "\n${BOLD}[%s/%s] %s${RESET}\n" "$1" "$TOTAL_STEPS" "$2"; }
log_ok() { printf " ${GREEN}${RESET} %s\n" "$1"; }
log_warn() { printf " ${YELLOW}${RESET} %s\n" "$1"; }
log_error() { printf "\n${RED}${BOLD}ERROR:${RESET}${RED} %s${RESET}\n" "$1" >&2; }
die() {
log_error "$1"
if [ "$PIC_DEBUG" -eq 0 ]; then
printf "\n${YELLOW}Last output (full log: %s):${RESET}\n" "$LOGFILE" >&2
tail -n 30 "$LOGFILE" | sed 's/^/ /' >&2
fi
exit 1
}
# ---------------------------------------------------------------------------
# run_step <label_in_progress> <label_done> <cmd> [args...]
#
# Default mode: redirect stdout+stderr to LOGFILE; print a single "in
# progress" line then overwrite it with a checkmark on success. On failure
# print the last 30 log lines and die.
#
# Debug mode (PIC_DEBUG=1): tee output to LOGFILE AND stdout (indented),
# print the done line at the end.
#
# TERM safety: when stdout is not a TTY the \r trick does not work, so we
# fall back to a plain two-line "... / done" style.
# ---------------------------------------------------------------------------
_IS_TTY=0
[ -t 1 ] && _IS_TTY=1
run_step() {
local label_running="$1"
local label_done="$2"
shift 2
# "$@" is the command to run
if [ "$PIC_DEBUG" -eq 1 ]; then
printf " → %s\n" "$label_running"
# set -o pipefail: the pipeline below fails if "$@" fails, regardless
# of tee's or sed's exit code.
{ "$@" 2>&1 | tee -a "$LOGFILE" | sed 's/^/ /'; } || \
die "Command failed. See $LOGFILE for details."
log_ok "$label_done"
return
fi
# Default (quiet) mode
if [ "$_IS_TTY" -eq 1 ]; then
printf " → %s..." "$label_running"
else
printf " → %s...\n" "$label_running"
fi
local exit_code=0
"$@" >> "$LOGFILE" 2>&1 || exit_code=$?
if [ "$exit_code" -ne 0 ]; then
[ "$_IS_TTY" -eq 1 ] && printf "\n"
die "Step failed: $label_running"
fi
if [ "$_IS_TTY" -eq 1 ]; then
printf "\r ${GREEN}${RESET} %-60s\n" "$label_done"
else
printf " ${GREEN}${RESET} %s\n" "$label_done"
fi
}
TOTAL_STEPS=7
# ---------------------------------------------------------------------------
# Sudo check — we need it for package installs and system user creation
# ---------------------------------------------------------------------------
if ! command -v sudo >/dev/null 2>&1; then
die "sudo is required. Install it and ensure your user has sudo access."
fi
printf " Full log: %s\n" "$LOGFILE"
[ "$PIC_DEBUG" -eq 1 ] && printf " ${YELLOW}Debug mode enabled — verbose output active${RESET}\n"
# ---------------------------------------------------------------------------
# Idempotency guard
# ---------------------------------------------------------------------------
if [ -f "${PIC_DIR}/.installed" ] && [ "$FORCE" -eq 0 ]; then
printf "${YELLOW}Already installed.${RESET} Run ${BOLD}'make update'${RESET} to update.\n"
printf "To force a full reinstall, run: ${BOLD}$0 --force${RESET}\n"
exit 0
fi
# ---------------------------------------------------------------------------
# Step 1 — Detect OS / package manager
# ---------------------------------------------------------------------------
log_step 1 "Detecting operating system..."
PKG_MANAGER=""
OS_ID=""
if [ -f /etc/os-release ]; then
# shellcheck source=/dev/null
. /etc/os-release
OS_ID="${ID:-unknown}"
fi
case "$OS_ID" in
ubuntu|debian|raspbian)
PKG_MANAGER="apt"
;;
fedora)
PKG_MANAGER="dnf"
;;
rhel|centos|almalinux|rocky)
PKG_MANAGER="dnf"
;;
alpine)
PKG_MANAGER="apk"
;;
*)
# Last-resort detection
if command -v apt-get >/dev/null 2>&1; then
PKG_MANAGER="apt"
elif command -v dnf >/dev/null 2>&1; then
PKG_MANAGER="dnf"
elif command -v apk >/dev/null 2>&1; then
PKG_MANAGER="apk"
else
die "Unsupported OS '${OS_ID}'. PIC requires Debian/Ubuntu, Fedora/RHEL, or Alpine Linux."
fi
;;
esac
log_ok "Detected OS: ${OS_ID} (package manager: ${PKG_MANAGER})"
# ---------------------------------------------------------------------------
# Step 2 — Install required packages
# ---------------------------------------------------------------------------
log_step 2 "Installing dependencies..."
_install_deps() {
case "$PKG_MANAGER" in
apt)
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update -qq
sudo apt-get install -y -qq git curl make docker.io docker-compose-plugin || true
if ! docker compose version >/dev/null 2>&1; then
sudo apt-get install -y -qq docker-compose || true
fi
# Ensure host clock is synchronised before DDNS/TOTP registration.
sudo apt-get install -y -qq chrony || true
if sudo systemctl enable --now chrony >/dev/null 2>&1; then
: # NTP enabled
elif sudo systemctl enable --now chronyd >/dev/null 2>&1; then
: # NTP enabled
fi
;;
dnf)
sudo dnf install -y -q git curl make docker || true
sudo systemctl enable --now docker >/dev/null 2>&1 || true
if ! docker compose version >/dev/null 2>&1; then
sudo dnf install -y -q docker-compose-plugin || true
fi
sudo dnf install -y -q chrony || true
sudo systemctl enable --now chronyd >/dev/null 2>&1 || true
;;
apk)
sudo apk add --quiet git curl make docker docker-cli-compose || true
sudo rc-update add docker default >/dev/null 2>&1 || true
sudo service docker start >/dev/null 2>&1 || true
sudo apk add --quiet chrony || true
sudo rc-update add chronyd default >/dev/null 2>&1 || true
sudo service chronyd start >/dev/null 2>&1 || true
;;
esac
}
run_step "Installing system packages" "System packages installed" _install_deps
# Report NTP status (informational, outside the noisy run_step)
case "$PKG_MANAGER" in
apt)
if sudo systemctl is-active --quiet chrony 2>/dev/null || \
sudo systemctl is-active --quiet chronyd 2>/dev/null; then
log_ok "Host NTP (chrony) is running"
else
log_warn "Could not start chrony — verify host clock is accurate before running the setup wizard"
fi
;;
dnf|apk)
if sudo systemctl is-active --quiet chronyd 2>/dev/null || \
sudo service chronyd status >/dev/null 2>&1; then
log_ok "Host NTP (chronyd) is running"
else
log_warn "Could not start chronyd — verify host clock is accurate before running the setup wizard"
fi
;;
esac
# Final sanity checks
command -v git >/dev/null 2>&1 || die "git could not be installed. Aborting."
command -v curl >/dev/null 2>&1 || die "curl could not be installed. Aborting."
command -v make >/dev/null 2>&1 || die "make could not be installed. Aborting."
command -v docker >/dev/null 2>&1 || die "docker could not be installed. Aborting."
docker compose version >/dev/null 2>&1 || \
docker-compose version >/dev/null 2>&1 || \
die "Neither 'docker compose' (plugin) nor 'docker-compose' is available. Aborting."
log_ok "All dependencies satisfied"
# ---------------------------------------------------------------------------
# Step 3 — Create system user
# ---------------------------------------------------------------------------
log_step 3 "Configuring system user..."
if ! id "$PIC_USER" >/dev/null 2>&1; then
case "$PKG_MANAGER" in
apk)
sudo adduser -S -D -H -s /sbin/nologin "$PIC_USER"
;;
*)
sudo useradd --system --no-create-home --shell /usr/sbin/nologin "$PIC_USER"
;;
esac
log_ok "Created system user: ${PIC_USER}"
else
log_ok "System user already exists: ${PIC_USER}"
fi
# Ensure docker group exists and invoking user is in it
if ! getent group docker >/dev/null 2>&1; then
sudo groupadd docker
log_ok "Created docker group"
fi
CURRENT_USER="${USER:-$(id -un)}"
if ! id -nG "$CURRENT_USER" | grep -qw docker; then
sudo usermod -aG docker "$CURRENT_USER"
log_ok "Added ${CURRENT_USER} to docker group (re-login or newgrp docker to apply)"
else
log_ok "${CURRENT_USER} is already in docker group"
fi
# ---------------------------------------------------------------------------
# Step 4 — Clone or update repository
# ---------------------------------------------------------------------------
log_step 4 "Setting up repository at ${PIC_DIR}..."
if [ -d "${PIC_DIR}/.git" ]; then
log_warn "Repository already cloned — running git pull"
run_step "Updating repository" "Repository updated" \
git -C "$PIC_DIR" pull --ff-only
elif [ -d "$PIC_DIR" ] && [ "$(ls -A "$PIC_DIR" 2>/dev/null)" ]; then
die "${PIC_DIR} exists and is not empty and is not a git repo. Aborting to avoid data loss."
else
mkdir -p "$(dirname "$PIC_DIR")"
run_step "Cloning repository" "Repository cloned to ${PIC_DIR}" \
git clone "$PIC_REPO" "$PIC_DIR"
fi
sudo git config --system --add safe.directory "$PIC_DIR" 2>/dev/null || true
# ---------------------------------------------------------------------------
# Step 5 — Run make install
# ---------------------------------------------------------------------------
log_step 5 "Generating configuration..."
cd "$PIC_DIR"
# run_step routes all output to LOGFILE. After it returns we scan LOGFILE
# for the admin password banner (printed once by setup_cell.py) and relay it
# to the user — it must never be silently buried in the log.
# We record the log byte-offset before the step so we only scan new output.
_LOG_OFFSET_BEFORE="$(wc -c < "$LOGFILE" 2>/dev/null || echo 0)"
run_step "Generating configuration" "Configuration generated" make install
# Extract only the lines added by this step.
_NEW_LOG="$(tail -c +"$(( _LOG_OFFSET_BEFORE + 1 ))" "$LOGFILE" 2>/dev/null || true)"
# Relay admin password banner if present.
if printf '%s\n' "$_NEW_LOG" | grep -qiE "(ADMIN PASSWORD|shown once)"; then
printf "\n"
printf '%s\n' "$_NEW_LOG" \
| awk '/ADMIN PASSWORD|shown once|={6}/{found=1} found{print} found && /^[[:space:]]*$/{exit}' \
| sed 's/^/ /'
printf "\n"
fi
log_ok "'make install' complete"
# ---------------------------------------------------------------------------
# Step 6 — Start core services
# ---------------------------------------------------------------------------
log_step 6 "Starting core services..."
cd "$PIC_DIR"
run_step \
"Downloading container images (first run can take a few minutes)" \
"Container images ready" \
make start-core
log_ok "Core services started"
# Enable and start the pic systemd unit so the stack survives a reboot.
# Skipped on Alpine (OpenRC) and on systems without systemd.
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl daemon-reload 2>/dev/null || true
if sudo systemctl enable --now pic 2>/dev/null; then
log_ok "systemd unit pic.service enabled and started"
else
log_warn "Could not enable pic.service — run: sudo systemctl enable --now pic"
fi
fi
# ---------------------------------------------------------------------------
# Step 7 — Health check + print wizard URL
# ---------------------------------------------------------------------------
log_step 7 "Waiting for API health check (up to ${API_HEALTH_TIMEOUT}s)..."
ELAPSED=0
HEALTHY=0
while [ "$ELAPSED" -lt "$API_HEALTH_TIMEOUT" ]; do
if curl -fsS "$API_HEALTH_URL" >/dev/null 2>&1; then
HEALTHY=1
break
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
printf " Waiting... (%ds)\r" "$ELAPSED"
done
printf "\n"
if [ "$HEALTHY" -ne 1 ]; then
log_warn "API did not respond within ${API_HEALTH_TIMEOUT}s at ${API_HEALTH_URL}"
log_warn "The stack may still be starting up. Check with: make -C ${PIC_DIR} status"
log_warn "Or follow logs with: make -C ${PIC_DIR} logs"
else
log_ok "API is healthy"
fi
# Detect the host's primary outbound IP address
HOST_IP="$(ip route get 1.1.1.1 2>/dev/null | awk '/src/{print $7; exit}' || true)"
if [ -z "$HOST_IP" ]; then
# Fallback: first non-loopback IPv4
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
fi
HOST_IP="${HOST_IP:-<host-ip>}"
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
printf "\n${GREEN}${BOLD}============================================================${RESET}\n"
printf "${GREEN}${BOLD} PIC installed successfully!${RESET}\n"
printf "${GREEN}${BOLD}============================================================${RESET}\n"
printf "\n"
printf " Open the setup wizard to configure your cell:\n"
printf "\n"
printf " ${BOLD}http://${HOST_IP}:${WEBUI_PORT}/setup${RESET}\n"
printf "\n"
printf " Useful commands:\n"
printf " make -C ${PIC_DIR} status — check service status\n"
printf " make -C ${PIC_DIR} logs — follow all service logs\n"
printf " make -C ${PIC_DIR} start — start all services\n"
printf " make -C ${PIC_DIR} stop — stop all services\n"
printf " make -C ${PIC_DIR} update — pull latest code and restart\n"
printf "\n"