From deecc5b6de534f4b003e882f37de7ba5dc003182 Mon Sep 17 00:00:00 2001 From: Wolfgang Date: Fri, 9 Jan 2026 20:09:29 +0100 Subject: [PATCH] locales in LXC --- install.sh | 297 ++++++++++++++++++------------------------------- libsupabase.sh | 197 ++++++++++++++------------------ 2 files changed, 190 insertions(+), 304 deletions(-) diff --git a/install.sh b/install.sh index 504e3f3..f1e369f 100755 --- a/install.sh +++ b/install.sh @@ -1,14 +1,32 @@ #!/usr/bin/env bash set -Eeuo pipefail -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=libsupabase.sh +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/libsupabase.sh" setup_traps -# ------------------------- +usage() { + cat >&2 <<'EOF' +Usage: + bash install.sh [options] + +Core options: + --ctid Force CT ID (optional). If omitted, a customer-safe CTID is generated. + --cores (default: 2) + --memory (default: 4096) + --swap (default: 512) + --disk (default: 50) + --bridge (default: vmbr0) + --storage (default: local-zfs) + --ip (default: dhcp) + --privileged Create privileged CT (default: unprivileged) + --tg-token Telegram bot token (optional) + --tg-chat Telegram chat id (optional) + --help Show help +EOF +} + # Defaults -# ------------------------- CTID="" CORES="2" MEMORY="4096" @@ -20,43 +38,10 @@ IPCFG="dhcp" UNPRIV="1" TG_TOKEN="" TG_CHAT="" -DOMAIN="userman.de" -CT_HOSTNAME="sb-$(date +%s)" - -usage() { - cat <<'USAGE' -Usage: - bash install.sh [options] - -Core options: - --ctid Force CT ID (optional) - --cores (default: 2) - --memory (default: 4096) - --swap (default: 512) - --disk (default: 50) - --bridge (default: vmbr0) - --storage (default: local-zfs) - --ip (default: dhcp) - --privileged Create privileged CT (default: unprivileged) - --domain FQDN base (default: userman.de) - -Telegram options: - --tg-token Telegram bot token (optional) - --tg-chat Telegram chat id (optional) - - --help Show help - -Examples: - bash install.sh - bash install.sh --storage local-zfs --bridge vmbr0 --ip dhcp - bash install.sh --ip 192.168.45.171/24 --bridge vmbr90 --domain userman.de -USAGE -} - -# ------------------------- -# Args -# ------------------------- +# --------------------------- +# Arg parsing +# --------------------------- while [[ $# -gt 0 ]]; do case "$1" in --ctid) CTID="${2:-}"; shift 2 ;; @@ -68,197 +53,133 @@ while [[ $# -gt 0 ]]; do --storage) STORAGE="${2:-}"; shift 2 ;; --ip) IPCFG="${2:-}"; shift 2 ;; --privileged) UNPRIV="0"; shift 1 ;; - --domain) DOMAIN="${2:-}"; shift 2 ;; --tg-token) TG_TOKEN="${2:-}"; shift 2 ;; --tg-chat) TG_CHAT="${2:-}"; shift 2 ;; --help|-h) usage; exit 0 ;; - *) die "Unknown arg: $1 (use --help)" ;; + *) die "Unknown option: $1 (use --help)" ;; esac done -# ------------------------- -# Validate basics -# ------------------------- -need_cmd pct pvesm pveam pvesh python3 awk grep date - +# Basic validation [[ "$CORES" =~ ^[0-9]+$ ]] || die "--cores must be integer" -[[ "$MEMORY" =~ ^[0-9]+$ ]] || die "--memory must be integer (MB)" -[[ "$SWAP" =~ ^[0-9]+$ ]] || die "--swap must be integer (MB)" -[[ "$DISK" =~ ^[0-9]+$ ]] || die "--disk must be integer (GB)" -[[ "$UNPRIV" == "0" || "$UNPRIV" == "1" ]] || die "Internal: UNPRIV must be 0/1" -[[ -n "$DOMAIN" ]] || die "--domain empty" +[[ "$MEMORY" =~ ^[0-9]+$ ]] || die "--memory must be integer" +[[ "$SWAP" =~ ^[0-9]+$ ]] || die "--swap must be integer" +[[ "$DISK" =~ ^[0-9]+$ ]] || die "--disk must be integer" +[[ "$UNPRIV" == "0" || "$UNPRIV" == "1" ]] || die "internal: UNPRIV invalid" if [[ "$IPCFG" != "dhcp" ]]; then - [[ "$IPCFG" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]] || die "--ip must be 'dhcp' or CIDR like 192.168.45.171/24" + [[ "$IPCFG" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$ ]] || die "--ip must be dhcp or CIDR (e.g. 192.168.45.171/24)" fi info "Argument-Parsing OK" -# ------------------------- -# Step 4: Preflight Proxmox -# ------------------------- -pve_storage_exists "$STORAGE" || die "Storage not found: ${STORAGE}" -pve_bridge_exists "$BRIDGE" || die "Bridge not found or not a Linux bridge: ${BRIDGE}" +# --------------------------- +# Preflight Proxmox +# --------------------------- +need_cmd pct pvesm pveam pvesh grep sed awk sort date + +pve_storage_exists "$STORAGE" || die "Storage not found: $STORAGE" +pve_bridge_exists "$BRIDGE" || die "Bridge not found: $BRIDGE" TEMPLATE="$(pve_template_ensure_debian12 "$STORAGE")" -[[ -n "$TEMPLATE" ]] || die "Template selection failed (empty)" info "Template OK: ${TEMPLATE}" +# Hostname based on unix time +CT_HOSTNAME="sb-$(date +%s)" + +# CTID selection if [[ -n "$CTID" ]]; then [[ "$CTID" =~ ^[0-9]+$ ]] || die "--ctid must be integer" + if pve_vmid_exists_cluster "$CTID"; then + die "Forced CTID=${CTID} already exists in cluster" + fi else - CTID="$(pve_next_free_ctid 2000 9999 || true)" - [[ -n "$CTID" ]] || die "CTID selection failed (empty)" + CTID="$(pve_select_customer_ctid)" fi -info "CTID selected: ${CTID}" +info "CTID selected: ${CTID}" info "SCRIPT_DIR=${SCRIPT_DIR}" info "CT_HOSTNAME=${CT_HOSTNAME}" info "cores=${CORES} memory=${MEMORY}MB swap=${SWAP}MB disk=${DISK}GB" info "bridge=${BRIDGE} storage=${STORAGE} ip=${IPCFG} unprivileged=${UNPRIV}" + if [[ -n "$TG_TOKEN" && -n "$TG_CHAT" ]]; then - info "Telegram enabled (token/chat provided)" + info "Telegram enabled (chat=${TG_CHAT})" else info "Telegram disabled" fi -# ------------------------- +# --------------------------- # Step 5: Create CT -# ------------------------- +# --------------------------- +NET0="$(pve_build_net0 "$BRIDGE" "$IPCFG")" +ROOTFS="${STORAGE}:${DISK}" +FEATURES="nesting=1,keyctl=1,fuse=1" + info "Step 5: Create CT" -pve_create_ct "$CTID" "$TEMPLATE" "$CT_HOSTNAME" "$CORES" "$MEMORY" "$SWAP" "$STORAGE" "$DISK" "$BRIDGE" "$IPCFG" "$UNPRIV" +info "Creating CT ${CTID} (${CT_HOSTNAME}) from ${TEMPLATE}" + +pct create "${CTID}" "${TEMPLATE}" \ + --hostname "${CT_HOSTNAME}" \ + --cores "${CORES}" \ + --memory "${MEMORY}" \ + --swap "${SWAP}" \ + --net0 "${NET0}" \ + --rootfs "${ROOTFS}" \ + --unprivileged "${UNPRIV}" \ + --features "${FEATURES}" \ + --start 0 info "CT created (not started). Next step: start CT + wait for IP" -pve_start_ct "$CTID" +info "Starting CT ${CTID}" +pct start "${CTID}" + +CT_IP="$(pct_wait_for_ip "${CTID}" || true)" +[[ -n "${CT_IP}" ]] || die "Could not determine CT IP after start" -CT_IP="$(pve_wait_ct_ip "$CTID" 180 || true)" -[[ -n "$CT_IP" ]] || die "CT IP not obtained (timeout)" info "Step 5 OK: LXC erstellt + IP ermittelt" info "CT_HOSTNAME=${CT_HOSTNAME}" info "CT_IP=${CT_IP}" -# ------------------------- -# Step 6: Provision inside CT -# ------------------------- -info "Step 6: Provisioning im CT (Docker + Stack)" +# --------------------------- +# Step 6: Provision inside CT (Locales + Docker + base stack folder) +# --------------------------- +info "Step 6: Provisioning im CT (Locales + Docker + Stack)" -# 6.1 Base packages + Docker (Debian 12: docker.io + compose plugin) -ct_exec "$CTID" "export DEBIAN_FRONTEND=noninteractive; apt-get update -y" -ct_exec "$CTID" "export DEBIAN_FRONTEND=noninteractive; apt-get install -y ca-certificates curl gnupg lsb-release apt-transport-https" -ct_exec "$CTID" "export DEBIAN_FRONTEND=noninteractive; apt-get install -y docker.io docker-compose-plugin" +# 6.0 Fix locales early (prevents perl/locale warnings during installs) +pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get update -y" +pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get install -y locales" -# enable/start docker -ct_exec "$CTID" "systemctl enable --now docker" +# Generate both (safe), set default to de_DE.UTF-8 (matches your /etc/default/locale goal) +pct_exec "${CTID}" "sed -i 's/^# *\\(de_DE.UTF-8 UTF-8\\)/\\1/' /etc/locale.gen" +pct_exec "${CTID}" "sed -i 's/^# *\\(en_US.UTF-8 UTF-8\\)/\\1/' /etc/locale.gen" +pct_exec "${CTID}" "locale-gen >/dev/null" +pct_exec "${CTID}" "update-locale LANG=de_DE.UTF-8 LC_ALL=de_DE.UTF-8" -# 6.2 customer-stack dirs -ct_exec "$CTID" "mkdir -p /opt/customer-stack/volumes/{n8n-data,n8n-db,pgvector-db} /opt/customer-stack/sql" -ct_exec "$CTID" "chmod 700 /opt/customer-stack/volumes/n8n-db /opt/customer-stack/volumes/pgvector-db || true" +# 6.1 Minimal base packages +pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get install -y ca-certificates curl gnupg lsb-release" -# 6.3 Write init sql for pgvector (copy from host sql/init_pgvector.sql if exists) -if [[ -f "${SCRIPT_DIR}/sql/init_pgvector.sql" ]]; then - ct_push "$CTID" "${SCRIPT_DIR}/sql/init_pgvector.sql" "/opt/customer-stack/sql/init_pgvector.sql" -else - # minimal fallback - cat > /tmp/init_pgvector.sql <<'SQL' -CREATE EXTENSION IF NOT EXISTS vector; -SQL - ct_push "$CTID" "/tmp/init_pgvector.sql" "/opt/customer-stack/sql/init_pgvector.sql" - rm -f /tmp/init_pgvector.sql +# 6.2 Docker official repo (Debian 12 / bookworm) +pct_exec "${CTID}" "install -m 0755 -d /etc/apt/keyrings" +pct_exec "${CTID}" "curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg" +pct_exec "${CTID}" "chmod a+r /etc/apt/keyrings/docker.gpg" +pct_exec "${CTID}" "echo \"deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \$(. /etc/os-release && echo \$VERSION_CODENAME) stable\" > /etc/apt/sources.list.d/docker.list" +pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get update -y" + +pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin" + +# Enable docker service +pct_exec "${CTID}" "systemctl enable --now docker >/dev/null || true" + +# 6.3 Create stack directories +pct_exec "${CTID}" "mkdir -p /opt/customer-stack /opt/customer-stack/volumes" + +# 6.4 Copy placeholder compose if present (will be replaced in later steps) +if [[ -f "${SCRIPT_DIR}/templates/docker-compose.yml" ]]; then + pct exec "${CTID}" -- bash -lc "cat > /opt/customer-stack/docker-compose.yml <<'YML' +$(cat "${SCRIPT_DIR}/templates/docker-compose.yml") +YML" fi -# 6.4 Generate docker compose (real stack) -# Notes: -# - n8n runs behind reverse proxy later -> for now disable secure cookie to avoid the browser warning until TLS is in place. -# - WEBHOOK_URL is set to https://./ (works once proxy/TLS is configured) -# - For local testing without TLS you can set WEBHOOK_URL=http://:5678/ and N8N_SECURE_COOKIE=false (already false here) -N8N_BASIC_USER="$(tr -dc 'a-zA-Z0-9' temp file -> push -cat > /tmp/docker-compose.yml <&2; } info() { log "INFO: $*"; } warn() { log "WARN: $*"; } @@ -28,9 +29,9 @@ setup_traps() { trap 'on_error "${LINENO}" "${BASH_COMMAND}" "$?"' ERR } -# ------------------------- +# --------------------------- # Proxmox helpers -# ------------------------- +# --------------------------- pve_storage_exists() { local st="$1" pvesm status --storage "$st" >/dev/null 2>&1 @@ -41,152 +42,116 @@ pve_bridge_exists() { [[ -d "/sys/class/net/${br}/bridge" ]] } -# Return a list of all VMIDs in the cluster (VM + CT) -# Output: one vmid per line (stdout only) -pve_cluster_vmids() { - need_cmd pvesh python3 - pvesh get /cluster/resources --type vm --output-format json \ - | python3 - <<'PY' -import json,sys -data=json.load(sys.stdin) -ids=sorted({int(x["vmid"]) for x in data if "vmid" in x}) -for i in ids: - print(i) -PY -} - -# Pick a free CTID cluster-wide. Default start=2000 to avoid collisions with "classic" ranges. -pve_next_free_ctid() { - need_cmd python3 - local start="${1:-2000}" - local end="${2:-9999}" - - local used - used="$(pve_cluster_vmids || true)" - - python3 - < fallback. + # Some storages (e.g. local-zfs) are not template storages for pveam. if ! pveam list "$store" >/dev/null 2>&1; then - warn "pveam storage '$store' not available for templates; falling back to 'local'" + warn "pveam storage '${store}' not available for templates; falling back to 'local'" store="local" fi - # Update template list (quietly) + # Update list and download if missing pveam update >/dev/null 2>&1 || true - # Download if missing - if ! pveam list "$store" 2>/dev/null | awk '{print $2}' | grep -qx "$tpl"; then + if ! pveam list "$store" | awk '{print $2}' | grep -qx "$tpl"; then info "Downloading CT template to ${store}: ${tpl}" pveam download "$store" "$tpl" >/dev/null fi - # Print template ref for pct create echo "${store}:vztmpl/${tpl}" } pve_build_net0() { - local bridge="$1" - local ip="$2" + local bridge="$1" ip="$2" if [[ "$ip" == "dhcp" ]]; then echo "name=eth0,bridge=${bridge},ip=dhcp" else - # expects CIDR e.g. 192.168.45.171/24 (no gateway here; can be set later if needed) - echo "name=eth0,bridge=${bridge},ip=${ip}" + echo "name=eth0,bridge=${bridge},ip=${ip},gw=$(echo "$ip" | sed -E 's/([0-9]+\.[0-9]+\.[0-9]+)\.[0-9]+\/[0-9]+/\1.1/')" fi } -pve_create_ct() { +# Execute inside CT with sane locale env to avoid perl warnings mid-run. +pct_exec() { + local ctid="$1"; shift + local cmd="$*" need_cmd pct - local ctid="$1" template="$2" hostname="$3" - local cores="$4" memory="$5" swap="$6" - local storage="$7" disk="$8" - local bridge="$9" ip="${10}" unpriv="${11}" - - local net0 rootfs features - net0="$(pve_build_net0 "$bridge" "$ip")" - rootfs="${storage}:${disk}" - features="nesting=1,keyctl=1,fuse=1" - - info "Creating CT ${ctid} (${hostname}) from ${template}" - pct create "$ctid" "$template" \ - --hostname "$hostname" \ - --cores "$cores" \ - --memory "$memory" \ - --swap "$swap" \ - --net0 "$net0" \ - --rootfs "$rootfs" \ - --unprivileged "$unpriv" \ - --features "$features" \ - --start 0 + pct exec "$ctid" -- bash -lc "export LANG=C.UTF-8 LC_ALL=C.UTF-8; ${cmd}" } -pve_start_ct() { - need_cmd pct +pct_wait_for_ip() { local ctid="$1" - info "Starting CT ${ctid}" - pct start "$ctid" >/dev/null -} + local i ip -# Wait for an IPv4 (non-empty) from pct exec "hostname -I" -pve_wait_ct_ip() { - need_cmd pct awk - local ctid="$1" - local timeout="${2:-120}" - local slept=0 - - while (( slept < timeout )); do - # hostname -I may return multiple; pick first IPv4 - local ip - ip="$(pct exec "$ctid" -- bash -lc "hostname -I 2>/dev/null | awk '{print \$1}'" || true)" - ip="${ip//$'\r'/}" - ip="${ip//$'\n'/}" + for i in {1..60}; do + # Try eth0 first (standard CT) + ip="$(pct exec "$ctid" -- bash -lc "ip -4 -o addr show dev eth0 2>/dev/null | awk '{print \$4}' | cut -d/ -f1 | head -n1" 2>/dev/null || true)" if [[ -n "$ip" ]]; then echo "$ip" return 0 fi - sleep 2 - slept=$((slept+2)) + sleep 1 done return 1 } -# Execute a command inside CT with proper shell -ct_exec() { - need_cmd pct - local ctid="$1"; shift - pct exec "$ctid" -- bash -lc "$*" +# --------------------------- +# Cluster VMID discovery (robust) +# --------------------------- +# Outputs one VMID per line (containers + VMs), cluster-wide if possible. +pve_cluster_vmids() { + need_cmd pvesh awk sed grep + + # Try cluster resources; output-format json gives stable machine output. + # If pvesh fails (no cluster or temporary glitch), fall back to local IDs. + if pvesh get /cluster/resources --output-format json >/dev/null 2>&1; then + # No python/jq parsing. Extract "vmid": safely via awk/grep. + pvesh get /cluster/resources --output-format json \ + | tr -d '\n' \ + | sed 's/},{/}\n{/g' \ + | grep -oE '"vmid":[0-9]+' \ + | grep -oE '[0-9]+' \ + | sort -n -u + return 0 + fi + + # Fallback: local node only + { + pct list 2>/dev/null | awk 'NR>1 {print $1}' + qm list 2>/dev/null | awk 'NR>1 {print $1}' + } | sort -n -u } -# Upload a local file/stream into CT (uses pct push) -ct_push() { - need_cmd pct - local ctid="$1" src="$2" dst="$3" - pct push "$ctid" "$src" "$dst" +pve_vmid_exists_cluster() { + local id="$1" + pve_cluster_vmids | awk -v x="$id" '$0==x {found=1} END{exit(found?0:1)}' +} + +# Customer CTID: unix_time - 1_000_000_000 (good until 2038), +# then increment until free cluster-wide. +pve_select_customer_ctid() { + local base now ctid tries + now="$(date +%s)" + base=$(( now - 1000000000 )) + # keep it within a sane positive range + if (( base < 100 )); then + base=100 + fi + + ctid="$base" + for tries in {1..2000}; do + if ! pve_vmid_exists_cluster "$ctid"; then + echo "$ctid" + return 0 + fi + ctid=$((ctid + 1)) + done + + die "CTID selection failed: tried ${base}..$((base+1999)) (all occupied?)" }