diff --git a/deploy/backup/create_postgres_backup.sh b/deploy/backup/create_postgres_backup.sh index 7c81cbe..81d38c2 100644 --- a/deploy/backup/create_postgres_backup.sh +++ b/deploy/backup/create_postgres_backup.sh @@ -3,7 +3,9 @@ set -eu COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.production.yml}" ENV_FILE="${ENV_FILE:-.env.production}" -BACKUP_DIR="${BACKUP_DIR:-./data/backups}" +BACKUP_DIR="${BACKUP_DIR:-${DATABASE_BACKUP_DIR:-./data/backups}}" +APP_DATA_BACKUP_DIR="${APP_DATA_BACKUP_DIR:-${BACKUP_DIR}/data}" +UPLOADS_BACKUP_DIR="${UPLOADS_BACKUP_DIR:-${BACKUP_DIR}/uploads}" RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}" SYNOLOGY_BACKUP_DIR="${SYNOLOGY_BACKUP_DIR:-}" @@ -66,6 +68,23 @@ find "${BACKUP_DIR}" -type f \( \ -name 'backup-*.manifest' \ \) -mtime +"${RETENTION_DAYS}" -delete +# Always keep a rolling incremental mirror of uploaded files in BACKUP_DIR. +mkdir -p "${APP_DATA_BACKUP_DIR}" +if [ -d /app/data ]; then + cp -a /app/data/. "${APP_DATA_BACKUP_DIR}/" +fi + +mkdir -p "${UPLOADS_BACKUP_DIR}/documents" "${UPLOADS_BACKUP_DIR}/photos" +if [ -f /app/uploads/homepage.md ] && [ ! -f "${UPLOADS_BACKUP_DIR}/homepage.md" ]; then + cp /app/uploads/homepage.md "${UPLOADS_BACKUP_DIR}/homepage.md" +fi +if [ -d /app/uploads/documents ]; then + cp -an /app/uploads/documents/. "${UPLOADS_BACKUP_DIR}/documents/" +fi +if [ -d /app/uploads/photos ]; then + cp -an /app/uploads/photos/. "${UPLOADS_BACKUP_DIR}/photos/" +fi + if [ -n "${SYNOLOGY_BACKUP_DIR}" ]; then mkdir -p "${SYNOLOGY_BACKUP_DIR}" @@ -89,21 +108,6 @@ if [ -n "${SYNOLOGY_BACKUP_DIR}" ]; then --entrypoint sh app -lc \ "if [ -d /app/data/logs ]; then tar -C /app/data/logs -czf /backup/${logs_file} .; else : > /backup/${logs_file}; fi" - # 3) Sync uploads incrementally to Synology (copy only new files). - docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" run --rm --no-deps \ - -v "${SYNOLOGY_BACKUP_DIR}:/backup" \ - --entrypoint sh app -lc ' - mkdir -p /backup/uploads/documents /backup/uploads/photos; - if [ -f /app/uploads/homepage.md ] && [ ! -f /backup/uploads/homepage.md ]; then - cp /app/uploads/homepage.md /backup/uploads/homepage.md; - fi; - if [ -d /app/uploads/documents ]; then - cp -an /app/uploads/documents/. /backup/uploads/documents/; - fi; - if [ -d /app/uploads/photos ]; then - cp -an /app/uploads/photos/. /backup/uploads/photos/; - fi - ' else if [ -d /app/data/logs ]; then tar -C /app/data/logs -czf "${SYNOLOGY_BACKUP_DIR}/${logs_file}" . @@ -111,16 +115,8 @@ if [ -n "${SYNOLOGY_BACKUP_DIR}" ]; then : > "${SYNOLOGY_BACKUP_DIR}/${logs_file}" fi - mkdir -p "${SYNOLOGY_BACKUP_DIR}/uploads/documents" "${SYNOLOGY_BACKUP_DIR}/uploads/photos" - if [ -f /app/uploads/homepage.md ] && [ ! -f "${SYNOLOGY_BACKUP_DIR}/uploads/homepage.md" ]; then - cp /app/uploads/homepage.md "${SYNOLOGY_BACKUP_DIR}/uploads/homepage.md" - fi - if [ -d /app/uploads/documents ]; then - cp -an /app/uploads/documents/. "${SYNOLOGY_BACKUP_DIR}/uploads/documents/" - fi - if [ -d /app/uploads/photos ]; then - cp -an /app/uploads/photos/. "${SYNOLOGY_BACKUP_DIR}/uploads/photos/" - fi + mkdir -p "${SYNOLOGY_BACKUP_DIR}/uploads" + cp -an "${UPLOADS_BACKUP_DIR}/." "${SYNOLOGY_BACKUP_DIR}/uploads/" fi cp "${BACKUP_DIR}/${manifest_file}" "${SYNOLOGY_BACKUP_DIR}/${manifest_file}" @@ -137,12 +133,14 @@ fi echo "Created backup set:" echo " ${BACKUP_DIR}/${postgres_file}" echo " ${BACKUP_DIR}/${manifest_file}" +echo " ${APP_DATA_BACKUP_DIR}/ (incremental app data mirror)" +echo " ${UPLOADS_BACKUP_DIR}/ (incremental uploads mirror)" if [ -n "${SYNOLOGY_BACKUP_DIR}" ]; then echo "Synology artifacts:" echo " ${SYNOLOGY_BACKUP_DIR}/${postgres_file}" echo " ${SYNOLOGY_BACKUP_DIR}/${config_file}" echo " ${SYNOLOGY_BACKUP_DIR}/${logs_file}" - echo " ${SYNOLOGY_BACKUP_DIR}/uploads/{homepage.md,documents/,photos/} (incremental)" + echo " ${SYNOLOGY_BACKUP_DIR}/uploads/ (copied from local incremental mirror)" echo " ${SYNOLOGY_BACKUP_DIR}/${manifest_file}" else echo "SYNOLOGY_BACKUP_DIR not set; skipped Synology copy." diff --git a/docker-compose.production.yml b/docker-compose.production.yml index b74d2ec..b78f4a0 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -8,6 +8,7 @@ services: - .env.production environment: RUN_EMBEDDED_WORKER: "false" + RUNTIME_SETTINGS_ENV_FILE: "/app/.env.production" depends_on: postgres: condition: service_healthy @@ -16,6 +17,8 @@ services: volumes: - app_uploads:/app/uploads - app_data:/app/data + - ./backup:/backup + - ./.env.production:/app/.env.production - ./prompts:/app/prompts:ro restart: unless-stopped healthcheck: @@ -36,6 +39,8 @@ services: volumes: - app_uploads:/app/uploads - app_data:/app/data + - ./backup:/backup + - ./.env.production:/app/.env.production:ro - ./prompts:/app/prompts:ro healthcheck: disable: true diff --git a/docs/backup_restore.md b/docs/backup_restore.md index e2144ae..548f7f2 100644 --- a/docs/backup_restore.md +++ b/docs/backup_restore.md @@ -1,19 +1,21 @@ # Backup and Restore (V6.0 Phase 4) -This guide defines operational backup/restore for clean-slate recovery of the Docker runtime and Synology replication. +This guide defines operational backup/restore for clean-slate recovery of the Docker runtime with host-level backups that Synology Drive can replicate. ## 1. Backup artifacts (clean-slate recovery set) -- Primary local backup location: `./data/backups` +- Primary local backup location: `DATABASE_BACKUP_DIR` (recommended production value: `/backup`) - Local files created per run: - `postgres-YYYYMMDD-HHMMSS.dump` (PostgreSQL custom dump via `pg_dump -Fc`) - `backup-YYYYMMDD-HHMMSS.manifest` (artifact index) -- Synology files created per run (when `SYNOLOGY_BACKUP_DIR` is set): +- Optional Synology files created per run (when `SYNOLOGY_BACKUP_DIR` is set): - `postgres-YYYYMMDD-HHMMSS.dump` (copied from local) - `config-YYYYMMDD-HHMMSS.tar.gz` (deployment config snapshot: `.env.production`, `docker-compose.production.yml`, `deploy/cloudflared/config.yml` when present) - `logs-YYYYMMDD-HHMMSS.tar.gz` (snapshot of `/app/data/logs`) - `backup-YYYYMMDD-HHMMSS.manifest` -- Synology incremental media mirror (copied only if missing): +- Local app-data mirror (updated each run): + - `data/**` +- Local incremental media mirror (copied only if missing): - `uploads/homepage.md` - `uploads/documents/**` - `uploads/photos/**` @@ -28,21 +30,30 @@ sh deploy/backup/create_postgres_backup.sh Environment overrides: -- `BACKUP_DIR` (default `./data/backups`) +- `BACKUP_DIR` (default `DATABASE_BACKUP_DIR`, then `./data/backups`) +- `APP_DATA_BACKUP_DIR` (default `${BACKUP_DIR}/data`) +- `UPLOADS_BACKUP_DIR` (default `${BACKUP_DIR}/uploads`) - `BACKUP_RETENTION_DAYS` (default `14`) - `SYNOLOGY_BACKUP_DIR` (if set, dump/config/log/manifest are copied to this mounted path and media is synced incrementally) - `ENV_FILE` (default `.env.production`) - `COMPOSE_FILE` (default `docker-compose.production.yml`) +Recommended production setup: + +- Mount a host-visible folder into `/backup` for both `app` and `worker` services. +- Set `DATABASE_BACKUP_DIR=/backup` in `.env.production`. +- Leave `SYNOLOGY_BACKUP_DIR` empty when Synology Drive Client handles replication from the host folder. + Example with Synology mount: ```bash SYNOLOGY_BACKUP_DIR=/mnt/synology-backups sh deploy/backup/create_postgres_backup.sh ``` -## 2.1 Persisting Synology mount (LXC) +## 2.1 Optional direct Synology mount (LXC) -The backup copy step depends on a mounted NAS path. Manual `mount` commands are lost after reboot unless persisted. +This section is only needed when using direct container-side Synology copy via `SYNOLOGY_BACKUP_DIR`. +Manual `mount` commands are lost after reboot unless persisted. Use the template (copy, edit placeholders, then run): diff --git a/docs/production-runbook.md b/docs/production-runbook.md index 2839672..9ca72f9 100644 --- a/docs/production-runbook.md +++ b/docs/production-runbook.md @@ -21,6 +21,7 @@ This runbook is the operational checklist for releasing and monitoring the trans 1. Deploy artifact/config to target environment. - V6.0 Phase 1 production stack: `docker compose -f docker-compose.production.yml up -d --build` + - For Runtime Settings writes in production, mount `.env.production` into the app container and set `RUNTIME_SETTINGS_ENV_FILE=/app/.env.production`. - For SQLite -> PostgreSQL cutover, run `uv run python tools/export_import_migration.py verify --source-db --target-db ` before switching runtime. 2. Validate service startup: - `/healthz` responds `200` @@ -36,7 +37,7 @@ This runbook is the operational checklist for releasing and monitoring the trans - stdout aggregation receives events - file logs are written under `./data/logs` 5. Create a fresh PostgreSQL backup after successful deployment: - - `sh deploy/backup/create_postgres_backup.sh` (creates local DB dump + Synology DB/config/log artifacts + incremental media mirror) + - `sh deploy/backup/create_postgres_backup.sh` (creates local DB dump + local incremental uploads mirror under `DATABASE_BACKUP_DIR`) ## 3. Rollback triggers and actions @@ -108,7 +109,7 @@ This runbook is the operational checklist for releasing and monitoring the trans 1. Verify `postgres` container is healthy and accepting connections. 2. Confirm dump file exists and is non-zero size. 3. Re-run backup/restore scripts with explicit `ENV_FILE` and `COMPOSE_FILE` if using non-default paths. -4. If Synology copy fails, keep local backup and resolve mount/network before next backup cycle. +4. If direct Synology copy fails, keep local backup and resolve mount/network before next backup cycle. - For LXC setups, use `deploy/backup/mount_synology_cifs.example.sh` as the persistent mount template. ## 6. Dependency upgrade policy diff --git a/docs/ui/pages/settings.md b/docs/ui/pages/settings.md index 42eec56..66bdc62 100644 --- a/docs/ui/pages/settings.md +++ b/docs/ui/pages/settings.md @@ -23,8 +23,10 @@ Settings manages installation-local registries, safe runtime .env settings, and - **Runtime Settings** - Runtime Settings exposes an allowlisted set of non-secret fields synchronized with `Settings` model fields except excluded secret/unsafe fields. - Runtime Settings is rendered as a compact two-column editor (**Setting**, **Value**) in a centered, narrower responsive container. -- Runtime Settings persists changes to `.env`, validates by constructing a `Settings` instance, and reports validation failures through the shared UI error presenter. +- Runtime Settings persists changes to the resolved runtime env file, validates by constructing a `Settings` instance, and reports validation failures through the shared UI error presenter. +- The write target resolution order is: explicit function override (tests/tools), `RUNTIME_SETTINGS_ENV_FILE` environment variable (deployment override), then `Settings.model_config.env_file` (default `.env`). - Runtime Settings changes require application restart to take effect. +- Runtime Settings renders a host-side restart command (`docker compose -f docker-compose.production.yml up -d --force-recreate app worker`) so operators can apply saved values without granting Docker control to the app container. - Runtime Settings includes an explicit "Other settings not shown here" markdown table listing: - secrets (`OPENROUTER_API_KEY`, `DATABASE__PASSWORD`) - high-risk database connection settings (`DATABASE__DRIVER`, `DATABASE__PATH`, `DATABASE__HOST`, `DATABASE__PORT`, `DATABASE__DATABASE`, `DATABASE__USER`) diff --git a/src/transcription/ui/pages/settings_page.py b/src/transcription/ui/pages/settings_page.py index 47a7764..ded2f3b 100644 --- a/src/transcription/ui/pages/settings_page.py +++ b/src/transcription/ui/pages/settings_page.py @@ -739,13 +739,23 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915 title="Runtime settings save failed", action=lambda: _write_runtime_settings(settings=settings, updates=updates), ) - if not save_outcome.ok: + if not save_outcome.ok or save_outcome.value is None: return - ui.notify("Runtime settings saved to .env", type="positive") + target_path = save_outcome.value.env_file_path + ui.notify( + f"Runtime settings saved to {target_path}. Restart app/worker to apply.", + type="positive", + ) render_runtime_settings.refresh() with ui.row().classes("items-center gap-2 mt-3"): ui.button("Save runtime settings", icon="save", on_click=save_runtime).classes("ui-btn-primary") + with ui.column().classes("w-full max-w-3xl mx-auto gap-1 mt-2"): + ui.label("Apply saved runtime settings").classes("text-xs font-semibold") + ui.label( + "Run this on the host from the deployment repo to recreate app/worker with new env values:" + ).classes("text-[11px] ui-text-muted") + ui.code(_runtime_restart_command(), language="bash").classes("w-full text-xs") with ui.tabs().classes("w-full") as tabs: document_types_tab = ui.tab("Document Types") @@ -855,3 +865,7 @@ def _format_duration(*, started_at: datetime | None, finished_at: datetime | Non async def _read_maintenance_log(*, maintenance: MaintenanceService, log_path: str) -> bytes: return await run_blocking(maintenance.read_log_bytes, log_path=log_path) + + +def _runtime_restart_command() -> str: + return "docker compose -f docker-compose.production.yml up -d --force-recreate app worker" diff --git a/src/transcription/ui/runtime_settings_store.py b/src/transcription/ui/runtime_settings_store.py index 1ea17f2..4c478f9 100644 --- a/src/transcription/ui/runtime_settings_store.py +++ b/src/transcription/ui/runtime_settings_store.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import re import tempfile from dataclasses import dataclass @@ -366,6 +367,10 @@ def _resolve_env_file_path(*, settings: Settings, env_file_path: Path | None) -> if env_file_path is not None: return env_file_path + override = os.getenv("RUNTIME_SETTINGS_ENV_FILE", "").strip() + if override: + return Path(override) + configured = settings.model_config.get("env_file") if configured is None: return Path(".env") diff --git a/tests/ui/test_runtime_settings_store.py b/tests/ui/test_runtime_settings_store.py index f2fbdeb..72e7d59 100644 --- a/tests/ui/test_runtime_settings_store.py +++ b/tests/ui/test_runtime_settings_store.py @@ -83,3 +83,18 @@ def test_save_runtime_settings_rejects_invalid_values(tmp_path: Path): updates={"port": "not-a-number"}, env_file_path=env_path, ) + + +def test_runtime_settings_uses_override_env_file(tmp_path: Path, monkeypatch): + settings = _settings_for_runtime_editing(tmp_path) + target = tmp_path / ".env.production" + target.write_text("OPENROUTER_API_KEY=test-key\nPORT=8000\n", encoding="utf-8") + monkeypatch.setenv("RUNTIME_SETTINGS_ENV_FILE", str(target)) + + snapshot = save_runtime_settings( + settings=settings, + updates={"port": "9001"}, + ) + + assert snapshot.env_file_path == target + assert "PORT=9001" in target.read_text(encoding="utf-8")