#!/bin/bash # Builds a NewsAtlas LXC container on a Proxmox VE host: creates the CT, # installs Docker inside it, deploys the app, bakes secrets/config from # prebuild-config.json into it, and (by default) packages the result as a # reusable .tar.zst template via vzdump. # # Run this ON the Proxmox VE host, as root. It has NOT been executed against # a real Proxmox host while developing this (no such host is available in # that environment) — read it before running, and use --dry-run first. # # SECURITY: the packaged template tarball contains the real values from # prebuild-config.json (API keys, etc.) baked into /opt/newsatlas/.env # inside its filesystem. That was an explicit choice (see deploy/lxc/README.md) # over the safer default of injecting secrets at deploy time — treat every # copy of the resulting .tar.zst as a credentials-bearing artifact: keep it # on trusted storage only, never upload it to a public template store, and # rotate the keys if a copy ever leaves your control. # # Usage: # ./build.sh [--dry-run] [--no-keep] [--no-template] # # --dry-run print every pct/pveam/vzdump command instead of running it # --no-keep destroy the scratch CT after packaging (template only) # --no-template deploy and leave the CT running; skip vzdump packaging set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG_FILE="$SCRIPT_DIR/prebuild-config.json" PROVISION_SCRIPT="$SCRIPT_DIR/provision.sh" APP_DIR_IN_CT="/opt/newsatlas" DRY_RUN=0 KEEP_CT=1 MAKE_TEMPLATE=1 TARGET="${1:-}" shift || true for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=1 ;; --no-keep) KEEP_CT=0 ;; --no-template) MAKE_TEMPLATE=0 ;; *) echo "Unknown flag: $arg" >&2; exit 1 ;; esac done run() { if [ "$DRY_RUN" = "1" ]; then printf '[dry-run]' printf ' %q' "$@" printf '\n' else "$@" fi } # ---- preflight -------------------------------------------------------- if [ "$(id -u)" != "0" ]; then echo "build.sh must run as root on the Proxmox VE host." >&2 exit 1 fi for bin in pct pveam pvesh jq; do if ! command -v "$bin" >/dev/null 2>&1; then echo "Missing required command: $bin (this must run on a Proxmox VE host with jq installed)." >&2 exit 1 fi done if [ "$MAKE_TEMPLATE" = "1" ] && ! command -v vzdump >/dev/null 2>&1; then echo "Missing required command: vzdump (needed to package a template; pass --no-template to skip)." >&2 exit 1 fi if [ ! -f "$CONFIG_FILE" ]; then cat >&2 < [--dry-run] [--no-keep] [--no-template]" >&2 exit 1 ;; esac jqc() { jq -r "$1" "$CONFIG_FILE"; } REPO_URL=$(jqc '.app.repo_url') BRANCH=$(jqc '.app.branch') HTTP_PORT=$(jqc '.app.http_port') HOSTNAME_BASE=$(jqc '.lxc.hostname') CORES=$(jqc '.lxc.cores') MEMORY_MB=$(jqc '.lxc.memory_mb') SWAP_MB=$(jqc '.lxc.swap_mb') DISK_GB=$(jqc '.lxc.disk_gb') STORAGE=$(jqc '.lxc.storage') TEMPLATE_STORAGE=$(jqc '.lxc.template_storage') BRIDGE=$(jqc '.lxc.bridge') VLAN=$(jqc '.lxc.vlan') IP_CFG=$(jqc '.lxc.ip') GATEWAY=$(jqc '.lxc.gateway') UNPRIVILEGED=$(jqc '.lxc.unprivileged') START_ON_BOOT=$(jqc '.lxc.start_on_boot') ROOT_PASSWORD=$(jqc '.lxc.root_password') SSH_KEY_FILE=$(jqc '.lxc.ssh_public_key_file') if [ "$TARGET" = "all" ]; then DISTROS="debian ubuntu alpine" else DISTROS="$TARGET" fi # ---- per-distro build --------------------------------------------------- build_one() { local distro="$1" local template_prefix ctid template hostname net0 rootfs features local unpriv_flag="" [ "$UNPRIVILEGED" = "true" ] && unpriv_flag="1" || unpriv_flag="0" template_prefix=$(jqc ".distros.${distro}") if [ -z "$template_prefix" ] || [ "$template_prefix" = "null" ]; then echo "No distros.${distro} entry in prebuild-config.json, skipping." >&2 return 1 fi echo "=== Building NewsAtlas LXC: $distro ===" echo "-- Resolving template for prefix '$template_prefix'..." pveam update >/dev/null 2>&1 || true template=$(pveam available --section system 2>/dev/null | awk '{print $2}' | grep "^${template_prefix}" | sort -V | tail -1) if [ -z "$template" ]; then echo "Could not find a template matching '${template_prefix}' via 'pveam available'. Check prebuild-config.json's distros.${distro} value against 'pveam available'." >&2 return 1 fi echo "-- Using template: $template" if ! pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -q "$template"; then run pveam download "$TEMPLATE_STORAGE" "$template" fi ctid=$(pvesh get /cluster/nextid) hostname="${HOSTNAME_BASE}-${distro}" rootfs="${STORAGE}:${DISK_GB}" features="nesting=1,keyctl=1" # required for Docker-in-LXC net0="name=eth0,bridge=${BRIDGE},ip=${IP_CFG}" [ -n "$VLAN" ] && net0="${net0},tag=${VLAN}" [ "$IP_CFG" != "dhcp" ] && [ -n "$GATEWAY" ] && net0="${net0},gw=${GATEWAY}" echo "-- Creating CT $ctid ($hostname)..." create_args=(pct create "$ctid" "${TEMPLATE_STORAGE}:vztmpl/${template}" --hostname "$hostname" --cores "$CORES" --memory "$MEMORY_MB" --swap "$SWAP_MB" --rootfs "$rootfs" --net0 "$net0" --unprivileged "$unpriv_flag" --features "$features" --onboot "$([ "$START_ON_BOOT" = "true" ] && echo 1 || echo 0)" ) if [ -n "$ROOT_PASSWORD" ]; then create_args+=(--password "$ROOT_PASSWORD") fi if [ -n "$SSH_KEY_FILE" ] && [ -f "$SSH_KEY_FILE" ]; then create_args+=(--ssh-public-keys "$SSH_KEY_FILE") fi run "${create_args[@]}" run pct start "$ctid" if [ "$DRY_RUN" != "1" ]; then echo "-- Waiting for CT $ctid to come up..." for i in $(seq 1 30); do pct exec "$ctid" -- true 2>/dev/null && break sleep 2 [ "$i" = "30" ] && { echo "CT $ctid did not become ready in time." >&2; return 1; } done fi echo "-- Provisioning (installing Docker, cloning repo)..." run pct push "$ctid" "$PROVISION_SCRIPT" /root/provision.sh --perms 0755 run pct exec "$ctid" -- /root/provision.sh "$REPO_URL" "$BRANCH" "$APP_DIR_IN_CT" echo "-- Writing .env (baking config from prebuild-config.json)..." local envfile envfile=$(mktemp) { echo "HTTP_PORT=${HTTP_PORT}" echo "OWM_API_KEY=$(jqc '.secrets.owm_api_key')" echo "ACLED_API_KEY=$(jqc '.secrets.acled_api_key')" echo "ACLED_EMAIL=$(jqc '.secrets.acled_email')" echo "OPENSKY_USERNAME=$(jqc '.secrets.opensky_username')" echo "OPENSKY_PASSWORD=$(jqc '.secrets.opensky_password')" echo "RSS_POLL_MINUTES=$(jqc '.polling.rss_poll_minutes')" echo "MARKET_POLL_MINUTES=$(jqc '.polling.market_poll_minutes')" echo "MARKET_SPIKE_THRESHOLD_PCT=$(jqc '.polling.market_spike_threshold_pct')" echo "MARKET_SPIKE_LOOKBACK_HOURS=$(jqc '.polling.market_spike_lookback_hours')" echo "CONFLICT_POLL_MINUTES=$(jqc '.polling.conflict_poll_minutes')" echo "FLIGHTS_POLL_SECONDS=$(jqc '.polling.flights_poll_seconds')" echo "ARTICLE_WINDOW_HOURS=$(jqc '.polling.article_window_hours')" echo "DATABASE_PATH=/data/newsatlas.db" } > "$envfile" run pct push "$ctid" "$envfile" "${APP_DIR_IN_CT}/.env" --perms 0600 rm -f "$envfile" echo "-- Starting the stack..." run pct exec "$ctid" -- bash -c "cd ${APP_DIR_IN_CT} && docker compose up -d --build" if [ "$DRY_RUN" != "1" ]; then echo "-- Waiting for the app to answer /api/health..." for i in $(seq 1 30); do pct exec "$ctid" -- curl -sf "http://localhost:${HTTP_PORT}/api/health" >/dev/null 2>&1 && break sleep 3 [ "$i" = "30" ] && echo "Warning: /api/health didn't respond in time; check 'pct exec $ctid -- docker compose -f ${APP_DIR_IN_CT}/docker-compose.yml logs'." >&2 done fi if [ "$MAKE_TEMPLATE" = "1" ]; then echo "-- Stopping CT $ctid to package as a template..." run pct shutdown "$ctid" --timeout 60 echo "-- Packaging via vzdump..." run vzdump "$ctid" --mode stop --compress zstd --storage "$TEMPLATE_STORAGE" if [ "$KEEP_CT" = "1" ]; then run pct start "$ctid" echo "-- CT $ctid restarted (kept running in addition to the packaged template)." else run pct destroy "$ctid" echo "-- CT $ctid destroyed after packaging (--no-keep)." fi fi echo "=== Done: $distro (CTID $ctid, hostname $hostname) ===" echo " Reminder: any packaged .tar.zst for this CT has live secrets baked in — see deploy/lxc/README.md." } for d in $DISTROS; do build_one "$d" done