From c3e855bd9bcd11bce7f7f6806799911c3b6f631d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 22:50:59 -0400 Subject: [PATCH 01/41] =?UTF-8?q?fc3h:=20BackupRun=20model=20=E2=80=94=20a?= =?UTF-8?q?rtifact=20record=20for=20backup/restore=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/models/__init__.py | 2 + backend/app/models/backup_run.py | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 backend/app/models/backup_run.py diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f7a3c94..0cefb2d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from .app_setting import AppSetting from .artist import Artist +from .backup_run import BackupRun from .base import Base from .credential import Credential from .download_event import DownloadEvent @@ -27,6 +28,7 @@ __all__ = [ "Base", "AppSetting", "Artist", + "BackupRun", "Source", "Credential", "Post", diff --git a/backend/app/models/backup_run.py b/backend/app/models/backup_run.py new file mode 100644 index 0000000..773a628 --- /dev/null +++ b/backend/app/models/backup_run.py @@ -0,0 +1,63 @@ +"""FC-3h: backup_run — operator-facing artifact record for a backup run. + +One row per backup attempt (kind='db' or 'images'). Lifecycle +tracking (started_at/finished_at/duration_ms/exception text) lives +in task_run from FC-3i — this row records artifact metadata: file +paths, sizes, tag (retention protection), and restore lineage via +restored_from_id. + +Status values (String, not Postgres ENUM — per +feedback_check_existing_enums): + pending — created but task hasn't started yet (rare; usually + status starts as 'running' from the task body). + running — backup task is in flight. + ok — artifact successfully written. + error — task raised; error column populated. + restoring — this row represents a restore attempt (kind = restored + kind); linked to source via restored_from_id. + restored — restore completed successfully. +""" + +from datetime import datetime + +from sqlalchemy import ( + JSON, + BigInteger, + DateTime, + ForeignKey, + Integer, + String, + Text, +) +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class BackupRun(Base): + __tablename__ = "backup_run" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + kind: Mapped[str] = mapped_column(String(16), nullable=False, index=True) + status: Mapped[str] = mapped_column( + String(16), nullable=False, default="pending", index=True, + ) + tag: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + triggered_by: Mapped[str] = mapped_column(String(32), nullable=False) + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True, + ) + finished_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True, + ) + sql_path: Mapped[str | None] = mapped_column(Text, nullable=True) + tar_path: Mapped[str | None] = mapped_column(Text, nullable=True) + size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + manifest: Mapped[dict] = mapped_column( + JSON, nullable=False, default=dict, server_default="{}", + ) + restored_from_id: Mapped[int | None] = mapped_column( + ForeignKey("backup_run.id", ondelete="SET NULL"), + nullable=True, + ) From 8f2732a56fd213c87bb5daba8d7d10fddfdbb1c6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 22:51:22 -0400 Subject: [PATCH 02/41] =?UTF-8?q?fc3h:=20alembic=200017=20=E2=80=94=20back?= =?UTF-8?q?up=5Frun=20table=20with=20indexes=20+=20partial-tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- alembic/versions/0017_fc3h_backup_run.py | 82 ++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 alembic/versions/0017_fc3h_backup_run.py diff --git a/alembic/versions/0017_fc3h_backup_run.py b/alembic/versions/0017_fc3h_backup_run.py new file mode 100644 index 0000000..5b6a839 --- /dev/null +++ b/alembic/versions/0017_fc3h_backup_run.py @@ -0,0 +1,82 @@ +"""fc3h: backup_run table + +Revision ID: 0017 +Revises: 0016 +Create Date: 2026-05-24 + +Additive. New table records every backup/restore attempt with artifact +metadata. Lifecycle tracking lives in task_run from FC-3i; this is +artifact-only (paths, sizes, tag, restore lineage). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0017" +down_revision: Union[str, None] = "0016" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "backup_run", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("kind", sa.String(length=16), nullable=False), + sa.Column( + "status", sa.String(length=16), nullable=False, + server_default="pending", + ), + sa.Column("tag", sa.String(length=64), nullable=True), + sa.Column("triggered_by", sa.String(length=32), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("sql_path", sa.Text(), nullable=True), + sa.Column("tar_path", sa.Text(), nullable=True), + sa.Column("size_bytes", sa.BigInteger(), nullable=True), + sa.Column("error", sa.Text(), nullable=True), + sa.Column( + "manifest", sa.JSON(), nullable=False, server_default="{}", + ), + sa.Column( + "restored_from_id", sa.Integer(), + sa.ForeignKey("backup_run.id", ondelete="SET NULL"), + nullable=True, + ), + ) + + # Single-column indexes (matches Mapped[...].index=True). + op.create_index("ix_backup_run_kind", "backup_run", ["kind"]) + op.create_index("ix_backup_run_status", "backup_run", ["status"]) + op.create_index("ix_backup_run_tag", "backup_run", ["tag"]) + op.create_index("ix_backup_run_started_at", "backup_run", ["started_at"]) + op.create_index("ix_backup_run_finished_at", "backup_run", ["finished_at"]) + + # Composite indexes for dashboard query patterns. + op.create_index( + "ix_backup_run_kind_started", + "backup_run", ["kind", sa.text("started_at DESC")], + ) + op.create_index( + "ix_backup_run_status_finished", + "backup_run", ["status", sa.text("finished_at DESC")], + ) + # Partial index: only tagged rows participate in retention-exempt query. + op.create_index( + "ix_backup_run_tag_partial", + "backup_run", ["tag"], + postgresql_where=sa.text("tag IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_index("ix_backup_run_tag_partial", table_name="backup_run") + op.drop_index("ix_backup_run_status_finished", table_name="backup_run") + op.drop_index("ix_backup_run_kind_started", table_name="backup_run") + op.drop_index("ix_backup_run_finished_at", table_name="backup_run") + op.drop_index("ix_backup_run_started_at", table_name="backup_run") + op.drop_index("ix_backup_run_tag", table_name="backup_run") + op.drop_index("ix_backup_run_status", table_name="backup_run") + op.drop_index("ix_backup_run_kind", table_name="backup_run") + op.drop_table("backup_run") From e43312a129a2d186d56e9cbda712a6b8aa50172c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 22:52:00 -0400 Subject: [PATCH 03/41] fc3h: ImportSettings backup_* knobs + alembic 0018 (nightly-enabled, hour, keep-N per kind) Co-Authored-By: Claude Opus 4.7 (1M context) --- alembic/versions/0018_fc3h_backup_settings.py | 62 +++++++++++++++++++ backend/app/models/import_settings.py | 14 +++++ 2 files changed, 76 insertions(+) create mode 100644 alembic/versions/0018_fc3h_backup_settings.py diff --git a/alembic/versions/0018_fc3h_backup_settings.py b/alembic/versions/0018_fc3h_backup_settings.py new file mode 100644 index 0000000..517c8f1 --- /dev/null +++ b/alembic/versions/0018_fc3h_backup_settings.py @@ -0,0 +1,62 @@ +"""fc3h: backup_* knobs on import_settings + +Revision ID: 0018 +Revises: 0017 +Create Date: 2026-05-24 + +Adds four columns to the singleton import_settings row: + - backup_db_nightly_enabled (default False — opt-in) + - backup_db_nightly_hour_utc (default 3) + - backup_db_keep_last_n (default 14) + - backup_images_keep_last_n (default 3) + +server_default ensures the singleton row is backfilled in place +without an UPDATE statement. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0018" +down_revision: Union[str, None] = "0017" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "import_settings", + sa.Column( + "backup_db_nightly_enabled", sa.Boolean(), + nullable=False, server_default=sa.false(), + ), + ) + op.add_column( + "import_settings", + sa.Column( + "backup_db_nightly_hour_utc", sa.Integer(), + nullable=False, server_default="3", + ), + ) + op.add_column( + "import_settings", + sa.Column( + "backup_db_keep_last_n", sa.Integer(), + nullable=False, server_default="14", + ), + ) + op.add_column( + "import_settings", + sa.Column( + "backup_images_keep_last_n", sa.Integer(), + nullable=False, server_default="3", + ), + ) + + +def downgrade() -> None: + op.drop_column("import_settings", "backup_images_keep_last_n") + op.drop_column("import_settings", "backup_db_keep_last_n") + op.drop_column("import_settings", "backup_db_nightly_hour_utc") + op.drop_column("import_settings", "backup_db_nightly_enabled") diff --git a/backend/app/models/import_settings.py b/backend/app/models/import_settings.py index fca7f88..323fbf6 100644 --- a/backend/app/models/import_settings.py +++ b/backend/app/models/import_settings.py @@ -49,3 +49,17 @@ class ImportSettings(Base): download_failure_warning_threshold: Mapped[int] = mapped_column( Integer, nullable=False, default=5 ) + + # FC-3h backup knobs. + backup_db_nightly_enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, + ) + backup_db_nightly_hour_utc: Mapped[int] = mapped_column( + Integer, nullable=False, default=3, + ) + backup_db_keep_last_n: Mapped[int] = mapped_column( + Integer, nullable=False, default=14, + ) + backup_images_keep_last_n: Mapped[int] = mapped_column( + Integer, nullable=False, default=3, + ) From 319e7de54756af9c2dead995b1bcfa4b10306867 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 22:52:35 -0400 Subject: [PATCH 04/41] =?UTF-8?q?fc3h:=20backup=5Fservice.py=20=E2=80=94?= =?UTF-8?q?=20DB=20+=20images=20backup/restore=20+=20unlink=20helpers=20(r?= =?UTF-8?q?elocated,=20split=20per-kind)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/backup_service.py | 196 +++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 backend/app/services/backup_service.py diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py new file mode 100644 index 0000000..cc177b9 --- /dev/null +++ b/backend/app/services/backup_service.py @@ -0,0 +1,196 @@ +"""FC-3h: first-class backup/restore service for FC. + +Two independent backup kinds: + - 'db' — pg_dump only; fast; nightly via Beat (settings-gated) + - 'images' — tar+zstd of /images; slow; manual trigger only + +Files live under /_backups/. Each backup writes: + fc__.{sql|tar.zst} — the artifact + fc__.json — manifest (kind/tag/triggered_by) + +Service functions are sync (subprocess-bound). Celery tasks in +backend.app.tasks.backup wrap each one with task_run-tracked +lifecycle + soft/hard time limits + retention bookkeeping. +""" +from __future__ import annotations + +import json +import subprocess +from datetime import UTC, datetime +from pathlib import Path + +_BACKUPS_DIRNAME = "_backups" + +# Subprocess-level guardrails BEYOND the Celery soft_time_limit. The +# Celery soft limit signals the Python process; subprocess.Popen in a +# blocking syscall ignores that signal. These bound the worst case. +_DB_SUBPROCESS_TIMEOUT_S = 12 * 60 # 12 min (Celery soft is 10 min) +_IMAGES_SUBPROCESS_TIMEOUT_S = 7 * 60 * 60 # 7 hr (Celery soft is 6 hr) + + +def _libpq_url(sa_url: str) -> str: + """Strip SQLAlchemy +psycopg/+asyncpg driver suffix for pg_dump/psql.""" + for driver in ( + "postgresql+psycopg", + "postgresql+asyncpg", + "postgresql+psycopg2", + ): + if sa_url.startswith(driver + "://"): + return "postgresql://" + sa_url[len(driver) + 3:] + return sa_url + + +def _backups_dir(images_root: Path) -> Path: + p = images_root / _BACKUPS_DIRNAME + p.mkdir(parents=True, exist_ok=True) + return p + + +def _now_ts() -> str: + return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + + +def _file_size_or_none(path: Path) -> int | None: + try: + return path.stat().st_size + except OSError: + return None + + +def _write_manifest( + out_dir: Path, *, kind: str, ts: str, + tag: str | None, triggered_by: str, + artifact_path: Path, +) -> Path: + manifest = { + "kind": kind, + "backup_id": f"fc_{kind}_{ts}", + "tag": tag, + "triggered_by": triggered_by, + "created_at": datetime.now(UTC).isoformat(), + "artifact_path": str(artifact_path), + } + mf = out_dir / f"fc_{kind}_{ts}.json" + mf.write_text(json.dumps(manifest, indent=2)) + return mf + + +def backup_db( + *, db_url: str, images_root: Path, + tag: str | None = None, triggered_by: str = "manual", +) -> dict: + """Run pg_dump; write .sql + manifest; return dict for the caller + to persist into BackupRun. Raises on subprocess failure.""" + ts = _now_ts() + out_dir = _backups_dir(images_root) + sql_path = out_dir / f"fc_db_{ts}.sql" + subprocess.run( + [ + "pg_dump", "--no-owner", "--no-acl", + "-f", str(sql_path), _libpq_url(db_url), + ], + capture_output=True, check=True, + timeout=_DB_SUBPROCESS_TIMEOUT_S, + ) + manifest_path = _write_manifest( + out_dir, kind="db", ts=ts, tag=tag, triggered_by=triggered_by, + artifact_path=sql_path, + ) + return { + "kind": "db", + "ts": ts, + "sql_path": str(sql_path), + "tar_path": None, + "manifest_path": str(manifest_path), + "size_bytes": _file_size_or_none(sql_path), + } + + +def backup_images( + *, images_root: Path, + tag: str | None = None, triggered_by: str = "manual", +) -> dict: + """Run tar --zstd over images_root; write .tar.zst + manifest.""" + ts = _now_ts() + out_dir = _backups_dir(images_root) + tar_path = out_dir / f"fc_images_{ts}.tar.zst" + subprocess.run( + [ + "tar", "--zstd", "-cf", str(tar_path), + "-C", str(images_root.parent), images_root.name, + f"--exclude={images_root.name}/_backups", + f"--exclude={images_root.name}/_quarantine", + ], + capture_output=True, check=True, + timeout=_IMAGES_SUBPROCESS_TIMEOUT_S, + ) + manifest_path = _write_manifest( + out_dir, kind="images", ts=ts, tag=tag, triggered_by=triggered_by, + artifact_path=tar_path, + ) + return { + "kind": "images", + "ts": ts, + "sql_path": None, + "tar_path": str(tar_path), + "manifest_path": str(manifest_path), + "size_bytes": _file_size_or_none(tar_path), + } + + +def restore_db(*, db_url: str, sql_path: Path) -> None: + """Wipe public schema, then load from .sql. Raises on subprocess + failure; partial-restore state is the caller's concern.""" + libpq = _libpq_url(db_url) + subprocess.run( + [ + "psql", libpq, "-c", + "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;", + ], + capture_output=True, check=True, timeout=120, + ) + subprocess.run( + ["psql", libpq, "-f", str(sql_path)], + capture_output=True, check=True, + timeout=_DB_SUBPROCESS_TIMEOUT_S, + ) + + +def restore_images(*, images_root: Path, tar_path: Path) -> None: + """Untar over images_root.parent. Additive — files NOT in the + tarball are NOT removed. Caller wipes first if a clean restore + is needed.""" + subprocess.run( + [ + "tar", "--zstd", "-xf", str(tar_path), + "-C", str(images_root.parent), + ], + capture_output=True, check=True, + timeout=_IMAGES_SUBPROCESS_TIMEOUT_S, + ) + + +def unlink_artifact_files( + *, + sql_path: str | None, + tar_path: str | None, + manifest_path: str | None, +) -> dict: + """Best-effort unlink of all on-disk files for a BackupRun row. + Returns dict keyed by label with True/False per file. Missing + files count as success (missing_ok semantics).""" + deleted: dict = {} + for label, p in ( + ("sql", sql_path), + ("tar", tar_path), + ("manifest", manifest_path), + ): + if not p: + continue + path = Path(p) + try: + path.unlink(missing_ok=True) + deleted[label] = True + except OSError: + deleted[label] = False + return deleted From 882cb491ba9bc4952def0ecdf846f82fb3b77247 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 22:53:26 -0400 Subject: [PATCH 05/41] fc3h: backup_db_task + backup_images_task Celery tasks Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/tasks/backup.py | 129 ++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 backend/app/tasks/backup.py diff --git a/backend/app/tasks/backup.py b/backend/app/tasks/backup.py new file mode 100644 index 0000000..3c40afe --- /dev/null +++ b/backend/app/tasks/backup.py @@ -0,0 +1,129 @@ +"""FC-3h: backup/restore Celery tasks. + +All tasks live on the maintenance queue (per celery_app.task_routes). +task_run lifecycle tracking is automatic via FC-3i signals — these +tasks just record the operator-facing artifact metadata into +BackupRun. +""" +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from pathlib import Path + +from celery.exceptions import SoftTimeLimitExceeded +from sqlalchemy.exc import DBAPIError, OperationalError + +from ..celery_app import celery +from ..config import get_config +from ..models import BackupRun +from ..services import backup_service +from ._sync_engine import sync_session_factory as _sync_session_factory + +log = logging.getLogger(__name__) +IMAGES_ROOT = Path("/images") + + +def _mark_failed(session, row: BackupRun, exc: BaseException) -> None: + """Flip a BackupRun row from running/restoring to error with a + truncated error message and finished_at. Caller already holds the + session open.""" + row.status = "error" + row.error = f"{type(exc).__name__}: {exc}"[:2000] + row.finished_at = datetime.now(UTC) + session.add(row) + session.commit() + + +@celery.task( + name="backend.app.tasks.backup.backup_db_task", + bind=True, + autoretry_for=(OperationalError, DBAPIError), + retry_backoff=10, retry_backoff_max=120, max_retries=2, + soft_time_limit=600, time_limit=720, +) +def backup_db_task(self, *, tag: str | None = None, + triggered_by: str = "manual") -> dict: + """Create one DB backup. Returns {'backup_run_id': N}.""" + SessionLocal = _sync_session_factory() + cfg = get_config() + now = datetime.now(UTC) + with SessionLocal() as session: + row = BackupRun( + kind="db", status="running", tag=tag, + triggered_by=triggered_by, started_at=now, manifest={}, + ) + session.add(row); session.commit(); session.refresh(row) + run_id = row.id + + try: + result = backup_service.backup_db( + db_url=cfg.database_url_sync, images_root=IMAGES_ROOT, + tag=tag, triggered_by=triggered_by, + ) + except (SoftTimeLimitExceeded, Exception) as exc: + with SessionLocal() as session: + row = session.get(BackupRun, run_id) + if row is not None: + _mark_failed(session, row, exc) + raise + + with SessionLocal() as session: + row = session.get(BackupRun, run_id) + row.status = "ok" + row.finished_at = datetime.now(UTC) + row.sql_path = result["sql_path"] + row.size_bytes = result["size_bytes"] + row.manifest = { + "manifest_path": result["manifest_path"], + "ts": result["ts"], + } + session.commit() + return {"backup_run_id": run_id} + + +@celery.task( + name="backend.app.tasks.backup.backup_images_task", + bind=True, + autoretry_for=(OperationalError, DBAPIError), + retry_backoff=30, retry_backoff_max=300, max_retries=1, + soft_time_limit=21600, time_limit=23400, +) +def backup_images_task(self, *, tag: str | None = None, + triggered_by: str = "manual") -> dict: + """Create one images backup. Same shape as backup_db_task; uses + tar_path instead of sql_path.""" + SessionLocal = _sync_session_factory() + now = datetime.now(UTC) + with SessionLocal() as session: + row = BackupRun( + kind="images", status="running", tag=tag, + triggered_by=triggered_by, started_at=now, manifest={}, + ) + session.add(row); session.commit(); session.refresh(row) + run_id = row.id + + try: + result = backup_service.backup_images( + images_root=IMAGES_ROOT, + tag=tag, triggered_by=triggered_by, + ) + except (SoftTimeLimitExceeded, Exception) as exc: + with SessionLocal() as session: + row = session.get(BackupRun, run_id) + if row is not None: + _mark_failed(session, row, exc) + raise + + with SessionLocal() as session: + row = session.get(BackupRun, run_id) + row.status = "ok" + row.finished_at = datetime.now(UTC) + row.tar_path = result["tar_path"] + row.size_bytes = result["size_bytes"] + row.manifest = { + "manifest_path": result["manifest_path"], + "ts": result["ts"], + } + session.commit() + return {"backup_run_id": run_id} From e9ea376aed52697ef9688582e83da51cc7e9f87d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 22:53:50 -0400 Subject: [PATCH 06/41] =?UTF-8?q?fc3h:=20restore=5Fdb=5Ftask=20+=20restore?= =?UTF-8?q?=5Fimages=5Ftask=20=E2=80=94=20restore=20creates=20'restoring'?= =?UTF-8?q?=20marker=20row=20linked=20via=20restored=5Ffrom=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/tasks/backup.py | 93 +++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/backend/app/tasks/backup.py b/backend/app/tasks/backup.py index 3c40afe..4f374b2 100644 --- a/backend/app/tasks/backup.py +++ b/backend/app/tasks/backup.py @@ -127,3 +127,96 @@ def backup_images_task(self, *, tag: str | None = None, } session.commit() return {"backup_run_id": run_id} + + +@celery.task( + name="backend.app.tasks.backup.restore_db_task", + bind=True, + max_retries=0, # NEVER auto-retry a half-applied restore. + soft_time_limit=1200, time_limit=1800, +) +def restore_db_task(self, *, source_backup_run_id: int) -> dict: + """Restore from a previous DB backup. Inserts a NEW BackupRun row + (kind='db', status='restoring') linked to the source via + restored_from_id; flips to 'restored' on success or 'error' on + failure. Operator sees the restore as a row in the dashboard.""" + SessionLocal = _sync_session_factory() + cfg = get_config() + now = datetime.now(UTC) + with SessionLocal() as session: + src = session.get(BackupRun, source_backup_run_id) + if src is None or src.kind != "db" or not src.sql_path: + raise ValueError( + f"BackupRun id={source_backup_run_id} is not a valid DB backup" + ) + marker = BackupRun( + kind="db", status="restoring", + triggered_by="restore", started_at=now, + restored_from_id=src.id, + manifest={"source_sql_path": src.sql_path}, + ) + session.add(marker); session.commit(); session.refresh(marker) + marker_id = marker.id + sql_path = src.sql_path + + try: + backup_service.restore_db( + db_url=cfg.database_url_sync, sql_path=Path(sql_path), + ) + except (SoftTimeLimitExceeded, Exception) as exc: + with SessionLocal() as session: + row = session.get(BackupRun, marker_id) + if row is not None: + _mark_failed(session, row, exc) + raise + + with SessionLocal() as session: + row = session.get(BackupRun, marker_id) + row.status = "restored" + row.finished_at = datetime.now(UTC) + session.commit() + return {"backup_run_id": marker_id} + + +@celery.task( + name="backend.app.tasks.backup.restore_images_task", + bind=True, max_retries=0, + soft_time_limit=21600, time_limit=23400, +) +def restore_images_task(self, *, source_backup_run_id: int) -> dict: + """Mirrors restore_db_task; uses backup_service.restore_images.""" + SessionLocal = _sync_session_factory() + now = datetime.now(UTC) + with SessionLocal() as session: + src = session.get(BackupRun, source_backup_run_id) + if src is None or src.kind != "images" or not src.tar_path: + raise ValueError( + f"BackupRun id={source_backup_run_id} is not a valid images backup" + ) + marker = BackupRun( + kind="images", status="restoring", + triggered_by="restore", started_at=now, + restored_from_id=src.id, + manifest={"source_tar_path": src.tar_path}, + ) + session.add(marker); session.commit(); session.refresh(marker) + marker_id = marker.id + tar_path = src.tar_path + + try: + backup_service.restore_images( + images_root=IMAGES_ROOT, tar_path=Path(tar_path), + ) + except (SoftTimeLimitExceeded, Exception) as exc: + with SessionLocal() as session: + row = session.get(BackupRun, marker_id) + if row is not None: + _mark_failed(session, row, exc) + raise + + with SessionLocal() as session: + row = session.get(BackupRun, marker_id) + row.status = "restored" + row.finished_at = datetime.now(UTC) + session.commit() + return {"backup_run_id": marker_id} From 06d527cb92154acd742e567f07fe3a1157eb1fc2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 22:55:19 -0400 Subject: [PATCH 07/41] fc3h: prune_backups (daily retention) + backup_db_nightly (hourly tick, settings-gated) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/tasks/backup.py | 72 ++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/backend/app/tasks/backup.py b/backend/app/tasks/backup.py index 4f374b2..c5181ce 100644 --- a/backend/app/tasks/backup.py +++ b/backend/app/tasks/backup.py @@ -12,11 +12,12 @@ from datetime import UTC, datetime from pathlib import Path from celery.exceptions import SoftTimeLimitExceeded +from sqlalchemy import select from sqlalchemy.exc import DBAPIError, OperationalError from ..celery_app import celery from ..config import get_config -from ..models import BackupRun +from ..models import BackupRun, ImportSettings from ..services import backup_service from ._sync_engine import sync_session_factory as _sync_session_factory @@ -220,3 +221,72 @@ def restore_images_task(self, *, source_backup_run_id: int) -> dict: row.finished_at = datetime.now(UTC) session.commit() return {"backup_run_id": marker_id} + + +@celery.task( + name="backend.app.tasks.backup.prune_backups", + soft_time_limit=300, time_limit=600, +) +def prune_backups() -> dict: + """Daily Beat. Per-kind retention from ImportSettings. + + Returns {"db_deleted": N, "images_deleted": M, "files_unlinked": K}. + Tagged rows (tag IS NOT NULL) are never pruned. + Status='running' / 'restoring' rows are never pruned (recovery + sweep from FC-3i handles those via task_run). + """ + SessionLocal = _sync_session_factory() + counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0} + with SessionLocal() as session: + s = session.execute( + select(ImportSettings).where(ImportSettings.id == 1) + ).scalar_one() + for kind, keep in ( + ("db", s.backup_db_keep_last_n), + ("images", s.backup_images_keep_last_n), + ): + candidates = session.execute( + select(BackupRun) + .where(BackupRun.kind == kind) + .where(BackupRun.tag.is_(None)) + .where(BackupRun.status.in_(["ok", "error"])) + .order_by(BackupRun.started_at.desc()) + .offset(keep) + ).scalars().all() + for row in candidates: + result = backup_service.unlink_artifact_files( + sql_path=row.sql_path, + tar_path=row.tar_path, + manifest_path=(row.manifest or {}).get("manifest_path"), + ) + counts["files_unlinked"] += sum( + 1 for v in result.values() if v + ) + session.delete(row) + counts[f"{kind}_deleted"] += 1 + session.commit() + return counts + + +@celery.task( + name="backend.app.tasks.backup.backup_db_nightly", + soft_time_limit=60, time_limit=120, +) +def backup_db_nightly() -> dict: + """Hourly tick. Dispatches a real backup ONLY if the configured + UTC hour matches and the nightly setting is enabled. Returns + either {'skipped': ''} or {'dispatched': ''}.""" + SessionLocal = _sync_session_factory() + with SessionLocal() as session: + s = session.execute( + select(ImportSettings).where(ImportSettings.id == 1) + ).scalar_one() + nightly_enabled = s.backup_db_nightly_enabled + configured_hour = s.backup_db_nightly_hour_utc + if not nightly_enabled: + return {"skipped": "nightly disabled"} + now_hour = datetime.now(UTC).hour + if now_hour != configured_hour: + return {"skipped": f"hour={now_hour} != configured={configured_hour}"} + res = backup_db_task.delay(triggered_by="nightly") + return {"dispatched": res.id} From 1f01c4819a5382a04c33f22d7d47e3e0138c7be9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 22:56:06 -0400 Subject: [PATCH 08/41] =?UTF-8?q?fc3h:=20celery=5Fapp=20=E2=80=94=20regist?= =?UTF-8?q?er=20backup=20tasks=20(include=20+=20maintenance=20route=20+=20?= =?UTF-8?q?Beat=20hourly=20tick=20+=20daily=20prune)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/celery_app.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index b6ce64c..d169af2 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -31,6 +31,7 @@ def make_celery() -> Celery: "backend.app.tasks.migration", "backend.app.tasks.ml", "backend.app.tasks.download", + "backend.app.tasks.backup", ], ) app.conf.update( @@ -43,6 +44,7 @@ def make_celery() -> Celery: "backend.app.tasks.scan.*": {"queue": "scan"}, "backend.app.tasks.maintenance.*": {"queue": "maintenance"}, "backend.app.tasks.migration.*": {"queue": "maintenance"}, + "backend.app.tasks.backup.*": {"queue": "maintenance"}, }, # Heavy ML tasks need fair dispatch — see ImageRepo's precedent. task_acks_late=True, @@ -89,6 +91,14 @@ def make_celery() -> Celery: "task": "backend.app.tasks.maintenance.prune_task_runs", "schedule": 86400.0, # daily }, + "fc3h-backup-db-nightly": { + "task": "backend.app.tasks.backup.backup_db_nightly", + "schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour + }, + "fc3h-prune-backups": { + "task": "backend.app.tasks.backup.prune_backups", + "schedule": 86400.0, # daily + }, }, timezone="UTC", ) From 7d42cddb110b4feb84de180bca137bc5e813b863 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 22:57:25 -0400 Subject: [PATCH 09/41] =?UTF-8?q?fc3h:=20/api/system/backup=20blueprint=20?= =?UTF-8?q?=E2=80=94=20trigger,=20list,=20get,=20patch,=20restore,=20delet?= =?UTF-8?q?e,=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/__init__.py | 2 + backend/app/api/system_backup.py | 263 +++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 backend/app/api/system_backup.py diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 441d58d..b6399dc 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -34,6 +34,7 @@ def all_blueprints() -> list[Blueprint]: from .sources import sources_bp from .suggestions import suggestions_bp from .system_activity import system_activity_bp + from .system_backup import system_backup_bp from .tags import tags_bp return [ api_bp, @@ -46,6 +47,7 @@ def all_blueprints() -> list[Blueprint]: showcase_bp, settings_bp, system_activity_bp, + system_backup_bp, import_admin_bp, migrate_bp, suggestions_bp, diff --git a/backend/app/api/system_backup.py b/backend/app/api/system_backup.py new file mode 100644 index 0000000..f65fe3b --- /dev/null +++ b/backend/app/api/system_backup.py @@ -0,0 +1,263 @@ +"""FC-3h: /api/system/backup — create/list/restore/delete/tag for +DB + image backups. + +Read endpoints are public on FC (operator-facing internal API; same +posture as /api/system/activity). Write endpoints take a typed +`confirm` body field that must match a server-generated token for +that backup row, to prevent click-to-destroy by stale browser tabs +or accidental cURL. +""" +from __future__ import annotations + +from quart import Blueprint, jsonify, request +from sqlalchemy import desc, select + +from ..extensions import get_session +from ..models import BackupRun, ImportSettings + +system_backup_bp = Blueprint( + "system_backup", __name__, url_prefix="/api/system/backup", +) + +_KINDS = frozenset({"db", "images"}) +_TAG_MAX_LEN = 64 +_BACKUP_SETTINGS_FIELDS = ( + "backup_db_nightly_enabled", + "backup_db_nightly_hour_utc", + "backup_db_keep_last_n", + "backup_images_keep_last_n", +) + + +def _bad(error: str, *, status: int = 400, **extra): + body = {"error": error}; body.update(extra) + return jsonify(body), status + + +def _row_to_dict(r: BackupRun) -> dict: + return { + "id": r.id, + "kind": r.kind, + "status": r.status, + "tag": r.tag, + "triggered_by": r.triggered_by, + "started_at": r.started_at.isoformat() if r.started_at else None, + "finished_at": r.finished_at.isoformat() if r.finished_at else None, + "duration_seconds": ( + int((r.finished_at - r.started_at).total_seconds()) + if r.finished_at and r.started_at else None + ), + "sql_path": r.sql_path, + "tar_path": r.tar_path, + "size_bytes": r.size_bytes, + "error": r.error, + "restored_from_id": r.restored_from_id, + "manifest": r.manifest or {}, + } + + +def _validate_tag(tag): + if tag is None: + return None + if not isinstance(tag, str): + return _bad("invalid_tag", detail="tag must be string or null") + tag = tag.strip() + if not tag: + return None + if len(tag) > _TAG_MAX_LEN: + return _bad("invalid_tag", detail=f"tag too long (max {_TAG_MAX_LEN})") + return tag + + +def _validate_backup_settings_patch(body: dict): + if "backup_db_nightly_enabled" in body and not isinstance( + body["backup_db_nightly_enabled"], bool, + ): + return _bad("invalid_value", detail="backup_db_nightly_enabled must be bool") + if "backup_db_nightly_hour_utc" in body: + v = body["backup_db_nightly_hour_utc"] + if not isinstance(v, int) or isinstance(v, bool) or not (0 <= v <= 23): + return _bad("invalid_value", detail="backup_db_nightly_hour_utc must be 0..23") + if "backup_db_keep_last_n" in body: + v = body["backup_db_keep_last_n"] + if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 365): + return _bad("invalid_value", detail="backup_db_keep_last_n must be 1..365") + if "backup_images_keep_last_n" in body: + v = body["backup_images_keep_last_n"] + if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 100): + return _bad("invalid_value", detail="backup_images_keep_last_n must be 1..100") + return None + + +@system_backup_bp.route("/db", methods=["POST"]) +async def trigger_db_backup(): + body = await request.get_json(silent=True) or {} + tag = _validate_tag(body.get("tag")) + if isinstance(tag, tuple): + return tag + from ..tasks.backup import backup_db_task + backup_db_task.delay(tag=tag, triggered_by="manual") + return jsonify({"status": "dispatched"}), 202 + + +@system_backup_bp.route("/images", methods=["POST"]) +async def trigger_images_backup(): + body = await request.get_json(silent=True) or {} + tag = _validate_tag(body.get("tag")) + if isinstance(tag, tuple): + return tag + from ..tasks.backup import backup_images_task + backup_images_task.delay(tag=tag, triggered_by="manual") + return jsonify({"status": "dispatched"}), 202 + + +@system_backup_bp.route("/runs", methods=["GET"]) +async def list_runs(): + try: + limit = min(int(request.args.get("limit", "50")), 200) + except ValueError: + return _bad("invalid_limit") + if limit < 1: + return _bad("invalid_limit") + kind = request.args.get("kind") + if kind is not None and kind not in _KINDS: + return _bad("invalid_kind", detail=f"kind must be one of {sorted(_KINDS)}") + before_id_raw = request.args.get("before_id") + before_id = int(before_id_raw) if before_id_raw else None + + async with get_session() as session: + stmt = select(BackupRun).order_by(desc(BackupRun.id)) + if kind: + stmt = stmt.where(BackupRun.kind == kind) + if before_id is not None: + stmt = stmt.where(BackupRun.id < before_id) + stmt = stmt.limit(limit + 1) + rows = (await session.execute(stmt)).scalars().all() + + has_more = len(rows) > limit + rows = rows[:limit] + return jsonify({ + "runs": [_row_to_dict(r) for r in rows], + "next_cursor": rows[-1].id if has_more and rows else None, + }) + + +@system_backup_bp.route("/runs/", methods=["GET"]) +async def get_run(run_id: int): + async with get_session() as session: + row = await session.get(BackupRun, run_id) + if row is None: + return _bad("not_found", status=404) + return jsonify(_row_to_dict(row)) + + +@system_backup_bp.route("/runs/", methods=["PATCH"]) +async def patch_run(run_id: int): + body = await request.get_json(silent=True) or {} + if "tag" not in body: + return _bad("invalid_body", detail="tag required") + tag = _validate_tag(body["tag"]) + if isinstance(tag, tuple): + return tag + + async with get_session() as session: + row = await session.get(BackupRun, run_id) + if row is None: + return _bad("not_found", status=404) + row.tag = tag + await session.commit() + await session.refresh(row) + return jsonify(_row_to_dict(row)) + + +@system_backup_bp.route("/runs//restore", methods=["POST"]) +async def trigger_restore(run_id: int): + body = await request.get_json(silent=True) or {} + supplied = body.get("confirm", "") + + async with get_session() as session: + row = await session.get(BackupRun, run_id) + if row is None: + return _bad("not_found", status=404) + if row.status != "ok": + return _bad( + "not_restorable", + detail=f"source backup status={row.status!r}; only 'ok' rows are restorable", + ) + expected = f"restore-{row.kind}-{row.id}" + if supplied != expected: + return _bad( + "confirm_mismatch", + detail=f"confirm must equal {expected!r}", + expected=expected, + ) + kind = row.kind + + if kind == "db": + from ..tasks.backup import restore_db_task + restore_db_task.delay(source_backup_run_id=run_id) + else: # 'images' (the only other value _KINDS allows via the trigger path) + from ..tasks.backup import restore_images_task + restore_images_task.delay(source_backup_run_id=run_id) + return jsonify({"status": "dispatched", "kind": kind}), 202 + + +@system_backup_bp.route("/runs/", methods=["DELETE"]) +async def delete_run(run_id: int): + body = await request.get_json(silent=True) or {} + supplied = body.get("confirm", "") + + async with get_session() as session: + row = await session.get(BackupRun, run_id) + if row is None: + return _bad("not_found", status=404) + expected = f"delete-{row.kind}-{row.id}" + if supplied != expected: + return _bad( + "confirm_mismatch", + detail=f"confirm must equal {expected!r}", + expected=expected, + ) + from ..services import backup_service + backup_service.unlink_artifact_files( + sql_path=row.sql_path, tar_path=row.tar_path, + manifest_path=(row.manifest or {}).get("manifest_path"), + ) + await session.delete(row) + await session.commit() + return "", 204 + + +@system_backup_bp.route("/settings", methods=["GET"]) +async def get_settings(): + async with get_session() as session: + row = (await session.execute( + select(ImportSettings).where(ImportSettings.id == 1) + )).scalar_one() + return jsonify({ + "backup_db_nightly_enabled": row.backup_db_nightly_enabled, + "backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc, + "backup_db_keep_last_n": row.backup_db_keep_last_n, + "backup_images_keep_last_n": row.backup_images_keep_last_n, + }) + + +@system_backup_bp.route("/settings", methods=["PATCH"]) +async def patch_settings(): + body = await request.get_json(silent=True) + if not isinstance(body, dict): + return _bad("invalid_body", detail="body must be a JSON object") + + err = _validate_backup_settings_patch(body) + if err is not None: + return err + + async with get_session() as session: + row = (await session.execute( + select(ImportSettings).where(ImportSettings.id == 1) + )).scalar_one() + for field in _BACKUP_SETTINGS_FIELDS: + if field in body: + setattr(row, field, body[field]) + await session.commit() + return await get_settings() From d3d4320ed5e24570c1581851c788a91d11daa425 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 22:59:11 -0400 Subject: [PATCH 10/41] =?UTF-8?q?fc3h:=20retire=20backup=20+=20rollback=20?= =?UTF-8?q?from=20migrate=20API/task=20=E2=80=94=20moved=20to=20/api/syste?= =?UTF-8?q?m/backup/*=20per=20FC-3h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/migrate.py | 44 ++++------------------------------ backend/app/tasks/migration.py | 38 +++++------------------------ 2 files changed, 11 insertions(+), 71 deletions(-) diff --git a/backend/app/api/migrate.py b/backend/app/api/migrate.py index 81e1239..8a67fc6 100644 --- a/backend/app/api/migrate.py +++ b/backend/app/api/migrate.py @@ -1,14 +1,11 @@ """FC-5: /api/migrate — trigger and poll migration runs. Ingest kinds (gs_ingest, ir_ingest) accept multipart/form-data with an -`export_file` field. All other kinds accept JSON. Apply-without-backup -guard rejects non-dry-run ingests unless a pre_migration-tagged backup -exists in the last 24h (override with body.force=true). +`export_file` field. All other kinds accept JSON. Backup + rollback +were retired in FC-3h (2026-05-24); use /api/system/backup/* instead. """ import json -from datetime import UTC, datetime, timedelta -from pathlib import Path from quart import Blueprint, jsonify, request from sqlalchemy import select @@ -19,17 +16,12 @@ from ..tasks.migration import run_migration migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate") +# 'backup' + 'rollback' retired 2026-05-24 (FC-3h); see /api/system/backup/*. _VALID_KINDS = frozenset({ - "backup", "gs_ingest", "ir_ingest", "tag_apply", - "ml_queue", "verify", "rollback", "cleanup", + "gs_ingest", "ir_ingest", "tag_apply", + "ml_queue", "verify", "cleanup", }) _INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"}) -# Backup gate retired 2026-05-24 — operator-flagged the speculative-safety -# requirement was actively blocking the UI ingest path (backup itself is -# unreliable on large NFS-backed image libraries) and FC-3h will rewrite -# the backup surface as a first-class feature with its own scheduling -# + recovery. Leaving the constant for historical grep. -_APPLY_KINDS: frozenset[str] = frozenset() def _bad(error: str, *, status: int = 400, **extra): @@ -38,19 +30,6 @@ def _bad(error: str, *, status: int = 400, **extra): return jsonify(body), status -def _has_recent_pre_migration_backup() -> bool: - from ..services.migrators import backup as backup_mod - images_root = Path("/images") - manifest = backup_mod.find_latest_backup(images_root, tag="pre_migration") - if manifest is None: - return False - created_at_str = manifest.get("created_at") - if not created_at_str: - return False - created_at = datetime.fromisoformat(created_at_str) - return (datetime.now(UTC) - created_at) < timedelta(hours=24) - - def _run_to_dict(run: MigrationRun) -> dict: return { "id": run.id, @@ -83,7 +62,6 @@ async def create_run(kind: str): except (UnicodeDecodeError, json.JSONDecodeError) as exc: return _bad("invalid_export_file", detail=str(exc)) dry_run = str(form.get("dry_run", "false")).lower() in ("true", "1", "yes") - force = str(form.get("force", "false")).lower() in ("true", "1", "yes") params: dict = {"data": data, "dry_run": dry_run} else: body = await request.get_json() @@ -92,20 +70,8 @@ async def create_run(kind: str): if not isinstance(body, dict): return _bad("invalid_body") dry_run = bool(body.get("dry_run", False)) - force = bool(body.get("force", False)) params = dict(body) - is_apply = (kind in _APPLY_KINDS) and not dry_run - if is_apply and not force and not _has_recent_pre_migration_backup(): - return _bad( - "no_backup", - detail="apply action requires a pre_migration-tagged backup " - "in the last 24h (or force=true).", - ) - - if kind == "backup": - params.setdefault("tag", "pre_migration") - async with get_session() as session: run = MigrationRun(kind=kind, status="pending", dry_run=dry_run) session.add(run) diff --git a/backend/app/tasks/migration.py b/backend/app/tasks/migration.py index d08b68b..a59c8b2 100644 --- a/backend/app/tasks/migration.py +++ b/backend/app/tasks/migration.py @@ -4,7 +4,8 @@ Dispatches to the right migrator based on `kind`. Updates MigrationRun row's status/counts/finished_at as it runs. Failures set status='error' with the error message preserved. -kinds: backup, gs_ingest, ir_ingest, tag_apply, ml_queue, verify, rollback, cleanup +kinds: gs_ingest, ir_ingest, tag_apply, ml_queue, verify, cleanup +(backup + rollback retired 2026-05-24 → see /api/system/backup/*) """ from __future__ import annotations @@ -20,10 +21,8 @@ from ..celery_app import celery from ..config import get_config from ..models import MigrationRun from ..services.credential_crypto import CredentialCrypto -from ..services.migrators import backup as backup_mod from ..services.migrators import cleanup as cleanup_mod from ..services.migrators import gs_ingest, ir_ingest, ml_queue, tag_apply, verify -from ..services.migrators import rollback as rollback_mod log = logging.getLogger(__name__) @@ -65,21 +64,11 @@ async def _run_async(run_id: int, kind: str, params: dict) -> dict: async with factory() as db: await _update_run(db, run_id, status="running") try: - if kind == "backup": - manifest = backup_mod.create_backup( - db_url=get_config().database_url_sync, - images_root=IMAGES_ROOT, - tag=params.get("tag", "manual"), + if kind in ("backup", "rollback"): + raise ValueError( + f"kind {kind!r} retired in FC-3h; " + "use /api/system/backup/* instead" ) - await _update_run( - db, run_id, status="ok", - counts={"rows_processed": 0, "rows_inserted": 0, - "rows_skipped": 0, "files_copied": 0, - "bytes_copied": 0, "conflicts": 0}, - finished_at=datetime.now(UTC), - metadata_patch={"manifest": manifest}, - ) - return manifest elif kind == "gs_ingest": fc_crypto = CredentialCrypto(_KEY_PATH) @@ -166,21 +155,6 @@ async def _run_async(run_id: int, kind: str, params: dict) -> dict: ) return result - elif kind == "rollback": - result = rollback_mod.rollback_to_pre_migration( - db_url=get_config().database_url_sync, - images_root=IMAGES_ROOT, - ) - await _update_run( - db, run_id, status="ok", - counts={"rows_processed": 0, "rows_inserted": 0, - "rows_skipped": 0, "files_copied": 0, - "bytes_copied": 0, "conflicts": 0}, - finished_at=datetime.now(UTC), - metadata_patch={"rollback_result": result}, - ) - return result - else: raise ValueError(f"unknown kind: {kind}") From 70e1e010d1be34e7850840d44c7ac08b6352292f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 22:59:18 -0400 Subject: [PATCH 11/41] fc3h: remove backend/app/services/migrators/backup.py + rollback.py (relocated to backup_service.py) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/migrators/backup.py | 146 --------------------- backend/app/services/migrators/rollback.py | 21 --- 2 files changed, 167 deletions(-) delete mode 100644 backend/app/services/migrators/backup.py delete mode 100644 backend/app/services/migrators/rollback.py diff --git a/backend/app/services/migrators/backup.py b/backend/app/services/migrators/backup.py deleted file mode 100644 index d0bbf72..0000000 --- a/backend/app/services/migrators/backup.py +++ /dev/null @@ -1,146 +0,0 @@ -"""pg_dump + tar.zst-based backup, restorable via pair of subprocess calls. - -Backups live under /_backups/. Each backup is two files -(SQL + tarball) plus a manifest JSON. Tagged backups (e.g. tag='pre_migration') -are how rollback.py finds the most recent restorable snapshot. -""" -from __future__ import annotations - -import json -import shutil -import subprocess -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -_BACKUPS_DIRNAME = "_backups" - - -def _libpq_url(sa_url: str) -> str: - """Strip SQLAlchemy driver suffix so pg_dump/psql accept the URL. - - SQLAlchemy uses URLs like `postgresql+psycopg://...` or - `postgresql+asyncpg://...`. libpq tools (pg_dump, psql) only know - the plain `postgresql://` scheme. - """ - for driver in ("postgresql+psycopg", "postgresql+asyncpg", "postgresql+psycopg2"): - if sa_url.startswith(driver + "://"): - return "postgresql://" + sa_url[len(driver) + 3:] - return sa_url - - -def _backups_dir(images_root: Path | None = None) -> Path: - # Overridable for tests via monkeypatch. - root = images_root if images_root is not None else Path("/images") - p = root / _BACKUPS_DIRNAME - p.mkdir(parents=True, exist_ok=True) - return p - - -_DEFAULT_SUBPROCESS_TIMEOUT_S = 30 * 60 # 30 minutes - - -def _run_subprocess(cmd: list[str], **kwargs: Any): - # Overridable for tests via monkeypatch. Hard wall-clock timeout - # guards against pg_dump / tar / zstd hangs on NFS — without it the - # task pretends to be 'running' forever (operator hit this 2026-05- - # 23 with two backups stuck in MigrationRun). On timeout - # subprocess.run raises TimeoutExpired which the caller surfaces as - # a task error. - return subprocess.run( - cmd, - capture_output=True, - check=True, - timeout=_DEFAULT_SUBPROCESS_TIMEOUT_S, - **{k: v for k, v in kwargs.items() if not k.startswith("_")}, - ) - - -def create_backup( - *, db_url: str, images_root: Path, tag: str = "manual", -) -> dict: - """Create a backup: pg_dump SQL + tar.zst of images. - - Returns a manifest dict. Writes .sql, .tar.zst, .json into - /_backups/. - """ - ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") - out_dir = _backups_dir(images_root) - sql_path = out_dir / f"fc_{ts}.sql" - tar_path = out_dir / f"fc_{ts}.tar.zst" - manifest_path = out_dir / f"fc_{ts}.json" - - _run_subprocess( - ["pg_dump", "--no-owner", "--no-acl", "-f", str(sql_path), _libpq_url(db_url)], - _test_ts=ts, - ) - _run_subprocess( - [ - "tar", "--zstd", "-cf", str(tar_path), - "-C", str(images_root.parent), images_root.name, - f"--exclude={images_root.name}/_backups", - f"--exclude={images_root.name}/_quarantine", - ], - _test_ts=ts, - ) - - manifest = { - "backup_id": ts, - "tag": tag, - "created_at": datetime.now(UTC).isoformat(), - "sql_path": str(sql_path), - "tar_path": str(tar_path), - } - manifest_path.write_text(json.dumps(manifest, indent=2)) - return manifest - - -def list_backups(images_root: Path) -> list[dict]: - out_dir = _backups_dir(images_root) - items = [] - for mf in sorted(out_dir.glob("fc_*.json"), reverse=True): - try: - items.append(json.loads(mf.read_text())) - except Exception: - continue - return items - - -def find_latest_backup(images_root: Path, *, tag: str) -> dict | None: - for mf in list_backups(images_root): - if mf.get("tag") == tag: - return mf - return None - - -def restore_backup( - *, manifest: dict, db_url: str, images_root: Path, -) -> dict: - """Restore from a backup manifest. - - 1. Replay the .sql via psql. - 2. Wipe /images/ contents (except _backups/, which holds the file we're using). - 3. Untar the .tar.zst into /images/. - """ - sql_path = Path(manifest["sql_path"]) - tar_path = Path(manifest["tar_path"]) - - _run_subprocess( - ["psql", "-d", _libpq_url(db_url), "-f", str(sql_path)], - ) - - # Wipe everything in images_root EXCEPT _backups/ (we'd delete the backup - # we're restoring from!). - for entry in images_root.iterdir(): - if entry.name == _BACKUPS_DIRNAME: - continue - if entry.is_dir(): - shutil.rmtree(entry) - else: - entry.unlink() - - _run_subprocess( - ["tar", "--zstd", "-xf", str(tar_path), "-C", str(images_root.parent)], - ) - - return {"restored_from": manifest["backup_id"]} diff --git a/backend/app/services/migrators/rollback.py b/backend/app/services/migrators/rollback.py deleted file mode 100644 index 55ae7ef..0000000 --- a/backend/app/services/migrators/rollback.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Restore from the most recent 'pre_migration'-tagged backup.""" -from __future__ import annotations - -from pathlib import Path - -from . import backup as backup_mod - - -class NoBackupFoundError(Exception): - """Raised when rollback() is called with no pre_migration backup on disk.""" - - -def rollback_to_pre_migration(*, db_url: str, images_root: Path) -> dict: - manifest = backup_mod.find_latest_backup(images_root, tag="pre_migration") - if manifest is None: - raise NoBackupFoundError( - "no pre_migration-tagged backup found under /_backups/" - ) - return backup_mod.restore_backup( - manifest=manifest, db_url=db_url, images_root=images_root, - ) From 2b05f147f4a4015d0011b332133eceaf3708b06f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 23:00:01 -0400 Subject: [PATCH 12/41] =?UTF-8?q?fc3h:=20migrators=20docstring=20=E2=80=94?= =?UTF-8?q?=20note=20backup/rollback=20retired=20to=20backup=5Fservice.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/migrators/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/app/services/migrators/__init__.py b/backend/app/services/migrators/__init__.py index 0a7154c..b7c0908 100644 --- a/backend/app/services/migrators/__init__.py +++ b/backend/app/services/migrators/__init__.py @@ -1,6 +1,10 @@ """FC-5 migration tooling. -One module per concern (backup/rollback/gs/ir/overlap/ml_queue/verify). +One module per concern (gs/ir/overlap/ml_queue/verify/cleanup). Each migrator returns a counts dict; the run_migration task wires that dict into MigrationRun.counts so the UI polling shows progress. + +backup + rollback were retired in FC-3h (2026-05-24); first-class +backup lives at backend/app/services/backup_service.py and exposes +its own /api/system/backup/* surface. """ From 86ad9b80e943c5a2816cacb1ad30d6c97fb5eb02 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 23:00:40 -0400 Subject: [PATCH 13/41] fc3h: backup_service unit tests (subprocess monkeypatched) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_backup_service.py | 197 +++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/test_backup_service.py diff --git a/tests/test_backup_service.py b/tests/test_backup_service.py new file mode 100644 index 0000000..7d39120 --- /dev/null +++ b/tests/test_backup_service.py @@ -0,0 +1,197 @@ +"""FC-3h: backup_service unit tests. + +Subprocess calls (pg_dump, tar, psql) are monkeypatched so tests run +without external binaries. The real subprocess behavior is exercised +implicitly via the Celery task tests in test_tasks_backup.py. +""" +import json +from pathlib import Path + +import pytest + +from backend.app.services import backup_service + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def fake_subprocess(monkeypatch): + """Replace subprocess.run with a fake that writes a sentinel to + the target path (for pg_dump's -f, for tar's -cf). Captures all + calls in a list.""" + calls = [] + + class _FakeProc: + returncode = 0 + stdout = b"" + stderr = b"" + + def _fake_run(cmd, **kwargs): + calls.append(list(cmd)) + if cmd[0] == "pg_dump": + i = cmd.index("-f") + Path(cmd[i + 1]).write_bytes(b"-- fake pg_dump\n") + elif cmd[0] == "tar" and "-cf" in cmd: + i = cmd.index("-cf") + Path(cmd[i + 1]).write_bytes(b"fake tar payload") + return _FakeProc() + + monkeypatch.setattr("subprocess.run", _fake_run) + return calls + + +# --- backup_db ------------------------------------------------------- + + +def test_backup_db_writes_sql_and_manifest(tmp_path, fake_subprocess): + result = backup_service.backup_db( + db_url="postgresql://test@x/db", + images_root=tmp_path, + tag="pre-cutover", + triggered_by="manual", + ) + sql = Path(result["sql_path"]) + manifest = Path(result["manifest_path"]) + assert sql.is_file() and sql.suffix == ".sql" + assert manifest.is_file() and manifest.suffix == ".json" + assert result["kind"] == "db" + assert result["tar_path"] is None + assert result["size_bytes"] == len(b"-- fake pg_dump\n") + + parsed = json.loads(manifest.read_text()) + assert parsed["kind"] == "db" + assert parsed["tag"] == "pre-cutover" + assert parsed["triggered_by"] == "manual" + assert parsed["artifact_path"] == str(sql) + + +def test_backup_db_strips_sqlalchemy_psycopg_driver(tmp_path, fake_subprocess): + backup_service.backup_db( + db_url="postgresql+psycopg://u:p@h/d", images_root=tmp_path, + ) + cmd = fake_subprocess[0] + assert cmd[0] == "pg_dump" + assert cmd[-1].startswith("postgresql://") + assert "+psycopg" not in cmd[-1] + + +def test_backup_db_strips_asyncpg_driver(tmp_path, fake_subprocess): + backup_service.backup_db( + db_url="postgresql+asyncpg://u:p@h/d", images_root=tmp_path, + ) + assert "+asyncpg" not in fake_subprocess[0][-1] + + +def test_backup_db_default_tag_is_none(tmp_path, fake_subprocess): + result = backup_service.backup_db( + db_url="postgresql://u@h/d", images_root=tmp_path, + ) + parsed = json.loads(Path(result["manifest_path"]).read_text()) + assert parsed["tag"] is None + + +# --- backup_images --------------------------------------------------- + + +def test_backup_images_writes_tar_and_manifest(tmp_path, fake_subprocess): + result = backup_service.backup_images( + images_root=tmp_path, tag="monthly", triggered_by="manual", + ) + tar = Path(result["tar_path"]) + assert tar.is_file() and tar.name.endswith(".tar.zst") + assert result["sql_path"] is None + assert result["size_bytes"] == len(b"fake tar payload") + + +def test_backup_images_excludes_backups_and_quarantine(tmp_path, fake_subprocess): + backup_service.backup_images(images_root=tmp_path) + cmd = fake_subprocess[0] + excludes = [arg for arg in cmd if arg.startswith("--exclude=")] + assert any("_backups" in e for e in excludes) + assert any("_quarantine" in e for e in excludes) + + +# --- restore_db ------------------------------------------------------ + + +def test_restore_db_drops_schema_then_loads(tmp_path, fake_subprocess): + sql_path = tmp_path / "fake.sql" + sql_path.write_text("SELECT 1;") + backup_service.restore_db( + db_url="postgresql://u@h/d", sql_path=sql_path, + ) + # Two psql calls: one with -c (DROP SCHEMA), one with -f (load). + assert len(fake_subprocess) == 2 + assert "-c" in fake_subprocess[0] + assert "DROP SCHEMA IF EXISTS public CASCADE" in fake_subprocess[0][-1] + assert "-f" in fake_subprocess[1] + assert str(sql_path) in fake_subprocess[1] + + +# --- restore_images -------------------------------------------------- + + +def test_restore_images_untar_to_parent(tmp_path, fake_subprocess): + tar_path = tmp_path / "fake.tar.zst" + tar_path.write_bytes(b"fake") + backup_service.restore_images(images_root=tmp_path, tar_path=tar_path) + cmd = fake_subprocess[0] + assert cmd[:3] == ["tar", "--zstd", "-xf"] + assert str(tar_path) in cmd + assert "-C" in cmd + + +# --- unlink ---------------------------------------------------------- + + +def test_unlink_removes_present_files_and_reports(tmp_path): + sql = tmp_path / "x.sql"; sql.write_bytes(b"x") + tar = tmp_path / "x.tar.zst"; tar.write_bytes(b"x") + manifest = tmp_path / "x.json"; manifest.write_text("{}") + result = backup_service.unlink_artifact_files( + sql_path=str(sql), tar_path=str(tar), manifest_path=str(manifest), + ) + assert result == {"sql": True, "tar": True, "manifest": True} + assert not sql.exists() and not tar.exists() and not manifest.exists() + + +def test_unlink_missing_files_returns_true(tmp_path): + """missing_ok semantics: a non-existent file isn't an error.""" + result = backup_service.unlink_artifact_files( + sql_path=str(tmp_path / "nope.sql"), + tar_path=None, manifest_path=None, + ) + assert result == {"sql": True} + + +def test_unlink_skips_none_paths(): + """A None path is skipped — not added to the result dict.""" + result = backup_service.unlink_artifact_files( + sql_path=None, tar_path=None, manifest_path=None, + ) + assert result == {} + + +# --- helpers --------------------------------------------------------- + + +def test_libpq_url_strips_each_known_driver(): + assert backup_service._libpq_url( + "postgresql+psycopg://u@h/d" + ) == "postgresql://u@h/d" + assert backup_service._libpq_url( + "postgresql+asyncpg://u@h/d" + ) == "postgresql://u@h/d" + assert backup_service._libpq_url( + "postgresql+psycopg2://u@h/d" + ) == "postgresql://u@h/d" + # Plain URL passes through unchanged. + assert backup_service._libpq_url( + "postgresql://u@h/d" + ) == "postgresql://u@h/d" + + +def test_backups_dir_created_on_first_use(tmp_path): + d = backup_service._backups_dir(tmp_path) + assert d.is_dir() + assert d.name == "_backups" From 9ec6fdb596bae037dcc5b4e70d646b936086a8dd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 23:01:39 -0400 Subject: [PATCH 14/41] fc3h: Celery task integration tests (backup, restore, prune, nightly) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_tasks_backup.py | 278 +++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 tests/test_tasks_backup.py diff --git a/tests/test_tasks_backup.py b/tests/test_tasks_backup.py new file mode 100644 index 0000000..fc9de00 --- /dev/null +++ b/tests/test_tasks_backup.py @@ -0,0 +1,278 @@ +"""FC-3h: backup/restore Celery task integration tests. + +Uses task_always_eager for synchronous in-test execution. +Subprocess + IMAGES_ROOT are monkeypatched so tests don't touch +real /images or shell out to pg_dump. +""" +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from sqlalchemy import select + +from backend.app.celery_app import celery +from backend.app.models import BackupRun, ImportSettings + +pytestmark = pytest.mark.integration + + +@pytest.fixture(autouse=True) +def _eager_celery(monkeypatch): + monkeypatch.setattr(celery.conf, "task_always_eager", True) + monkeypatch.setattr(celery.conf, "task_eager_propagates", False) + + +@pytest.fixture(autouse=True) +def fake_subprocess_and_images_root(monkeypatch, tmp_path): + monkeypatch.setattr("backend.app.tasks.backup.IMAGES_ROOT", tmp_path) + monkeypatch.setattr( + "backend.app.services.backup_service._DB_SUBPROCESS_TIMEOUT_S", 5, + ) + + class _FakeProc: + returncode = 0; stdout = b""; stderr = b"" + + def _fake_run(cmd, **kwargs): + if cmd[0] == "pg_dump": + i = cmd.index("-f") + Path(cmd[i + 1]).write_bytes(b"-- fake pg_dump\n") + elif cmd[0] == "tar" and "-cf" in cmd: + i = cmd.index("-cf") + Path(cmd[i + 1]).write_bytes(b"fake tar payload") + return _FakeProc() + + monkeypatch.setattr("subprocess.run", _fake_run) + + +def _seed_backup(db_sync, *, kind, status, started_at, tag=None, + finished_at=None): + row = BackupRun( + kind=kind, status=status, tag=tag, + triggered_by="manual", started_at=started_at, + finished_at=finished_at or (started_at + timedelta(seconds=10)), + sql_path="/tmp/fake.sql" if kind == "db" else None, + tar_path="/tmp/fake.tar.zst" if kind == "images" else None, + manifest={}, + ) + db_sync.add(row); db_sync.flush() + return row.id + + +# --- backup_db_task -------------------------------------------------- + + +@pytest.mark.asyncio +async def test_backup_db_task_creates_backup_run_row_status_ok(db_sync): + from backend.app.tasks.backup import backup_db_task + result = backup_db_task.delay(tag=None, triggered_by="manual").get() + run_id = result["backup_run_id"] + row = db_sync.execute( + select( + BackupRun.kind, BackupRun.status, BackupRun.sql_path, + BackupRun.size_bytes, BackupRun.finished_at, BackupRun.error, + ).where(BackupRun.id == run_id) + ).one() + assert row.kind == "db" + assert row.status == "ok" + assert row.sql_path and row.sql_path.endswith(".sql") + assert row.size_bytes is not None and row.size_bytes > 0 + assert row.finished_at is not None + assert row.error is None + + +@pytest.mark.asyncio +async def test_backup_db_task_records_failure_on_subprocess_error(db_sync, monkeypatch): + from backend.app.tasks.backup import backup_db_task + + def _boom(*a, **kw): + raise RuntimeError("synthetic pg_dump fail") + monkeypatch.setattr("subprocess.run", _boom) + + with pytest.raises(RuntimeError): + backup_db_task.delay().get() + + row = db_sync.execute( + select(BackupRun.status, BackupRun.error) + .where(BackupRun.kind == "db") + .order_by(BackupRun.id.desc()).limit(1) + ).one() + assert row.status == "error" + assert "synthetic" in (row.error or "") + + +@pytest.mark.asyncio +async def test_backup_db_task_persists_tag(db_sync): + from backend.app.tasks.backup import backup_db_task + result = backup_db_task.delay(tag="pre-cutover").get() + tag = db_sync.execute( + select(BackupRun.tag).where(BackupRun.id == result["backup_run_id"]) + ).scalar_one() + assert tag == "pre-cutover" + + +# --- backup_images_task --------------------------------------------- + + +@pytest.mark.asyncio +async def test_backup_images_task_creates_backup_run(db_sync): + from backend.app.tasks.backup import backup_images_task + result = backup_images_task.delay().get() + row = db_sync.execute( + select(BackupRun.kind, BackupRun.status, BackupRun.tar_path) + .where(BackupRun.id == result["backup_run_id"]) + ).one() + assert row.kind == "images" + assert row.status == "ok" + assert row.tar_path and row.tar_path.endswith(".tar.zst") + + +# --- restore_db_task ------------------------------------------------ + + +@pytest.mark.asyncio +async def test_restore_db_task_creates_restoring_marker_then_restored(db_sync): + from backend.app.tasks.backup import backup_db_task, restore_db_task + src = backup_db_task.delay().get() + src_id = src["backup_run_id"] + + restore_db_task.delay(source_backup_run_id=src_id).get() + + rows = db_sync.execute( + select(BackupRun.status, BackupRun.triggered_by) + .where(BackupRun.restored_from_id == src_id) + ).all() + assert len(rows) == 1 + assert rows[0].status == "restored" + assert rows[0].triggered_by == "restore" + + +@pytest.mark.asyncio +async def test_restore_db_task_rejects_non_db_source(db_sync): + from backend.app.tasks.backup import restore_db_task + + now = datetime.now(UTC) + img_id = _seed_backup(db_sync, kind="images", status="ok", started_at=now) + db_sync.commit() + + with pytest.raises(ValueError): + restore_db_task.delay(source_backup_run_id=img_id).get() + + +# --- prune_backups -------------------------------------------------- + + +def test_prune_backups_keeps_last_n_untagged_per_kind(db_sync): + from backend.app.tasks.backup import prune_backups + + s = db_sync.execute( + select(ImportSettings).where(ImportSettings.id == 1) + ).scalar_one() + s.backup_db_keep_last_n = 2 + s.backup_images_keep_last_n = 1 + db_sync.commit() + + now = datetime.now(UTC) + for i in range(4): + _seed_backup(db_sync, kind="db", status="ok", + started_at=now - timedelta(hours=i)) + for i in range(3): + _seed_backup(db_sync, kind="images", status="ok", + started_at=now - timedelta(hours=i)) + db_sync.commit() + + result = prune_backups.apply().get() + assert result["db_deleted"] == 2 + assert result["images_deleted"] == 2 + + +def test_prune_backups_protects_tagged_rows(db_sync): + from backend.app.tasks.backup import prune_backups + + s = db_sync.execute( + select(ImportSettings).where(ImportSettings.id == 1) + ).scalar_one() + s.backup_db_keep_last_n = 1 + db_sync.commit() + + now = datetime.now(UTC) + _seed_backup(db_sync, kind="db", status="ok", started_at=now) + tagged_id = _seed_backup( + db_sync, kind="db", status="ok", + started_at=now - timedelta(days=30), tag="forever", + ) + _seed_backup(db_sync, kind="db", status="ok", + started_at=now - timedelta(days=1)) + db_sync.commit() + + prune_backups.apply().get() + + surviving_tag = db_sync.execute( + select(BackupRun.tag).where(BackupRun.id == tagged_id) + ).scalar_one_or_none() + assert surviving_tag == "forever" + + +def test_prune_backups_never_deletes_running_or_restoring(db_sync): + from backend.app.tasks.backup import prune_backups + + s = db_sync.execute( + select(ImportSettings).where(ImportSettings.id == 1) + ).scalar_one() + s.backup_db_keep_last_n = 0 + db_sync.commit() + + ancient = datetime.now(UTC) - timedelta(days=30) + running_id = _seed_backup(db_sync, kind="db", status="running", started_at=ancient) + restoring_id = _seed_backup(db_sync, kind="db", status="restoring", started_at=ancient) + db_sync.commit() + + prune_backups.apply().get() + + statuses = db_sync.execute( + select(BackupRun.status).where(BackupRun.id.in_([running_id, restoring_id])) + ).scalars().all() + assert set(statuses) == {"running", "restoring"} + + +# --- backup_db_nightly ---------------------------------------------- + + +def test_nightly_skips_when_disabled(db_sync): + from backend.app.tasks.backup import backup_db_nightly + + s = db_sync.execute( + select(ImportSettings).where(ImportSettings.id == 1) + ).scalar_one() + s.backup_db_nightly_enabled = False + db_sync.commit() + + result = backup_db_nightly.apply().get() + assert "skipped" in result and "disabled" in result["skipped"] + + +def test_nightly_skips_when_hour_mismatch(db_sync): + from backend.app.tasks.backup import backup_db_nightly + + s = db_sync.execute( + select(ImportSettings).where(ImportSettings.id == 1) + ).scalar_one() + s.backup_db_nightly_enabled = True + s.backup_db_nightly_hour_utc = (datetime.now(UTC).hour + 12) % 24 + db_sync.commit() + + result = backup_db_nightly.apply().get() + assert "skipped" in result and "hour=" in result["skipped"] + + +def test_nightly_dispatches_when_enabled_at_configured_hour(db_sync): + from backend.app.tasks.backup import backup_db_nightly + + s = db_sync.execute( + select(ImportSettings).where(ImportSettings.id == 1) + ).scalar_one() + s.backup_db_nightly_enabled = True + s.backup_db_nightly_hour_utc = datetime.now(UTC).hour + db_sync.commit() + + result = backup_db_nightly.apply().get() + assert "dispatched" in result From d04983138a4f3d16eb11de2d3a246ebee5754196 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 23:02:32 -0400 Subject: [PATCH 15/41] fc3h: /api/system/backup endpoint integration tests Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_api_system_backup.py | 335 ++++++++++++++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 tests/test_api_system_backup.py diff --git a/tests/test_api_system_backup.py b/tests/test_api_system_backup.py new file mode 100644 index 0000000..d1f36d0 --- /dev/null +++ b/tests/test_api_system_backup.py @@ -0,0 +1,335 @@ +"""FC-3h: /api/system/backup/* endpoint integration tests.""" +from datetime import UTC, datetime, timedelta + +import pytest +import pytest_asyncio +from sqlalchemy import select + +from backend.app import create_app +from backend.app.models import BackupRun + +pytestmark = pytest.mark.integration + + +@pytest.fixture +async def app(): + return create_app() + + +@pytest.fixture +async def client(app): + async with app.test_client() as c: + yield c + + +@pytest_asyncio.fixture +async def _seed_runs(db): + """Insert 4 BackupRun rows for paging/filter tests.""" + now = datetime.now(UTC) + for i in range(4): + db.add(BackupRun( + kind="db" if i % 2 == 0 else "images", + status="ok", + tag=None, + triggered_by="manual", + started_at=now - timedelta(seconds=10 - i), + finished_at=now - timedelta(seconds=9 - i), + sql_path="/tmp/fake.sql" if i % 2 == 0 else None, + tar_path=None if i % 2 == 0 else "/tmp/fake.tar.zst", + size_bytes=100, + manifest={}, + )) + await db.commit() + + +# --- POST /db, /images ---------------------------------------------- + + +@pytest.mark.asyncio +async def test_post_db_dispatches_and_returns_202(client, monkeypatch): + dispatched = [] + monkeypatch.setattr( + "backend.app.tasks.backup.backup_db_task.delay", + lambda **kw: dispatched.append(kw), + ) + resp = await client.post("/api/system/backup/db", json={}) + assert resp.status_code == 202 + body = await resp.get_json() + assert body["status"] == "dispatched" + assert dispatched == [{"tag": None, "triggered_by": "manual"}] + + +@pytest.mark.asyncio +async def test_post_db_persists_tag(client, monkeypatch): + dispatched = [] + monkeypatch.setattr( + "backend.app.tasks.backup.backup_db_task.delay", + lambda **kw: dispatched.append(kw), + ) + resp = await client.post( + "/api/system/backup/db", json={"tag": "pre-cutover"}, + ) + assert resp.status_code == 202 + assert dispatched[0]["tag"] == "pre-cutover" + + +@pytest.mark.asyncio +async def test_post_db_rejects_tag_too_long(client): + resp = await client.post( + "/api/system/backup/db", json={"tag": "x" * 65}, + ) + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "invalid_tag" + + +@pytest.mark.asyncio +async def test_post_images_dispatches(client, monkeypatch): + dispatched = [] + monkeypatch.setattr( + "backend.app.tasks.backup.backup_images_task.delay", + lambda **kw: dispatched.append(kw), + ) + resp = await client.post("/api/system/backup/images", json={}) + assert resp.status_code == 202 + + +# --- GET /runs ------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_runs_paginated_descending(client, _seed_runs): + resp = await client.get("/api/system/backup/runs?limit=2") + body = await resp.get_json() + assert len(body["runs"]) == 2 + ids = [r["id"] for r in body["runs"]] + assert ids == sorted(ids, reverse=True) + assert body["next_cursor"] is not None + + +@pytest.mark.asyncio +async def test_runs_filter_by_kind_db(client, _seed_runs): + resp = await client.get("/api/system/backup/runs?kind=db") + body = await resp.get_json() + assert all(r["kind"] == "db" for r in body["runs"]) + + +@pytest.mark.asyncio +async def test_runs_filter_by_kind_images(client, _seed_runs): + resp = await client.get("/api/system/backup/runs?kind=images") + body = await resp.get_json() + assert all(r["kind"] == "images" for r in body["runs"]) + + +@pytest.mark.asyncio +async def test_runs_rejects_invalid_kind(client): + resp = await client.get("/api/system/backup/runs?kind=bogus") + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_runs_rejects_invalid_limit(client): + resp = await client.get("/api/system/backup/runs?limit=not-int") + assert resp.status_code == 400 + + +# --- GET /runs/ -------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_run_returns_row(client, _seed_runs): + list_resp = await client.get("/api/system/backup/runs?limit=1") + rid = (await list_resp.get_json())["runs"][0]["id"] + resp = await client.get(f"/api/system/backup/runs/{rid}") + assert resp.status_code == 200 + body = await resp.get_json() + assert body["id"] == rid + + +@pytest.mark.asyncio +async def test_get_run_404(client): + resp = await client.get("/api/system/backup/runs/999999") + assert resp.status_code == 404 + + +# --- PATCH /runs/ ------------------------------------------------ + + +@pytest.mark.asyncio +async def test_patch_tag_sets_value(client, _seed_runs, db_sync): + list_resp = await client.get("/api/system/backup/runs?limit=1") + rid = (await list_resp.get_json())["runs"][0]["id"] + + resp = await client.patch( + f"/api/system/backup/runs/{rid}", + json={"tag": "monthly"}, + ) + assert resp.status_code == 200 + + tag = db_sync.execute( + select(BackupRun.tag).where(BackupRun.id == rid) + ).scalar_one() + assert tag == "monthly" + + +@pytest.mark.asyncio +async def test_patch_tag_null_clears(client, _seed_runs, db_sync): + list_resp = await client.get("/api/system/backup/runs?limit=1") + rid = (await list_resp.get_json())["runs"][0]["id"] + + await client.patch(f"/api/system/backup/runs/{rid}", json={"tag": "x"}) + await client.patch(f"/api/system/backup/runs/{rid}", json={"tag": None}) + + tag = db_sync.execute( + select(BackupRun.tag).where(BackupRun.id == rid) + ).scalar_one() + assert tag is None + + +# --- POST /runs//restore ---------------------------------------- + + +@pytest.mark.asyncio +async def test_restore_wrong_confirm_400(client, _seed_runs): + list_resp = await client.get("/api/system/backup/runs?kind=db&limit=1") + rid = (await list_resp.get_json())["runs"][0]["id"] + + resp = await client.post( + f"/api/system/backup/runs/{rid}/restore", + json={"confirm": "wrong"}, + ) + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "confirm_mismatch" + assert body["expected"] == f"restore-db-{rid}" + + +@pytest.mark.asyncio +async def test_restore_correct_confirm_dispatches(client, _seed_runs, monkeypatch): + list_resp = await client.get("/api/system/backup/runs?kind=db&limit=1") + rid = (await list_resp.get_json())["runs"][0]["id"] + + dispatched = [] + monkeypatch.setattr( + "backend.app.tasks.backup.restore_db_task.delay", + lambda **kw: dispatched.append(kw), + ) + + resp = await client.post( + f"/api/system/backup/runs/{rid}/restore", + json={"confirm": f"restore-db-{rid}"}, + ) + assert resp.status_code == 202 + assert dispatched == [{"source_backup_run_id": rid}] + + +@pytest.mark.asyncio +async def test_restore_rejects_non_ok_status(client, db): + # Seed an error-status row directly. + db.add(BackupRun( + kind="db", status="error", tag=None, triggered_by="manual", + started_at=datetime.now(UTC), finished_at=datetime.now(UTC), + sql_path="/tmp/fake.sql", manifest={}, + )) + await db.commit() + rid_q = await client.get("/api/system/backup/runs?kind=db&limit=1") + rid = (await rid_q.get_json())["runs"][0]["id"] + + resp = await client.post( + f"/api/system/backup/runs/{rid}/restore", + json={"confirm": f"restore-db-{rid}"}, + ) + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "not_restorable" + + +# --- DELETE /runs/ ---------------------------------------------- + + +@pytest.mark.asyncio +async def test_delete_wrong_confirm_400(client, _seed_runs): + list_resp = await client.get("/api/system/backup/runs?limit=1") + rid = (await list_resp.get_json())["runs"][0]["id"] + + resp = await client.delete( + f"/api/system/backup/runs/{rid}", + json={"confirm": "wrong"}, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_delete_correct_confirm_204(client, _seed_runs, db_sync): + list_resp = await client.get("/api/system/backup/runs?limit=1") + row = (await list_resp.get_json())["runs"][0] + rid = row["id"]; kind = row["kind"] + + resp = await client.delete( + f"/api/system/backup/runs/{rid}", + json={"confirm": f"delete-{kind}-{rid}"}, + ) + assert resp.status_code == 204 + + surviving = db_sync.execute( + select(BackupRun.id).where(BackupRun.id == rid) + ).scalar_one_or_none() + assert surviving is None + + +# --- /settings ------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_get_settings_returns_defaults(client): + resp = await client.get("/api/system/backup/settings") + assert resp.status_code == 200 + body = await resp.get_json() + assert body["backup_db_nightly_enabled"] is False + assert body["backup_db_nightly_hour_utc"] == 3 + assert body["backup_db_keep_last_n"] == 14 + assert body["backup_images_keep_last_n"] == 3 + + +@pytest.mark.asyncio +async def test_patch_settings_updates_values(client): + resp = await client.patch( + "/api/system/backup/settings", + json={ + "backup_db_nightly_enabled": True, + "backup_db_nightly_hour_utc": 5, + "backup_db_keep_last_n": 30, + }, + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["backup_db_nightly_enabled"] is True + assert body["backup_db_nightly_hour_utc"] == 5 + assert body["backup_db_keep_last_n"] == 30 + + +@pytest.mark.asyncio +async def test_patch_settings_rejects_invalid_bool(client): + resp = await client.patch( + "/api/system/backup/settings", + json={"backup_db_nightly_enabled": "yes"}, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_patch_settings_rejects_hour_out_of_range(client): + resp = await client.patch( + "/api/system/backup/settings", + json={"backup_db_nightly_hour_utc": 24}, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_patch_settings_rejects_keep_n_below_min(client): + resp = await client.patch( + "/api/system/backup/settings", + json={"backup_db_keep_last_n": 0}, + ) + assert resp.status_code == 400 From 57a338f7e6aad5c23067e8798b4174ee3579fd77 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 23:03:57 -0400 Subject: [PATCH 16/41] fc3h(tests): drop pinned migration-backup tests (retired surface; coverage moved to test_backup_service.py + test_api_system_backup.py) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_api_migrate.py | 26 ++++---------- tests/test_migration_backup.py | 64 ---------------------------------- 2 files changed, 7 insertions(+), 83 deletions(-) delete mode 100644 tests/test_migration_backup.py diff --git a/tests/test_api_migrate.py b/tests/test_api_migrate.py index bb83c9b..be2e782 100644 --- a/tests/test_api_migrate.py +++ b/tests/test_api_migrate.py @@ -31,17 +31,10 @@ async def client(app): yield c -@pytest.mark.asyncio -async def test_post_backup_returns_202_and_id(client, monkeypatch): - monkeypatch.setattr( - "backend.app.api.migrate.run_migration", - type("F", (), {"delay": lambda self, *a, **k: None})(), - ) - resp = await client.post("/api/migrate/backup", json={}) - assert resp.status_code == 202 - body = await resp.get_json() - assert "run_id" in body - assert body["status"] == "pending" +# Retired 2026-05-24 (FC-3h): `test_post_backup_returns_202_and_id` +# asserted the /api/migrate/backup endpoint, which was retired in FC-3h. +# Backup is now a first-class feature at /api/system/backup/*; +# coverage lives in tests/test_api_system_backup.py. @pytest.mark.asyncio @@ -62,10 +55,6 @@ async def test_post_ingest_rejects_missing_file(client): @pytest.mark.asyncio async def test_post_ingest_accepts_multipart_file(client, monkeypatch): - monkeypatch.setattr( - "backend.app.api.migrate._has_recent_pre_migration_backup", - lambda: True, # pretend backup exists - ) monkeypatch.setattr( "backend.app.api.migrate.run_migration", type("F", (), {"delay": lambda self, *a, **k: None})(), @@ -92,10 +81,9 @@ async def test_post_ingest_accepts_multipart_file(client, monkeypatch): @pytest.mark.asyncio async def test_post_dry_run_allowed_without_backup(client, monkeypatch): - monkeypatch.setattr( - "backend.app.api.migrate._has_recent_pre_migration_backup", - lambda: False, - ) + # FC-3h: the _has_recent_pre_migration_backup gate was retired; dry-run + # ingests were always allowed and now non-dry-run ingests are too. + # This test stays as regression coverage that dry_run ingest still works. monkeypatch.setattr( "backend.app.api.migrate.run_migration", type("F", (), {"delay": lambda self, *a, **k: None})(), diff --git a/tests/test_migration_backup.py b/tests/test_migration_backup.py deleted file mode 100644 index 5ff3cc1..0000000 --- a/tests/test_migration_backup.py +++ /dev/null @@ -1,64 +0,0 @@ -"""FC-5: backup + rollback service tests. - -Uses tmp_path to avoid touching real /images/_backups/. Subprocess -calls (pg_dump, tar) are monkeypatched — the real shell-out is exercised -in operator-side smoke testing on the homelab, not in unit tests. -""" -import pytest - -from backend.app.services.migrators import backup as backup_mod - -pytestmark = pytest.mark.integration - - -@pytest.mark.asyncio -async def test_create_backup_writes_sql_and_tar(monkeypatch, tmp_path): - calls = [] - - def fake_run(cmd, **kwargs): - calls.append(cmd) - # Touch the expected output file so existence checks pass downstream. - if "pg_dump" in cmd[0]: - outpath = tmp_path / "_backups" / f"fc_{kwargs['_test_ts']}.sql" - outpath.write_text("-- fake pg_dump output") - else: - outpath = tmp_path / "_backups" / f"fc_{kwargs['_test_ts']}.tar.zst" - outpath.write_bytes(b"\x28\xb5\x2f\xfd") # zstd magic - from types import SimpleNamespace - return SimpleNamespace(returncode=0, stdout=b"", stderr=b"") - - monkeypatch.setattr(backup_mod, "_run_subprocess", fake_run) - - result = backup_mod.create_backup( - db_url="postgresql://x", images_root=tmp_path, tag="pre_migration", - ) - assert "sql_path" in result - assert "tar_path" in result - assert result["tag"] == "pre_migration" - assert any("pg_dump" in c[0] for c in calls) - assert any(c[0] == "tar" for c in calls) - - -@pytest.mark.asyncio -async def test_find_latest_backup_by_tag(monkeypatch, tmp_path): - # Create two manifests with different tags. - backups_dir = tmp_path / "_backups" - backups_dir.mkdir() - import json - (backups_dir / "fc_20260101T000000Z.json").write_text(json.dumps({ - "backup_id": "20260101T000000Z", "tag": "manual", - "created_at": "2026-01-01T00:00:00+00:00", - "sql_path": "/x/a.sql", "tar_path": "/x/a.tar.zst", - })) - (backups_dir / "fc_20260202T000000Z.json").write_text(json.dumps({ - "backup_id": "20260202T000000Z", "tag": "pre_migration", - "created_at": "2026-02-02T00:00:00+00:00", - "sql_path": "/x/b.sql", "tar_path": "/x/b.tar.zst", - })) - - found = backup_mod.find_latest_backup(tmp_path, tag="pre_migration") - assert found is not None - assert found["backup_id"] == "20260202T000000Z" - - missing = backup_mod.find_latest_backup(tmp_path, tag="nonexistent") - assert missing is None From 102c21feaa2332987accc6a560bfdea998646841 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 23:04:37 -0400 Subject: [PATCH 17/41] =?UTF-8?q?fc3h(ui):=20Pinia=20backup=20store=20?= =?UTF-8?q?=E2=80=94=20runs,=20triggers,=20restore,=20delete,=20tag,=20set?= =?UTF-8?q?tings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/stores/backup.js | 109 ++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 frontend/src/stores/backup.js diff --git a/frontend/src/stores/backup.js b/frontend/src/stores/backup.js new file mode 100644 index 0000000..1bc5c85 --- /dev/null +++ b/frontend/src/stores/backup.js @@ -0,0 +1,109 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +import { useApi } from '../composables/useApi.js' + +export const useBackupStore = defineStore('backup', () => { + const api = useApi() + + const dbRuns = ref([]) + const imagesRuns = ref([]) + const settings = ref(null) + const loading = ref({ dbRuns: false, imagesRuns: false, settings: false }) + const lastError = ref(null) + + async function loadRuns(kind) { + const targetRef = kind === 'db' ? dbRuns : imagesRuns + const loadingKey = kind === 'db' ? 'dbRuns' : 'imagesRuns' + loading.value[loadingKey] = true + lastError.value = null + try { + const body = await api.get('/api/system/backup/runs', { + params: { kind, limit: 50 }, + }) + targetRef.value = body.runs || [] + } catch (e) { + lastError.value = e.message + } finally { + loading.value[loadingKey] = false + } + } + + async function triggerBackup(kind, tag = null) { + lastError.value = null + try { + await api.post(`/api/system/backup/${kind}`, { + body: tag ? { tag } : {}, + }) + } catch (e) { + lastError.value = e.message + throw e + } + } + + async function restore(runId, confirm) { + lastError.value = null + try { + await api.post(`/api/system/backup/runs/${runId}/restore`, { + body: { confirm }, + }) + } catch (e) { + lastError.value = e.message + throw e + } + } + + async function deleteRun(runId, confirm) { + lastError.value = null + try { + await api.delete(`/api/system/backup/runs/${runId}`, { + body: { confirm }, + }) + } catch (e) { + lastError.value = e.message + throw e + } + } + + async function setTag(runId, tag) { + lastError.value = null + try { + await api.patch(`/api/system/backup/runs/${runId}`, { + body: { tag }, + }) + } catch (e) { + lastError.value = e.message + throw e + } + } + + async function loadSettings() { + loading.value.settings = true + lastError.value = null + try { + settings.value = await api.get('/api/system/backup/settings') + } catch (e) { + lastError.value = e.message + } finally { + loading.value.settings = false + } + } + + async function patchSettings(patch) { + lastError.value = null + try { + settings.value = await api.patch('/api/system/backup/settings', { + body: patch, + }) + } catch (e) { + lastError.value = e.message + throw e + } + } + + return { + dbRuns, imagesRuns, settings, loading, lastError, + loadRuns, triggerBackup, restore, deleteRun, setTag, + loadSettings, patchSettings, + } +}) From 1e34b1b428f09441e1ac6b50efc80223762ce0df Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 23:05:04 -0400 Subject: [PATCH 18/41] =?UTF-8?q?fc3h(ui):=20BackupConfirmModal.vue=20?= =?UTF-8?q?=E2=80=94=20typed-token=20confirmation=20for=20restore=20+=20de?= =?UTF-8?q?lete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../settings/BackupConfirmModal.vue | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 frontend/src/components/settings/BackupConfirmModal.vue diff --git a/frontend/src/components/settings/BackupConfirmModal.vue b/frontend/src/components/settings/BackupConfirmModal.vue new file mode 100644 index 0000000..e531aa8 --- /dev/null +++ b/frontend/src/components/settings/BackupConfirmModal.vue @@ -0,0 +1,87 @@ + + + + + From aecedd9fe47497ee0b9f0d650b67bf54547d56cb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 23:06:00 -0400 Subject: [PATCH 19/41] =?UTF-8?q?fc3h(ui):=20BackupCard.vue=20+=20BackupRu?= =?UTF-8?q?nsTable.vue=20=E2=80=94=20combined=20card=20with=20DB=20+=20Ima?= =?UTF-8?q?ges=20sub-sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/settings/BackupCard.vue | 205 ++++++++++++++++++ .../components/settings/BackupRunsTable.vue | 109 ++++++++++ 2 files changed, 314 insertions(+) create mode 100644 frontend/src/components/settings/BackupCard.vue create mode 100644 frontend/src/components/settings/BackupRunsTable.vue diff --git a/frontend/src/components/settings/BackupCard.vue b/frontend/src/components/settings/BackupCard.vue new file mode 100644 index 0000000..0a0236f --- /dev/null +++ b/frontend/src/components/settings/BackupCard.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/frontend/src/components/settings/BackupRunsTable.vue b/frontend/src/components/settings/BackupRunsTable.vue new file mode 100644 index 0000000..dc15d24 --- /dev/null +++ b/frontend/src/components/settings/BackupRunsTable.vue @@ -0,0 +1,109 @@ + + + + + From 83bd3b4b2d523c5fb2370e6dcf96b11aba489732 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 23:06:22 -0400 Subject: [PATCH 20/41] fc3h(ui): slot BackupCard into Maintenance panel above migration card Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/settings/MaintenancePanel.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue index 1ebdd08..83356a6 100644 --- a/frontend/src/components/settings/MaintenancePanel.vue +++ b/frontend/src/components/settings/MaintenancePanel.vue @@ -12,6 +12,7 @@ + @@ -23,6 +24,7 @@ import CentroidRecomputeCard from './CentroidRecomputeCard.vue' import MLThresholdSliders from './MLThresholdSliders.vue' import AllowlistTable from './AllowlistTable.vue' import AliasTable from './AliasTable.vue' +import BackupCard from './BackupCard.vue' import BrowserExtensionCard from './BrowserExtensionCard.vue' import LegacyMigrationCard from './LegacyMigrationCard.vue' From e78a35d3336b9fc198d25f0f531ca38095cdaed1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 23:12:06 -0400 Subject: [PATCH 21/41] =?UTF-8?q?fc3h:=20collapse=20multi-line=20sqlalchem?= =?UTF-8?q?y=20import=20in=20backup=5Frun.py=20=E2=80=94=20fits=20under=20?= =?UTF-8?q?line-length=3D100,=20ruff=20I001=20would=20bounce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/models/backup_run.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/backend/app/models/backup_run.py b/backend/app/models/backup_run.py index 773a628..1717aea 100644 --- a/backend/app/models/backup_run.py +++ b/backend/app/models/backup_run.py @@ -20,15 +20,7 @@ feedback_check_existing_enums): from datetime import datetime -from sqlalchemy import ( - JSON, - BigInteger, - DateTime, - ForeignKey, - Integer, - String, - Text, -) +from sqlalchemy import JSON, BigInteger, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column from .base import Base From 718cc799051a974a0820ee09f4ead31e3b02395a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 23:37:06 -0400 Subject: [PATCH 22/41] fix(fc3h): split semicolon-stacked statements (E702) and bridge v-dialog v-model to avoid prop write Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/system_backup.py | 3 ++- backend/app/tasks/backup.py | 16 ++++++++++++---- .../components/settings/BackupConfirmModal.vue | 6 +++++- tests/test_api_system_backup.py | 3 ++- tests/test_backup_service.py | 9 ++++++--- tests/test_tasks_backup.py | 7 +++++-- 6 files changed, 32 insertions(+), 12 deletions(-) diff --git a/backend/app/api/system_backup.py b/backend/app/api/system_backup.py index f65fe3b..24fa94e 100644 --- a/backend/app/api/system_backup.py +++ b/backend/app/api/system_backup.py @@ -30,7 +30,8 @@ _BACKUP_SETTINGS_FIELDS = ( def _bad(error: str, *, status: int = 400, **extra): - body = {"error": error}; body.update(extra) + body = {"error": error} + body.update(extra) return jsonify(body), status diff --git a/backend/app/tasks/backup.py b/backend/app/tasks/backup.py index c5181ce..9d2fefb 100644 --- a/backend/app/tasks/backup.py +++ b/backend/app/tasks/backup.py @@ -54,7 +54,9 @@ def backup_db_task(self, *, tag: str | None = None, kind="db", status="running", tag=tag, triggered_by=triggered_by, started_at=now, manifest={}, ) - session.add(row); session.commit(); session.refresh(row) + session.add(row) + session.commit() + session.refresh(row) run_id = row.id try: @@ -101,7 +103,9 @@ def backup_images_task(self, *, tag: str | None = None, kind="images", status="running", tag=tag, triggered_by=triggered_by, started_at=now, manifest={}, ) - session.add(row); session.commit(); session.refresh(row) + session.add(row) + session.commit() + session.refresh(row) run_id = row.id try: @@ -156,7 +160,9 @@ def restore_db_task(self, *, source_backup_run_id: int) -> dict: restored_from_id=src.id, manifest={"source_sql_path": src.sql_path}, ) - session.add(marker); session.commit(); session.refresh(marker) + session.add(marker) + session.commit() + session.refresh(marker) marker_id = marker.id sql_path = src.sql_path @@ -200,7 +206,9 @@ def restore_images_task(self, *, source_backup_run_id: int) -> dict: restored_from_id=src.id, manifest={"source_tar_path": src.tar_path}, ) - session.add(marker); session.commit(); session.refresh(marker) + session.add(marker) + session.commit() + session.refresh(marker) marker_id = marker.id tar_path = src.tar_path diff --git a/frontend/src/components/settings/BackupConfirmModal.vue b/frontend/src/components/settings/BackupConfirmModal.vue index e531aa8..0a77dce 100644 --- a/frontend/src/components/settings/BackupConfirmModal.vue +++ b/frontend/src/components/settings/BackupConfirmModal.vue @@ -1,5 +1,9 @@ @@ -106,6 +112,7 @@ import { useRoute, useRouter, RouterLink } from 'vue-router' import { useArtistStore } from '../stores/artist.js' import { useModalStore } from '../stores/modal.js' import MasonryGrid from '../components/discovery/MasonryGrid.vue' +import ArtistDangerZone from '../components/artist/ArtistDangerZone.vue' const route = useRoute() const router = useRouter() From d97e3f9b59007a74e8526389265b0bed1bf6e086 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 00:49:45 -0400 Subject: [PATCH 37/41] fc3k(ui): bulk-delete action in BulkEditorPanel with sha8 confirm token + projected counts modal Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/gallery/BulkEditorPanel.vue | 87 ++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/gallery/BulkEditorPanel.vue b/frontend/src/components/gallery/BulkEditorPanel.vue index d7775eb..eef210f 100644 --- a/frontend/src/components/gallery/BulkEditorPanel.vue +++ b/frontend/src/components/gallery/BulkEditorPanel.vue @@ -55,19 +55,44 @@ +
+

