generated from john/python-template
This commit is contained in:
+11
-7
@@ -31,7 +31,14 @@ SQLITE_CHECK_SAME_THREAD=false
|
|||||||
# --- filesystem paths ---
|
# --- filesystem paths ---
|
||||||
UPLOAD_DIR=/app/uploads
|
UPLOAD_DIR=/app/uploads
|
||||||
PROMPT_DIR=/app/prompts
|
PROMPT_DIR=/app/prompts
|
||||||
DATABASE_BACKUP_DIR=/app/data/backups
|
DATABASE_BACKUP_DIR=/backup
|
||||||
|
# --- backup workflow helpers (not Runtime Settings model fields) ---
|
||||||
|
BACKUP_DIR=/backup
|
||||||
|
# APP_DATA_BACKUP_DIR=/backup/data
|
||||||
|
# UPLOADS_BACKUP_DIR=/backup/uploads
|
||||||
|
BACKUP_RETENTION_DAYS=14
|
||||||
|
# Optional mounted Synology destination path for replicated dumps
|
||||||
|
# SYNOLOGY_BACKUP_DIR=/mnt/synology/transcription-backups
|
||||||
|
|
||||||
# --- worker reliability ---
|
# --- worker reliability ---
|
||||||
WORKER_MAX_RETRIES=0
|
WORKER_MAX_RETRIES=0
|
||||||
@@ -53,9 +60,6 @@ POSTGRES_PASSWORD=replace-with-strong-password
|
|||||||
# Required for token-based tunnel startup.
|
# Required for token-based tunnel startup.
|
||||||
CLOUDFLARE_TUNNEL_TOKEN=replace-with-cloudflare-tunnel-token
|
CLOUDFLARE_TUNNEL_TOKEN=replace-with-cloudflare-tunnel-token
|
||||||
|
|
||||||
# --- backup workflow helpers ---
|
# --- deployment wiring helpers ---
|
||||||
# Used by deploy/backup/create_postgres_backup.sh
|
# Runtime Settings writes target this file path inside the app container.
|
||||||
BACKUP_DIR=./data/backups
|
RUNTIME_SETTINGS_ENV_FILE=/app/.env.production
|
||||||
BACKUP_RETENTION_DAYS=14
|
|
||||||
# Optional mounted Synology destination path for replicated dumps
|
|
||||||
# SYNOLOGY_BACKUP_DIR=/mnt/synology/transcription-backups
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ logs_file="logs-${timestamp}.tar.gz"
|
|||||||
manifest_file="backup-${timestamp}.manifest"
|
manifest_file="backup-${timestamp}.manifest"
|
||||||
|
|
||||||
mkdir -p "${BACKUP_DIR}"
|
mkdir -p "${BACKUP_DIR}"
|
||||||
|
mkdir -p "${APP_DATA_BACKUP_DIR}"
|
||||||
|
app_data_backup_abs="$(cd "${APP_DATA_BACKUP_DIR}" && pwd -P)"
|
||||||
|
|
||||||
# If SYNOLOGY_BACKUP_DIR wasn't exported in the shell, read it from ENV_FILE.
|
# If SYNOLOGY_BACKUP_DIR wasn't exported in the shell, read it from ENV_FILE.
|
||||||
if [ -z "${SYNOLOGY_BACKUP_DIR}" ] && [ -f "${ENV_FILE}" ]; then
|
if [ -z "${SYNOLOGY_BACKUP_DIR}" ] && [ -f "${ENV_FILE}" ]; then
|
||||||
@@ -68,10 +70,17 @@ find "${BACKUP_DIR}" -type f \( \
|
|||||||
-name 'backup-*.manifest' \
|
-name 'backup-*.manifest' \
|
||||||
\) -mtime +"${RETENTION_DAYS}" -delete
|
\) -mtime +"${RETENTION_DAYS}" -delete
|
||||||
|
|
||||||
# Always keep a rolling incremental mirror of uploaded files in BACKUP_DIR.
|
# Always keep a rolling mirror of app data in BACKUP_DIR, unless that target
|
||||||
mkdir -p "${APP_DATA_BACKUP_DIR}"
|
# is nested under /app/data (which would recursively copy into itself).
|
||||||
if [ -d /app/data ]; then
|
if [ -d /app/data ]; then
|
||||||
cp -a /app/data/. "${APP_DATA_BACKUP_DIR}/"
|
case "${app_data_backup_abs}" in
|
||||||
|
/app/data|/app/data/*)
|
||||||
|
echo "Skipping app data mirror because APP_DATA_BACKUP_DIR is nested under /app/data: ${APP_DATA_BACKUP_DIR}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
cp -a /app/data/. "${APP_DATA_BACKUP_DIR}/"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "${UPLOADS_BACKUP_DIR}/documents" "${UPLOADS_BACKUP_DIR}/photos"
|
mkdir -p "${UPLOADS_BACKUP_DIR}/documents" "${UPLOADS_BACKUP_DIR}/photos"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Settings manages installation-local registries, safe runtime .env settings, and
|
|||||||
- Runtime Settings includes an explicit "Other settings not shown here" markdown table listing:
|
- Runtime Settings includes an explicit "Other settings not shown here" markdown table listing:
|
||||||
- secrets (`OPENROUTER_API_KEY`, `DATABASE__PASSWORD`)
|
- secrets (`OPENROUTER_API_KEY`, `DATABASE__PASSWORD`)
|
||||||
- high-risk database connection settings (`DATABASE__DRIVER`, `DATABASE__PATH`, `DATABASE__HOST`, `DATABASE__PORT`, `DATABASE__DATABASE`, `DATABASE__USER`)
|
- high-risk database connection settings (`DATABASE__DRIVER`, `DATABASE__PATH`, `DATABASE__HOST`, `DATABASE__PORT`, `DATABASE__DATABASE`, `DATABASE__USER`)
|
||||||
and directs edits for those keys to `.env`.
|
and deployment/helper keys (`POSTGRES_*`, `CLOUDFLARE_TUNNEL_TOKEN`, `BACKUP_DIR`, `APP_DATA_BACKUP_DIR`, `UPLOADS_BACKUP_DIR`, `BACKUP_RETENTION_DAYS`, `SYNOLOGY_BACKUP_DIR`, `RUNTIME_SETTINGS_ENV_FILE`, `ENV_FILE`, `COMPOSE_FILE`), and directs edits for those keys to the resolved runtime env file path.
|
||||||
- Document Types, Person Roles, and Tags support Add/Edit/Delete with existing guardrails.
|
- Document Types, Person Roles, and Tags support Add/Edit/Delete with existing guardrails.
|
||||||
- Prompts exposes only `transcribe_document.md` for editing and restore-from-backup.
|
- Prompts exposes only `transcribe_document.md` for editing and restore-from-backup.
|
||||||
- Home Page Text edits the same Markdown content rendered on `/homepage`.
|
- Home Page Text edits the same Markdown content rendered on `/homepage`.
|
||||||
|
|||||||
@@ -711,7 +711,7 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
with ui.column().classes("w-full max-w-3xl mx-auto gap-2 mt-3"):
|
with ui.column().classes("w-full max-w-3xl mx-auto gap-2 mt-3"):
|
||||||
ui.label("Other settings not shown here").classes("text-sm font-semibold")
|
ui.label("Other settings not shown here").classes("text-sm font-semibold")
|
||||||
ui.label("Edit these directly in .env:").classes("text-xs ui-text-muted")
|
ui.label(f"Edit these directly in {snapshot.env_file_path}:").classes("text-xs ui-text-muted")
|
||||||
table_rows = [
|
table_rows = [
|
||||||
f"| {category.title} | {', '.join(f'`{key}`' for key in category.env_keys)} |"
|
f"| {category.title} | {', '.join(f'`{key}`' for key in category.env_keys)} |"
|
||||||
for category in HIDDEN_SETTINGS_CATEGORIES
|
for category in HIDDEN_SETTINGS_CATEGORIES
|
||||||
|
|||||||
@@ -84,6 +84,29 @@ HIDDEN_SETTINGS_CATEGORIES: tuple[HiddenSettingsCategory, ...] = (
|
|||||||
"DATABASE__USER",
|
"DATABASE__USER",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
HiddenSettingsCategory(
|
||||||
|
title="Deployment and backup/tunnel helpers (not Runtime Settings model fields)",
|
||||||
|
reason=(
|
||||||
|
"These keys are consumed by docker-compose, backup scripts, or deployment adapters and are "
|
||||||
|
"intentionally edited outside the Runtime Settings UI."
|
||||||
|
),
|
||||||
|
env_keys=(
|
||||||
|
"POSTGRES_DB",
|
||||||
|
"POSTGRES_USER",
|
||||||
|
"POSTGRES_PASSWORD",
|
||||||
|
"POSTGRES_HOST",
|
||||||
|
"POSTGRES_PORT",
|
||||||
|
"CLOUDFLARE_TUNNEL_TOKEN",
|
||||||
|
"RUNTIME_SETTINGS_ENV_FILE",
|
||||||
|
"BACKUP_DIR",
|
||||||
|
"APP_DATA_BACKUP_DIR",
|
||||||
|
"UPLOADS_BACKUP_DIR",
|
||||||
|
"BACKUP_RETENTION_DAYS",
|
||||||
|
"SYNOLOGY_BACKUP_DIR",
|
||||||
|
"ENV_FILE",
|
||||||
|
"COMPOSE_FILE",
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
RUNTIME_SETTINGS_CATALOG: tuple[RuntimeSettingDescriptor, ...] = (
|
RUNTIME_SETTINGS_CATALOG: tuple[RuntimeSettingDescriptor, ...] = (
|
||||||
@@ -358,7 +381,15 @@ def save_runtime_settings(
|
|||||||
env_map[descriptor.env_key] = encoded
|
env_map[descriptor.env_key] = encoded
|
||||||
|
|
||||||
_validate_candidate_env(env_map=env_map, env_file_path=resolved_env_path)
|
_validate_candidate_env(env_map=env_map, env_file_path=resolved_env_path)
|
||||||
_write_env_lines_atomic(path=resolved_env_path, lines=mutable_lines)
|
try:
|
||||||
|
_write_env_lines_atomic(path=resolved_env_path, lines=mutable_lines)
|
||||||
|
except OSError as exc:
|
||||||
|
raise AppError(
|
||||||
|
"Runtime settings file is not writable.",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Verify file path and write permissions, then retry.",
|
||||||
|
detail=f"Failed writing runtime env file {resolved_env_path}: {type(exc).__name__}: {exc}",
|
||||||
|
) from exc
|
||||||
refreshed = Settings(_env_file=resolved_env_path, _cli_parse_args=False)
|
refreshed = Settings(_env_file=resolved_env_path, _cli_parse_args=False)
|
||||||
return read_runtime_settings_snapshot(settings=refreshed, env_file_path=resolved_env_path)
|
return read_runtime_settings_snapshot(settings=refreshed, env_file_path=resolved_env_path)
|
||||||
|
|
||||||
@@ -438,7 +469,15 @@ def _validate_candidate_env(*, env_map: dict[str, str], env_file_path: Path) ->
|
|||||||
def _read_env_lines(path: Path) -> list[str]:
|
def _read_env_lines(path: Path) -> list[str]:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return []
|
return []
|
||||||
return path.read_text(encoding="utf-8").splitlines()
|
try:
|
||||||
|
return path.read_text(encoding="utf-8").splitlines()
|
||||||
|
except OSError as exc:
|
||||||
|
raise AppError(
|
||||||
|
"Runtime settings file is unreadable.",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Verify file path and read permissions, then retry.",
|
||||||
|
detail=f"Failed reading runtime env file {path}: {type(exc).__name__}: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def _parse_env_map(lines: list[str]) -> dict[str, str]:
|
def _parse_env_map(lines: list[str]) -> dict[str, str]:
|
||||||
@@ -485,21 +524,29 @@ def _upsert_env_key(lines: list[str], key: str, value: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _write_env_lines_atomic(*, path: Path, lines: list[str]) -> None:
|
def _write_env_lines_atomic(*, path: Path, lines: list[str]) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
try:
|
||||||
content = "\n".join(lines).rstrip("\n")
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if content:
|
content = "\n".join(lines).rstrip("\n")
|
||||||
content += "\n"
|
if content:
|
||||||
with tempfile.NamedTemporaryFile(
|
content += "\n"
|
||||||
mode="w",
|
with tempfile.NamedTemporaryFile(
|
||||||
delete=False,
|
mode="w",
|
||||||
dir=path.parent,
|
delete=False,
|
||||||
encoding="utf-8",
|
dir=path.parent,
|
||||||
newline="\n",
|
encoding="utf-8",
|
||||||
suffix=".env.tmp",
|
newline="\n",
|
||||||
) as handle:
|
suffix=".env.tmp",
|
||||||
temp_path = Path(handle.name)
|
) as handle:
|
||||||
handle.write(content)
|
temp_path = Path(handle.name)
|
||||||
temp_path.replace(path)
|
handle.write(content)
|
||||||
|
temp_path.replace(path)
|
||||||
|
except OSError as exc:
|
||||||
|
raise AppError(
|
||||||
|
"Runtime settings file is not writable.",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Verify file path and write permissions, then retry.",
|
||||||
|
detail=f"Failed writing runtime env file {path}: {type(exc).__name__}: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def _escape_json_string(value: str) -> str:
|
def _escape_json_string(value: str) -> str:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import pytest
|
|||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.errors import AppError
|
from transcription.errors import AppError
|
||||||
|
from transcription.ui import runtime_settings_store
|
||||||
from transcription.ui.runtime_settings_store import HIDDEN_SETTINGS_CATEGORIES
|
from transcription.ui.runtime_settings_store import HIDDEN_SETTINGS_CATEGORIES
|
||||||
from transcription.ui.runtime_settings_store import RUNTIME_SETTINGS_CATALOG
|
from transcription.ui.runtime_settings_store import RUNTIME_SETTINGS_CATALOG
|
||||||
from transcription.ui.runtime_settings_store import SETTINGS_UI_EXCLUDED_FIELDS
|
from transcription.ui.runtime_settings_store import SETTINGS_UI_EXCLUDED_FIELDS
|
||||||
@@ -35,6 +36,12 @@ def test_hidden_settings_categories_list_secret_and_high_risk_env_keys():
|
|||||||
assert "DATABASE__PASSWORD" in hidden_keys
|
assert "DATABASE__PASSWORD" in hidden_keys
|
||||||
assert "DATABASE__DRIVER" in hidden_keys
|
assert "DATABASE__DRIVER" in hidden_keys
|
||||||
assert "DATABASE__HOST" in hidden_keys
|
assert "DATABASE__HOST" in hidden_keys
|
||||||
|
assert "POSTGRES_DB" in hidden_keys
|
||||||
|
assert "POSTGRES_USER" in hidden_keys
|
||||||
|
assert "POSTGRES_PASSWORD" in hidden_keys
|
||||||
|
assert "CLOUDFLARE_TUNNEL_TOKEN" in hidden_keys
|
||||||
|
assert "BACKUP_DIR" in hidden_keys
|
||||||
|
assert "SYNOLOGY_BACKUP_DIR" in hidden_keys
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_settings_snapshot_includes_catalog_fields(tmp_path: Path):
|
def test_runtime_settings_snapshot_includes_catalog_fields(tmp_path: Path):
|
||||||
@@ -98,3 +105,31 @@ def test_runtime_settings_uses_override_env_file(tmp_path: Path, monkeypatch):
|
|||||||
|
|
||||||
assert snapshot.env_file_path == target
|
assert snapshot.env_file_path == target
|
||||||
assert "PORT=9001" in target.read_text(encoding="utf-8")
|
assert "PORT=9001" in target.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_runtime_settings_surfaces_unreadable_target(tmp_path: Path, monkeypatch):
|
||||||
|
settings = _settings_for_runtime_editing(tmp_path)
|
||||||
|
env_path = tmp_path / ".env"
|
||||||
|
|
||||||
|
def _raise_read_error(_self, encoding="utf-8"):
|
||||||
|
_ = encoding
|
||||||
|
raise OSError("denied")
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "read_text", _raise_read_error)
|
||||||
|
with pytest.raises(AppError) as exc:
|
||||||
|
save_runtime_settings(settings=settings, updates={"port": "9001"}, env_file_path=env_path)
|
||||||
|
assert exc.value.message == "Runtime settings file is unreadable."
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_runtime_settings_surfaces_unwritable_target(tmp_path: Path, monkeypatch):
|
||||||
|
settings = _settings_for_runtime_editing(tmp_path)
|
||||||
|
env_path = tmp_path / ".env"
|
||||||
|
|
||||||
|
def _raise_write_error(*args, **kwargs):
|
||||||
|
_ = args, kwargs
|
||||||
|
raise OSError("denied")
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime_settings_store, "_write_env_lines_atomic", _raise_write_error)
|
||||||
|
with pytest.raises(AppError) as exc:
|
||||||
|
save_runtime_settings(settings=settings, updates={"port": "9001"}, env_file_path=env_path)
|
||||||
|
assert exc.value.message == "Runtime settings file is not writable."
|
||||||
|
|||||||
Reference in New Issue
Block a user