dashboard-nanobot/offline/export-offline-bundle.sh

460 lines
12 KiB
Bash
Executable File

#!/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 <<EOF
Usage: $(basename "$0") [--mode full|prod] [--env-file path] [--output-dir path]
Options:
--mode Export deployment bundle for full or prod mode. Default: full
--env-file Compose env file used for image tags and build args.
--output-dir Output directory for offline bundles. Default: offline-dist
-h, --help Show this help message.
EOF
}
parse_args() {
while (( $# > 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/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" <<EOF
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="\$(cd "\$(dirname "\$0")" && pwd)"
IMAGE_ARCHIVE="\$SCRIPT_DIR/${IMAGE_ARCHIVE}"
if [[ ! -f "\$IMAGE_ARCHIVE" ]]; then
echo "Missing image archive: \$IMAGE_ARCHIVE"
exit 1
fi
echo "[import-images] loading images from ${IMAGE_ARCHIVE}"
gunzip -c "\$IMAGE_ARCHIVE" | docker load
echo "[import-images] done"
EOF
cat > "$BUNDLE_DIR/init-db.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="\$(cd "\$(dirname "\$0")" && pwd)"
ENV_FILE="\$SCRIPT_DIR/${ROOT_ENV_FILE}"
if [[ ! -f "\$ENV_FILE" ]]; then
echo "Missing env file: \$ENV_FILE"
exit 1
fi
if [[ "${MODE}" == "prod" ]]; then
if [[ ! -f "\$SCRIPT_DIR/offline/init-prod-db-offline.sh" ]]; then
echo "Missing script: \$SCRIPT_DIR/offline/init-prod-db-offline.sh"
exit 1
fi
"\$SCRIPT_DIR/offline/init-prod-db-offline.sh" "\$ENV_FILE"
else
if [[ ! -f "\$SCRIPT_DIR/offline/init-full-db-offline.sh" ]]; then
echo "Missing script: \$SCRIPT_DIR/offline/init-full-db-offline.sh"
exit 1
fi
"\$SCRIPT_DIR/offline/init-full-db-offline.sh" "\$ENV_FILE"
fi
EOF
cat > "$BUNDLE_DIR/start.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="\$(cd "\$(dirname "\$0")" && pwd)"
ENV_FILE="\$SCRIPT_DIR/${ROOT_ENV_FILE}"
if [[ ! -f "\$ENV_FILE" ]]; then
echo "Missing env file: \$ENV_FILE"
exit 1
fi
"\$SCRIPT_DIR/offline/deploy-${MODE}-offline.sh" "\$ENV_FILE"
EOF
cat > "$BUNDLE_DIR/stop.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="\$(cd "\$(dirname "\$0")" && pwd)"
ENV_FILE="\$SCRIPT_DIR/${ROOT_ENV_FILE}"
if [[ ! -f "\$ENV_FILE" ]]; then
echo "Missing env file: \$ENV_FILE"
exit 1
fi
docker compose --env-file "\$ENV_FILE" -f "\$SCRIPT_DIR/docker-compose.yml" down
EOF
chmod +x \
"$BUNDLE_DIR/import-images.sh" \
"$BUNDLE_DIR/init-db.sh" \
"$BUNDLE_DIR/start.sh" \
"$BUNDLE_DIR/stop.sh"
}
write_bundle_readme() {
cat > "$BUNDLE_DIR/README.txt" <<EOF
Dashboard Nanobot Offline Bundle
Mode: ${MODE}
Version: ${VERSION}
This directory is ready to send directly to the customer.
Included:
- ${IMAGE_ARCHIVE}
- ${ROOT_ENV_FILE}
- docker-compose.yml
- import-images.sh
- init-db.sh
- start.sh
- stop.sh
- offline/
- sql/
- data/templates/
- data/skills/
- data/model/
Customer Quick Start:
1. Extract this bundle on the target server.
2. Import images:
./import-images.sh
3. If a separate bot base image archive was also provided, import it before startup:
gunzip -c nanobot-base-<version>.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" <<EOF
- DATABASE_URL
- REDIS_ENABLED
- REDIS_URL
Prod mode note:
- Customer must prepare external PostgreSQL in advance.
- Customer can run ./init-db.sh to initialize external PostgreSQL automatically.
- If they prefer manual import, run sql/create-tables.sql and sql/init-data.sql before startup.
EOF
else
cat >> "$BUNDLE_DIR/README.txt" <<EOF
- POSTGRES_SUPERPASSWORD
- POSTGRES_APP_PASSWORD
Full mode note:
- PostgreSQL and Redis are already included in this bundle.
- ./init-db.sh can initialize the application database manually if needed.
- start.sh will also trigger database initialization during full startup flow.
EOF
fi
cat >> "$BUNDLE_DIR/README.txt" <<EOF
Access URL:
- http://<PUBLIC_HOST>:<NGINX_PORT>
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[@]}"