#!/usr/bin/env bash # ══════════════════════════════════════════════════════════════════════ # Devolyn Guard — one-command installer for the self-hosted stack. # # From a checkout or an extracted release archive: # sudo ./deploy/install.sh # # From nothing (clones the release into /opt/nucleus-guard): # curl -fsSL https://raw.githubusercontent.com/nucleus-guard/nucleus_guard/main/deploy/install.sh | sudo bash # # What it does, in order, and every step is safe to run again: # # 1. Preflight root, OS/arch, the handful of tools the rest needs. # 2. Docker remove snap docker; install Engine + Compose if absent; # verify versions; start the daemon (systemd or not). # 3. Docker DNS a container that cannot resolve names cannot build. Detect, # write 8.8.8.8/1.1.1.1 into /etc/docker/daemon.json, restart. # 4. Source use the checkout we are in, or clone the release. # 5. Secrets every REQUIRED value in deploy/.env that is blank gets # generated. Existing values are never touched. Validated # with `docker compose config` before anything starts. # 6. Images pull the five prebuilt images from GHCR at the pinned # version. --build compiles them from source instead, for # an air-gapped host or one that cannot reach ghcr.io. # 7. Database postgres + redis up; the two least-privilege roles are # created or repaired if the init script did not run (a # bind mount without the execute bit, a CRLF checkout, a # volume that predates the script) -- idempotent. # 8. Stack everything up, and WAIT for every service to be healthy. # On failure: that service's logs and a specific hint. # 9. Verify /health/ready through nginx over TLS. # 10. Summary the URL, the admin login (printed ONCE, on the run that # generated it), where the secrets live, the next step. # # Flags: # --dir PATH where to clone/run from (default: /opt/nucleus-guard, # or the checkout this script lives in) # --ref TAG|BRANCH git ref to clone (default: $NUCLEUS_VERSION or main) # --domain HOST the URL to print and record (default: this host's IP) # --admin-email EMAIL dashboard admin identity (default: admin@) # --reset-admin-password generate a new admin password and print it once # --skip-docker-install fail rather than install Docker # --build build the images from source instead of pulling them # --no-build skip images entirely (re-run on a host that has them) # -y, --yes never prompt (also the default when stdin is not a TTY) # -h, --help # # Environment overrides: NUCLEUS_DIR, NUCLEUS_VERSION, NUCLEUS_REPO_URL. # ══════════════════════════════════════════════════════════════════════ set -Eeuo pipefail INSTALLER_VERSION="0.3.1" # The release this installer pulls. PINNED on purpose: `latest` is also published, # but a stranger running this today and someone running it next month must get the # same stack, or "works on mine" stops meaning anything. Must equal the VERSION file # and the compose tags -- tests/unit/test_release_images.py asserts all three. GUARD_VERSION="0.3.1" REPO_URL_DEFAULT="https://github.com/nucleus-guard/nucleus_guard.git" # Where `curl | bash` gets the deploy bundle when there is no checkout to run from. # Deliberately NOT a git clone: the product repository is private, and a stranger # installing Guard does not need its source -- only the compose file, this script, # the database role-init and an env template. Override with NUCLEUS_BUNDLE_URL. BUNDLE_URL="${NUCLEUS_BUNDLE_URL:-https://get.devolyn.com/devolyn-guard.tar.gz}" INSTALL_DIR_DEFAULT="/opt/nucleus-guard" MIN_DOCKER="24.0.0" MIN_COMPOSE="2.20.0" HEALTH_TIMEOUT="${NUCLEUS_HEALTH_TIMEOUT:-900}" # seconds; first build on a small box is slow # ── output ──────────────────────────────────────────────────────────── if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then C_BOLD=$'\e[1m'; C_DIM=$'\e[2m'; C_RED=$'\e[31m'; C_GRN=$'\e[32m'; C_YLW=$'\e[33m'; C_CYN=$'\e[36m'; C_RST=$'\e[0m' else C_BOLD=""; C_DIM=""; C_RED=""; C_GRN=""; C_YLW=""; C_CYN=""; C_RST="" fi STEP=0 T0=$(date +%s) step() { STEP=$((STEP + 1)); printf '\n%s==> [%d] %s%s %s(%ss)%s\n' "$C_BOLD$C_CYN" "$STEP" "$*" "$C_RST" "$C_DIM" "$(( $(date +%s) - T0 ))" "$C_RST"; } ok() { printf ' %s✔%s %s\n' "$C_GRN" "$C_RST" "$*"; } info() { printf ' %s·%s %s\n' "$C_DIM" "$C_RST" "$*"; } warn() { printf ' %s!%s %s\n' "$C_YLW" "$C_RST" "$*"; } fixed() { printf ' %s⚒ fixed%s %s\n' "$C_YLW" "$C_RST" "$*"; } die() { printf '\n%s✘ %s%s\n' "$C_RED$C_BOLD" "$*" "$C_RST" >&2; exit 1; } on_error() { local line=$1 caller=$2 local where="line ${line}" [[ "$caller" != 0 && "$caller" != "$line" ]] && where="line ${caller} (in a helper at line ${line})" printf '\n%s✘ install.sh failed at %s, step %s.%s\n' "$C_RED$C_BOLD" "$where" "$STEP" "$C_RST" >&2 printf ' Re-run the same command: every step is idempotent and picks up where this left off.\n' >&2 } trap 'on_error $LINENO ${BASH_LINENO[0]:-0}' ERR # ── args ────────────────────────────────────────────────────────────── INSTALL_DIR="${NUCLEUS_DIR:-}" REF="${NUCLEUS_VERSION:-main}" REPO_URL="${NUCLEUS_REPO_URL:-$REPO_URL_DEFAULT}" DOMAIN="" ADMIN_EMAIL="" RESET_ADMIN=0 SKIP_DOCKER_INSTALL=0 NO_BUILD=0 BUILD_FROM_SOURCE=0 ASSUME_YES=0 [[ -t 0 ]] || ASSUME_YES=1 usage() { sed -n '2,50p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//'; exit 0; } while [[ $# -gt 0 ]]; do case "$1" in --dir) INSTALL_DIR="$2"; shift 2 ;; --ref) REF="$2"; shift 2 ;; --domain) DOMAIN="$2"; shift 2 ;; --admin-email) ADMIN_EMAIL="$2"; shift 2 ;; --reset-admin-password) RESET_ADMIN=1; shift ;; --skip-docker-install) SKIP_DOCKER_INSTALL=1; shift ;; --build) BUILD_FROM_SOURCE=1; shift ;; --no-build) NO_BUILD=1; shift ;; -y|--yes) ASSUME_YES=1; shift ;; -h|--help) usage ;; *) die "unknown flag: $1 (try --help)" ;; esac done confirm() { # $1 = question. Returns 0 to proceed. Non-interactive runs always proceed. [[ "$ASSUME_YES" == 1 ]] && return 0 local reply read -r -p " $1 [Y/n] " reply [[ -z "$reply" || "$reply" =~ ^[Yy] ]] } # ── helpers ─────────────────────────────────────────────────────────── have() { command -v "$1" >/dev/null 2>&1; } # version_ge A B : true when A >= B (dotted numerics). version_ge() { [[ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n1)" == "$2" ]] } rand_b64() { openssl rand -base64 "$1" | tr -d '\n'; } rand_hex() { openssl rand -hex "$1" | tr -d '\n'; } # env_get KEY -> value from $ENV_FILE (empty if absent/blank). Strips one layer of quotes. env_get() { local line line=$(grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -n1 || true) line="${line#*=}" line="${line%\"}"; line="${line#\"}" line="${line%\'}"; line="${line#\'}" printf '%s' "$line" } # env_set KEY VALUE -> replace the line in place, or append. awk with ENVIRON, so the # value is never interpreted by sed/shell -- a hash full of `$` goes in verbatim. env_set() { local key="$1" K="$key" V="$2" awk ' BEGIN { k = ENVIRON["K"]; v = ENVIRON["V"]; done = 0 } $0 ~ ("^" k "=") { if (!done) { print k "=" v; done = 1 }; next } { print } END { if (!done) print k "=" v } ' "$ENV_FILE" > "$ENV_FILE.tmp" mv "$ENV_FILE.tmp" "$ENV_FILE" chmod 600 "$ENV_FILE" } # fill_secret KEY GENERATOR-CMD... -> generate only when blank. Never overwrites. fill_secret() { local key="$1"; shift if [[ -z "$(env_get "$key")" ]]; then env_set "$key" "$("$@")" GENERATED+=("$key") fi } # Prod pulls prebuilt images and has no `build:` anywhere. --build layers the build # overlay on top, which adds the build definitions back to those same services -- # so mounts, limits and health gates have one definition and cannot drift. compose() { if [[ "$BUILD_FROM_SOURCE" == 1 && -f "$BUILD_COMPOSE_FILE" ]]; then docker compose -f "$COMPOSE_FILE" -f "$BUILD_COMPOSE_FILE" "$@" else docker compose -f "$COMPOSE_FILE" "$@" fi } # ══════════════════════════════════════════════════════════════════════ # 1. Preflight # ══════════════════════════════════════════════════════════════════════ printf '%s\n' "${C_BOLD}Devolyn Guard installer ${INSTALLER_VERSION}${C_RST}" step "Preflight" [[ "$(id -u)" == 0 ]] || die "run as root: sudo $0 $*" OS_ID="unknown"; OS_VERSION=""; OS_LIKE="" if [[ -r /etc/os-release ]]; then # shellcheck disable=SC1091 . /etc/os-release OS_ID="${ID:-unknown}"; OS_VERSION="${VERSION_ID:-}"; OS_LIKE="${ID_LIKE:-}" fi ARCH=$(uname -m) case "$ARCH" in x86_64|aarch64|arm64) ;; *) die "unsupported architecture: $ARCH (need x86_64 or arm64)" ;; esac ok "OS: ${OS_ID} ${OS_VERSION} (${ARCH})" PKG="" if have apt-get; then PKG=apt elif have dnf; then PKG=dnf elif have yum; then PKG=yum fi HAS_SYSTEMD=0 if have systemctl && [[ -d /run/systemd/system ]]; then HAS_SYSTEMD=1; fi [[ "$HAS_SYSTEMD" == 1 ]] && ok "init: systemd" || info "init: no systemd (container or minimal host) -- daemons will be started directly" pkg_install() { case "$PKG" in apt) DEBIAN_FRONTEND=noninteractive apt-get install -y -q --no-install-recommends "$@" >/dev/null ;; dnf) dnf install -y -q "$@" >/dev/null ;; yum) yum install -y -q "$@" >/dev/null ;; *) die "no supported package manager (apt/dnf/yum); install $* by hand and re-run" ;; esac } # The tools the rest of this script needs. ubuntu:24.04 base has none of curl/git/openssl. NEED=() for t in curl openssl git; do have "$t" || NEED+=("$t"); done have update-ca-certificates || have update-ca-trust || NEED+=(ca-certificates) if [[ ${#NEED[@]} -gt 0 ]]; then info "installing: ${NEED[*]}" [[ "$PKG" == apt ]] && apt-get update -q >/dev/null pkg_install "${NEED[@]}" fixed "installed ${NEED[*]}" fi ok "tools: curl, openssl, git" # ══════════════════════════════════════════════════════════════════════ # 2. Docker Engine + Compose # ══════════════════════════════════════════════════════════════════════ step "Docker" # Snap docker is the single most common broken-Docker on Ubuntu: confined, stale, and it # ships its own compose that ignores /etc/docker/daemon.json. Out it goes. if have snap && snap list docker >/dev/null 2>&1; then warn "snap docker found -- removing (it cannot run this stack)" snap remove --purge docker >/dev/null 2>&1 || snap remove docker >/dev/null fixed "removed snap docker" fi install_docker() { [[ "$SKIP_DOCKER_INSTALL" == 1 ]] && die "Docker is not installed and --skip-docker-install was given" case "$OS_ID:$OS_LIKE" in ubuntu:*|debian:*|*:*debian*|*:*ubuntu*|fedora:*|centos:*|rhel:*|rocky:*|almalinux:*|*:*rhel*|*:*fedora*) ;; *) die "don't know how to install Docker on ${OS_ID}; install Docker Engine 24+ with the compose plugin, then re-run" ;; esac info "installing Docker Engine + Compose plugin (get.docker.com)" curl -fsSL https://get.docker.com -o /tmp/get-docker.sh sh /tmp/get-docker.sh >/tmp/get-docker.log 2>&1 || { tail -n 30 /tmp/get-docker.log; die "Docker install failed (log: /tmp/get-docker.log)"; } rm -f /tmp/get-docker.sh fixed "installed Docker Engine" } have docker || install_docker # On cgroup v2 with no init system (a container, an LXC guest, some CI runners) the root # cgroup still holds our own processes, so the kernel's "no internal processes" rule stops # the domain controllers (memory, io) from being delegated to children. dockerd then gets a # root that is `domain threaded`, and any container with a memory limit -- which is every # service in the compose file -- fails with "cannot enter cgroupv2 ... in threaded mode". # Plain `docker run` still works, which is what makes this one confusing. # # The cure is what Docker's own dind entrypoint does: move every process into a leaf # cgroup, then enable every controller for the subtree. Harmless where not needed. prepare_cgroups() { [[ "$HAS_SYSTEMD" == 0 && -f /sys/fs/cgroup/cgroup.controllers ]] || return 0 [[ -w /sys/fs/cgroup/cgroup.subtree_control ]] || return 0 mkdir -p /sys/fs/cgroup/init xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null || : sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || : } # A daemon that was started WITHOUT that preparation has already created a threaded # `docker` cgroup; it has to go before a restart can help. Only ever called with the daemon # stopped, and only removes cgroups that are empty (rmdir refuses otherwise). remove_threaded_cgroups() { local f d # cgroup paths never contain whitespace; deepest first so parents empty out. while IFS= read -r f; do grep -q '^threaded' "$f" 2>/dev/null || continue d=$(dirname "$f") find "$d" -depth -mindepth 1 -type d -exec rmdir {} \; 2>/dev/null || : rmdir "$d" 2>/dev/null || : done < <(find /sys/fs/cgroup -mindepth 1 -maxdepth 3 -name cgroup.type 2>/dev/null | sort -r) } start_dockerd() { if [[ "$HAS_SYSTEMD" == 1 ]]; then systemctl enable --now docker >/dev/null 2>&1 || systemctl start docker else # No init system: run the daemon ourselves. Same daemon.json, same socket. if ! pgrep -x dockerd >/dev/null; then prepare_cgroups nohup dockerd >/var/log/dockerd.log 2>&1 & fi fi for _ in $(seq 1 60); do docker info >/dev/null 2>&1 && return 0 sleep 1 done [[ "$HAS_SYSTEMD" == 1 ]] || tail -n 20 /var/log/dockerd.log >&2 return 1 } restart_dockerd() { if [[ "$HAS_SYSTEMD" == 1 ]]; then systemctl restart docker else pkill -x dockerd || true for _ in $(seq 1 30); do pgrep -x dockerd >/dev/null || break; sleep 1; done pkill -x containerd || true remove_threaded_cgroups prepare_cgroups nohup dockerd >/var/log/dockerd.log 2>&1 & fi for _ in $(seq 1 60); do docker info >/dev/null 2>&1 && return 0; sleep 1; done return 1 } if ! docker info >/dev/null 2>&1; then info "docker daemon not running -- starting it" start_dockerd || die "could not start the Docker daemon" fixed "docker daemon started" fi DOCKER_VER=$(docker version --format '{{.Server.Version}}' 2>/dev/null | sed 's/[^0-9.].*//') version_ge "$DOCKER_VER" "$MIN_DOCKER" || die "Docker ${DOCKER_VER} is too old; need ${MIN_DOCKER}+ (apt-get install docker-ce, or remove the distro package and re-run)" ok "Docker Engine ${DOCKER_VER}" if ! docker compose version >/dev/null 2>&1; then info "compose plugin missing -- installing" case "$PKG" in apt) apt-get update -q >/dev/null; pkg_install docker-compose-plugin ;; dnf|yum) pkg_install docker-compose-plugin ;; esac docker compose version >/dev/null 2>&1 || die "docker compose plugin could not be installed" fixed "installed docker-compose-plugin" fi COMPOSE_VER=$(docker compose version --short 2>/dev/null | sed 's/^v//; s/[^0-9.].*//') version_ge "$COMPOSE_VER" "$MIN_COMPOSE" || die "Docker Compose ${COMPOSE_VER} is too old; need ${MIN_COMPOSE}+" ok "Docker Compose ${COMPOSE_VER}" # Can the daemon actually run what the compose file asks for? Every service carries a # memory limit, and that is exactly the thing a mis-delegated cgroup v2 root cannot do # while a plain `docker run` still can. Test the real case, not the easy one. LIMITS_PROBE_IMAGE="alpine:3.20" if ! docker image inspect "$LIMITS_PROBE_IMAGE" >/dev/null 2>&1; then docker pull -q "$LIMITS_PROBE_IMAGE" >/dev/null 2>&1 \ || die "cannot pull ${LIMITS_PROBE_IMAGE}: the HOST cannot reach Docker Hub. Check /etc/resolv.conf and outbound HTTPS, then re-run" fi limits_ok() { docker run --rm --memory 64m --cpus 0.5 "$LIMITS_PROBE_IMAGE" true >/dev/null 2>&1; } if limits_ok; then ok "containers start with resource limits" else if [[ "$HAS_SYSTEMD" == 0 ]]; then warn "a container with a memory limit cannot start (cgroup v2 delegation) -- repairing and restarting the daemon" restart_dockerd || die "docker did not come back after the cgroup repair (see /var/log/dockerd.log)" limits_ok || die "containers with memory limits still cannot start. A daemon that was started before this installer (without cgroup preparation) converts the cgroup root to 'threaded', and that is not reversible from inside: reboot this host or recreate this container, then re-run -- the installer prepares cgroups before it ever starts the daemon" fixed "cgroup v2 controllers delegated; daemon restarted" else die "a container with a memory limit cannot start. On a systemd host this is usually cgroup configuration -- check 'docker info' for cgroup driver/version and 'journalctl -u docker'" fi fi # ══════════════════════════════════════════════════════════════════════ # 3. Docker DNS # ══════════════════════════════════════════════════════════════════════ step "Docker DNS" # The daemon resolves names for PULLS with the host's resolver; CONTAINERS resolve with # whatever the daemon hands them, which on a host running systemd-resolved (127.0.0.53) or # behind some VPNs is nothing that works. Every `RUN apt-get` and `pip install` in the # build then fails with "Temporary failure resolving". Test from inside a container. DNS_PROBE_IMAGE="$LIMITS_PROBE_IMAGE" if ! docker image inspect "$DNS_PROBE_IMAGE" >/dev/null 2>&1; then docker pull -q "$DNS_PROBE_IMAGE" >/dev/null 2>&1 \ || die "cannot pull ${DNS_PROBE_IMAGE}: the HOST cannot reach Docker Hub. Check /etc/resolv.conf and outbound HTTPS, then re-run" fi container_dns_ok() { docker run --rm "$DNS_PROBE_IMAGE" sh -c 'nslookup deb.debian.org >/dev/null 2>&1 && nslookup registry-1.docker.io >/dev/null 2>&1' } if container_dns_ok; then ok "containers can resolve names" else warn "containers cannot resolve names -- applying the daemon.json DNS fix" DAEMON_JSON=/etc/docker/daemon.json mkdir -p /etc/docker if [[ -s "$DAEMON_JSON" ]]; then cp "$DAEMON_JSON" "$DAEMON_JSON.bak.$(date +%s)" if have python3; then python3 - "$DAEMON_JSON" <<'PY' import json, sys p = sys.argv[1] d = json.load(open(p)) d["dns"] = ["8.8.8.8", "1.1.1.1"] json.dump(d, open(p, "w"), indent=2) PY elif have jq; then jq '.dns = ["8.8.8.8","1.1.1.1"]' "$DAEMON_JSON" > "$DAEMON_JSON.tmp" && mv "$DAEMON_JSON.tmp" "$DAEMON_JSON" else die "${DAEMON_JSON} exists and neither python3 nor jq is available to merge into it; add \"dns\": [\"8.8.8.8\",\"1.1.1.1\"] by hand and re-run" fi else printf '{\n "dns": ["8.8.8.8", "1.1.1.1"]\n}\n' > "$DAEMON_JSON" fi restart_dockerd || die "docker did not come back after the DNS change (see: journalctl -u docker)" container_dns_ok || die "containers still cannot resolve names after the fix; the host may block outbound DNS (port 53)" fixed "docker DNS -> 8.8.8.8 / 1.1.1.1 (${DAEMON_JSON})" fi # ══════════════════════════════════════════════════════════════════════ # 4. Source # ══════════════════════════════════════════════════════════════════════ step "Source" # Are we running from inside a checkout? (When piped through bash, BASH_SOURCE is empty.) SELF="${BASH_SOURCE[0]:-}" if [[ -n "$SELF" && -f "$SELF" ]]; then CANDIDATE=$(cd "$(dirname "$SELF")/.." && pwd) if [[ -f "$CANDIDATE/deploy/docker-compose.prod.yml" ]]; then INSTALL_DIR="${INSTALL_DIR:-$CANDIDATE}" fi fi INSTALL_DIR="${INSTALL_DIR:-$INSTALL_DIR_DEFAULT}" if [[ -f "$INSTALL_DIR/deploy/docker-compose.prod.yml" ]]; then ok "using ${INSTALL_DIR}" elif [[ -n "${NUCLEUS_REPO_URL:-}" ]]; then # Explicitly asked for a source checkout. This is the path for someone who HAS # access to the repository and wants the full tree -- notably the GitHub PR gate, # whose runner bind-mounts src/, scripts/ and action/ (see docker-compose.prod.yml). info "cloning ${REPO_URL} @ ${REF} -> ${INSTALL_DIR}" git clone --quiet --depth 1 --branch "$REF" "$REPO_URL" "$INSTALL_DIR" \ || die "clone failed. Is NUCLEUS_REPO_URL right (${REPO_URL}) and the ref '${REF}' published?" fixed "cloned ${REF}" else # The default when piped through `curl | bash`: fetch the DEPLOY BUNDLE, not the # source. Guard's repository is private, so cloning it is not something a stranger # can do -- and they do not need to. The bundle carries the compose file, this # installer, the database's role-init script and an env template; the images # themselves come from GHCR. Verified against its published checksum before a # single byte of it is trusted. info "downloading the deploy bundle from ${BUNDLE_URL}" have curl || die "curl is required to download the deploy bundle" have tar || die "tar is required to unpack the deploy bundle" TMP_BUNDLE=$(mktemp -d) trap 'rm -rf "$TMP_BUNDLE"' EXIT curl -fsSL "$BUNDLE_URL" -o "$TMP_BUNDLE/bundle.tar.gz" \ || die "could not download ${BUNDLE_URL}" # The checksum is fetched from the same origin, so this is not a defence against # someone who controls that origin. It IS a defence against the thing that actually # happens: a truncated download, a captive portal, a proxy that returns a login # page with a 200. Running a partial shell archive as root is the failure this # prevents. if curl -fsSL "${BUNDLE_URL}.sha256" -o "$TMP_BUNDLE/bundle.sha256" 2>/dev/null; then EXPECTED=$(awk '{print $1}' "$TMP_BUNDLE/bundle.sha256") if have sha256sum; then ACTUAL=$(sha256sum "$TMP_BUNDLE/bundle.tar.gz" | awk '{print $1}') else ACTUAL=$(shasum -a 256 "$TMP_BUNDLE/bundle.tar.gz" | awk '{print $1}') fi [[ "$EXPECTED" == "$ACTUAL" ]] \ || die "bundle checksum mismatch (expected ${EXPECTED}, got ${ACTUAL}). Refusing to run it." ok "bundle checksum verified" else warn "no published checksum at ${BUNDLE_URL}.sha256 -- continuing unverified" fi mkdir -p "$INSTALL_DIR" # --strip-components=1: the tarball is rooted at devolyn-guard-/. tar xzf "$TMP_BUNDLE/bundle.tar.gz" -C "$INSTALL_DIR" --strip-components=1 \ || die "could not unpack the deploy bundle" [[ -f "$INSTALL_DIR/deploy/docker-compose.prod.yml" ]] \ || die "the bundle did not contain deploy/docker-compose.prod.yml" fixed "deploy bundle unpacked into ${INSTALL_DIR}" fi cd "$INSTALL_DIR" COMPOSE_FILE="$INSTALL_DIR/deploy/docker-compose.prod.yml" BUILD_COMPOSE_FILE="$INSTALL_DIR/deploy/docker-compose.build.yml" ENV_FILE="$INSTALL_DIR/deploy/.env" GIT_SHA=$(git -C "$INSTALL_DIR" rev-parse --short HEAD 2>/dev/null || true) [[ -n "$GIT_SHA" ]] && info "commit ${GIT_SHA}" || info "not a git checkout (release archive)" # ══════════════════════════════════════════════════════════════════════ # 5. Secrets # ══════════════════════════════════════════════════════════════════════ step "Secrets (deploy/.env)" GENERATED=() if [[ ! -f "$ENV_FILE" ]]; then cp "$INSTALL_DIR/deploy/.env.example" "$ENV_FILE" chmod 600 "$ENV_FILE" fixed "created deploy/.env from .env.example" else ok "deploy/.env exists -- only blank values will be filled" fi # Where the dashboard will be reached. Recorded so `devolyn onboard` can print the # right URL later, and so the summary is true. Not a NUCLEUS_ key: the app ignores it. if [[ -z "$DOMAIN" ]]; then DOMAIN=$(env_get GUARD_PUBLIC_URL); DOMAIN="${DOMAIN#https://}"; DOMAIN="${DOMAIN#http://}"; DOMAIN="${DOMAIN%%/*}" fi if [[ -z "$DOMAIN" ]]; then DOMAIN=$(hostname -I 2>/dev/null | awk '{print $1}') [[ -n "$DOMAIN" ]] || DOMAIN=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -n1) [[ -n "$DOMAIN" ]] || DOMAIN=localhost fi PUBLIC_URL="https://${DOMAIN}" env_set GUARD_PUBLIC_URL "$PUBLIC_URL" # The same value under the name the GitHub settings class reads directly. The backend # falls back to GUARD_PUBLIC_URL when this is absent, so an older deploy/.env keeps # working -- writing both means a fresh install does not depend on that fallback at all. env_set NUCLEUS_GITHUB_PUBLIC_URL "$PUBLIC_URL" # Fixed-by-design values. Written only if blank, so an operator's edit survives. [[ -n "$(env_get NUCLEUS_ENVIRONMENT)" ]] || env_set NUCLEUS_ENVIRONMENT PRODUCTION [[ -n "$(env_get NUCLEUS_DEBUG)" ]] || env_set NUCLEUS_DEBUG false [[ -n "$(env_get NUCLEUS_DASHBOARD_COOKIE_SECURE)" ]] || env_set NUCLEUS_DASHBOARD_COOKIE_SECURE true [[ -n "$(env_get NUCLEUS_IMPACT_BLAST_RADIUS_ADVISORY)" ]] || env_set NUCLEUS_IMPACT_BLAST_RADIUS_ADVISORY true [[ -n "$GIT_SHA" ]] && env_set GIT_SHA "$GIT_SHA" # The release this install is pinned to. Written rather than left to the compose # default so that `docker compose` run BY HAND on this host resolves the same images # the installer pulled -- otherwise a later `compose up` could quietly pull a # different version than the one that was verified here. env_set DEVOLYN_VERSION "$GUARD_VERSION" # And so Settings -> About names the release that is actually running. The setting # defaults to the development version; without this the dashboard reports that # number to every user of every release, which is the one fact that page exists for. env_set NUCLEUS_APP_VERSION "$GUARD_VERSION" fill_secret POSTGRES_SUPERUSER_PASSWORD rand_b64 24 fill_secret NUCLEUS_DB_PASSWORD rand_b64 24 fill_secret NUCLEUS_DB_MIGRATION_PASSWORD rand_b64 24 fill_secret NUCLEUS_REDIS_PASSWORD rand_b64 24 fill_secret NUCLEUS_SECURITY_SECRET_KEY rand_hex 32 fill_secret NUCLEUS_AUDIT_EXPORT_TOKEN rand_b64 32 fill_secret NUCLEUS_VERIFIER_CI_TOKEN rand_b64 32 # Connect GitHub. Generated whether or not it is used yet: they cost nothing unused, # and generating the encryption key LATER would mean the first person to click Connect # hits a refusal instead of a working flow. # # The encryption key protects the stored GitHub App private key -- the App's identity. # Changing it does not re-encrypt anything; the stored credential simply becomes # unreadable and GitHub has to be reconnected. That is why it is generated ONCE here # and never regenerated: fill_secret only ever fills a blank. fill_secret NUCLEUS_GITHUB_ENCRYPTION_KEY rand_hex 32 fill_secret NUCLEUS_GITHUB_RUNNER_TOKEN rand_b64 32 # Admin identity. The hash needs the backend image (scrypt via the app's own function, so # the format can never drift from what the login check expects) -- so it is filled in # step 6, after the build. Decide the plaintext now. if [[ -z "$ADMIN_EMAIL" ]]; then ADMIN_EMAIL=$(env_get NUCLEUS_DASHBOARD_ADMIN_EMAIL) [[ -n "$ADMIN_EMAIL" && "$ADMIN_EMAIL" != "admin@example.com" ]] || ADMIN_EMAIL="admin@${DOMAIN}" fi env_set NUCLEUS_DASHBOARD_ADMIN_EMAIL "$ADMIN_EMAIL" ADMIN_PASSWORD="" if [[ "$RESET_ADMIN" == 1 || -z "$(env_get NUCLEUS_DASHBOARD_ADMIN_PASSWORD_HASH)" ]]; then ADMIN_PASSWORD=$(rand_b64 18) fi if [[ ${#GENERATED[@]} -gt 0 ]]; then fixed "generated: ${GENERATED[*]}" fi ok "admin email: ${ADMIN_EMAIL}" ok "public URL: ${PUBLIC_URL}" # ══════════════════════════════════════════════════════════════════════ # 6. Images # ══════════════════════════════════════════════════════════════════════ step "Images" if [[ "$NO_BUILD" == 1 ]]; then info "--no-build: skipping" elif [[ "$BUILD_FROM_SOURCE" == 1 ]]; then # The escape hatch: no GHCR reachability, an air-gapped host, or local changes. # nginx first -- it is the one most likely to fail for a reason unrelated to the # code, and it is fast. Then the rest; the backend is needed before the admin hash. info "--build: compiling from source (Python wheels + a Next.js production build)" info "this is the slow step; pulling prebuilt images instead is the default" compose build --quiet nginx ok "nginx image (config baked with 644 -- see deploy/nginx/Dockerfile)" compose build --quiet backend ok "backend image" compose build --quiet frontend ok "frontend image" compose build --quiet runner ok "runner image (self-hosted GitHub Actions runner; idle until a repo is connected)" else # The default, and the reason a first install is minutes rather than most of an hour: # download five prebuilt images instead of compiling them. It also takes the build # toolchain out of the list of things that can fail on a stranger's machine. info "pulling prebuilt images (ghcr.io/harshit8588h/devolyn-*:${GUARD_VERSION})" if compose pull --quiet 2>/dev/null || compose pull; then ok "images pulled at ${GUARD_VERSION} (pinned, so this install is reproducible)" else # Every likely cause has the same fix, and none of them is obvious from a # registry error: the packages are private, this host cannot reach ghcr.io, or # the pinned version was never pushed. warn "could not pull from ghcr.io" printf '%s ' " Likely one of:" " - the packages are still PRIVATE (make them public once, by hand:" " https://github.com/users/Harshit8588h/packages)" " - this host cannot reach ghcr.io (proxy/firewall)" " - version ${GUARD_VERSION} was never pushed" "" " To build from source instead, re-run with: --build" die "image pull failed" fi fi if [[ -n "$ADMIN_PASSWORD" ]]; then # scrypt hash by the app's own hash_password(). Compose interpolates `$` in .env, so every # `$` in the hash is doubled -- forgetting this is a login that silently fails. # # The image is named explicitly rather than relying on a local `:latest` tag, which # stopped existing when the stack moved to pulled images. The hash is computed on THIS # machine: the password never leaves it, and only the hash is written to .env. BACKEND_IMAGE="ghcr.io/harshit8588h/devolyn-backend:${GUARD_VERSION}" HASH=$(printf '%s' "$ADMIN_PASSWORD" | docker run --rm -i --entrypoint python "$BACKEND_IMAGE" \ -c "import sys; from nucleus_guard.dashboard.credentials import hash_password; print(hash_password(sys.stdin.read()))" \ | tr -d '\n' | sed 's/[$]/$$/g') [[ "$HASH" == scrypt* ]] || die "admin password hashing failed (got: ${HASH:0:40})" env_set NUCLEUS_DASHBOARD_ADMIN_PASSWORD_HASH "$HASH" fixed "admin password generated and hashed" fi # Nothing blank that the stack requires. The compose file's `:?` guards catch the ones it # interpolates; the app's own REQUIRED values are checked here so the failure is a line in # this output rather than a backend in a restart loop. for key in POSTGRES_SUPERUSER_PASSWORD NUCLEUS_DB_PASSWORD NUCLEUS_DB_MIGRATION_PASSWORD \ NUCLEUS_SECURITY_SECRET_KEY NUCLEUS_AUDIT_EXPORT_TOKEN NUCLEUS_VERIFIER_CI_TOKEN \ NUCLEUS_GITHUB_ENCRYPTION_KEY NUCLEUS_GITHUB_RUNNER_TOKEN \ NUCLEUS_DASHBOARD_ADMIN_EMAIL NUCLEUS_DASHBOARD_ADMIN_PASSWORD_HASH; do [[ -n "$(env_get "$key")" ]] || die "${key} is blank in deploy/.env after generation -- this should not happen; please report it" done compose config -q || die "docker compose config rejected deploy/.env" ok "deploy/.env validated (docker compose config)" # ══════════════════════════════════════════════════════════════════════ # 7. Database roles # ══════════════════════════════════════════════════════════════════════ step "Database" # wait_for SERVICE MODE TIMEOUT : MODE is 'healthy', 'exited0' or 'running'. container_state() { # -> "status health exitcode" for a compose service, or "absent none 0". local id id=$(compose ps -q -a "$1" 2>/dev/null | head -n1) [[ -n "$id" ]] || { printf 'absent none 0'; return; } docker inspect --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}} {{.State.ExitCode}}' "$id" } explain_failure() { local svc="$1" printf '\n%s--- last 40 lines of %s ---%s\n' "$C_DIM" "$svc" "$C_RST" >&2 compose logs --no-color --tail 40 "$svc" >&2 || true printf '%s--- end ---%s\n' "$C_DIM" "$C_RST" >&2 case "$svc" in postgres) warn "postgres: a stale volume with a different superuser password? 'docker volume rm nucleus_postgres_data_prod' DESTROYS DATA -- back up first" ;; migrator) warn "migrator: usually a role/password mismatch between deploy/.env and the database. Re-running this installer repairs the roles" ;; backend) warn "backend: a REQUIRED value in deploy/.env is missing or wrong; the log above names it" ;; frontend) warn "frontend: the Next.js server did not come up; the log above has the reason" ;; nginx) warn "nginx: ports 80/443 already in use on this host? 'ss -ltnp | grep -E \":(80|443) \"'" ;; runner) warn "runner: needs NUCLEUS_GITHUB_RUNNER_TOKEN and a reachable backend; the log above says which" ;; esac } wait_for() { local svc="$1" mode="$2" timeout="$3" start now st status health code last="" start=$(date +%s) while :; do st=$(container_state "$svc"); read -r status health code <<<"$st" case "$mode" in healthy) [[ "$health" == healthy ]] && return 0 ;; exited0) [[ "$status" == exited && "$code" == 0 ]] && return 0 ;; # For a service with no healthcheck. Weaker than 'healthy', and honest about it: it # proves the process started, not that it is doing anything useful. running) [[ "$status" == running ]] && return 0 ;; esac # Hard failures: don't sit out the timeout on something that has already died. if [[ "$status" == exited && "$code" != 0 && "$mode" != exited0 ]]; then explain_failure "$svc"; die "${svc} exited with code ${code}" fi if [[ "$mode" == exited0 && "$status" == exited && "$code" != 0 ]]; then # migrator restarts on failure; give it a couple of attempts before calling it. now=$(date +%s); (( now - start > 90 )) && { explain_failure "$svc"; die "${svc} keeps failing (exit ${code})"; } fi now=$(date +%s) if (( now - start > timeout )); then explain_failure "$svc"; die "${svc} not ${mode} after ${timeout}s"; fi if [[ "$status $health" != "$last" ]]; then info "${svc}: ${status}${health:+ / $health}"; last="$status $health"; fi sleep 3 done } compose up -d --quiet-pull postgres redis >/dev/null 2>&1 || compose up -d postgres redis >/dev/null 2>&1 || true wait_for postgres healthy 180 wait_for redis healthy 60 ok "postgres and redis healthy" # The init script in docker/init runs only on an EMPTY volume, and only if the bind mount # delivered it runnable. Make the roles true regardless. Everything here is idempotent and # it also repairs a password that drifted between deploy/.env and the database. PG_DB=$(env_get NUCLEUS_DB_DATABASE); PG_DB="${PG_DB:-nucleus_guard}" ROLE_SQL=$(cat <<'SQL' DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nucleus_migrator') THEN CREATE ROLE nucleus_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS; END IF; IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nucleus_app') THEN CREATE ROLE nucleus_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS; END IF; END $$; ALTER ROLE nucleus_migrator WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS PASSWORD :'migrator_pw'; ALTER ROLE nucleus_app WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS PASSWORD :'app_pw'; ALTER SCHEMA public OWNER TO nucleus_migrator; REVOKE CREATE ON SCHEMA public FROM PUBLIC; REVOKE ALL ON SCHEMA public FROM nucleus_app; GRANT USAGE ON SCHEMA public TO nucleus_app; GRANT CONNECT ON DATABASE :"dbname" TO nucleus_migrator; GRANT CONNECT ON DATABASE :"dbname" TO nucleus_app; SQL ) ROLES_BEFORE=$(compose exec -T postgres psql -U nucleus -d "$PG_DB" -tAc "SELECT count(*) FROM pg_roles WHERE rolname IN ('nucleus_migrator','nucleus_app')" | tr -d '[:space:]') printf '%s\n' "$ROLE_SQL" | compose exec -T postgres psql -v ON_ERROR_STOP=1 -q -U nucleus -d "$PG_DB" \ -v migrator_pw="$(env_get NUCLEUS_DB_MIGRATION_PASSWORD)" -v app_pw="$(env_get NUCLEUS_DB_PASSWORD)" -v dbname="$PG_DB" -f - \ || die "could not create/repair the database roles" if [[ "$ROLES_BEFORE" == 2 ]]; then ok "roles nucleus_migrator + nucleus_app present (passwords synced to deploy/.env)" else fixed "created roles nucleus_migrator + nucleus_app (the init script had not run)" fi # ══════════════════════════════════════════════════════════════════════ # 8. Stack # ══════════════════════════════════════════════════════════════════════ step "Start the stack" # A container already in a crash loop -- a role that has since been repaired, a value in # .env that has since been fixed -- keeps its old failure, and `compose up` will not start # anything that depends on it. Recreate whatever has actually failed so it starts clean # against the repaired database. Healthy containers, and ones still in their start-up # grace period, are left alone. recreate_failed() { local svc st status health code for svc in "$@"; do st=$(container_state "$svc"); read -r status health code <<<"$st" if [[ "$status" == restarting || "$status" == exited || "$status" == dead || "$health" == unhealthy ]]; then info "${svc}: ${status}${health:+ / $health} -- recreating" compose up -d --no-deps --force-recreate "$svc" >/dev/null 2>&1 || true fi done } # The migrator is a one-shot: `up` re-runs it when it has exited, which is what we want on # every run -- alembic is a no-op, the grants are re-applied, the append-only proof re-runs. compose up -d --quiet-pull migrator >/dev/null 2>&1 || compose up -d migrator >/dev/null 2>&1 || true wait_for migrator exited0 300 ok "migrations applied, grants applied, audit log verified append-only" recreate_failed backend compose up -d --no-deps backend >/dev/null 2>&1 || true wait_for backend healthy "$HEALTH_TIMEOUT"; ok "backend healthy" recreate_failed frontend nginx runner compose up -d --quiet-pull >/dev/null 2>&1 || compose up -d >/dev/null 2>&1 || true wait_for frontend healthy 180; ok "frontend healthy" wait_for nginx healthy 120; ok "nginx healthy" # The runner has no healthcheck: it is a client, not a server, so "the process is up" is # the only thing compose can report. Whether it is SERVING is a question only Guard can # answer, which is why the supervisor reports a heartbeat per repository and # `devolyn status` shows it. wait_for runner running 120; ok "runner up (idle until a repo is connected)" # ══════════════════════════════════════════════════════════════════════ # 9. Verify # ══════════════════════════════════════════════════════════════════════ step "Verify" READY="" for _ in $(seq 1 20); do READY=$(curl -sk --max-time 5 https://127.0.0.1/health/ready 2>/dev/null || true) [[ "$READY" == *'"status":"healthy"'* ]] && break sleep 3 done [[ "$READY" == *'"status":"healthy"'* ]] || { explain_failure nginx; die "/health/ready is not healthy through nginx: ${READY:-no response}"; } SCHEMA=$(printf '%s' "$READY" | sed -n 's/.*"schema_version":"\([^"]*\)".*/\1/p') ok "GET /health/ready -> healthy (schema ${SCHEMA:-?}) via https://127.0.0.1" # The operator CLI. Same repo, so `devolyn update` can find itself. # # Two names, one file. `devolyn` is the command; `nucleus-guard` is kept as an alias so # that an existing deployment's scripts, cron entries and runbooks keep working across # this rename. `ln -sf` also repoints an old alias that still points at the pre-rename # deploy/nucleus-guard path, which `git pull` has just deleted -- without that, upgrading # would leave a dangling symlink and the operator with no CLI at all. if [[ -f "$INSTALL_DIR/deploy/devolyn" ]]; then chmod +x "$INSTALL_DIR/deploy/devolyn" ln -sf "$INSTALL_DIR/deploy/devolyn" /usr/local/bin/devolyn ln -sf "$INSTALL_DIR/deploy/devolyn" /usr/local/bin/nucleus-guard ok "installed CLI: devolyn (onboard, status, logs, restart, reset-admin-password, update)" ok "kept alias: nucleus-guard -> devolyn (existing scripts keep working)" fi # ══════════════════════════════════════════════════════════════════════ # 10. Summary # ══════════════════════════════════════════════════════════════════════ ELAPSED=$(( $(date +%s) - T0 )) cat <