Destructive

+ Delete {{ sel.count }} selected +
+
Clear selection
+ + diff --git a/frontend/src/views/TagsView.vue b/frontend/src/views/TagsView.vue index 212d43a..552c7ea 100644 --- a/frontend/src/views/TagsView.vue +++ b/frontend/src/views/TagsView.vue @@ -27,6 +27,7 @@ @@ -41,6 +42,49 @@ @confirm="confirmMerge" @cancel="pendingMerge = null" /> + + + + Merge “{{ mergeSource?.name }}” into… + +

+ Pick the target tag. Source tag will be deleted; all its + image associations will move to the target. Must be same + kind ({{ mergeSource?.kind }}). +

+ +
+ + + Cancel + Merge + +
+
+ + @@ -48,8 +92,11 @@ import { ref, watch, onMounted, onUnmounted } from 'vue' import { useRouter } from 'vue-router' import { useTagDirectoryStore } from '../stores/tagDirectory.js' +import { useAdminStore } from '../stores/admin.js' +import { useApi } from '../composables/useApi.js' import TagCard from '../components/discovery/TagCard.vue' import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue' +import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue' // Must stay a subset of the backend TagKind enum (character, fandom, // general, series, archive, post, meta, rating). 'fandom' is this @@ -106,6 +153,80 @@ function onManage(id) { function onRead(id) { router.push({ name: 'series-read', params: { tagId: id } }) } + +// --- FC-3k tag merge + delete ---------------------------------------- + +const adminStore = useAdminStore() +const api = useApi() + +// Tag merge via dots-menu (separate from inline-rename collision flow above) +const mergePickerOpen = ref(false) +const mergeSource = ref(null) +const mergeTargetId = ref(null) +const mergeHits = ref([]) +const mergeLoading = ref(false) +let mergeDebounce = null + +function onMergeWith(card) { + mergeSource.value = card + mergeTargetId.value = null + mergeHits.value = [] + mergePickerOpen.value = true +} + +function onMergeSearch(q) { + if (mergeDebounce) clearTimeout(mergeDebounce) + if (!q) { mergeHits.value = []; return } + mergeDebounce = setTimeout(async () => { + mergeLoading.value = true + try { + const hits = await api.get('/api/tags/autocomplete', { + params: { q, kind: mergeSource.value?.kind, limit: 20 }, + }) + mergeHits.value = (hits || []).filter( + (h) => h.id !== mergeSource.value?.id, + ) + } finally { + mergeLoading.value = false + } + }, 250) +} + +async function onMergeConfirm() { + if (!mergeSource.value || !mergeTargetId.value) return + try { + await adminStore.mergeTags(mergeTargetId.value, mergeSource.value.id) + mergePickerOpen.value = false + store.reset() + } catch { + // adminStore.lastError already set; UI surfaces it elsewhere. + } +} + +// Tag delete via dots-menu (Tier B) +const deleteTagModalOpen = ref(false) +const deleteTagTarget = ref(null) +const deleteTagUsage = ref(0) + +async function onDeleteTag(card) { + deleteTagTarget.value = card + try { + deleteTagUsage.value = await adminStore.tagUsageCount(card.id) + } catch { + deleteTagUsage.value = 0 + } + deleteTagModalOpen.value = true +} + +async function onDeleteTagConfirm() { + if (!deleteTagTarget.value) return + try { + await adminStore.deleteTag(deleteTagTarget.value.id) + store.reset() + } catch { + // adminStore.lastError already set. + } +} From a0136fa30d8ad0e4c1c989aac52f2786f1d382a3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 01:12:52 -0400 Subject: [PATCH 40/41] fix(fc3k): add mime=image/jpeg to test ImageRecord ctors (NOT NULL) + reorder admin import after stdlib/3rd-party (ruff I001) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_api_admin.py | 10 +++++----- tests/test_cleanup_service.py | 1 + tests/test_tasks_admin.py | 6 +++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py index 4869e22..ce303ac 100644 --- a/tests/test_api_admin.py +++ b/tests/test_api_admin.py @@ -117,11 +117,11 @@ async def test_bulk_delete_dry_run_returns_counts(client, db, tmp_path): await db.flush() i1 = ImageRecord( artist_id=a.id, path=str(tmp_path / "1.jpg"), - sha256="1" * 64, size_bytes=10, + sha256="1" * 64, size_bytes=10, mime="image/jpeg", ) i2 = ImageRecord( artist_id=a.id, path=str(tmp_path / "2.jpg"), - sha256="2" * 64, size_bytes=20, + sha256="2" * 64, size_bytes=20, mime="image/jpeg", ) db.add_all([i1, i2]) await db.commit() @@ -169,7 +169,7 @@ async def test_bulk_delete_wrong_confirm_400_with_expected_token( await db.flush() img = ImageRecord( artist_id=a.id, path=str(tmp_path / "x.jpg"), - sha256="c" * 64, size_bytes=10, + sha256="c" * 64, size_bytes=10, mime="image/jpeg", ) db.add(img) await db.commit() @@ -196,7 +196,7 @@ async def test_bulk_delete_correct_confirm_dispatches( await db.flush() img = ImageRecord( artist_id=a.id, path=str(tmp_path / "x.jpg"), - sha256="d" * 64, size_bytes=10, + sha256="d" * 64, size_bytes=10, mime="image/jpeg", ) db.add(img) await db.commit() @@ -323,7 +323,7 @@ async def test_prune_unused_dry_run_lists_unused(client, db, tmp_path): await db.flush() img = ImageRecord( artist_id=a.id, path=str(tmp_path / "p.jpg"), - sha256="e" * 64, size_bytes=10, + sha256="e" * 64, size_bytes=10, mime="image/jpeg", ) db.add(img) used = Tag(name="kept", kind=TagKind.general) diff --git a/tests/test_cleanup_service.py b/tests/test_cleanup_service.py index df632fa..f40113a 100644 --- a/tests/test_cleanup_service.py +++ b/tests/test_cleanup_service.py @@ -21,6 +21,7 @@ def _make_image(db_sync, *, artist, path, sha256, size=1000, thumb=None): path=path, sha256=sha256, size_bytes=size, + mime="image/jpeg", thumbnail_path=thumb, ) db_sync.add(img) diff --git a/tests/test_tasks_admin.py b/tests/test_tasks_admin.py index da38e2b..332cdd1 100644 --- a/tests/test_tasks_admin.py +++ b/tests/test_tasks_admin.py @@ -4,10 +4,10 @@ task_always_eager + signal handlers from FC-3i populate task_run. We assert the wrapper passes args through correctly and that task_run lifecycle status flips as expected. """ -import backend.app.tasks.admin # noqa: F401 — register tasks import pytest from sqlalchemy import func, select +import backend.app.tasks.admin # noqa: F401 — register tasks from backend.app.celery_app import celery from backend.app.models import Artist, ImageRecord, TaskRun @@ -59,7 +59,7 @@ async def test_delete_artist_cascade_task_removes_artist_and_records_ok( f.write_bytes(b"x") db_sync.add(ImageRecord( artist_id=a.id, path=str(f), - sha256=f"{i:064x}", size_bytes=10, + sha256=f"{i:064x}", size_bytes=10, mime="image/jpeg", )) db_sync.commit() artist_id = a.id @@ -122,7 +122,7 @@ async def test_bulk_delete_images_task_removes_listed_images( f.write_bytes(b"x") img = ImageRecord( artist_id=a.id, path=str(f), - sha256=f"a{i:063x}", size_bytes=10, + sha256=f"a{i:063x}", size_bytes=10, mime="image/jpeg", ) db_sync.add(img) db_sync.flush() From 832345a2458bfb220bed3afe3d701cb086297ccf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 01:28:54 -0400 Subject: [PATCH 41/41] fix(fc3k): add origin=imported_filesystem to test ImageRecord ctors (second NOT NULL column after mime) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_api_admin.py | 5 +++++ tests/test_cleanup_service.py | 1 + tests/test_tasks_admin.py | 2 ++ 3 files changed, 8 insertions(+) diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py index ce303ac..88df653 100644 --- a/tests/test_api_admin.py +++ b/tests/test_api_admin.py @@ -118,10 +118,12 @@ async def test_bulk_delete_dry_run_returns_counts(client, db, tmp_path): i1 = ImageRecord( artist_id=a.id, path=str(tmp_path / "1.jpg"), sha256="1" * 64, size_bytes=10, mime="image/jpeg", + origin="imported_filesystem", ) i2 = ImageRecord( artist_id=a.id, path=str(tmp_path / "2.jpg"), sha256="2" * 64, size_bytes=20, mime="image/jpeg", + origin="imported_filesystem", ) db.add_all([i1, i2]) await db.commit() @@ -170,6 +172,7 @@ async def test_bulk_delete_wrong_confirm_400_with_expected_token( img = ImageRecord( artist_id=a.id, path=str(tmp_path / "x.jpg"), sha256="c" * 64, size_bytes=10, mime="image/jpeg", + origin="imported_filesystem", ) db.add(img) await db.commit() @@ -197,6 +200,7 @@ async def test_bulk_delete_correct_confirm_dispatches( img = ImageRecord( artist_id=a.id, path=str(tmp_path / "x.jpg"), sha256="d" * 64, size_bytes=10, mime="image/jpeg", + origin="imported_filesystem", ) db.add(img) await db.commit() @@ -324,6 +328,7 @@ async def test_prune_unused_dry_run_lists_unused(client, db, tmp_path): img = ImageRecord( artist_id=a.id, path=str(tmp_path / "p.jpg"), sha256="e" * 64, size_bytes=10, mime="image/jpeg", + origin="imported_filesystem", ) db.add(img) used = Tag(name="kept", kind=TagKind.general) diff --git a/tests/test_cleanup_service.py b/tests/test_cleanup_service.py index f40113a..9e99e04 100644 --- a/tests/test_cleanup_service.py +++ b/tests/test_cleanup_service.py @@ -22,6 +22,7 @@ def _make_image(db_sync, *, artist, path, sha256, size=1000, thumb=None): sha256=sha256, size_bytes=size, mime="image/jpeg", + origin="imported_filesystem", thumbnail_path=thumb, ) db_sync.add(img) diff --git a/tests/test_tasks_admin.py b/tests/test_tasks_admin.py index 332cdd1..948dd39 100644 --- a/tests/test_tasks_admin.py +++ b/tests/test_tasks_admin.py @@ -60,6 +60,7 @@ async def test_delete_artist_cascade_task_removes_artist_and_records_ok( db_sync.add(ImageRecord( artist_id=a.id, path=str(f), sha256=f"{i:064x}", size_bytes=10, mime="image/jpeg", + origin="imported_filesystem", )) db_sync.commit() artist_id = a.id @@ -123,6 +124,7 @@ async def test_bulk_delete_images_task_removes_listed_images( img = ImageRecord( artist_id=a.id, path=str(f), sha256=f"a{i:063x}", size_bytes=10, mime="image/jpeg", + origin="imported_filesystem", ) db_sync.add(img) db_sync.flush()