#!/usr/bin/env bash # install.sh — Install Venact on a fresh server. # # Usage: # curl -fsSL https://install.venact.eu | bash # # or download and run: # bash install.sh # # with a pre-configured site-overrides file (skips prompts for values in the file): # curl -fsSL https://install.venact.eu | VENACT_CONFIG=~/site-overrides.yaml bash # # or: # bash install.sh -c /path/to/shared-config-site-overrides.yaml # # fully unattended (no prompts, uses config + defaults): # curl -fsSL https://install.venact.eu | VENACT_CONFIG=~/site-overrides.yaml bash -s -- -f # # provide an explicit environment id when no config file is used: # bash install.sh -i demo5 # # skip shared template bootstrap: # bash install.sh --no-shared-templates # # accepted for compatibility; standard templates are replaced wholesale by default: # bash install.sh --force-update-templates # # Prerequisites: Docker + Compose, 6 GB RAM, 40 GB disk. # ────────────────────────────────────────────────────────────────── set -euo pipefail # Wrap in main() so bash reads the entire script before executing. # This prevents curl|bash from losing script data when child processes # (docker run, docker exec) consume stdin. main() { # ── Parse arguments ────────────────────────────────────────────── CONFIG_FILE="${VENACT_CONFIG:-}" ENV_ID_ARG="" VINFRA_DEFAULT="" FORCE=false NO_SHARED_TEMPLATES=false FORCE_UPDATE_TEMPLATES=false while [ $# -gt 0 ]; do case "$1" in -c|--config) [ $# -ge 2 ] || die "Missing value for $1" CONFIG_FILE="$2" shift 2 ;; -i|--id) [ $# -ge 2 ] || die "Missing value for $1" ENV_ID_ARG="$2" shift 2 ;; -f|--force) FORCE=true shift ;; --vinfra-default) [ $# -ge 2 ] || die "Missing value for $1" VINFRA_DEFAULT="$2" shift 2 ;; --vinfra-default=*) VINFRA_DEFAULT="${1#*=}" shift ;; --vauth-default) [ $# -ge 2 ] || die "Missing value for $1" VAUTH_DEFAULT="$2" shift 2 ;; --vauth-default=*) VAUTH_DEFAULT="${1#*=}" shift ;; --no-shared-templates) NO_SHARED_TEMPLATES=true shift ;; --force-update-templates) FORCE_UPDATE_TEMPLATES=true shift ;; -h|--help) echo "Usage: $0 [-f|--force] [-c|--config site-overrides.yaml] [-i|--id env-id] [--vinfra-default path] [--vauth-default path] [--no-shared-templates] [--force-update-templates]" exit 0 ;; *) echo "Usage: $0 [-f|--force] [-c|--config site-overrides.yaml] [-i|--id env-id] [--vinfra-default path] [--vauth-default path] [--no-shared-templates] [--force-update-templates]" exit 1 ;; esac done # ── Constants ──────────────────────────────────────────────────── INSTALL_SERVER="${INSTALL_SERVER:-https://install.venact.eu}" VENACT_DIR="/opt/venact" OPS_ROOT_DIR="${VENACT_DIR}/venact-shared-deploy" MODULES=(login admin studio compute mentor) MIN_RAM_MB=4096 MIN_DISK_GB=20 STANDARD_TEMPLATES_REPO="venact-standard-templates" STANDARD_TEMPLATES_ARCHIVE="${VENACT_DIR}/standard-templates.tar.gz" # ── Colors ─────────────────────────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' info() { echo -e "${CYAN}[INFO]${NC} $*"; } ok() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } err() { echo -e "${RED}[ERROR]${NC} $*" >&2; } die() { err "$@"; exit 1; } # Wrapper: run docker commands with stdin closed (prevents curl|bash stdin consumption) dkr() { "$@" < /dev/null; } ensure_host_managed_dir() { local path="$1" [ -n "$path" ] || return 0 case "$path" in /opt/venact/*) ;; *) return 0 ;; esac # Prevent Docker from auto-creating host bind-mount sources as root:root. # The compute entrypoint chown handles a separate runtime rewrite case. if [ "$(id -u)" = "0" ]; then install -o venact -g venact -d "$path" else sudo -n install -o venact -g venact -d "$path" fi } ensure_host_managed_dirs_from_compose() { local compose_file path for compose_file in "$@"; do [ -f "$compose_file" ] || continue while IFS= read -r path; do ensure_host_managed_dir "$path" done < <(grep -hoE '/opt/venact/[^ :"]+' "$compose_file" | sort -u) done } clear_orphan_named_containers() { local compose_file="$1" name [ -f "$compose_file" ] || return 0 while IFS= read -r name; do [ -n "$name" ] || continue dkr docker rm -f "$name" 2>/dev/null || true done < <(sed -n -E 's/^[[:space:]]*container_name:[[:space:]]*"?([^" #]+)"?.*/\1/p' "$compose_file") } ensure_deploy_lock() { local lockfile=/var/lock/venact-deploy.lock local owner_group if [ ! -e "$lockfile" ]; then touch "$lockfile" 2>/dev/null || sudo -n touch "$lockfile" || die "Cannot create deploy lockfile $lockfile" fi owner_group="$(stat -c '%U:%G' "$lockfile" 2>/dev/null || true)" if [ "$(id -u)" = "0" ]; then chown venact:venact "$lockfile" || die "Cannot set venact ownership on deploy lockfile $lockfile" chmod 0666 "$lockfile" || die "Cannot set permissions on deploy lockfile $lockfile" elif [ "$owner_group" = "venact:venact" ]; then chmod 0666 "$lockfile" || die "Cannot set permissions on deploy lockfile $lockfile" else sudo -n chown venact:venact "$lockfile" || die "Cannot repair deploy lockfile ownership for $lockfile" sudo -n chmod 0666 "$lockfile" || die "Cannot repair deploy lockfile permissions for $lockfile" fi [ -w "$lockfile" ] || die "lockfile $lockfile exists but is not writable by $(id -un)" exec 9>"$lockfile" flock -n 9 || { err "another update/install/redeploy is in progress on $(hostname)"; exit 75; } ok "Deploy lock acquired" } if [ "$(id -u)" != "0" ] && ! sudo -n true 2>/dev/null; then die "install.sh must run as root or with passwordless sudo" fi TMP_FILES=() track_tmp_file() { TMP_FILES+=("$1") } cleanup_tmp_files() { if [ ${#TMP_FILES[@]} -gt 0 ]; then rm -rf "${TMP_FILES[@]}" 2>/dev/null || true fi } trap cleanup_tmp_files EXIT MANIFEST_JSON="" manifest_query() { local expr="$1" MANIFEST_JSON_DATA="$MANIFEST_JSON" python3 - "$expr" <<'PY' import json import os import sys expr = sys.argv[1].split(".") data = json.loads(os.environ["MANIFEST_JSON_DATA"]) for part in expr: data = data[part] print(data) PY } manifest_file_digest() { local filename="$1" MANIFEST_JSON_DATA="$MANIFEST_JSON" python3 - "$filename" <<'PY' import json import os import sys filename = sys.argv[1] manifest = json.loads(os.environ["MANIFEST_JSON_DATA"]) try: print(manifest["files"][filename]) except KeyError as exc: raise SystemExit(f"manifest.json does not contain checksum for {filename}") from exc PY } verify_sha256() { local file="$1" expected="$2" actual actual="sha256:$(sha256sum "$file" | awk '{print $1}')" [ "$actual" = "$expected" ] || die "SHA-256 mismatch for $(basename "$file"): expected $expected, got $actual" } download_release_file() { local filename="$1" dest="$2" tmp expected mkdir -p "$(dirname "$dest")" tmp=$(mktemp "$(dirname "$dest")/.${filename}.XXXXXX") curl -fsSL "${INSTALL_SERVER}/releases/${VERSION}/${filename}" -o "$tmp" expected=$(manifest_file_digest "$filename") verify_sha256 "$tmp" "$expected" mv "$tmp" "$dest" ok "${filename} verified" } get_image() { local key="$1" MANIFEST_JSON_DATA="$MANIFEST_JSON" python3 - "$key" <<'PY' import json import os import sys key = sys.argv[1] manifest = json.loads(os.environ["MANIFEST_JSON_DATA"]) image = manifest["images"][key] tag = image["tag"] digest = image.get("digest", "") if digest: print(f"{tag.rsplit(':', 1)[0]}@{digest}") else: print(tag) PY } write_release_info() { MANIFEST_JSON_DATA="$MANIFEST_JSON" python3 - "$VERSION" "${VENACT_DIR}/.release-info.json" <<'PY' import json import os import sys from datetime import datetime, timezone version, output_path = sys.argv[1:] manifest = json.loads(os.environ["MANIFEST_JSON_DATA"]) release_info = { "version": version, "date": manifest.get("date"), "repos": manifest.get("repos", {}), "installed_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), } with open(output_path, "w", encoding="utf-8") as fh: json.dump(release_info, fh, indent=2, sort_keys=True) fh.write("\n") PY } header() { echo "" echo -e "${BOLD}─── $* ───${NC}" echo "" } # ── Helper: prompt with default ────────────────────────────────── prompt() { local var_name="$1" prompt_text="$2" default="${3:-}" local value if [ -n "$default" ]; then read -rp " $prompt_text [$default]: " value < /dev/tty value="${value:-$default}" else while true; do read -rp " $prompt_text: " value < /dev/tty [ -n "$value" ] && break echo " (required)" done fi eval "$var_name=\$value" } prompt_password() { local var_name="$1" prompt_text="$2" local pw1 pw2 while true; do read -rsp " $prompt_text: " pw1 < /dev/tty; echo "" [ ${#pw1} -ge 8 ] || { echo " (minimum 8 characters)"; continue; } read -rsp " Confirm password: " pw2 < /dev/tty; echo "" [ "$pw1" = "$pw2" ] && break echo " (passwords do not match, try again)" done eval "$var_name=\$pw1" } source_install_helper() { local helper="$1" local script_dir="" local local_path="" local tmp_path="" if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" local_path="${script_dir}/install/${helper}" fi if [ -n "$local_path" ] && [ -f "$local_path" ]; then # shellcheck source=/dev/null . "$local_path" return 0 fi tmp_path=$(mktemp "/tmp/venact-install-${helper}.XXXXXX") track_tmp_file "$tmp_path" curl -fsSL "${INSTALL_SERVER}/scripts/install/${helper}" -o "$tmp_path" \ || die "Failed to download installer helper ${helper} from ${INSTALL_SERVER}/scripts/install/" # shellcheck source=/dev/null . "$tmp_path" } source_install_helper "passwords.sh" source_install_helper "site-config.sh" source_install_helper "release-assets.sh" source_install_helper "infisical.sh" source_install_helper "module-compose.sh" source_install_helper "database.sh" source_install_helper "service-startup.sh" source_install_helper "templates.sh" source_install_helper "health.sh" # ── Banner ────────────────────────────────────────────────────── echo "" echo -e "${BOLD}${GREEN}╔══════════════════════════════════════════╗${NC}" echo -e "${BOLD}${GREEN}║ Venact Installer ║${NC}" echo -e "${BOLD}${GREEN}╚══════════════════════════════════════════╝${NC}" echo "" # ══════════════════════════════════════════════════════════════════ # STEP 0: User and permissions # ══════════════════════════════════════════════════════════════════ header "Step 0: Checking user and permissions" IS_ROOT=false [ "$(id -u)" -eq 0 ] && IS_ROOT=true if id venact &>/dev/null; then ok "venact user exists" if [ -d "$VENACT_DIR" ] && [ ! -w "$VENACT_DIR" ]; then die "Cannot write to ${VENACT_DIR}. Run as root or as the venact user." fi if [ -d "/venact" ] && [ ! -w "/venact" ]; then die "Cannot write to /venact. Run as root or as the venact user." fi else if [ "$IS_ROOT" = false ]; then die "venact user does not exist. Run as root for first install, or create the venact user first." fi info "Creating venact user (UID 1000) ..." useradd -m -s /bin/bash -u 1000 venact ok "venact user created" fi if ! groups venact 2>/dev/null | grep -q docker; then if [ "$IS_ROOT" = true ]; then usermod -aG docker venact 2>/dev/null || true ok "venact added to docker group" else warn "venact is not in the docker group — Docker commands may fail" fi else ok "venact in docker group" fi if [ "$IS_ROOT" = true ]; then mkdir -p "$VENACT_DIR" /venact chown venact:venact "$VENACT_DIR" /venact ok "Directories created with venact ownership" else mkdir -p "$VENACT_DIR" /venact 2>/dev/null || true fi ensure_deploy_lock # ══════════════════════════════════════════════════════════════════ # STEP 1: Prerequisites # ══════════════════════════════════════════════════════════════════ header "Step 1: Checking prerequisites" # Docker if ! command -v docker &>/dev/null; then die "Docker is not installed. Install Docker first: https://docs.docker.com/engine/install/" fi ok "Docker found: $(docker --version | head -1)" # Docker Compose (v2) if ! docker compose version &>/dev/null; then die "Docker Compose v2 not found. Install it: https://docs.docker.com/compose/install/" fi ok "Docker Compose found: $(docker compose version | head -1)" # RAM TOTAL_RAM_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}') TOTAL_RAM_MB=$((TOTAL_RAM_KB / 1024)) if [ "$TOTAL_RAM_MB" -lt "$MIN_RAM_MB" ]; then die "Insufficient RAM: ${TOTAL_RAM_MB} MB available, ${MIN_RAM_MB} MB required" fi ok "RAM: ${TOTAL_RAM_MB} MB" # Disk AVAIL_DISK_GB=$(df -BG "$VENACT_DIR" 2>/dev/null || df -BG / | tail -1 | awk '{gsub(/G/,"",$4); print $4}') # Fallback parse if ! [[ "$AVAIL_DISK_GB" =~ ^[0-9]+$ ]]; then AVAIL_DISK_GB=$(df -BG / | tail -1 | awk '{gsub(/G/,"",$4); print $4}') fi if [ "${AVAIL_DISK_GB:-0}" -lt "$MIN_DISK_GB" ]; then warn "Low disk space: ${AVAIL_DISK_GB} GB available (${MIN_DISK_GB} GB recommended)" else ok "Disk: ${AVAIL_DISK_GB} GB available" fi # Key ports REQUIRED_PORTS=(80 5432 7801 7810 7811 7820 7821 7831 7840 7841 7890 8443) BLOCKED_PORTS=() for port in "${REQUIRED_PORTS[@]}"; do if ss -tlnp 2>/dev/null | grep -q ":${port} " || \ netstat -tlnp 2>/dev/null | grep -q ":${port} "; then BLOCKED_PORTS+=("$port") fi done if [ ${#BLOCKED_PORTS[@]} -gt 0 ]; then warn "Ports already in use: ${BLOCKED_PORTS[*]}" if $FORCE; then warn "Force mode — continuing despite port conflicts" else warn "These may conflict with Venact services. Continue anyway? (y/N)" read -rp " " confirm < /dev/tty [[ "$confirm" =~ ^[Yy] ]] || die "Aborted. Free the listed ports and try again." fi else ok "Required ports available" fi collect_install_configuration download_release_artifacts generate_site_overrides_config prepare_install_time_config initialize_install_passwords configure_module_compose_files # ══════════════════════════════════════════════════════════════════ # STEP 7: Pull Docker images # ══════════════════════════════════════════════════════════════════ header "Step 7: Pulling Docker images" pull_release_images # ── Seed module config dirs ────────────────────────────────────── seed_module_config_inputs start_database_and_run_seeds start_all_services # ══════════════════════════════════════════════════════════════════ # STEP 10: Bootstrap Infisical (login only) # ══════════════════════════════════════════════════════════════════ bootstrap_infisical restart_modules_after_infisical run_health_checks_and_bootstrap_targets finalize_install } main "$@"