From fb2d9eaa0537e137c14c1ec8b7ed1f38bd1eadda Mon Sep 17 00:00:00 2001 From: Kirill Samusenko Date: Wed, 18 Feb 2026 14:51:04 +0300 Subject: [PATCH] Add airgap --- airgap/.gitignore | 3 + airgap/README-airgap.md | 86 ++++++++ airgap/build-bundle.sh | 211 +++++++++++++++++++ airgap/docker-compose-airgap.override.yml | 9 + airgap/install.sh | 235 ++++++++++++++++++++++ airgap/values-airgap-livekit.yaml | 12 ++ airgap/values-airgap-stack.yaml | 18 ++ 7 files changed, 574 insertions(+) create mode 100644 airgap/.gitignore create mode 100644 airgap/README-airgap.md create mode 100755 airgap/build-bundle.sh create mode 100644 airgap/docker-compose-airgap.override.yml create mode 100755 airgap/install.sh create mode 100644 airgap/values-airgap-livekit.yaml create mode 100644 airgap/values-airgap-stack.yaml diff --git a/airgap/.gitignore b/airgap/.gitignore new file mode 100644 index 0000000..fb91f20 --- /dev/null +++ b/airgap/.gitignore @@ -0,0 +1,3 @@ +# Build artifacts — do not commit +loop-airgap-bundle-*/ +*.tar.gz diff --git a/airgap/README-airgap.md b/airgap/README-airgap.md new file mode 100644 index 0000000..5e81307 --- /dev/null +++ b/airgap/README-airgap.md @@ -0,0 +1,86 @@ +# Loop Air-Gapped Installation + +This bundle contains everything needed to deploy Loop without internet access. + +## Contents + +- `images/` — Docker images (pre-pulled, saved as tar archives) +- `helm/` — Helm charts with vendored dependencies +- `docker-compose/` — Docker Compose files +- `install.sh` — Installation script + +## Prerequisites + +### For Kubernetes (Helm) deployment: +- Kubernetes cluster (v1.21+) +- `helm` CLI (v3.x) +- `kubectl` configured for your cluster +- `docker` or `ctr` (containerd) on nodes for image loading + +### For Docker Compose deployment: +- Docker Engine (v20+) +- Docker Compose (v2+) + +## Quick Start + +### Option A: Kubernetes (Helm) + +```bash +# 1. Load images on the current machine +# (repeat on ALL cluster nodes, or use --nodes flag) +./install.sh helm --namespace loop + +# With SSH to cluster nodes (loads images on all nodes automatically): +./install.sh helm --namespace loop --nodes node1,node2,node3 + +# With LiveKit: +./install.sh helm --namespace loop --with-livekit + +# With custom values: +./install.sh helm --namespace loop --values my-values.yaml +``` + +### Option B: Docker Compose + +```bash +./install.sh compose + +# With LiveKit: +./install.sh compose --with-livekit +``` + +## Manual Image Loading (Kubernetes) + +If you cannot use `--nodes`, load images manually on EACH cluster node: + +```bash +# For containerd (most K8s distributions): +for f in images/*.tar; do + sudo ctr -n k8s.io images import "$f" +done + +# For Docker: +for f in images/*.tar; do + docker load -i "$f" +done +``` + +## Configuration + +Edit `helm/loop-enterprise-stack/values.yaml` before installation to customize: + +- `global.siteUrl` — your Loop URL (REQUIRED) +- `global.loopLicense` — your license key (REQUIRED) +- PostgreSQL credentials +- MinIO credentials +- Ingress settings + +The `values-airgap.yaml` file is applied automatically and sets: +- `imagePullPolicy: IfNotPresent` (prevents pull attempts) +- Push notifications disabled (external server unreachable) + +## Updating + +To update Loop in an air-gapped environment: +1. Receive a new bundle from your provider +2. Extract and run `./install.sh` again (uses `helm upgrade --install`) diff --git a/airgap/build-bundle.sh b/airgap/build-bundle.sh new file mode 100755 index 0000000..99bcb7f --- /dev/null +++ b/airgap/build-bundle.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Defaults +WITH_LIVEKIT=false +VERSION="" + +usage() { + cat < Building air-gapped bundle v${VERSION}" +echo " LiveKit: ${WITH_LIVEKIT}" + +# ---- Image lists ---- + +# Core Helm images (used by loop-enterprise-stack) +HELM_IMAGES=( + "registry.loop.ru/loop/server:${VERSION}" + "appropriate/curl:latest" +) + +# Core Docker Compose images +COMPOSE_IMAGES=( + "registry.loop.ru/loop/server:${VERSION}" + "nginx:latest" + "postgres:15-alpine3.21" + "minio/minio:RELEASE.2022-08-26T19-53-15Z" + "minio/mc:RELEASE.2022-08-28T20-08-11Z" +) + +# LiveKit images (optional) +LIVEKIT_IMAGES=() + +echo "==> Image lists prepared" + +# ---- Prerequisites check ---- +for cmd in docker helm tar gzip; do + if ! command -v "$cmd" &>/dev/null; then + echo "ERROR: '$cmd' is required but not found in PATH" + exit 1 + fi +done + +# ---- Vendor Helm dependencies ---- +echo "==> Vendoring Helm dependencies for loop-enterprise-stack..." +helm dependency update "$REPO_ROOT/loop-enterprise-stack/" + +echo "==> Extracting Bitnami image references via helm template..." +BITNAMI_IMAGES=$(helm template test "$REPO_ROOT/loop-enterprise-stack/" 2>/dev/null \ + | grep 'image:' \ + | sed -E 's/.*image:[[:space:]]*"?([^"[:space:]]+)"?.*/\1/' \ + | sort -u \ + | grep -v 'registry.loop.ru' \ + | grep -v 'appropriate/curl') + +while IFS= read -r img; do + [[ -n "$img" ]] && HELM_IMAGES+=("$img") +done <<< "$BITNAMI_IMAGES" + +if [[ "$WITH_LIVEKIT" == "true" ]]; then + echo "==> Vendoring Helm dependencies for loop-livekit-server..." + helm dependency update "$REPO_ROOT/loop-livekit-server/" + + echo "==> Extracting LiveKit image references via helm template..." + LK_IMAGES=$(helm template test "$REPO_ROOT/loop-livekit-server/" 2>/dev/null \ + | grep 'image:' \ + | sed -E 's/.*image:[[:space:]]*"?([^"[:space:]]+)"?.*/\1/' \ + | sort -u) + + while IFS= read -r img; do + [[ -n "$img" ]] && LIVEKIT_IMAGES+=("$img") + done <<< "$LK_IMAGES" +fi + +echo "==> Helm images to bundle:" +printf ' %s\n' "${HELM_IMAGES[@]}" +if [[ "$WITH_LIVEKIT" == "true" ]]; then + echo "==> LiveKit images to bundle:" + printf ' %s\n' "${LIVEKIT_IMAGES[@]}" +fi +echo "==> Compose images to bundle:" +printf ' %s\n' "${COMPOSE_IMAGES[@]}" + +# ---- Prepare bundle directory ---- +rm -rf "$BUNDLE_DIR" +mkdir -p "$BUNDLE_DIR/images" +mkdir -p "$BUNDLE_DIR/helm" +mkdir -p "$BUNDLE_DIR/docker-compose" +if [[ "$WITH_LIVEKIT" == "true" ]]; then + mkdir -p "$BUNDLE_DIR/images/livekit" +fi + +# ---- Pull and save Helm images ---- +echo "==> Pulling and saving Helm images..." +for img in "${HELM_IMAGES[@]}"; do + echo " Pulling: $img" + docker pull "$img" + safe_name=$(echo "$img" | tr '/:' '_') + echo " Saving: $img -> images/${safe_name}.tar" + docker save "$img" -o "$BUNDLE_DIR/images/${safe_name}.tar" +done + +# ---- Pull and save Compose-only images (skip duplicates) ---- +echo "==> Pulling and saving Compose-only images..." +for img in "${COMPOSE_IMAGES[@]}"; do + safe_name=$(echo "$img" | tr '/:' '_') + if [[ -f "$BUNDLE_DIR/images/${safe_name}.tar" ]]; then + echo " Skipping (already saved): $img" + continue + fi + echo " Pulling: $img" + docker pull "$img" + echo " Saving: $img -> images/${safe_name}.tar" + docker save "$img" -o "$BUNDLE_DIR/images/${safe_name}.tar" +done + +# ---- Pull and save LiveKit images ---- +if [[ "$WITH_LIVEKIT" == "true" ]] && [[ ${#LIVEKIT_IMAGES[@]} -gt 0 ]]; then + echo "==> Pulling and saving LiveKit images..." + for img in "${LIVEKIT_IMAGES[@]}"; do + echo " Pulling: $img" + docker pull "$img" + safe_name=$(echo "$img" | tr '/:' '_') + echo " Saving: $img -> images/livekit/${safe_name}.tar" + docker save "$img" -o "$BUNDLE_DIR/images/livekit/${safe_name}.tar" + done +fi + +# ---- Copy Helm charts (with vendored dependencies) ---- +echo "==> Copying Helm charts..." +cp -r "$REPO_ROOT/loop-enterprise-stack" "$BUNDLE_DIR/helm/loop-enterprise-stack" +rm -rf "$BUNDLE_DIR/helm/loop-enterprise-stack/.git" + +if [[ "$WITH_LIVEKIT" == "true" ]]; then + cp -r "$REPO_ROOT/loop-livekit-server" "$BUNDLE_DIR/helm/loop-livekit-server" + rm -rf "$BUNDLE_DIR/helm/loop-livekit-server/.git" +fi + +# ---- Copy docker-compose files ---- +echo "==> Copying docker-compose files..." +cp -r "$REPO_ROOT/docker-compose/"* "$BUNDLE_DIR/docker-compose/" + +# ---- Copy air-gapped values overrides ---- +echo "==> Copying air-gapped values overrides..." +if [[ -f "$SCRIPT_DIR/values-airgap-stack.yaml" ]]; then + cp "$SCRIPT_DIR/values-airgap-stack.yaml" "$BUNDLE_DIR/helm/loop-enterprise-stack/values-airgap.yaml" +fi +if [[ -f "$SCRIPT_DIR/docker-compose-airgap.override.yml" ]]; then + cp "$SCRIPT_DIR/docker-compose-airgap.override.yml" "$BUNDLE_DIR/docker-compose/docker-compose-airgap.override.yml" +fi +if [[ "$WITH_LIVEKIT" == "true" ]] && [[ -f "$SCRIPT_DIR/values-airgap-livekit.yaml" ]]; then + cp "$SCRIPT_DIR/values-airgap-livekit.yaml" "$BUNDLE_DIR/helm/loop-livekit-server/values-airgap.yaml" +fi + +# ---- Copy install script ---- +echo "==> Copying install script..." +if [[ -f "$SCRIPT_DIR/install.sh" ]]; then + cp "$SCRIPT_DIR/install.sh" "$BUNDLE_DIR/install.sh" + chmod +x "$BUNDLE_DIR/install.sh" +fi + +# ---- Copy README ---- +if [[ -f "$SCRIPT_DIR/README-airgap.md" ]]; then + cp "$SCRIPT_DIR/README-airgap.md" "$BUNDLE_DIR/README.md" +fi + +# ---- Package bundle ---- +echo "==> Packaging bundle..." +tar -czf "$SCRIPT_DIR/${BUNDLE_NAME}.tar.gz" -C "$SCRIPT_DIR" "$BUNDLE_NAME" +rm -rf "$BUNDLE_DIR" + +BUNDLE_SIZE=$(du -sh "$SCRIPT_DIR/${BUNDLE_NAME}.tar.gz" | awk '{print $1}') +echo "" +echo "==> Bundle created: ${BUNDLE_NAME}.tar.gz (${BUNDLE_SIZE})" +echo " Transfer this file to the air-gapped environment and run install.sh" diff --git a/airgap/docker-compose-airgap.override.yml b/airgap/docker-compose-airgap.override.yml new file mode 100644 index 0000000..bfb667b --- /dev/null +++ b/airgap/docker-compose-airgap.override.yml @@ -0,0 +1,9 @@ +# Air-gapped overrides for Docker Compose deployment +# Disables external services unreachable in closed networks + +services: + loop: + environment: + MM_EMAILSETTINGS_PUSHNOTIFICATIONSERVER: "" + MM_EMAILSETTINGS_SENDPUSHNOTIFICATIONS: "false" + MM_CALLS_RTCD_URL: "" diff --git a/airgap/install.sh b/airgap/install.sh new file mode 100755 index 0000000..69b8495 --- /dev/null +++ b/airgap/install.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Defaults +MODE="" +WITH_LIVEKIT=false +CUSTOM_VALUES="" +NODES="" +NAMESPACE="loop" +RELEASE_NAME="loop-stack" +LIVEKIT_RELEASE_NAME="loop-livekit" + +usage() { + cat < Loop Air-Gapped Installer" +echo " Mode: ${MODE}" +echo " LiveKit: ${WITH_LIVEKIT}" + +# ---- Detect container runtime ---- +detect_runtime() { + if command -v docker &>/dev/null; then + echo "docker" + elif command -v ctr &>/dev/null; then + echo "containerd" + elif command -v crictl &>/dev/null; then + echo "crictl" + else + echo "none" + fi +} + +# ---- Load images locally ---- +load_images_local() { + local dir="$1" + local runtime + runtime=$(detect_runtime) + + echo "==> Loading images from ${dir} (runtime: ${runtime})..." + + for tarfile in "$dir"/*.tar; do + [[ -f "$tarfile" ]] || continue + echo " Loading: $(basename "$tarfile")" + case "$runtime" in + docker) + docker load -i "$tarfile" + ;; + containerd) + ctr -n k8s.io images import "$tarfile" + ;; + crictl) + if command -v ctr &>/dev/null; then + ctr -n k8s.io images import "$tarfile" + else + echo " WARNING: crictl detected but ctr not found. Please load manually:" + echo " ctr -n k8s.io images import $tarfile" + fi + ;; + *) + echo " ERROR: No container runtime found. Install docker or containerd." + exit 1 + ;; + esac + done +} + +# ---- Load images on remote nodes via SSH ---- +load_images_remote() { + local dir="$1" + local nodes_csv="$2" + + IFS=',' read -ra NODE_LIST <<< "$nodes_csv" + + for node in "${NODE_LIST[@]}"; do + node=$(echo "$node" | xargs) + echo "==> Loading images on remote node: ${node}" + + ssh "$node" "mkdir -p /tmp/loop-airgap-images" + + echo " Copying image tars to ${node}..." + scp "$dir"/*.tar "${node}:/tmp/loop-airgap-images/" + + ssh "$node" bash -s <<'REMOTE_SCRIPT' +set -euo pipefail +if command -v ctr &>/dev/null; then + for f in /tmp/loop-airgap-images/*.tar; do + echo " Loading: $(basename "$f")" + ctr -n k8s.io images import "$f" + done +elif command -v docker &>/dev/null; then + for f in /tmp/loop-airgap-images/*.tar; do + echo " Loading: $(basename "$f")" + docker load -i "$f" + done +else + echo "ERROR: No container runtime found on $(hostname)" + exit 1 +fi +rm -rf /tmp/loop-airgap-images +REMOTE_SCRIPT + + echo " Done: ${node}" + done +} + +# ==== MAIN INSTALLATION ==== + +# ---- Load core images locally ---- +load_images_local "$SCRIPT_DIR/images" + +# ---- Load LiveKit images if requested ---- +if [[ "$WITH_LIVEKIT" == "true" ]] && [[ -d "$SCRIPT_DIR/images/livekit" ]]; then + load_images_local "$SCRIPT_DIR/images/livekit" +fi + +# ---- Load images on remote nodes (Helm mode only) ---- +if [[ "$MODE" == "helm" ]] && [[ -n "$NODES" ]]; then + load_images_remote "$SCRIPT_DIR/images" "$NODES" + if [[ "$WITH_LIVEKIT" == "true" ]] && [[ -d "$SCRIPT_DIR/images/livekit" ]]; then + load_images_remote "$SCRIPT_DIR/images/livekit" "$NODES" + fi +fi + +# ==== HELM MODE ==== +if [[ "$MODE" == "helm" ]]; then + if ! command -v helm &>/dev/null; then + echo "ERROR: 'helm' is required but not found in PATH" + exit 1 + fi + + CHART_DIR="$SCRIPT_DIR/helm/loop-enterprise-stack" + AIRGAP_VALUES="$CHART_DIR/values-airgap.yaml" + + echo "==> Installing Loop via Helm..." + echo " Namespace: ${NAMESPACE}" + echo " Release: ${RELEASE_NAME}" + + HELM_CMD="helm upgrade --install ${RELEASE_NAME} ${CHART_DIR}" + HELM_CMD+=" --namespace ${NAMESPACE} --create-namespace" + + if [[ -f "$AIRGAP_VALUES" ]]; then + HELM_CMD+=" -f ${AIRGAP_VALUES}" + fi + + if [[ -n "$CUSTOM_VALUES" ]]; then + HELM_CMD+=" -f ${CUSTOM_VALUES}" + fi + + echo " Running: ${HELM_CMD}" + eval "$HELM_CMD" + + if [[ "$WITH_LIVEKIT" == "true" ]]; then + LK_CHART_DIR="$SCRIPT_DIR/helm/loop-livekit-server" + LK_AIRGAP_VALUES="$LK_CHART_DIR/values-airgap.yaml" + + echo "==> Installing LiveKit via Helm..." + LK_CMD="helm upgrade --install ${LIVEKIT_RELEASE_NAME} ${LK_CHART_DIR}" + LK_CMD+=" --namespace ${NAMESPACE} --create-namespace" + + if [[ -f "$LK_AIRGAP_VALUES" ]]; then + LK_CMD+=" -f ${LK_AIRGAP_VALUES}" + fi + + echo " Running: ${LK_CMD}" + eval "$LK_CMD" + fi + + echo "" + echo "==> Helm installation complete!" + echo " Check status: helm list -n ${NAMESPACE}" + echo " Check pods: kubectl get pods -n ${NAMESPACE}" +fi + +# ==== COMPOSE MODE ==== +if [[ "$MODE" == "compose" ]]; then + COMPOSE_DIR="$SCRIPT_DIR/docker-compose" + AIRGAP_OVERRIDE="$COMPOSE_DIR/docker-compose-airgap.override.yml" + + echo "==> Installing Loop via Docker Compose..." + + COMPOSE_CMD="docker compose -f ${COMPOSE_DIR}/docker-compose.yml" + if [[ -f "$AIRGAP_OVERRIDE" ]]; then + COMPOSE_CMD+=" -f ${AIRGAP_OVERRIDE}" + fi + COMPOSE_CMD+=" up -d" + + echo " Running: ${COMPOSE_CMD}" + eval "$COMPOSE_CMD" + + echo "" + echo "==> Docker Compose installation complete!" + echo " Check status: docker compose -f ${COMPOSE_DIR}/docker-compose.yml ps" +fi diff --git a/airgap/values-airgap-livekit.yaml b/airgap/values-airgap-livekit.yaml new file mode 100644 index 0000000..c2debc8 --- /dev/null +++ b/airgap/values-airgap-livekit.yaml @@ -0,0 +1,12 @@ +# Air-gapped overrides for loop-livekit-server +# Applied automatically by install.sh in air-gapped environments + +redis: + image: + pullPolicy: IfNotPresent + master: + persistence: + storageClass: "" + replica: + persistence: + storageClass: "" diff --git a/airgap/values-airgap-stack.yaml b/airgap/values-airgap-stack.yaml new file mode 100644 index 0000000..b74ff1e --- /dev/null +++ b/airgap/values-airgap-stack.yaml @@ -0,0 +1,18 @@ +# Air-gapped overrides for loop-enterprise-stack +# Applied automatically by install.sh in air-gapped environments + +loop-enterprise-edition: + # Prevent Kubernetes from trying to pull images from external registries + loopApp: + image: + pullPolicy: IfNotPresent + + initContainerImage: + imagePullPolicy: IfNotPresent + + # Disable push notifications (push.loop.ru unreachable in air-gapped) + global: + features: + notifications: + push: + useHPNS: false