#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" MODE="full" ENV_FILE="" OUTPUT_DIR="$ROOT_DIR/offline-dist" VERSION="$(date +"%Y%m%d_%H%M%S")" usage() { cat < 0 )); do case "$1" in --mode) [[ $# -ge 2 ]] || { echo "Missing value for --mode"; exit 1; } MODE="$2" shift ;; --env-file) [[ $# -ge 2 ]] || { echo "Missing value for --env-file"; exit 1; } ENV_FILE="$2" shift ;; --output-dir) [[ $# -ge 2 ]] || { echo "Missing value for --output-dir"; exit 1; } OUTPUT_DIR="$2" shift ;; -h|--help) usage exit 0 ;; *) echo "Unexpected argument: $1" usage exit 1 ;; esac shift done } require_file() { local path="$1" [[ -f "$path" ]] || { echo "Missing file: $path"; exit 1; } } require_dir() { local path="$1" [[ -d "$path" ]] || { echo "Missing directory: $path"; exit 1; } } read_env_value() { local env_path="$1" local key="$2" local line="" local value="" while IFS= read -r line || [[ -n "$line" ]]; do line="${line%$'\r'}" [[ -z "${line//[[:space:]]/}" ]] && continue [[ "${line#\#}" != "$line" ]] && continue [[ "${line#export }" != "$line" ]] && line="${line#export }" [[ "$line" == "$key="* ]] || continue value="${line#*=}" if [[ "$value" =~ ^\"(.*)\"$ ]]; then value="${BASH_REMATCH[1]}" elif [[ "$value" =~ ^\'(.*)\'$ ]]; then value="${BASH_REMATCH[1]}" fi printf '%s' "$value" return 0 done < "$env_path" return 1 } load_env_value() { local key="$1" local default_value="${2:-}" local value="" value="$(read_env_value "$ENV_FILE" "$key" || true)" if [[ -z "$value" ]]; then value="$default_value" fi printf '%s' "$value" } ensure_image_available() { local image_ref="$1" if docker image inspect "$image_ref" >/dev/null 2>&1; then return 0 fi echo "[export] local image not found, pulling: $image_ref" docker pull "$image_ref" } copy_into_bundle() { local src="$1" local dst="$BUNDLE_DIR/$1" mkdir -p "$(dirname "$dst")" if [[ -d "$ROOT_DIR/$src" ]]; then cp -R "$ROOT_DIR/$src" "$dst" else cp "$ROOT_DIR/$src" "$dst" fi } write_bundle_compose() { cp "$ROOT_DIR/offline/docker-compose.$MODE.yml" "$BUNDLE_DIR/docker-compose.yml" } copy_sql_bundle() { mkdir -p "$BUNDLE_DIR/sql" cp "$ROOT_DIR/scripts/sql/create-tables.sql" "$BUNDLE_DIR/sql/" cp "$ROOT_DIR/scripts/sql/init-data.sql" "$BUNDLE_DIR/sql/" if [[ "$MODE" == "full" ]]; then cp "$ROOT_DIR/scripts/sql/init-postgres-bootstrap.sql" "$BUNDLE_DIR/sql/" cp "$ROOT_DIR/scripts/sql/init-postgres-app.sql" "$BUNDLE_DIR/sql/" fi } upsert_env_file() { local file="$1" local key="$2" local value="$3" local tmp_file="" tmp_file="$(mktemp)" awk -v key="$key" -v value="$value" ' BEGIN { updated = 0 } { if ($0 ~ "^[[:space:]]*#") { print next } if ($0 ~ "^" key "=") { print key "=" value updated = 1 next } print } END { if (!updated) { print key "=" value } } ' "$file" > "$tmp_file" mv "$tmp_file" "$file" } prepare_bundle_env() { local target="$BUNDLE_DIR/${ROOT_ENV_FILE}" cp "$ROOT_DIR/.env.$MODE.example" "$target" { echo "" echo "# Offline bundle helper field." echo "# Used only for README / start script output." echo "PUBLIC_HOST=127.0.0.1" } >> "$target" upsert_env_file "$target" "BACKEND_IMAGE_TAG" "$BACKEND_IMAGE_TAG" upsert_env_file "$target" "FRONTEND_IMAGE_TAG" "$FRONTEND_IMAGE_TAG" upsert_env_file "$target" "NGINX_PORT" "$(load_env_value NGINX_PORT 8080)" upsert_env_file "$target" "HOST_BOTS_WORKSPACE_ROOT" "$(load_env_value HOST_BOTS_WORKSPACE_ROOT /opt/dashboard-nanobot/workspace/bots)" upsert_env_file "$target" "DOCKER_NETWORK_NAME" "$(load_env_value DOCKER_NETWORK_NAME dashboard-nanobot-network)" upsert_env_file "$target" "DOCKER_NETWORK_SUBNET" "$(load_env_value DOCKER_NETWORK_SUBNET 172.20.0.0/16)" upsert_env_file "$target" "PANEL_ACCESS_PASSWORD" "$(load_env_value PANEL_ACCESS_PASSWORD change_me_panel_password)" if [[ "$MODE" == "prod" ]]; then upsert_env_file "$target" "DATABASE_URL" "$(load_env_value DATABASE_URL postgresql+psycopg://postgres:change_me_db_password@127.0.0.1:5432/nanobot)" upsert_env_file "$target" "REDIS_ENABLED" "$(load_env_value REDIS_ENABLED true)" upsert_env_file "$target" "REDIS_URL" "$(load_env_value REDIS_URL redis://127.0.0.1:6379/8)" else upsert_env_file "$target" "POSTGRES_IMAGE" "$POSTGRES_IMAGE" upsert_env_file "$target" "REDIS_IMAGE" "$REDIS_IMAGE" upsert_env_file "$target" "POSTGRES_SUPERPASSWORD" "$(load_env_value POSTGRES_SUPERPASSWORD change_me_pg_super_password)" upsert_env_file "$target" "POSTGRES_APP_PASSWORD" "$(load_env_value POSTGRES_APP_PASSWORD change_me_nanobot_password)" fi } write_root_helper_scripts() { cat > "$BUNDLE_DIR/import-images.sh" < "$BUNDLE_DIR/init-db.sh" < "$BUNDLE_DIR/start.sh" < "$BUNDLE_DIR/stop.sh" < "$BUNDLE_DIR/README.txt" <.tar.gz | docker load 4. Edit config: ${ROOT_ENV_FILE} 5. Initialize database: ./init-db.sh 6. Start service: ./start.sh 7. Stop service: ./stop.sh Fields customer usually needs to edit: - PUBLIC_HOST - NGINX_PORT - HOST_BOTS_WORKSPACE_ROOT - DOCKER_NETWORK_SUBNET - PANEL_ACCESS_PASSWORD EOF if [[ "$MODE" == "prod" ]]; then cat >> "$BUNDLE_DIR/README.txt" <> "$BUNDLE_DIR/README.txt" <> "$BUNDLE_DIR/README.txt" <: Mounts used by this deployment: - ./data -> /app/data - HOST_BOTS_WORKSPACE_ROOT -> same path inside backend container - /var/run/docker.sock -> /var/run/docker.sock Mount note: - Customer can edit docker-compose.yml directly if they want to change host mount paths. - If customer also received a separate nanobot-base image archive and does not import it, Bot-related runtime containers may fail to start. EOF } parse_args "$@" case "$MODE" in full|prod) ;; *) echo "Unsupported mode: $MODE" exit 1 ;; esac if [[ -z "$ENV_FILE" ]]; then if [[ -f "$ROOT_DIR/.env.$MODE" ]]; then ENV_FILE="$ROOT_DIR/.env.$MODE" else ENV_FILE="$ROOT_DIR/.env.$MODE.example" fi fi COMPOSE_FILE="$ROOT_DIR/docker-compose.$MODE.yml" BUNDLE_NAME="dashboard-nanobot-${MODE}-offline-${VERSION}" BUNDLE_DIR="$OUTPUT_DIR/$BUNDLE_NAME" ARCHIVE_FILE="$OUTPUT_DIR/${BUNDLE_NAME}.tar.gz" IMAGE_ARCHIVE="docker-images-${MODE}.tar.gz" ROOT_ENV_FILE=".env" require_file "$ENV_FILE" require_file "$COMPOSE_FILE" require_file "$ROOT_DIR/offline/deploy-${MODE}-offline.sh" require_dir "$ROOT_DIR/data/templates" require_dir "$ROOT_DIR/data/skills" require_dir "$ROOT_DIR/data/model" BACKEND_IMAGE_TAG="$(load_env_value BACKEND_IMAGE_TAG latest)" FRONTEND_IMAGE_TAG="$(load_env_value FRONTEND_IMAGE_TAG latest)" BACKEND_IMAGE="dashboard-nanobot/backend:${BACKEND_IMAGE_TAG}" FRONTEND_IMAGE="dashboard-nanobot/nginx:${FRONTEND_IMAGE_TAG}" IMAGE_REFS=("$BACKEND_IMAGE" "$FRONTEND_IMAGE") if [[ "$MODE" == "full" ]]; then POSTGRES_IMAGE="$(load_env_value POSTGRES_IMAGE postgres:16-alpine)" REDIS_IMAGE="$(load_env_value REDIS_IMAGE redis:7-alpine)" IMAGE_REFS+=("$POSTGRES_IMAGE" "$REDIS_IMAGE") fi mkdir -p "$OUTPUT_DIR" rm -rf "$BUNDLE_DIR" mkdir -p "$BUNDLE_DIR" echo "=== Export Dashboard Nanobot Offline Bundle ===" echo "[export] mode: $MODE" echo "[export] env file: $ENV_FILE" echo "[export] bundle dir: $BUNDLE_DIR" echo "[1/5] validating compose file" docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config -q echo "[2/5] building backend and nginx images" docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" build backend nginx if [[ "$MODE" == "full" ]]; then echo "[3/5] ensuring dependency images are available" ensure_image_available "$POSTGRES_IMAGE" ensure_image_available "$REDIS_IMAGE" else echo "[3/5] prod mode uses external PostgreSQL/Redis" fi echo "[4/5] exporting docker images" docker save "${IMAGE_REFS[@]}" | gzip > "$BUNDLE_DIR/$IMAGE_ARCHIVE" echo "[5/5] collecting deployment files" copy_into_bundle "offline/deploy-$MODE-offline.sh" copy_into_bundle "data/templates" copy_into_bundle "data/skills" copy_into_bundle "data/model" if [[ "$MODE" == "prod" ]]; then copy_into_bundle "offline/init-prod-db-offline.sh" fi if [[ "$MODE" == "full" ]]; then copy_into_bundle "offline/init-full-db-offline.sh" fi copy_sql_bundle write_bundle_compose prepare_bundle_env write_root_helper_scripts write_bundle_readme tar -C "$OUTPUT_DIR" -czf "$ARCHIVE_FILE" "$BUNDLE_NAME" echo "[done] archive: $ARCHIVE_FILE" echo "[done] images:" printf ' - %s\n' "${IMAGE_REFS[@]}"