249 lines
8.5 KiB
Bash
Executable File
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
|