NewsAtlas/deploy/lxc/build.sh

249 lines
8.5 KiB
Bash
Executable File

#!/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 <debian|ubuntu|alpine|all> [--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 <<EOF
Missing $CONFIG_FILE.
Copy prebuild-config.json.template to prebuild-config.json and fill it in:
cp "$SCRIPT_DIR/prebuild-config.json.template" "$CONFIG_FILE"
EOF
exit 1
fi
case "$TARGET" in
debian|ubuntu|alpine|all) ;;
*)
echo "Usage: $0 <debian|ubuntu|alpine|all> [--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