#!/usr/bin/env bash set -euo pipefail [ -r "./env-longhorn" ] && . ./env-longhorn LONGHORN_VERSION="${LONGHORN_VERSION:-1.11.1}" LONGHORN_NAMESPACE="${LONGHORN_NAMESPACE:-longhorn-system}" LONGHORN_STORAGE_CLASS="${LONGHORN_STORAGE_CLASS:-longhorn}" LONGHORN_IS_DEFAULT="${LONGHORN_IS_DEFAULT:-true}" LONGHORN_RECLAIM_POLICY="${LONGHORN_RECLAIM_POLICY:-Retain}" LONGHORN_ALLOW_EXPANSION="${LONGHORN_ALLOW_EXPANSION:-true}" LONGHORN_REPLICA_COUNT="${LONGHORN_REPLICA_COUNT:-2}" LONGHORN_DATA_PATH="${LONGHORN_DATA_PATH:-/var/lib/longhorn}" LONGHORN_DEMOTE_CLASS="${LONGHORN_DEMOTE_CLASS:-hcloud-volumes}" LONGHORN_STORAGE_NET_IF="${LONGHORN_STORAGE_NET_IF:-}" LONGHORN_TAINT_TOLERATION="${LONGHORN_TAINT_TOLERATION:-}" LONGHORN_STORAGE_NODES="${LONGHORN_STORAGE_NODES:-}" LONGHORN_UI_ENABLED="${LONGHORN_UI_ENABLED:-true}" # When storage nodes are restricted via labels, tell Longhorn to only create # default disks on those labeled nodes — prevents OS disk of cp/wrk nodes # from being silently consumed as Longhorn storage (adr-020). CREATE_DISK_LABELED_LINE="" if [ -n "${LONGHORN_STORAGE_NODES}" ]; then CREATE_DISK_LABELED_LINE=" create-default-disk-labeled-nodes: true" fi LONGHORN_UI_INGRESS_ENABLED="${LONGHORN_UI_INGRESS_ENABLED:-false}" LONGHORN_UI_INGRESS_HOST="${LONGHORN_UI_INGRESS_HOST:-}" LONGHORN_UI_INGRESS_CLASS="${LONGHORN_UI_INGRESS_CLASS:-cilium}" LONGHORN_UI_INGRESS_TLS_SECRET="${LONGHORN_UI_INGRESS_TLS_SECRET:-}" MANIFEST_URL="https://raw.githubusercontent.com/longhorn/longhorn/v${LONGHORN_VERSION}/deploy/longhorn.yaml" # ── kubectl + kubeconfig resolution ────────────────────────────────────────── # Mirror the pattern used by cilium, democratic_csi, hetzner_csi: # k0s kubectl takes precedence; fall back to system kubectl. # KUBECONFIG fallback chain: env override → kubeadm admin.conf → k0s pki → generate. # The kubernetes component patches the server address to 127.0.0.1 in all # kubeconfigs so local connectivity works regardless of Hetzner's networking. if command -v k0s &>/dev/null && k0s kubectl version --client=true &>/dev/null; then kubectl() { k0s kubectl "$@"; } K0S_DATA_DIR="${K0S_DATA_DIR:-/var/lib/k0s}" if [ -z "${KUBECONFIG:-}" ] || [ ! -f "${KUBECONFIG}" ]; then if [ -f "${K0S_DATA_DIR}/pki/admin.conf" ]; then export KUBECONFIG="${K0S_DATA_DIR}/pki/admin.conf" else export KUBECONFIG="/tmp/k0s-longhorn.conf" k0s kubeconfig admin > "${KUBECONFIG}" fi fi elif command -v kubectl &>/dev/null; then if [ -z "${KUBECONFIG:-}" ] || [ ! -f "${KUBECONFIG}" ]; then if [ -f "/etc/kubernetes/admin.conf" ]; then export KUBECONFIG="/etc/kubernetes/admin.conf" elif [ -f "/root/.kube/config" ]; then export KUBECONFIG="/root/.kube/config" else echo "ERROR: no kubeconfig found — run this taskserv after kubernetes(cp) completes" >&2 exit 1 fi fi else echo "ERROR: neither kubectl nor k0s found" >&2 exit 1 fi echo "=== longhorn: verifying cluster connectivity ===" if ! kubectl cluster-info --request-timeout=10s &>/dev/null; then echo "ERROR: kubectl cannot reach the cluster API (KUBECONFIG=${KUBECONFIG:-})" >&2 exit 1 fi # ── Auto-label storage nodes ───────────────────────────────────────────────── # When LONGHORN_STORAGE_NODES is provided, apply the disk-creation label before # the availability check. --overwrite is idempotent for re-runs. if [ -n "${LONGHORN_STORAGE_NODES}" ]; then echo "=== longhorn: labeling storage nodes ===" for node in ${LONGHORN_STORAGE_NODES}; do if kubectl get node "${node}" &>/dev/null; then kubectl label node "${node}" \ node.longhorn.io/create-default-disk=true \ --overwrite echo " labeled: ${node}" else echo "WARNING: node ${node} not found in cluster — skipping" >&2 fi done fi # ── Storage node availability check ───────────────────────────────────────── # Hard requirement: at least LONGHORN_REPLICA_COUNT nodes with the Longhorn # label must be Ready. Installing with fewer nodes means every PVC create will # fail immediately due to insufficient replicas — abort here instead. echo "=== longhorn: verifying storage node availability (need >= ${LONGHORN_REPLICA_COUNT}) ===" READY_STORAGE_NODES=$(kubectl get nodes \ -l "node.longhorn.io/create-default-disk=true" \ --no-headers 2>/dev/null \ | grep -v "NotReady\|SchedulingDisabled" \ | wc -l) if [ "${READY_STORAGE_NODES}" -lt "${LONGHORN_REPLICA_COUNT}" ]; then echo "ERROR: found ${READY_STORAGE_NODES} Ready storage node(s), need ${LONGHORN_REPLICA_COUNT}." >&2 echo " Provision and label all storage nodes before running the longhorn taskserv." >&2 echo " Label: kubectl label node node.longhorn.io/create-default-disk=true" >&2 exit 1 fi echo "=== longhorn: ${READY_STORAGE_NODES} Ready storage node(s) confirmed ===" # Verify storage nodes are reachable via private IPs (10.0.8.x) NON_PRIVATE=$(kubectl get nodes \ -l "node.longhorn.io/create-default-disk=true" \ -o jsonpath='{range .items[*]}{range .status.addresses[?(@.type=="InternalIP")]}{.address}{"\n"}{end}{end}' \ | grep -v "^10\." || true) if [ -n "${NON_PRIVATE}" ]; then echo "ERROR: storage node(s) have non-private internal IP(s): ${NON_PRIVATE}" >&2 echo " All storage nodes must use private network addresses (10.0.8.0/24)." >&2 exit 1 fi # ── Namespace + default settings ───────────────────────────────────────────── echo "=== longhorn: creating namespace ${LONGHORN_NAMESPACE} ===" kubectl create namespace "${LONGHORN_NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - # Build the storage-network setting line — empty means Longhorn auto-selects using # the K8s node internal IP (already private on Hetzner). STORAGE_NET_LINE="" if [ -n "${LONGHORN_STORAGE_NET_IF}" ]; then STORAGE_NET_LINE=" storage-network-interface: ${LONGHORN_STORAGE_NET_IF}" fi echo "=== longhorn: applying default-setting ConfigMap ===" kubectl apply -f - </dev/null || echo "") # reclaimPolicy is immutable — recreate if it changed if [ -n "${CURRENT_POLICY}" ] && [ "${CURRENT_POLICY}" != "${LONGHORN_RECLAIM_POLICY}" ]; then echo "=== longhorn: reclaimPolicy changed — recreating StorageClass ${LONGHORN_STORAGE_CLASS} ===" kubectl delete storageclass "${LONGHORN_STORAGE_CLASS}" 2>/dev/null || true CURRENT_POLICY="" fi if [ -z "${CURRENT_POLICY}" ]; then echo "=== longhorn: creating StorageClass ${LONGHORN_STORAGE_CLASS} (reclaim=${LONGHORN_RECLAIM_POLICY}, default=${LONGHORN_IS_DEFAULT}) ===" kubectl apply -f - </dev/null; then echo "=== longhorn: demoting ${LONGHORN_DEMOTE_CLASS} (removing is-default annotation) ===" kubectl annotate storageclass "${LONGHORN_DEMOTE_CLASS}" \ "storageclass.kubernetes.io/is-default-class-" --overwrite 2>/dev/null || true fi fi # ── Ingress for UI ──────────────────────────────────────────────────────────── if [ "${LONGHORN_UI_INGRESS_ENABLED}" = "true" ]; then if [ -z "${LONGHORN_UI_INGRESS_HOST}" ]; then echo "ERROR: ui_ingress_enabled=true but ui_ingress_host is not set" >&2 exit 1 fi echo "=== longhorn: creating Ingress for UI (class=${LONGHORN_UI_INGRESS_CLASS}, host=${LONGHORN_UI_INGRESS_HOST}) ===" TLS_BLOCK="" if [ -n "${LONGHORN_UI_INGRESS_TLS_SECRET}" ]; then TLS_BLOCK=" tls: - hosts: - ${LONGHORN_UI_INGRESS_HOST} secretName: ${LONGHORN_UI_INGRESS_TLS_SECRET}" fi kubectl apply -f - <