Files
ducoterra a86caec821
Build and Push Containers / build-and-push-app (push) Successful in 1m41s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 173_docs_push_creds_split
All gates green. Final report:

**Phase 173 — final verification pass (all tasks already complete in `complete/`)**

- Verified the full implementation is in the working tree: `docs_user`/`docs_token` settings + catalogue rows (task 01), `docs_repo` PUT 422 guard + `docs_token` write-only pin (task 02), `run_git(env)` + `push_document(user, token)` credential env (task 03), `settings_migrate.py` wired into `create_app()` (task 04), E2E suite (task 05).
- **Completion criteria:**
  1. GET `/api/admin/settings` lists `docs_user` (plain) + `docs_token` (secret, `value=null`), token never in any body — ✅ `test_docs_token_is_write_only_masked_end_to_end` + E2E sentinel-absent-from-DOM
  2. PUT `docs_repo` with `://user:pass@` → 422 naming the fields; clean URL passes — ✅ integration + E2E UI-side
  3. Token set → `GIT_CURL_USERNAME`/`GIT_CURL_PASSWORD`/`GIT_TERMINAL_PROMPT=0` on every git step; empty → `env=None` — ✅ mocked-git unit tests
  4. Legacy env split once at startup, idempotent, one INFO line, `.env` untouched — ✅ `test_settings_migrate.py` (4 shapes) + `test_docs_push_migrate.py`
  5. Full suite green, coverage >90%, lint clean — ✅
  6. E2E isolated: `test_docs_push_creds.py` 4/4; regression set (panel + layout + response_to_docs) 21/21 — ✅
  7. Commit + phase move — harness's job (per instructions, not mine).
  8. README/AGENTS consistent — ✅ (below)
- Tests/lint: `uv run pytest` exit 0 (4112 passed) · `uv run pytest --cov=app --cov-report=term-missing` TOTAL 99% · `uv run ruff check .` clean · `uv run pyright` 0 errors
- Docs: README.md (incl. Docs-push config section, guard/migration paragraph, `.env.example` block) and AGENTS.md verified consistent in full; fixed two stale docstrings in `app/config.py` + `app/core/settings_catalog.py` (`is_set` → the API's actual `has_value` field).
- No live-infrastructure changes (no live step in this phase). No next pending phase — `todo/` held only 173.
2026-10-10 16:47:39 -04:00

117 lines
5.0 KiB
Python

"""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")
#: ``://<user>:<pass>@`` — 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 ``://<user>:<pass>@`` 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