#!/usr/bin/env bash # Runs on the Proxmox host as the claude-deploy user (restricted sudo: only # pct create/set/start/stop/exec/status/list and pveam update/list/download # for VMIDs 200-299). Creates the Graylog LXC container and triggers the # in-container install. Idempotent: safe to re-run. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ---- defaults (override via flags or env) ---------------------------------- VMID="${VMID:-}" CT_HOSTNAME="${CT_HOSTNAME:-graylog}" CORES="${CORES:-4}" MEMORY_MB="${MEMORY_MB:-8192}" SWAP_MB="${SWAP_MB:-512}" DISK_GB="${DISK_GB:-50}" TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local-btrfs}" ROOTFS_STORAGE="${ROOTFS_STORAGE:-EX-Ceph}" BRIDGE="${BRIDGE:-vmbr0}" VLAN_TAG="${VLAN_TAG:-}" IP_CIDR="${IP_CIDR:-}" GATEWAY="${GATEWAY:-}" NAMESERVER="${NAMESERVER:-1.1.1.1}" SEARCHDOMAIN="${SEARCHDOMAIN:-}" TIMEZONE="${TIMEZONE:-Europe/Kyiv}" DEBIAN_TEMPLATE_GLOB="${DEBIAN_TEMPLATE_GLOB:-debian-12-standard_*_amd64.tar.zst}" GRAYLOG_EXTERNAL_URI="${GRAYLOG_EXTERNAL_URI:-}" DISCORD_WEBHOOK_URL="${DISCORD_WEBHOOK_URL:-}" # Colors only when stderr is an actual terminal (never garble log files/pipes). if [ -t 2 ]; then C_RESET=$'\033[0m'; C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RED=$'\033[1;31m' else C_RESET=''; C_CYAN=''; C_GREEN=''; C_YELLOW=''; C_RED='' fi log() { echo "${C_CYAN}[create-graylog-lxc]${C_RESET} $*" >&2; } ok() { echo "${C_GREEN}[create-graylog-lxc] ✓${C_RESET} $*" >&2; } skip() { echo "${C_YELLOW}[create-graylog-lxc] ⏭${C_RESET} $*" >&2; } die() { echo "${C_RED}[create-graylog-lxc] ✗ ERROR:${C_RESET} $*" >&2; exit 1; } usage() { cat < Static IP for the container, e.g. 10.254.254.202/24 --gw Gateway IP --external-uri Public URL used for GRAYLOG_HTTP_EXTERNAL_URI, e.g. http://93.171.241.5:9000/ Optional (sane defaults shown): --vmid <200-299> default: first free VMID in 200-299 --hostname default: graylog --cores default: 4 --memory default: 8192 --swap default: 512 --disk default: 50 --bridge default: vmbr0 --vlan default: none (untagged) --nameserver default: 1.1.1.1 --searchdomain default: (none) --timezone default: Europe/Kyiv --template-storage default: local-btrfs (must support content type vztmpl) --rootfs-storage default: EX-Ceph (container disk) --discord-webhook optional, passed through to install-graylog.sh Everything can also be set via environment variables of the same name in UPPER_SNAKE_CASE (see top of script). EOF } while [ $# -gt 0 ]; do case "$1" in --vmid) VMID="$2"; shift 2 ;; --hostname) CT_HOSTNAME="$2"; shift 2 ;; --cores) CORES="$2"; shift 2 ;; --memory) MEMORY_MB="$2"; shift 2 ;; --swap) SWAP_MB="$2"; shift 2 ;; --disk) DISK_GB="$2"; shift 2 ;; --bridge) BRIDGE="$2"; shift 2 ;; --vlan) VLAN_TAG="$2"; shift 2 ;; --ip) IP_CIDR="$2"; shift 2 ;; --gw) GATEWAY="$2"; shift 2 ;; --nameserver) NAMESERVER="$2"; shift 2 ;; --searchdomain) SEARCHDOMAIN="$2"; shift 2 ;; --timezone) TIMEZONE="$2"; shift 2 ;; --template-storage) TEMPLATE_STORAGE="$2"; shift 2 ;; --rootfs-storage) ROOTFS_STORAGE="$2"; shift 2 ;; --external-uri) GRAYLOG_EXTERNAL_URI="$2"; shift 2 ;; --discord-webhook) DISCORD_WEBHOOK_URL="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) die "unknown argument: $1 (see --help)" ;; esac done [ -n "$IP_CIDR" ] || { usage; die "--ip is required"; } [ -n "$GATEWAY" ] || { usage; die "--gw is required"; } [ -n "$GRAYLOG_EXTERNAL_URI" ] || { usage; die "--external-uri is required"; } # --------------------------------------------------------------------------- pick_free_vmid() { local used id used="$(sudo /usr/sbin/pct list | awk 'NR>1{print $1}')" for id in $(seq 200 299); do if ! echo "$used" | grep -qx "$id"; then echo "$id" return fi done die "no free VMID in range 200-299" } [ -n "$VMID" ] || VMID="$(pick_free_vmid)" case "$VMID" in 2[0-9][0-9]) ;; *) die "VMID must be in range 200-299, got: $VMID" ;; esac CONTAINER_EXISTS=0 if sudo /usr/sbin/pct list | awk 'NR>1{print $1}' | grep -qx "$VMID"; then CONTAINER_EXISTS=1 skip "container VMID $VMID already exists, reusing it" fi # --------------------------------------------------------------------------- ensure_template() { log "Checking for Debian 12 template on storage '$TEMPLATE_STORAGE'..." local found found="$(sudo /usr/bin/pveam list "$TEMPLATE_STORAGE" 2>/dev/null | awk -v s="$TEMPLATE_STORAGE" '$1 ~ s":vztmpl/debian-12-standard" {print $1}' | tail -1)" if [ -n "$found" ]; then TEMPLATE_VOLID="$found" skip "template already cached: $TEMPLATE_VOLID" return fi log "No cached template found, downloading (this can take a minute)..." sudo /usr/bin/pveam update local latest latest="$(sudo /usr/bin/pveam available --section system | awk '/debian-12-standard/{print $2}' | sort -V | tail -1)" [ -n "$latest" ] || die "could not find a debian-12-standard template in the pveam catalog" sudo /usr/bin/pveam download "$TEMPLATE_STORAGE" "$latest" TEMPLATE_VOLID="$(sudo /usr/bin/pveam list "$TEMPLATE_STORAGE" | awk -v s="$TEMPLATE_STORAGE" '$1 ~ s":vztmpl/debian-12-standard" {print $1}' | tail -1)" [ -n "$TEMPLATE_VOLID" ] || die "template download reported success but volume not found" ok "downloaded template: $TEMPLATE_VOLID" } create_container() { local net0="name=eth0,bridge=$BRIDGE,ip=${IP_CIDR},gw=${GATEWAY}" [ -n "$VLAN_TAG" ] && net0="${net0},tag=${VLAN_TAG}" log "Creating container $VMID ($CT_HOSTNAME) from $TEMPLATE_VOLID ..." sudo /usr/sbin/pct create "$VMID" "$TEMPLATE_VOLID" \ --hostname "$CT_HOSTNAME" \ --cores "$CORES" \ --memory "$MEMORY_MB" \ --swap "$SWAP_MB" \ --rootfs "${ROOTFS_STORAGE}:${DISK_GB}" \ --net0 "$net0" \ --unprivileged 1 \ --features nesting=1,keyctl=1 \ --onboot 1 \ --nameserver "$NAMESERVER" \ ${SEARCHDOMAIN:+--searchdomain "$SEARCHDOMAIN"} \ --timezone "$TIMEZONE" \ --description "Graylog centralized log server" ok "container $VMID created" } start_container_and_wait_net() { log "Starting container $VMID..." sudo /usr/sbin/pct start "$VMID" log "Waiting for network..." local i for i in $(seq 1 20); do if sudo /usr/sbin/pct exec "$VMID" -- ping -c1 -W2 "$(echo "$GATEWAY")" >/dev/null 2>&1; then ok "network is up" return fi sleep 2 done die "container did not get network connectivity within ~40s" } check_apparmor_hint() { # If bootstrap-host.sh has been run on this host, the narrow sudoers rule # for fix-lxc-apparmor.sh exists and we can just fix this ourselves. # Otherwise fall back to the same manual instructions as before - this # must never hard-fail the script over an optional convenience path. if sudo -n /usr/local/sbin/fix-lxc-apparmor.sh "$VMID" 2>/dev/null; then ok "AppArmor profile fixed automatically (bootstrap-host.sh was run on this host)" return fi log "NOTE: if Docker fails inside the container with a sysctl/AppArmor permission error," log " the host admin must run:" log " echo 'lxc.apparmor.profile: unconfined' >> /etc/pve/lxc/${VMID}.conf && pct reboot ${VMID}" log " (or run bootstrap-host.sh once as root to automate this for good)" } copy_install_payload() { log "Copying install payload into the container (/opt/graylog-deploy)..." sudo /usr/sbin/pct exec "$VMID" -- mkdir -p /opt/graylog-deploy tar czf - -C "$SCRIPT_DIR" rules streams pipelines alerts dashboards install-graylog.sh setup-forgejo-runner.sh docker-compose.yml nftables.conf \ | sudo /usr/sbin/pct exec "$VMID" -- bash -c 'tar xzf - -C /opt/graylog-deploy' sudo /usr/sbin/pct exec "$VMID" -- chmod +x /opt/graylog-deploy/install-graylog.sh /opt/graylog-deploy/setup-forgejo-runner.sh } run_install() { log "Running install-graylog.sh inside the container..." # GRAYLOG_ADMIN_PASSWORD is only needed on a re-run after the one-time # credentials file was deleted (as instructed in the README); forward it # through if the operator set it, otherwise install-graylog.sh reads the # one-time file itself on a fresh install. sudo /usr/sbin/pct exec "$VMID" -- env \ "GRAYLOG_EXTERNAL_URI=$GRAYLOG_EXTERNAL_URI" \ "DISCORD_WEBHOOK_URL=$DISCORD_WEBHOOK_URL" \ ${GRAYLOG_ADMIN_PASSWORD:+"GRAYLOG_ADMIN_PASSWORD=$GRAYLOG_ADMIN_PASSWORD"} \ bash /opt/graylog-deploy/install-graylog.sh } # --------------------------------------------------------------------------- if [ "$CONTAINER_EXISTS" -eq 0 ]; then ensure_template create_container start_container_and_wait_net check_apparmor_hint else sudo /usr/sbin/pct status "$VMID" | grep -q running || sudo /usr/sbin/pct start "$VMID" fi copy_install_payload run_install echo >&2 echo "${C_GREEN}==================================================${C_RESET}" >&2 echo "${C_GREEN} DONE - VMID=$VMID, Web UI: $GRAYLOG_EXTERNAL_URI${C_RESET}" >&2 echo "${C_GREEN}==================================================${C_RESET}" >&2