From 41a123fdcc72a27f2156422d6cd73fdfbea9da75 Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Sat, 11 Jul 2026 01:41:55 +0530 Subject: [PATCH 1/2] docs(DEPLOY-004): Postgres worked guide + pool_pre_ping for server DBs docs/operations.md already said "set DATABASE_URL for Postgres" but left every operational step to the reader. The Database section now carries: - a worked end-to-end self-hosted example: provision (container or package server, least-privilege role), DATABASE_URL, schema via the automatic startup migration or manual `python -m alembic upgrade head`, and a verification checklist (alembic_version, \dt, Scan history, Admin health); - a SQLite -> Postgres data-migration recipe for existing history: stop writers, build the target schema with Alembic first (never copy alembic_version), copy rows in Base.metadata.sorted_tables order with the session pinned to UTC (SQLite stored naive-UTC timestamps), then bump every serial sequence past the copied max ids - the classic duplicate-key trap; verify counts before flipping DATABASE_URL, keep the SQLite file as the rollback copy; - connection-pool guidance: default pool sizing is ample for this app; PgBouncer session pooling as the conservative default. Code rider: _make_engine now sets pool_pre_ping=True for any non-SQLite URL. Managed Postgres proxies / PgBouncer / cloud NAT idle-kill TCP connections, so the first scan after a quiet stretch used to inherit a dead pooled connection ("server closed the connection unexpectedly"). The ping is one lightweight round-trip per checkout. SQLite engine arguments are byte-identical (locked by the new test alongside the existing pragma tests). The CI command strings and scheduler/database contracts asserted verbatim by tests/test_supply_chain_policy.py are untouched (test passing proves it). Gates: 1,387 passed, coverage 88.13% (floor 87); pre-commit validate, compileall, ruff, mypy (119 files), bandit, pip-audit all clean. Co-Authored-By: Claude Fable 5 --- backend/storage/database.py | 13 +++ docs/operations.md | 139 ++++++++++++++++++++++++++++ tests/test_scan_storage_database.py | 26 ++++++ 3 files changed, 178 insertions(+) diff --git a/backend/storage/database.py b/backend/storage/database.py index 2b11748..932d18d 100644 --- a/backend/storage/database.py +++ b/backend/storage/database.py @@ -21,6 +21,7 @@ import threading from collections.abc import Iterator from contextlib import contextmanager +from typing import Any from sqlalchemy import create_engine, event, inspect from sqlalchemy.engine import Engine @@ -67,16 +68,28 @@ def _make_engine(url: str | None = None) -> Engine: """ database_url = url or get_database_url() connect_args = {} + engine_kwargs: dict[str, Any] = {} if database_url.startswith("sqlite"): # Streamlit can re-run the app and use worker threads while the same # module-level engine stays imported. SQLite defaults to one-thread-only # connections, so we relax that guard and keep sessions short-lived. connect_args["check_same_thread"] = False + else: + # DEPLOY-004: server databases sit behind infrastructure that silently + # drops idle TCP connections (managed Postgres proxies, PgBouncer, + # cloud NAT). A pooled connection can therefore be dead by the next + # morning's first scan, which would fail with "server closed the + # connection unexpectedly". ``pool_pre_ping`` issues a lightweight + # liveness probe on checkout and transparently replaces dead + # connections. SQLite is a local file with no server to lose, so its + # engine arguments stay exactly as they were. + engine_kwargs["pool_pre_ping"] = True created_engine = create_engine( database_url, connect_args=connect_args, future=True, + **engine_kwargs, ) if database_url.startswith("sqlite"): diff --git a/docs/operations.md b/docs/operations.md index 50737df..90048bb 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -347,6 +347,145 @@ The schema is managed by Alembic; both the app and the daily job run `alembic upgrade head` equivalent automatically at startup. To pre-provision or debug: `python -m alembic upgrade head`. +### Worked example: self-hosted Postgres, end to end + +The steps below take a machine with nothing on it to a scanner reading and +writing shared Postgres history. Substitute a managed instance (Render, RDS, +Cloud SQL) from step 3 onward — the app-side steps are identical. + +1. **Provision Postgres** (any 15+ works; the container route is the fastest + self-hosted path): + + ```bash + docker run -d --name scanner-postgres \ + -e POSTGRES_USER=scanner \ + -e POSTGRES_PASSWORD='' \ + -e POSTGRES_DB=scanner \ + -p 5432:5432 \ + -v scanner-pgdata:/var/lib/postgresql/data \ + postgres:16 + ``` + + For a package-managed server instead, create the role and database: + + ```sql + CREATE ROLE scanner WITH LOGIN PASSWORD ''; + CREATE DATABASE scanner OWNER scanner; + ``` + + The app needs no superuser rights: Alembic creates every table as the + `scanner` role, so database ownership is the only privilege required. + +2. **Point the app at it.** In the environment (or `Dependencies/.env`): + + ```env + DATABASE_URL=postgresql+psycopg://scanner:@db-host:5432/scanner + ``` + + Production deployments (`APP_ENV=production`) must also set `DATA_DIR` + explicitly — startup validation refuses implicit local fallbacks. + +3. **Apply the schema.** Nothing to script: the Streamlit app and the headless + daily job both run the migration pass automatically at startup. To + pre-provision from a workstation instead (e.g. before first deploy): + + ```bash + DATABASE_URL=postgresql+psycopg://scanner:@db-host:5432/scanner \ + python -m alembic upgrade head + ``` + +4. **Verify.** `psql` should show the Alembic version and the scanner tables: + + ```bash + psql "postgresql://scanner:@db-host:5432/scanner" \ + -c "SELECT version_num FROM alembic_version;" -c "\dt" + ``` + + Then run one scan and confirm it lands: the **Scan history** view lists the + run, and **Admin health** shows the database as reachable. From the shell: + `SELECT id, screener_key, status FROM scan_runs ORDER BY id DESC LIMIT 3;`. + +### Moving existing SQLite history into Postgres + +Switching `DATABASE_URL` starts you with an **empty** Postgres history; the +old runs stay in `data/scanner.db` unless you copy them. The recipe below +moves everything (runs, results, audit rows, IPO evidence) in dependency +order. + +1. **Stop every writer** — the Streamlit app and any scheduled jobs. A copy + taken while a scan is writing is torn. +2. **Build the empty schema on Postgres first** (step 3 above). The copy + script inserts into tables Alembic created, so both sides agree on shape, + and `alembic_version` is already correct on the target — do not copy that + table. +3. **Copy rows in dependency order.** One-off operator script, run from the + repo root (it reuses the ORM metadata, so new tables are picked up + automatically): + + ```python + from sqlalchemy import create_engine, select, text + + from backend.storage.models import Base + + source = create_engine("sqlite:///data/scanner.db") + target = create_engine( + "postgresql+psycopg://scanner:@db-host:5432/scanner" + ) + + with source.connect() as read, target.begin() as write: + # SQLite stored these timestamps as naive UTC. Fix the session + # timezone so Postgres does not reinterpret them as local time. + write.execute(text("SET TIME ZONE 'UTC'")) + # sorted_tables yields parents before children, so foreign keys + # (scan_results -> scan_runs, IPO children -> ipo_issues) are safe. + for table in Base.metadata.sorted_tables: + rows = [dict(row._mapping) for row in read.execute(select(table))] + if rows: + write.execute(table.insert(), rows) + + with target.begin() as write: + # The rows arrived with their original primary keys, so each serial + # sequence still says "next id = 1". Bump every sequence past the + # copied maximum or the first new scan fails with a duplicate key. + for table in Base.metadata.sorted_tables: + if "id" not in table.columns: + continue + sequence = write.execute( + text(f"SELECT pg_get_serial_sequence('{table.name}', 'id')") + ).scalar() + if sequence: + write.execute( + text( + f"SELECT setval('{sequence}', " + f"(SELECT COALESCE(MAX(id), 1) FROM {table.name}))" + ) + ) + ``` + +4. **Verify counts, then flip.** Compare `SELECT COUNT(*)` for `scan_runs`, + `scan_results`, and `audit_log` on both sides; open the Scan history page + against Postgres and spot-check an old run's details. Only then make the + new `DATABASE_URL` permanent. Keep `data/scanner.db` (and its `-wal`/`-shm` + siblings) as the rollback copy until the first few Postgres-backed scans + look healthy. + +### Connection-pool behavior and guidance + +- The engine enables **`pool_pre_ping` automatically for any non-SQLite URL** + (DEPLOY-004): managed Postgres proxies, PgBouncer, and cloud NAT silently + drop idle TCP connections, and without the ping the first scan after a + quiet stretch inherits a dead pooled connection and fails with "server + closed the connection unexpectedly". The ping is one lightweight round-trip + per checkout; SQLite behavior is unchanged. +- SQLAlchemy's defaults (pool of 5, overflow 10) are ample here: the UI holds + sessions only for short transactions, and the daily job is a single writer. + Size the *database's* `max_connections` for the number of app instances, + not the other way around. +- If you front Postgres with **PgBouncer**, use *session* pooling. The app's + short `session_scope()` transactions are compatible with transaction + pooling too, but session pooling avoids surprises with any + connection-scoped state and is the conservative default. + --- ## Docker / container deployment diff --git a/tests/test_scan_storage_database.py b/tests/test_scan_storage_database.py index 2f7694a..d8d4d89 100644 --- a/tests/test_scan_storage_database.py +++ b/tests/test_scan_storage_database.py @@ -124,6 +124,32 @@ def test_make_engine_applies_sqlite_concurrency_pragmas(tmp_path: Path): engine.dispose() +def test_make_engine_enables_pool_pre_ping_only_for_server_databases(tmp_path: Path): + """DEPLOY-004: dead pooled Postgres connections must be replaced on checkout. + + Managed Postgres proxies and PgBouncer idle-kill TCP connections, so the + first scan after a quiet stretch used to inherit a dead pooled connection. + ``pool_pre_ping`` probes on checkout and reconnects transparently. SQLite + is a local file with no server to lose, so its engine must stay exactly as + before (no pre-ping). Building the Postgres engine is safe without a + server: SQLAlchemy connects lazily, and the pinned psycopg driver only has + to import. + """ + from backend.storage.database import _make_engine + + sqlite_engine = _make_engine(f"sqlite:///{(tmp_path / 'pre-ping.db').as_posix()}") + try: + assert sqlite_engine.pool._pre_ping is False + finally: + sqlite_engine.dispose() + + postgres_engine = _make_engine("postgresql+psycopg://scanner:secret@localhost:5432/scanner") + try: + assert postgres_engine.pool._pre_ping is True + finally: + postgres_engine.dispose() + + def test_missing_expected_tables_empty_when_schema_complete(tmp_path: Path): """A fully built database reports no missing tables (the healthy path).""" from backend.storage.database import _make_engine, _missing_expected_tables From 2528eaf9624c311495653047ef531c0af4321b68 Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Sat, 11 Jul 2026 18:45:35 +0530 Subject: [PATCH 2/2] docs: harden the Postgres operations guide Co-authored-by: Hemant Co-authored-by: Codex --- README.md | 12 ++-- docs/operations.md | 92 ++++++++++++++++++++------- migrations/env.py | 8 ++- tests/test_docker_artifacts.py | 4 +- tests/test_scan_storage_migrations.py | 35 ++++++++++ tests/test_supply_chain_policy.py | 58 +++++++++++++++++ 6 files changed, 177 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 5e6ce7f..3bb99d1 100644 --- a/README.md +++ b/README.md @@ -470,19 +470,19 @@ local prefetch/relaunch wrapper at container boot. Production containers default to fail-closed settings (`APP_ENV=production`, `AUTH_REQUIRED=true`, `DATA_DIR=/data`). Supply the same runtime environment the non-container app expects, mount a persistent `/data` volume, and provide -Streamlit's Google OIDC secrets file. The inline `-e` values below are -placeholders for a manual run; prefer your host's managed secret/environment -injection for real deployments: +Streamlit's Google OIDC secrets file. Put `DATABASE_URL`, `DHAN_CLIENT_ID`, and +`DHAN_ACCESS_TOKEN` in the already-ignored `Dependencies/.env`, run +`chmod 600 Dependencies/.env`, and load it with `--env-file` so credentials do +not enter shell history or process arguments. Prefer your host's managed secret +injection for long-lived deployments: ```bash docker run -d --name streamlit-scanner-app \ -p 8501:8501 \ + --env-file Dependencies/.env \ -e APP_ENV=production \ -e AUTH_REQUIRED=true \ -e DATA_DIR=/data \ - -e DATABASE_URL=postgresql+psycopg://scanner:@db-host:5432/scanner \ - -e DHAN_CLIENT_ID=your-dhan-client-id \ - -e DHAN_ACCESS_TOKEN=your-dhan-access-token \ -e ALLOWED_EMAILS=you@gmail.com \ -e ADMIN_EMAILS=you@gmail.com \ -e LOG_FORMAT=json \ diff --git a/docs/operations.md b/docs/operations.md index 90048bb..f29f4c7 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -336,7 +336,7 @@ Move to Postgres when the daily job and the UI run on different machines, or when more than one person reads scan history: ```env -DATABASE_URL=postgresql+psycopg://scanner:@db-host:5432/scanner +DATABASE_URL=postgresql+psycopg://scanner:@db-host:5432/scanner ``` The normal pinned setup installs `psycopg[binary]`, which supplies the psycopg 3 @@ -354,32 +354,72 @@ writing shared Postgres history. Substitute a managed instance (Render, RDS, Cloud SQL) from step 3 onward — the app-side steps are identical. 1. **Provision Postgres** (any 15+ works; the container route is the fastest - self-hosted path): + self-hosted path). Create the container environment file outside the + repository, restrict it to your account, and edit it with your normal + secret-aware editor: + + ```bash + touch postgres.env + chmod 600 postgres.env + ${EDITOR:-vi} postgres.env + ``` + + Put the values below in that protected file. Generate a real strong + password; do not paste it into a command line. + + ```env + POSTGRES_USER=scanner + POSTGRES_PASSWORD= + POSTGRES_DB=scanner + ``` + + Docker reads the values from the file, so the password does not enter shell + history or the process argument list: ```bash docker run -d --name scanner-postgres \ - -e POSTGRES_USER=scanner \ - -e POSTGRES_PASSWORD='' \ - -e POSTGRES_DB=scanner \ + --env-file postgres.env \ -p 5432:5432 \ -v scanner-pgdata:/var/lib/postgresql/data \ postgres:16 ``` - For a package-managed server instead, create the role and database: + Never commit `postgres.env`; use the host's secret manager when one is + available. For a package-managed server, create the role/database as an + administrator and use psql's hidden password prompt instead of embedding a + password in SQL history: ```sql - CREATE ROLE scanner WITH LOGIN PASSWORD ''; + CREATE ROLE scanner WITH LOGIN; CREATE DATABASE scanner OWNER scanner; + \password scanner ``` The app needs no superuser rights: Alembic creates every table as the `scanner` role, so database ownership is the only privilege required. -2. **Point the app at it.** In the environment (or `Dependencies/.env`): +2. **Point the app at it.** Keep the URL in the already-ignored + `Dependencies/.env`, protect that file before editing it, and use a + deployment secret manager in production: + + ```bash + touch Dependencies/.env + chmod 600 Dependencies/.env + ${EDITOR:-vi} Dependencies/.env + ``` ```env - DATABASE_URL=postgresql+psycopg://scanner:@db-host:5432/scanner + DATABASE_URL=postgresql+psycopg://scanner:@db-host:5432/scanner + ``` + + A URL password is not plain text with delimiters: percent-encode reserved + characters such as `@`, `:`, `/`, `?`, `#`, and `%` (`@` becomes `%40`). + This prompt reads the password without echoing it or placing it in command + history/process arguments; paste the encoded result into the protected env + file, not into a shell command: + + ```bash + python -c "from getpass import getpass; from urllib.parse import quote; print(quote(getpass('Database password: '), safe=''))" ``` Production deployments (`APP_ENV=production`) must also set `DATA_DIR` @@ -390,17 +430,22 @@ Cloud SQL) from step 3 onward — the app-side steps are identical. pre-provision from a workstation instead (e.g. before first deploy): ```bash - DATABASE_URL=postgresql+psycopg://scanner:@db-host:5432/scanner \ - python -m alembic upgrade head + python -m alembic upgrade head ``` + The settings loader reads `Dependencies/.env`, so no credential needs to + be repeated on the command line. + 4. **Verify.** `psql` should show the Alembic version and the scanner tables: ```bash - psql "postgresql://scanner:@db-host:5432/scanner" \ + psql -h db-host -U scanner -d scanner -W \ -c "SELECT version_num FROM alembic_version;" -c "\dt" ``` + `-W` asks for the password interactively rather than exposing it in process + arguments. + Then run one scan and confirm it lands: the **Scan history** view lists the run, and **Admin health** shows the database as reachable. From the shell: `SELECT id, screener_key, status FROM scan_runs ORDER BY id DESC LIMIT 3;`. @@ -423,14 +468,14 @@ order. automatically): ```python + import os + from sqlalchemy import create_engine, select, text from backend.storage.models import Base source = create_engine("sqlite:///data/scanner.db") - target = create_engine( - "postgresql+psycopg://scanner:@db-host:5432/scanner" - ) + target = create_engine(os.environ["DATABASE_URL"]) with source.connect() as read, target.begin() as write: # SQLite stored these timestamps as naive UTC. Fix the session @@ -463,7 +508,7 @@ order. ``` 4. **Verify counts, then flip.** Compare `SELECT COUNT(*)` for `scan_runs`, - `scan_results`, and `audit_log` on both sides; open the Scan history page + `scan_results`, and `audit_logs` on both sides; open the Scan history page against Postgres and spot-check an old run's details. Only then make the new `DATABASE_URL` permanent. Keep `data/scanner.db` (and its `-wal`/`-shm` siblings) as the rollback copy until the first few Postgres-backed scans @@ -590,18 +635,19 @@ docker run --rm \ ``` For production, keep `/data` on persistent storage and point `DATABASE_URL` at -Postgres. The inline `-e` values are placeholders for a manual run; prefer the -host platform's managed secret/environment injection for long-lived deployments: +Postgres. Put `DATABASE_URL`, `DHAN_CLIENT_ID`, and `DHAN_ACCESS_TOKEN` in the +already-ignored `Dependencies/.env` file described above, then protect it with +`chmod 600 Dependencies/.env`. Docker's `--env-file` option keeps those values +out of shell history and the process arguments. Prefer the host platform's +managed secret/environment injection for long-lived deployments: ```bash docker run -d --name streamlit-scanner-app \ -p 8501:8501 \ + --env-file Dependencies/.env \ -e APP_ENV=production \ -e AUTH_REQUIRED=true \ -e DATA_DIR=/data \ - -e DATABASE_URL=postgresql+psycopg://scanner:@db-host:5432/scanner \ - -e DHAN_CLIENT_ID= \ - -e DHAN_ACCESS_TOKEN= \ -e ALLOWED_EMAILS=you@gmail.com \ -e ADMIN_EMAILS=you@gmail.com \ -e LOG_FORMAT=json \ @@ -626,12 +672,10 @@ Run the headless daily job with the same image and runtime configuration: ```bash docker run --rm \ --entrypoint python \ + --env-file Dependencies/.env \ -e APP_ENV=production \ -e AUTH_REQUIRED=true \ -e DATA_DIR=/data \ - -e DATABASE_URL=postgresql+psycopg://scanner:@db-host:5432/scanner \ - -e DHAN_CLIENT_ID= \ - -e DHAN_ACCESS_TOKEN= \ -v streamlit-scanner-data:/data \ -v /absolute/path/secrets.toml:/app/.streamlit/secrets.toml:ro \ streamlit-scanner-app \ diff --git a/migrations/env.py b/migrations/env.py index 960a8dd..394f6c7 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -54,7 +54,13 @@ def run_migrations_online() -> None: command time so tests can point migrations at a temporary SQLite file and deployments can point them at Postgres. """ - config.set_main_option("sqlalchemy.url", get_database_url()) + # Alembic stores option values through ConfigParser, where ``%`` starts an + # interpolation expression. Database passwords commonly contain URL escapes + # such as ``%40`` for ``@``; doubling the percent sign protects that literal + # value inside ConfigParser. Reading the option gives SQLAlchemy the original + # URL again, while malformed interpolation can no longer echo the full secret + # URL in an early traceback. + config.set_main_option("sqlalchemy.url", get_database_url().replace("%", "%%")) connectable = engine_from_config( config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", diff --git a/tests/test_docker_artifacts.py b/tests/test_docker_artifacts.py index 8aa9d93..c8b438a 100644 --- a/tests/test_docker_artifacts.py +++ b/tests/test_docker_artifacts.py @@ -125,7 +125,9 @@ def test_readme_and_operations_document_docker_runtime() -> None: assert "AUTH_REQUIRED=false" in readme assert "APP_ENV=production" in readme assert "DATA_DIR=/data" in readme - assert "DATABASE_URL=postgresql+psycopg://" in readme + assert "--env-file Dependencies/.env" in readme + assert "-e DATABASE_URL=" not in readme + assert "-e DHAN_ACCESS_TOKEN=" not in readme assert ".streamlit/secrets.toml" in readme assert "Docker / container deployment" in operations diff --git a/tests/test_scan_storage_migrations.py b/tests/test_scan_storage_migrations.py index 46224df..2e08b41 100644 --- a/tests/test_scan_storage_migrations.py +++ b/tests/test_scan_storage_migrations.py @@ -14,6 +14,9 @@ import datetime as dt import logging +import os +import subprocess +import sys from pathlib import Path import pytest @@ -26,6 +29,38 @@ from backend.storage.models import Base, IpoIssue, IpoManualExtraction +def test_alembic_cli_does_not_echo_percent_encoded_database_password(): + """Alembic errors must not print credentials from a URL-encoded password. + + Beginner note: Alembic stores configuration with ConfigParser, where a + percent sign has special interpolation syntax. A normal URL escape such as + ``%40`` must be escaped for that configuration layer. Otherwise Alembic + raises before connecting and its traceback includes the complete database + URL, including the password. + """ + secret = "dummy%40secret" + env = os.environ.copy() + env["DATABASE_URL"] = ( + f"postgresql+psycopg://scanner:{secret}@127.0.0.1:1/scanner" + "?connect_timeout=1" + ) + + completed = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=Path(__file__).resolve().parents[1], + env=env, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + output = completed.stdout + completed.stderr + + assert completed.returncode != 0 + assert secret not in output + assert "dummy@secret" not in output + + def test_alembic_upgrade_and_downgrade_use_temp_sqlite(monkeypatch, tmp_path: Path): """Upgrade creates the expected schema; downgrade removes it again. diff --git a/tests/test_supply_chain_policy.py b/tests/test_supply_chain_policy.py index 3b350ad..2219748 100644 --- a/tests/test_supply_chain_policy.py +++ b/tests/test_supply_chain_policy.py @@ -173,6 +173,64 @@ def test_operations_guide_matches_scheduler_database_and_ci_contracts(): assert "python -m pip_audit -r requirements.txt -r requirements-dev.txt" not in text +def test_postgres_guide_keeps_credentials_out_of_shell_arguments(): + """DEPLOY-004 examples should teach a secret-safe operator workflow. + + Beginner note: placeholders in a command are often replaced in-place by an + operator. That puts the real password into shell history and, while the + command runs, into the process argument list. A protected env file and an + interactive ``psql`` prompt avoid both leaks. + """ + text = (ROOT / "docs" / "operations.md").read_text(encoding="utf-8") + worked_example = text.split( + "### Worked example: self-hosted Postgres, end to end", maxsplit=1 + )[1].split("### Connection-pool behavior and guidance", maxsplit=1)[0] + + assert "chmod 600 postgres.env" in worked_example + assert "--env-file postgres.env" in worked_example + assert "chmod 600 Dependencies/.env" in worked_example + assert "percent-encode" in worked_example.lower() + assert "psql -h db-host -U scanner -d scanner -W" in worked_example + assert "audit_logs" in worked_example + + assert "-e POSTGRES_PASSWORD=" not in worked_example + assert "DATABASE_URL=postgresql+psycopg://scanner:" not in worked_example + assert 'psql "postgresql://scanner:' not in worked_example + assert "`audit_log`" not in worked_example + + +def test_container_examples_keep_runtime_secrets_out_of_process_arguments(): + """Production Docker examples should load secrets from a protected env file. + + Beginner note: ``docker run -e NAME=value`` makes the value part of the + command line. A real password or provider token can then remain in shell + history and may be visible to local process-inspection tools. ``--env-file`` + keeps those values out of the command arguments while preserving the same + container environment. + """ + text = (ROOT / "docs" / "operations.md").read_text(encoding="utf-8") + container_examples = text.split("For production,", maxsplit=1)[1].split( + "### Backing up scan history", maxsplit=1 + )[0] + + assert container_examples.count("--env-file Dependencies/.env") == 2 + assert "-e DATABASE_URL=" not in container_examples + assert "-e DHAN_ACCESS_TOKEN=" not in container_examples + + readme = (ROOT / "README.md").read_text(encoding="utf-8") + readme_production = readme.split( + "Production containers default to fail-closed settings", maxsplit=1 + )[1].split("## Running the daily scan job", maxsplit=1)[0] + assert "--env-file Dependencies/.env" in readme_production + assert "-e DATABASE_URL=" not in readme_production + assert "-e DHAN_ACCESS_TOKEN=" not in readme_production + + # The quick URL example should agree with the worked guidance: reserved + # password characters are encoded before the URL enters the protected file. + assert "scanner:@db-host" not in text + assert "scanner:@db-host" in text + + def test_ai_architecture_docs_describe_validation_fallback_and_safe_errors(): scan_service = ( ROOT