"""One-time settings migrations (phase 173). ``migrate_legacy_docs_creds`` splits a legacy ``://user:pass@`` ``docs_repo`` value into ``docs_repo`` (clean) + ``docs_user`` + ``docs_token`` — DB rows that SHADOW the env (the settings model's standing rule: the database wins over the environment). ``.env`` is never read for writing or rewritten. Idempotent: it runs only while no ``docs_token`` override exists, so owner-set values are never clobbered. One INFO line when it acts (AGENTS rule 10). The read side rides the overlay (``load_overrides`` / ``resolve_settings`` — the same fail-soft engine); the write side is the PUT route's own write (an ``app_settings`` upsert through the app session — the overlay exposes no write path). A malformed ``://user:pass@`` segment (an empty user OR pass) is NOT the legacy shape: the value stays as-is (the pre-phase behavior — nothing breaks, nothing half-migrates), no rows, no log. Called ONCE from ``create_app()`` (the composition root, task 04 step 2), after the DB is available; a failure there logs a WARNING and never blocks startup — the app boots with the pre-phase behavior (the migration is a convenience, not a dependency). """ from __future__ import annotations import logging import re from sqlalchemy import func from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session from app.core.settings_overlay import load_overrides, resolve_settings from app.db import SessionLocal from app.models import AppSetting logger = logging.getLogger("app.settings") #: ``://:@`` — the legacy credentials-bearing docs-push #: remote (the phase-173 leak class). BOTH groups must be non-empty: #: ``://:@…`` / ``://u:@…`` are malformed, not the legacy shape. The #: same regex the PUT guard uses (``admin_settings._EMBEDDED_CREDS``) #: plus the capture groups; a ``://user:pass@`` subdomain cannot match #: (the character classes exclude ``/`` and the trailing ``@`` #: requirement does the job). _EMBEDDED_CREDS = re.compile(r"://([^/@:]+):([^@/]+)@") def _write_rows(rows: dict[str, str]) -> None: """Write one row per (key, value) into ``app_settings`` — the PUT route's own write (INSERT … ON CONFLICT (key) DO UPDATE, the row's ``updated_at`` moves), one session, one commit. The overlay exposes no write path, so the direct upsert IS the write path. ``.env`` is never touched (LOCKED A1 — the DB rows shadow the env; the env file stays the base).""" db: Session = SessionLocal() try: for key, value in rows.items(): stmt = pg_insert(AppSetting).values(key=key, value=value) stmt = stmt.on_conflict_do_update( index_elements=["key"], set_={"value": stmt.excluded.value, "updated_at": func.now()}, ) db.execute(stmt) db.commit() finally: db.close() def migrate_legacy_docs_creds() -> bool: """The phase-173 C3 migration — split a legacy ``://user:pass@`` ``docs_repo`` into three ``app_settings`` rows. (1) read the ``app_settings`` overrides (the overlay's state); (2) the target is the EFFECTIVE ``docs_repo`` (an override if present, else the env value — what ``resolve_settings()`` resolves to); (3) match ``://:@`` with BOTH groups non-empty — otherwise no-op (a malformed segment stays as-is, the pre-phase value: nothing breaks, nothing half-migrates); (4) a ``docs_token`` override already present → no-op (idempotent — owner-set values are never clobbered, and a previous run of this migration counts as one); (5) otherwise write the three rows (clean repo / user / token — the PUT route's own write) and log ONE INFO line. Returns ``True`` when it acted, ``False`` for every no-op shape. ``.env`` is never read for writing or rewritten: the DB rows shadow the env (the settings model's standing rule). """ effective = resolve_settings().docs_repo match = _EMBEDDED_CREDS.search(effective) if match is None: # (3) clean value OR malformed ``user:pass@`` (empty user or # pass) — the pre-phase value stays as-is; no rows, no log. return False if "docs_token" in load_overrides(): # (4) idempotent: a token override (owner-set, or this # migration's own previous run) is never clobbered. return False user, token = match.group(1), match.group(2) # (5) the ``user:pass@`` segment removed, the ``://`` separator # KEPT — ``https://u:pass@host/r.git`` → ``https://host/r.git``. clean = effective.replace(match.group(0), "://", 1) _write_rows( { "docs_repo": clean, "docs_user": user, "docs_token": token, } ) # ONE INFO line when it acts (AGENTS rule 10 — the value is never # in the line, A4's log discipline). logger.info( "settings: migrated embedded docs-push credentials out of " "docs_repo into docs_user/docs_token" ) return True