#!/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="" 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 # Remove install dir only if config wizard never ran (no user data to lose) 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 # 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 &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 ""