From 9ad37d0aa65287c55012df843d76db742d4d6044 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Mon, 13 Apr 2026 15:47:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ( | 0 .env.full.example | 82 +++++++++++++ .gitignore | 1 + README.md | 50 ++++++++ design/code-structure-standards.md | 2 +- docker-compose.full.yml | 153 ++++++++++++++++++++++++ scripts/deploy-full.sh | 140 ++++++++++++++++++++++ scripts/init-full-db.sh | 135 +++++++++++++++++++++ scripts/sql/init-postgres-app.sql | 4 + scripts/sql/init-postgres-bootstrap.sql | 20 ++++ scripts/stop-full.sh | 14 +++ 11 files changed, 600 insertions(+), 1 deletion(-) delete mode 100644 ( create mode 100644 .env.full.example create mode 100644 docker-compose.full.yml create mode 100755 scripts/deploy-full.sh create mode 100755 scripts/init-full-db.sh create mode 100644 scripts/sql/init-postgres-app.sql create mode 100644 scripts/sql/init-postgres-bootstrap.sql create mode 100755 scripts/stop-full.sh diff --git a/( b/( deleted file mode 100644 index e69de29..0000000 diff --git a/.env.full.example b/.env.full.example new file mode 100644 index 0000000..d71b4c2 --- /dev/null +++ b/.env.full.example @@ -0,0 +1,82 @@ +# Public exposed port (only nginx is exposed) +NGINX_PORT=8080 + +# Required absolute host paths. +# They must exist or be creatable by the deployment user and writable by docker daemon. +HOST_DATA_ROOT=/opt/dashboard-nanobot/data +HOST_BOTS_WORKSPACE_ROOT=/opt/dashboard-nanobot/workspace/bots + +# Optional custom image tags +BACKEND_IMAGE_TAG=latest +FRONTEND_IMAGE_TAG=latest + +# Optional base images / mirrors +PYTHON_BASE_IMAGE=python:3.12-slim +NODE_BASE_IMAGE=node:22-alpine +NGINX_BASE_IMAGE=nginx:alpine +POSTGRES_IMAGE=postgres:16-alpine +REDIS_IMAGE=redis:7-alpine + +# Python package index mirror (recommended in CN) +PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple +PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn + +# Frontend package registry mirror (used by yarn, recommended in CN) +NPM_REGISTRY=https://registry.npmmirror.com + +# Container timezone +TZ=Asia/Shanghai + +# PostgreSQL bootstrap account. +# These values are used by the postgres container itself. +POSTGRES_SUPERUSER=postgres +POSTGRES_SUPERPASSWORD=change_me_pg_super_password +POSTGRES_BOOTSTRAP_DB=postgres + +# Dashboard application database account. +# deploy-full.sh will call scripts/init-full-db.sh to create/update these idempotently. +POSTGRES_APP_DB=dashboard +POSTGRES_APP_USER=dashboard +POSTGRES_APP_PASSWORD=change_me_dashboard_password +DATABASE_POOL_SIZE=20 +DATABASE_MAX_OVERFLOW=40 +DATABASE_POOL_TIMEOUT=30 +DATABASE_POOL_RECYCLE=1800 + +# Redis cache (managed by docker-compose.full.yml) +REDIS_ENABLED=true +REDIS_DB=8 +REDIS_PREFIX=nanobot +REDIS_DEFAULT_TTL=60 + +# Chat history page size for upward lazy loading (per request) +CHAT_PULL_PAGE_SIZE=60 +COMMAND_AUTO_UNLOCK_SECONDS=10 +DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai + +# Panel access protection +PANEL_ACCESS_PASSWORD=change_me_panel_password + +# Browser credential requests must use an explicit CORS allowlist. +# If frontend and backend are served under the same origin via nginx `/api` proxy, +# this can usually stay unset. Otherwise set the real dashboard origin(s). +# Example: +# CORS_ALLOWED_ORIGINS=https://dashboard.example.com + +# Max upload size for backend validation (MB) +UPLOAD_MAX_MB=200 + +# Workspace files that should use direct download behavior in dashboard +WORKSPACE_DOWNLOAD_EXTENSIONS=.pdf,.doc,.docx,.xls,.xlsx,.xlsm,.ppt,.pptx,.odt,.ods,.odp,.wps,.stl,.scad,.zip,.rar + +# Local speech-to-text (Whisper via whisper.cpp model file) +STT_ENABLED=true +STT_MODEL=ggml-small-q8_0.bin +STT_MODEL_DIR=${HOST_DATA_ROOT}/model +STT_DEVICE=cpu +STT_MAX_AUDIO_SECONDS=20 +STT_DEFAULT_LANGUAGE=zh +STT_FORCE_SIMPLIFIED=true +STT_AUDIO_PREPROCESS=true +STT_AUDIO_FILTER=highpass=f=120,lowpass=f=7600,afftdn=nf=-20 +STT_INITIAL_PROMPT=以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。 diff --git a/.gitignore b/.gitignore index c5ed1d7..b58757e 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ frontend/coverage/ .env .env.* !.env.example +!.env.full.example !.env.prod.example backend/.env frontend/.env diff --git a/README.md b/README.md index d097d92..7561d82 100644 --- a/README.md +++ b/README.md @@ -125,3 +125,53 @@ graph TD - `HOST_BOTS_WORKSPACE_ROOT` 必须是宿主机绝对路径,并且在 `docker-compose.prod.yml` 中以“同路径”挂载到后端容器。 原因:后端通过 Docker API 创建 Bot 容器时,使用的是宿主机可见的 bind 路径。 - 语音识别当前基于 `pywhispercpp==1.3.1` + Whisper `.bin` 模型文件,不使用 `faster-whisper`。 + +## Docker 完整部署(内置 PostgreSQL / Redis) + +这套方案和 `deploy-prod.sh` 并存,适合目标机器上直接把前端、后端、PostgreSQL、Redis 一起拉起。 + +### 文件 + +- `docker-compose.full.yml` +- `.env.full.example` +- `scripts/deploy-full.sh` +- `scripts/init-full-db.sh` +- `scripts/stop-full.sh` +- `scripts/sql/init-postgres-bootstrap.sql` +- `scripts/sql/init-postgres-app.sql` + +### 启动步骤 + +1. 准备部署变量 + - 复制 `.env.full.example` 为 `.env.full` + - 必填修改: + - `HOST_DATA_ROOT` + - `HOST_BOTS_WORKSPACE_ROOT` + - `POSTGRES_SUPERPASSWORD` + - `POSTGRES_APP_PASSWORD` + - `PANEL_ACCESS_PASSWORD` + - 如启用本地语音识别,请将 Whisper `.bin` 模型文件放到 `${HOST_DATA_ROOT}/model/` +2. 启动完整栈 + - `./scripts/deploy-full.sh` +3. 访问 + - `http://:${NGINX_PORT}`(默认 `8080`) + +### 初始化说明 + +- `scripts/deploy-full.sh` 会先启动 `postgres` / `redis`,然后自动调用 `scripts/init-full-db.sh`。 +- `scripts/init-full-db.sh` 负责: + - 等待 PostgreSQL 就绪 + - 创建或更新业务账号 + - 创建业务库并授权 + - 修正 `public` schema 权限 +- Dashboard 业务表本身仍由后端启动时自动执行 `SQLModel.metadata.create_all(...)` 与补列/索引对齐。 + +### 停止 + +- `./scripts/stop-full.sh` + +### 注意事项 + +- `deploy-prod.sh` 和 `deploy-full.sh` 使用的是两套 compose 文件,但复用了相同容器名,不能同时在同一台机器上并行启动。 +- PostgreSQL 数据默认落盘到 `${HOST_DATA_ROOT}/postgres`,Redis 数据默认落盘到 `${HOST_DATA_ROOT}/redis`。 +- 如果你只想保留前后端容器,继续使用 `deploy-prod.sh`;如果希望把依赖也打包进来,使用 `deploy-full.sh`。 diff --git a/design/code-structure-standards.md b/design/code-structure-standards.md index dd892fb..5510b20 100644 --- a/design/code-structure-standards.md +++ b/design/code-structure-standards.md @@ -6,7 +6,7 @@ - 保持装配层足够薄 - 保持业务边界清晰 -- 避免再次出现单文件多职责膨胀 +- 避免出现单文件多职责膨胀 - 让后续迭代继续走低风险、小步验证路线 本文档自落地起作为**后续开发强制规范**执行。 diff --git a/docker-compose.full.yml b/docker-compose.full.yml new file mode 100644 index 0000000..ed81450 --- /dev/null +++ b/docker-compose.full.yml @@ -0,0 +1,153 @@ +services: + postgres: + image: ${POSTGRES_IMAGE:-postgres:16-alpine} + container_name: dashboard-nanobot-postgres + restart: unless-stopped + environment: + TZ: ${TZ:-Asia/Shanghai} + POSTGRES_USER: ${POSTGRES_SUPERUSER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_SUPERPASSWORD:?POSTGRES_SUPERPASSWORD is required} + POSTGRES_DB: ${POSTGRES_BOOTSTRAP_DB:-postgres} + volumes: + - ${HOST_DATA_ROOT}/postgres:/var/lib/postgresql/data + expose: + - "5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + redis: + image: ${REDIS_IMAGE:-redis:7-alpine} + container_name: dashboard-nanobot-redis + restart: unless-stopped + environment: + TZ: ${TZ:-Asia/Shanghai} + command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"] + volumes: + - ${HOST_DATA_ROOT}/redis:/data + expose: + - "6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + backend: + build: + context: . + dockerfile: backend/Dockerfile + args: + PYTHON_BASE_IMAGE: ${PYTHON_BASE_IMAGE:-python:3.12-slim} + PIP_INDEX_URL: ${PIP_INDEX_URL:-https://pypi.org/simple} + PIP_TRUSTED_HOST: ${PIP_TRUSTED_HOST:-} + image: dashboard-nanobot/backend:${BACKEND_IMAGE_TAG:-latest} + container_name: dashboard-nanobot-backend + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + TZ: ${TZ:-Asia/Shanghai} + APP_HOST: 0.0.0.0 + APP_PORT: 8000 + APP_RELOAD: "false" + DATABASE_ECHO: "false" + DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-20} + DATABASE_MAX_OVERFLOW: ${DATABASE_MAX_OVERFLOW:-40} + DATABASE_POOL_TIMEOUT: ${DATABASE_POOL_TIMEOUT:-30} + DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800} + UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-100} + DATA_ROOT: ${HOST_DATA_ROOT} + BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT} + DATABASE_URL: postgresql+psycopg://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_APP_DB} + REDIS_ENABLED: ${REDIS_ENABLED:-true} + REDIS_URL: redis://redis:6379/${REDIS_DB:-8} + REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot} + REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60} + CHAT_PULL_PAGE_SIZE: ${CHAT_PULL_PAGE_SIZE:-60} + COMMAND_AUTO_UNLOCK_SECONDS: ${COMMAND_AUTO_UNLOCK_SECONDS:-10} + DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai} + PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} + WORKSPACE_DOWNLOAD_EXTENSIONS: ${WORKSPACE_DOWNLOAD_EXTENSIONS:-} + STT_ENABLED: ${STT_ENABLED:-true} + STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin} + STT_MODEL_DIR: ${STT_MODEL_DIR:-${HOST_DATA_ROOT}/model} + STT_DEVICE: ${STT_DEVICE:-cpu} + STT_MAX_AUDIO_SECONDS: ${STT_MAX_AUDIO_SECONDS:-20} + STT_DEFAULT_LANGUAGE: ${STT_DEFAULT_LANGUAGE:-zh} + STT_FORCE_SIMPLIFIED: ${STT_FORCE_SIMPLIFIED:-true} + STT_AUDIO_PREPROCESS: ${STT_AUDIO_PREPROCESS:-true} + STT_AUDIO_FILTER: ${STT_AUDIO_FILTER:-highpass=f=120,lowpass=f=7600,afftdn=nf=-20} + STT_INITIAL_PROMPT: ${STT_INITIAL_PROMPT:-以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${HOST_DATA_ROOT}:${HOST_DATA_ROOT} + - ${HOST_BOTS_WORKSPACE_ROOT}:${HOST_BOTS_WORKSPACE_ROOT} + expose: + - "8000" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3).read()"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + nginx: + build: + context: ./frontend + dockerfile: Dockerfile + args: + NODE_BASE_IMAGE: ${NODE_BASE_IMAGE:-node:22-alpine} + NGINX_BASE_IMAGE: ${NGINX_BASE_IMAGE:-nginx:alpine} + NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org/} + VITE_API_BASE: /api + VITE_WS_BASE: /ws/monitor + image: dashboard-nanobot/nginx:${FRONTEND_IMAGE_TAG:-latest} + container_name: dashboard-nanobot-nginx + restart: unless-stopped + environment: + TZ: ${TZ:-Asia/Shanghai} + UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-100} + depends_on: + backend: + condition: service_healthy + ports: + - "${NGINX_PORT}:80" + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + +networks: + default: + name: dashboard-nanobot-network diff --git a/scripts/deploy-full.sh b/scripts/deploy-full.sh new file mode 100755 index 0000000..48025cd --- /dev/null +++ b/scripts/deploy-full.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${1:-$ROOT_DIR/.env.full}" +COMPOSE_FILE="$ROOT_DIR/docker-compose.full.yml" + +require_file() { + local path="$1" + local hint="$2" + if [[ -f "$path" ]]; then + return 0 + fi + echo "Missing file: $path" + if [[ -n "$hint" ]]; then + echo "$hint" + fi + exit 1 +} + +require_env() { + local name="$1" + if [[ -n "${!name:-}" ]]; then + return 0 + fi + echo "Missing required env: $name" + exit 1 +} + +read_env_value() { + local key="$1" + 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_FILE" + + return 1 +} + +load_env_var() { + local name="$1" + local default_value="${2:-}" + local value="" + + value="$(read_env_value "$name" || true)" + if [[ -z "$value" ]]; then + value="$default_value" + fi + printf -v "$name" '%s' "$value" +} + +wait_for_health() { + local container_name="$1" + local timeout_seconds="$2" + local elapsed=0 + local status="" + + while (( elapsed < timeout_seconds )); do + status="$( + docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true + )" + if [[ "$status" == "healthy" || "$status" == "running" ]]; then + echo "[deploy-full] $container_name is $status" + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + + echo "[deploy-full] timed out waiting for $container_name (last status: ${status:-unknown})" + docker logs --tail 80 "$container_name" 2>/dev/null || true + exit 1 +} + +require_file "$ENV_FILE" "Create it from: $ROOT_DIR/.env.full.example" +require_file "$COMPOSE_FILE" "" + +load_env_var HOST_DATA_ROOT +load_env_var HOST_BOTS_WORKSPACE_ROOT +load_env_var POSTGRES_SUPERUSER postgres +load_env_var POSTGRES_SUPERPASSWORD +load_env_var POSTGRES_BOOTSTRAP_DB postgres +load_env_var POSTGRES_APP_DB +load_env_var POSTGRES_APP_USER +load_env_var POSTGRES_APP_PASSWORD +load_env_var NGINX_PORT 8080 + +require_env HOST_DATA_ROOT +require_env HOST_BOTS_WORKSPACE_ROOT +require_env POSTGRES_SUPERUSER +require_env POSTGRES_SUPERPASSWORD +require_env POSTGRES_BOOTSTRAP_DB +require_env POSTGRES_APP_DB +require_env POSTGRES_APP_USER +require_env POSTGRES_APP_PASSWORD +require_env NGINX_PORT + +echo "[deploy-full] using env: $ENV_FILE" +mkdir -p \ + "$HOST_DATA_ROOT" \ + "$HOST_DATA_ROOT/postgres" \ + "$HOST_DATA_ROOT/redis" \ + "$HOST_DATA_ROOT/model" \ + "$HOST_BOTS_WORKSPACE_ROOT" + +docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config -q + +echo "[deploy-full] starting postgres and redis" +docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d postgres redis + +wait_for_health "dashboard-nanobot-postgres" 120 +wait_for_health "dashboard-nanobot-redis" 60 + +echo "[deploy-full] initializing application database" +"$ROOT_DIR/scripts/init-full-db.sh" "$ENV_FILE" + +echo "[deploy-full] starting backend and nginx" +docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d --build backend nginx + +wait_for_health "dashboard-nanobot-backend" 180 +wait_for_health "dashboard-nanobot-nginx" 120 + +echo "[deploy-full] service status" +docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps + +echo "[deploy-full] done" diff --git a/scripts/init-full-db.sh b/scripts/init-full-db.sh new file mode 100755 index 0000000..69f8081 --- /dev/null +++ b/scripts/init-full-db.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${1:-$ROOT_DIR/.env.full}" +COMPOSE_FILE="$ROOT_DIR/docker-compose.full.yml" +BOOTSTRAP_SQL="$ROOT_DIR/scripts/sql/init-postgres-bootstrap.sql" +APP_SQL="$ROOT_DIR/scripts/sql/init-postgres-app.sql" + +require_file() { + local path="$1" + local hint="$2" + if [[ -f "$path" ]]; then + return 0 + fi + echo "Missing file: $path" + if [[ -n "$hint" ]]; then + echo "$hint" + fi + exit 1 +} + +require_env() { + local name="$1" + if [[ -n "${!name:-}" ]]; then + return 0 + fi + echo "Missing required env: $name" + exit 1 +} + +read_env_value() { + local key="$1" + 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_FILE" + + return 1 +} + +load_env_var() { + local name="$1" + local default_value="${2:-}" + local value="" + + value="$(read_env_value "$name" || true)" + if [[ -z "$value" ]]; then + value="$default_value" + fi + printf -v "$name" '%s' "$value" +} + +wait_for_postgres() { + local timeout_seconds="${1:-120}" + local elapsed=0 + + while (( elapsed < timeout_seconds )); do + if docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \ + -e PGPASSWORD="$POSTGRES_SUPERPASSWORD" \ + postgres \ + pg_isready -U "$POSTGRES_SUPERUSER" -d "$POSTGRES_BOOTSTRAP_DB" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + + echo "[init-full-db] timed out waiting for postgres" + docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" logs --tail 100 postgres || true + exit 1 +} + +require_file "$ENV_FILE" "Create it from: $ROOT_DIR/.env.full.example" +require_file "$COMPOSE_FILE" "" +require_file "$BOOTSTRAP_SQL" "" +require_file "$APP_SQL" "" + +load_env_var POSTGRES_SUPERUSER postgres +load_env_var POSTGRES_SUPERPASSWORD +load_env_var POSTGRES_BOOTSTRAP_DB postgres +load_env_var POSTGRES_APP_DB +load_env_var POSTGRES_APP_USER +load_env_var POSTGRES_APP_PASSWORD + +require_env POSTGRES_SUPERUSER +require_env POSTGRES_SUPERPASSWORD +require_env POSTGRES_BOOTSTRAP_DB +require_env POSTGRES_APP_DB +require_env POSTGRES_APP_USER +require_env POSTGRES_APP_PASSWORD + +docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d postgres >/dev/null + +wait_for_postgres 120 + +echo "[init-full-db] ensuring role/database exist" +docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \ + -e PGPASSWORD="$POSTGRES_SUPERPASSWORD" \ + postgres \ + psql \ + -v ON_ERROR_STOP=1 \ + -v app_db="$POSTGRES_APP_DB" \ + -v app_user="$POSTGRES_APP_USER" \ + -v app_password="$POSTGRES_APP_PASSWORD" \ + -U "$POSTGRES_SUPERUSER" \ + -d "$POSTGRES_BOOTSTRAP_DB" \ + -f - < "$BOOTSTRAP_SQL" + +echo "[init-full-db] ensuring schema privileges in $POSTGRES_APP_DB" +docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \ + -e PGPASSWORD="$POSTGRES_SUPERPASSWORD" \ + postgres \ + psql \ + -v ON_ERROR_STOP=1 \ + -v app_user="$POSTGRES_APP_USER" \ + -U "$POSTGRES_SUPERUSER" \ + -d "$POSTGRES_APP_DB" \ + -f - < "$APP_SQL" + +echo "[init-full-db] done" diff --git a/scripts/sql/init-postgres-app.sql b/scripts/sql/init-postgres-app.sql new file mode 100644 index 0000000..7f0187e --- /dev/null +++ b/scripts/sql/init-postgres-app.sql @@ -0,0 +1,4 @@ +\set ON_ERROR_STOP on + +SELECT format('ALTER SCHEMA public OWNER TO %I', :'app_user')\gexec +SELECT format('GRANT ALL ON SCHEMA public TO %I', :'app_user')\gexec diff --git a/scripts/sql/init-postgres-bootstrap.sql b/scripts/sql/init-postgres-bootstrap.sql new file mode 100644 index 0000000..d867f9e --- /dev/null +++ b/scripts/sql/init-postgres-bootstrap.sql @@ -0,0 +1,20 @@ +\set ON_ERROR_STOP on + +SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'app_user', :'app_password') +WHERE NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_roles + WHERE rolname = :'app_user' +)\gexec + +SELECT format('ALTER ROLE %I WITH LOGIN PASSWORD %L', :'app_user', :'app_password')\gexec + +SELECT format('CREATE DATABASE %I OWNER %I', :'app_db', :'app_user') +WHERE NOT EXISTS ( + SELECT 1 + FROM pg_database + WHERE datname = :'app_db' +)\gexec + +SELECT format('ALTER DATABASE %I OWNER TO %I', :'app_db', :'app_user')\gexec +SELECT format('GRANT ALL PRIVILEGES ON DATABASE %I TO %I', :'app_db', :'app_user')\gexec diff --git a/scripts/stop-full.sh b/scripts/stop-full.sh new file mode 100755 index 0000000..2b66295 --- /dev/null +++ b/scripts/stop-full.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${1:-$ROOT_DIR/.env.full}" +COMPOSE_FILE="$ROOT_DIR/docker-compose.full.yml" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing env file: $ENV_FILE" + echo "Create it from: $ROOT_DIR/.env.full.example" + exit 1 +fi + +docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" down