scripts/install.sh cleanup trap previously removed $INSTALL_DIR on
any non-zero exit if .env wasn't written yet. That made sense for
half-extracted state, but also bit us when config.sh failed at
/dev/tty (the common "curl | ssh bash" non-interactive case) — the
15MB tarball had already extracted cleanly and the user was forced
to re-download to retry on a console.
New EXTRACT_COMPLETE state flag:
- Set to true after the tar xzf step verifies docker-compose.yml.
- cleanup() distinguishes "extract OK, config wizard didn't run"
from "extraction never completed":
* First case: preserve the dir, print a resumption hint
(cd $INSTALL_DIR && bash config.sh on an interactive console).
* Second case: unchanged behaviour — remove the partial dir.
Typical SSH-without-tty recovery path now costs zero re-download.
Bunker Admin
432 lines
15 KiB
Bash
Executable File
432 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# =============================================================================
|
|
# Changemaker Lite — One-Line Installer
|
|
#
|
|
# Downloads the latest release tarball and runs the configuration wizard.
|
|
#
|
|
# Usage:
|
|
# curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
|
|
# bash install.sh [OPTIONS]
|
|
#
|
|
# Options:
|
|
# --dir DIR Install directory (default: ~/changemaker.lite)
|
|
# --version TAG Specific version tag (default: latest release)
|
|
# --tarball FILE Use a local tarball instead of downloading
|
|
# --help Show this help
|
|
# =============================================================================
|
|
set -euo pipefail
|
|
|
|
GITEA_URL="https://gitea.bnkops.com"
|
|
REPO="admin/changemaker.lite"
|
|
INSTALL_DIR="${HOME}/changemaker.lite"
|
|
VERSION=""
|
|
LOCAL_TARBALL=""
|
|
MIN_DISK_MB=10000
|
|
HEALTH_TIMEOUT=180
|
|
HEALTH_INTERVAL=5
|
|
|
|
# --- State flags for cleanup ---
|
|
TARBALL_PATH=""
|
|
EXTRACT_COMPLETE=false
|
|
CONFIG_COMPLETE=false
|
|
|
|
# --- Colors ---
|
|
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
|
|
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
|
|
BLUE='\033[0;34m' BOLD='\033[1m' NC='\033[0m'
|
|
else
|
|
RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC=''
|
|
fi
|
|
|
|
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
|
success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
|
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
|
|
|
# --- Cleanup on failure ---
|
|
cleanup() {
|
|
local exit_code=$?
|
|
if [[ $exit_code -ne 0 ]]; then
|
|
# Clean up downloaded tarball (but not user-provided ones)
|
|
if [[ -z "$LOCAL_TARBALL" ]] && [[ -n "$TARBALL_PATH" ]] && [[ -f "$TARBALL_PATH" ]]; then
|
|
rm -f "$TARBALL_PATH"
|
|
fi
|
|
|
|
# Post-extract state: tarball unpacked OK but config wizard didn't complete
|
|
# (common case: /dev/tty unavailable over non-interactive SSH). The expensive
|
|
# step succeeded, so preserve the dir and tell the user how to resume instead
|
|
# of forcing a full re-download.
|
|
if [[ "$EXTRACT_COMPLETE" == "true" ]] \
|
|
&& [[ "$CONFIG_COMPLETE" == "false" ]] \
|
|
&& [[ -d "$INSTALL_DIR" ]] \
|
|
&& [[ ! -f "$INSTALL_DIR/.env" ]]; then
|
|
echo ""
|
|
echo -e "${YELLOW}[INFO]${NC} Tarball extracted to $INSTALL_DIR — preserving."
|
|
echo -e "${YELLOW}[INFO]${NC} To finish setup on an interactive console:"
|
|
echo -e " ${BOLD}cd $INSTALL_DIR && bash config.sh${NC}"
|
|
echo ""
|
|
error "Configuration wizard could not run (likely no TTY available)."
|
|
return 0
|
|
fi
|
|
|
|
# Extraction failed or never happened — discard the partial dir so the next
|
|
# run-through starts from a clean slate.
|
|
if [[ "$CONFIG_COMPLETE" == "false" ]] && [[ -d "$INSTALL_DIR" ]] && [[ ! -f "$INSTALL_DIR/.env" ]]; then
|
|
rm -rf "$INSTALL_DIR"
|
|
fi
|
|
echo ""
|
|
error "Installation failed. See errors above."
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# --- Arg parser ---
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--dir) INSTALL_DIR="$2"; shift 2 ;;
|
|
--version) VERSION="$2"; shift 2 ;;
|
|
--tarball) LOCAL_TARBALL="$2"; shift 2 ;;
|
|
--help|-h)
|
|
sed -n '2,20p' "$0" | grep '^#' | sed 's/^# \?//'
|
|
exit 0 ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
|
|
echo -e "${BOLD}Changemaker Lite — Installer${NC}"
|
|
echo ""
|
|
|
|
# =============================================================================
|
|
# Step 1: Check prerequisites
|
|
# =============================================================================
|
|
|
|
info "Checking prerequisites..."
|
|
MISSING=()
|
|
command -v docker >/dev/null 2>&1 || MISSING+=("docker")
|
|
docker compose version >/dev/null 2>&1 || MISSING+=("docker-compose-v2")
|
|
command -v openssl >/dev/null 2>&1 || MISSING+=("openssl")
|
|
command -v curl >/dev/null 2>&1 || MISSING+=("curl")
|
|
|
|
if [[ ${#MISSING[@]} -gt 0 ]]; then
|
|
error "Missing required tools: ${MISSING[*]}"
|
|
echo ""
|
|
echo "Install Docker: https://docs.docker.com/engine/install/"
|
|
echo "Install OpenSSL: apt install openssl (or equivalent)"
|
|
exit 1
|
|
fi
|
|
success "Prerequisites OK (Docker $(docker --version | grep -oP '\d+\.\d+\.\d+'), OpenSSL available)"
|
|
|
|
# Docker daemon must be running (not just installed)
|
|
if ! docker info >/dev/null 2>&1; then
|
|
error "Docker daemon is not running."
|
|
echo ""
|
|
echo " Start it with: sudo systemctl start docker"
|
|
echo " Or: sudo service docker start"
|
|
exit 1
|
|
fi
|
|
success "Docker daemon is running"
|
|
|
|
# Disk space check
|
|
AVAILABLE_MB=$(df -m "$(dirname "$INSTALL_DIR")" | awk 'NR==2 {print $4}')
|
|
if [[ "$AVAILABLE_MB" -lt "$MIN_DISK_MB" ]]; then
|
|
error "Insufficient disk space: ${AVAILABLE_MB}MB available, ${MIN_DISK_MB}MB required."
|
|
echo ""
|
|
echo " The full stack (images + volumes) needs ~10GB of free space."
|
|
exit 1
|
|
fi
|
|
success "Disk space: ${AVAILABLE_MB}MB available (${MIN_DISK_MB}MB required)"
|
|
|
|
# Host port availability — checks the ports the stack will try to bind BEFORE
|
|
# we've downloaded anything. Avoids partially-installed state when e.g. cockpit
|
|
# owns :9090 and breaks prometheus mid-startup.
|
|
if command -v ss >/dev/null 2>&1; then
|
|
HOST_CONFLICTS=()
|
|
for port in 3000 4000 4100 5433 9090 3001 3030 9001 5678 8091 8025 8888 3010 4003; do
|
|
if ss -Htln 2>/dev/null | awk -v p=":$port" '$4 ~ p"$" {found=1} END{exit !found}'; then
|
|
HOST_CONFLICTS+=("$port")
|
|
fi
|
|
done
|
|
if [[ ${#HOST_CONFLICTS[@]} -gt 0 ]]; then
|
|
error "Host ports already in use: ${HOST_CONFLICTS[*]}"
|
|
echo ""
|
|
echo " These ports must be free for the Changemaker Lite stack:"
|
|
for p in "${HOST_CONFLICTS[@]}"; do
|
|
case "$p" in
|
|
9090) echo " :$p — commonly cockpit.socket. Fix: sudo systemctl disable --now cockpit.socket" ;;
|
|
80|443) echo " :$p — host nginx/apache. Stop the host service or use a different subdomain entrypoint." ;;
|
|
3030) echo " :$p — another Gitea or service on this port." ;;
|
|
*) echo " :$p" ;;
|
|
esac
|
|
done
|
|
echo ""
|
|
echo " Or re-run later with --dir to install into a directory whose stack remaps ports."
|
|
exit 1
|
|
fi
|
|
success "Host ports available"
|
|
else
|
|
warn "ss not installed — skipping host port check (apt install iproute2 to enable)"
|
|
fi
|
|
|
|
# =============================================================================
|
|
# Step 2: Check install directory
|
|
# =============================================================================
|
|
|
|
if [[ -d "$INSTALL_DIR" ]]; then
|
|
if [[ -f "$INSTALL_DIR/docker-compose.yml" ]]; then
|
|
error "Changemaker Lite is already installed at $INSTALL_DIR"
|
|
echo " To upgrade: cd $INSTALL_DIR && ./scripts/upgrade.sh"
|
|
echo " To reinstall: rm -rf $INSTALL_DIR && re-run this script"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# =============================================================================
|
|
# Step 3: Get tarball
|
|
# =============================================================================
|
|
|
|
if [[ -n "$LOCAL_TARBALL" ]]; then
|
|
if [[ ! -f "$LOCAL_TARBALL" ]]; then
|
|
error "Tarball not found: $LOCAL_TARBALL"
|
|
exit 1
|
|
fi
|
|
TARBALL_PATH="$LOCAL_TARBALL"
|
|
info "Using local tarball: $LOCAL_TARBALL"
|
|
else
|
|
# Determine download URL
|
|
if [[ -n "$VERSION" ]]; then
|
|
RELEASE_URL="${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${VERSION}"
|
|
else
|
|
RELEASE_URL="${GITEA_URL}/api/v1/repos/${REPO}/releases/latest"
|
|
fi
|
|
|
|
info "Fetching release info from Gitea..."
|
|
RELEASE_JSON=$(curl -sf "$RELEASE_URL" 2>/dev/null || true)
|
|
|
|
if [[ -z "$RELEASE_JSON" ]]; then
|
|
error "Could not fetch release info from ${GITEA_URL}"
|
|
echo ""
|
|
echo "If the server is unreachable:"
|
|
echo " 1. Download the tarball manually from ${GITEA_URL}/${REPO}/releases"
|
|
echo " 2. Run: bash install.sh --tarball /path/to/changemaker-lite-*.tar.gz"
|
|
exit 1
|
|
fi
|
|
|
|
TARBALL_URL=$(echo "$RELEASE_JSON" | python3 -c "
|
|
import sys, json
|
|
data = json.load(sys.stdin)
|
|
assets = data.get('assets', [])
|
|
for a in assets:
|
|
if a['name'].endswith('.tar.gz'):
|
|
print(a['browser_download_url'])
|
|
break
|
|
" 2>/dev/null || true)
|
|
|
|
if [[ -z "$TARBALL_URL" ]]; then
|
|
error "No tarball found in the release. Check ${GITEA_URL}/${REPO}/releases"
|
|
exit 1
|
|
fi
|
|
|
|
RELEASE_TAG=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tag_name','unknown'))" 2>/dev/null)
|
|
info "Downloading Changemaker Lite ${RELEASE_TAG}..."
|
|
TARBALL_PATH="/tmp/changemaker-lite-install.tar.gz"
|
|
curl -fSL "$TARBALL_URL" -o "$TARBALL_PATH"
|
|
success "Downloaded $(du -h "$TARBALL_PATH" | cut -f1)"
|
|
fi
|
|
|
|
# =============================================================================
|
|
# Step 4: Extract
|
|
# =============================================================================
|
|
|
|
info "Extracting to ${INSTALL_DIR}..."
|
|
|
|
# Ensure the install directory exists and is empty
|
|
if [[ -d "$INSTALL_DIR" ]]; then
|
|
# Remove any leftover files (may need sudo for root-owned Docker artifacts)
|
|
rm -rf "${INSTALL_DIR:?}"/* "${INSTALL_DIR}"/.[!.]* 2>/dev/null || true
|
|
if [[ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]]; then
|
|
if command -v sudo &>/dev/null; then
|
|
warn "Removing root-owned leftovers from previous install..."
|
|
sudo rm -rf "${INSTALL_DIR:?}"/* "${INSTALL_DIR}"/.[!.]* 2>/dev/null || true
|
|
fi
|
|
if [[ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]]; then
|
|
error "Cannot clean install directory ${INSTALL_DIR} — remove manually (may need sudo)"
|
|
exit 1
|
|
fi
|
|
fi
|
|
else
|
|
mkdir -p "$INSTALL_DIR"
|
|
fi
|
|
|
|
# Extract directly into INSTALL_DIR, stripping the tarball's root directory
|
|
tar xzf "$TARBALL_PATH" --strip-components=1 -C "$INSTALL_DIR"
|
|
|
|
if [[ ! -f "$INSTALL_DIR/docker-compose.yml" ]]; then
|
|
error "Tarball extraction failed — docker-compose.yml not found"
|
|
exit 1
|
|
fi
|
|
EXTRACT_COMPLETE=true
|
|
|
|
# Clean up downloaded tarball
|
|
if [[ -z "$LOCAL_TARBALL" ]] && [[ -f "$TARBALL_PATH" ]]; then
|
|
rm -f "$TARBALL_PATH"
|
|
fi
|
|
|
|
success "Extracted to ${INSTALL_DIR}"
|
|
|
|
# =============================================================================
|
|
# Step 5: Run config wizard
|
|
# =============================================================================
|
|
|
|
echo ""
|
|
echo -e "${BOLD}Starting configuration wizard...${NC}"
|
|
echo ""
|
|
cd "$INSTALL_DIR"
|
|
# Redirect stdin from terminal — when piped (curl | bash), stdin is the pipe,
|
|
# so interactive prompts in config.sh would read garbage instead of user input.
|
|
if [[ -e /dev/tty ]]; then
|
|
bash config.sh </dev/tty
|
|
else
|
|
bash config.sh
|
|
fi
|
|
CONFIG_COMPLETE=true
|
|
|
|
# =============================================================================
|
|
# Step 6: Start services
|
|
# =============================================================================
|
|
|
|
echo ""
|
|
echo -e "${BOLD}Configuration complete!${NC}"
|
|
echo ""
|
|
|
|
START_SERVICES="y"
|
|
if [[ -t 0 ]]; then
|
|
read -rp "Start all services now? [Y/n]: " START_SERVICES
|
|
START_SERVICES=${START_SERVICES:-y}
|
|
elif [[ -e /dev/tty ]]; then
|
|
read -rp "Start all services now? [Y/n]: " START_SERVICES </dev/tty
|
|
START_SERVICES=${START_SERVICES:-y}
|
|
fi
|
|
|
|
if [[ "$START_SERVICES" =~ ^[Yy]$ ]]; then
|
|
echo ""
|
|
info "Pulling images from registry (this may take a few minutes on first run)..."
|
|
echo ""
|
|
cd "$INSTALL_DIR"
|
|
|
|
# --ignore-pull-failures: optional services may not have images in the
|
|
# registry yet — don't abort the whole install for those.
|
|
if ! docker compose pull --ignore-pull-failures 2>&1; then
|
|
echo ""
|
|
error "Failed to pull images from the registry."
|
|
echo ""
|
|
echo " Check that the registry is reachable:"
|
|
echo " curl -sf https://gitea.bnkops.com/api/v1/repos/admin/changemaker.lite/releases/latest"
|
|
echo ""
|
|
echo " If pulling from a private registry, log in first:"
|
|
echo " docker login gitea.bnkops.com"
|
|
echo ""
|
|
echo " Then retry:"
|
|
echo " cd ${INSTALL_DIR} && docker compose pull && docker compose up -d"
|
|
exit 1
|
|
fi
|
|
success "All images pulled"
|
|
|
|
echo ""
|
|
info "Starting services..."
|
|
if ! docker compose up -d 2>&1; then
|
|
echo ""
|
|
error "Failed to start services."
|
|
echo " Check logs: docker compose logs --tail 30"
|
|
exit 1
|
|
fi
|
|
|
|
# --- Post-startup health verification ---
|
|
echo ""
|
|
info "Waiting for services to become healthy (up to ${HEALTH_TIMEOUT}s)..."
|
|
info " Database migrations and seeding run automatically on first boot."
|
|
echo ""
|
|
|
|
CORE_SERVICES=("v2-postgres" "redis" "api" "admin" "nginx")
|
|
ELAPSED=0
|
|
ALL_HEALTHY=false
|
|
|
|
while [[ $ELAPSED -lt $HEALTH_TIMEOUT ]]; do
|
|
ALL_HEALTHY=true
|
|
|
|
for svc in "${CORE_SERVICES[@]}"; do
|
|
# Detect crashed containers early
|
|
state=$(docker compose ps "$svc" --format '{{.State}}' 2>/dev/null || echo "missing")
|
|
if [[ "$state" == "exited" || "$state" == "dead" ]]; then
|
|
echo ""
|
|
error "Service '${svc}' exited unexpectedly. Last logs:"
|
|
docker compose logs "$svc" --tail 20 2>/dev/null || true
|
|
echo ""
|
|
error "Fix the issue and retry: cd ${INSTALL_DIR} && docker compose up -d"
|
|
exit 1
|
|
fi
|
|
|
|
# Check health status
|
|
health=$(docker compose ps "$svc" --format '{{.Health}}' 2>/dev/null || echo "")
|
|
if [[ "$health" != "healthy" ]]; then
|
|
ALL_HEALTHY=false
|
|
fi
|
|
done
|
|
|
|
if [[ "$ALL_HEALTHY" == "true" ]]; then
|
|
break
|
|
fi
|
|
|
|
sleep "$HEALTH_INTERVAL"
|
|
ELAPSED=$((ELAPSED + HEALTH_INTERVAL))
|
|
done
|
|
|
|
# Print status table
|
|
echo ""
|
|
echo -e "${BOLD} Service Status:${NC}"
|
|
for svc in "${CORE_SERVICES[@]}"; do
|
|
health=$(docker compose ps "$svc" --format '{{.Health}}' 2>/dev/null || echo "unknown")
|
|
state=$(docker compose ps "$svc" --format '{{.State}}' 2>/dev/null || echo "unknown")
|
|
if [[ "$health" == "healthy" ]]; then
|
|
echo -e " ${GREEN}[healthy]${NC} $svc"
|
|
elif [[ "$state" == "running" ]]; then
|
|
echo -e " ${YELLOW}[starting]${NC} $svc"
|
|
else
|
|
echo -e " ${RED}[${state}]${NC} $svc"
|
|
fi
|
|
done
|
|
echo ""
|
|
|
|
if [[ "$ALL_HEALTHY" == "true" ]]; then
|
|
success "All core services are healthy! (${ELAPSED}s)"
|
|
echo ""
|
|
echo " Admin GUI: http://localhost:3000"
|
|
echo " API: http://localhost:4000"
|
|
echo ""
|
|
echo " Check full stack: docker compose ps"
|
|
echo " View API logs: docker compose logs -f api --tail 20"
|
|
else
|
|
warn "Some services are still starting after ${HEALTH_TIMEOUT}s."
|
|
echo ""
|
|
echo " This may be normal on first boot (migrations + seeding can be slow)."
|
|
echo " Monitor progress with:"
|
|
echo " docker compose logs -f api --tail 30"
|
|
echo ""
|
|
echo " Check status with:"
|
|
echo " docker compose ps"
|
|
fi
|
|
else
|
|
echo ""
|
|
info "Skipped. Start services manually when ready:"
|
|
echo ""
|
|
echo " cd ${INSTALL_DIR} && docker compose up -d"
|
|
echo ""
|
|
echo " Pre-built images will be pulled from the registry (~2 min first time)."
|
|
echo " Database migrations and seeding run automatically on startup."
|
|
fi
|
|
|
|
echo ""
|
|
echo -e "${BOLD}${GREEN}Installation complete!${NC}"
|
|
echo ""
|
|
echo -e " \033[0;33mIMPORTANT: Change your admin password after first login!\033[0m"
|
|
echo ""
|