460 lines
12 KiB
Bash
Executable File
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[@]}"
|