# retoor import asyncio import json import logging import shutil import sqlite3 import sys from datetime import datetime, timezone from pathlib import Path from devplacepy import config from devplacepy.attachments import _directory_for from devplacepy.services.backup import store from devplacepy.services.devii.tasks.schedule import next_run as schedule_next_run from devplacepy.services.devii.tasks.schedule import now_utc, to_iso from devplacepy.services.jobs import queue from devplacepy.services.jobs.base import JobService, _human_bytes from devplacepy.utils import generate_uid logger = logging.getLogger(__name__) WORKER_MODULE = "devplacepy.services.jobs.backup_worker" class BackupService(JobService): kind = "backup" title = "Backup" description = ( "Builds compressed tar.gz backups of selected data targets in a subprocess off the " "request path, snapshots the database consistently, records full statistics, fires " "scheduled backups, and rotates them by retention." ) def __init__(self): super().__init__(name="backup", interval_seconds=15) async def run_once(self) -> None: await super().run_once() try: self._fire_due_schedules() except Exception as exc: self.log(f"Schedule pass failed: {exc}") async def process(self, job: dict) -> dict: from devplacepy.services.audit import record as audit payload = job["payload"] target = payload.get("target", "") schedule_uid = payload.get("schedule_uid", "") keep_last = int(payload.get("keep_last") or 0) job_uid = job["uid"] record = store.get_backup_by_job(job_uid) if record is None: backup_uid = store.create_backup( target=target, created_by=job.get("owner_id", ""), job_uid=job_uid, schedule_uid=schedule_uid, ) record = store.get_backup(backup_uid) backup_uid = record["uid"] if not store.is_valid_target(target): store.fail_backup(backup_uid, f"unknown backup target: {target}") raise ValueError(f"unknown backup target: {target}") store.mark_running(backup_uid) staging = config.BACKUP_STAGING_DIR / job_uid try: sources = await asyncio.to_thread(self._materialize, target, staging) spec_path = staging / "spec.json" await asyncio.to_thread( spec_path.write_text, json.dumps({"sources": sources}) ) final_dir = config.BACKUPS_DIR / _directory_for(backup_uid) await asyncio.to_thread(final_dir.mkdir, parents=True, exist_ok=True) filename = self._archive_name(target, backup_uid) tmp_path = final_dir / f"{generate_uid()}.partial" stats = await self._run_worker(spec_path, tmp_path) final_path = final_dir / filename await asyncio.to_thread(tmp_path.replace, final_path) except Exception as exc: store.fail_backup(backup_uid, str(exc) or exc.__class__.__name__) audit.record_system( "job.backup.failed", actor_kind="system", origin="scheduler" if schedule_uid else "web", result="failure", target_type="backup", target_uid=backup_uid, metadata={"target": target, "schedule_uid": schedule_uid}, summary=f"backup {target} failed", links=[audit.job(job_uid)], ) raise finally: await asyncio.to_thread(shutil.rmtree, staging, ignore_errors=True) store.finalize_backup( backup_uid, filename=filename, local_path=str(final_path), stats=stats, ) removed = store.rotate_schedule(schedule_uid, keep_last) audit.record_system( "job.backup.complete", actor_kind="system", origin="scheduler" if schedule_uid else "web", target_type="backup", target_uid=backup_uid, metadata={ "target": target, "schedule_uid": schedule_uid, "bytes_out": stats["bytes_out"], "sha256": stats["sha256"], "file_count": stats["file_count"], "rotated": removed, }, summary=f"backup {target} completed ({_human_bytes(stats['bytes_out'])})", links=[audit.job(job_uid)], ) return { "backup_uid": backup_uid, "target": target, "filename": filename, "local_path": str(final_path), "sha256": stats["sha256"], "file_count": stats["file_count"], "dir_count": stats["dir_count"], "bytes_in": stats["bytes_in"], "bytes_out": stats["bytes_out"], "item_count": stats["file_count"], } def cleanup(self, job: dict) -> None: shutil.rmtree(config.BACKUP_STAGING_DIR / job["uid"], ignore_errors=True) def _materialize(self, target: str, staging: Path) -> list[dict]: staging.mkdir(parents=True, exist_ok=True) sources: list[dict] = [] if target in ("database", "full"): snapshot = staging / "db_snapshot" self._snapshot_databases(snapshot) sources.append({"root": "database", "path": str(snapshot)}) if target in ("uploads", "full"): sources.append({"root": "uploads", "path": str(config.UPLOADS_DIR)}) if target in ("keys", "full"): sources.append({"root": "keys", "path": str(config.KEYS_DIR)}) return sources def _snapshot_databases(self, dest: Path) -> None: dest.mkdir(parents=True, exist_ok=True) database_file = Path(str(config.DATABASE_URL).replace("sqlite:///", "")) for source in (database_file, config.DEVII_TASKS_DB, config.DEVII_LESSONS_DB): if Path(source).exists(): self._sqlite_backup(Path(source), dest / Path(source).name) def _sqlite_backup(self, source: Path, destination: Path) -> None: origin = sqlite3.connect(f"file:{source}?mode=ro", uri=True) try: target = sqlite3.connect(str(destination)) try: origin.backup(target) finally: target.close() finally: origin.close() async def _run_worker(self, spec_path: Path, output_path: Path) -> dict: proc = await asyncio.create_subprocess_exec( sys.executable, "-m", WORKER_MODULE, str(spec_path), str(output_path), cwd=str(config.BASE_DIR), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) out, err = await proc.communicate() if proc.returncode != 0: raise RuntimeError( f"backup worker exited {proc.returncode}: " f"{err.decode('utf-8', 'replace')[:500]}" ) payload = out.decode("utf-8").strip() if not payload: raise RuntimeError( f"backup worker produced no output: " f"{err.decode('utf-8', 'replace')[:500]}" ) return json.loads(payload) def _archive_name(self, target: str, backup_uid: str) -> str: stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") tail = backup_uid.replace("-", "")[-8:] return f"{target}-{stamp}-{tail}.tar.gz" def _fire_due_schedules(self) -> None: reference = to_iso(now_utc()) for schedule in store.list_due_schedules(reference): self._enqueue_scheduled(schedule, reference) def _enqueue_scheduled(self, schedule: dict, reference: str) -> None: target = schedule["target"] if not store.is_valid_target(target): self.log(f"Schedule {schedule['uid']} skipped: unknown target {target}") return keep_last = int(schedule.get("keep_last") or 0) created_by = schedule.get("created_by", "") job_uid = queue.enqueue( "backup", { "target": target, "schedule_uid": schedule["uid"], "created_by": created_by, "keep_last": keep_last, }, owner_kind="system", owner_id=created_by, preferred_name=f"{schedule.get('name', target)} ({target})", ) store.create_backup( target=target, created_by=created_by, job_uid=job_uid, schedule_uid=schedule["uid"], ) moment = schedule_next_run( schedule["kind"], int(schedule.get("every_seconds") or 0), schedule.get("cron") or None, now_utc(), ) store.set_schedule_runtime( schedule["uid"], next_run_at=to_iso(moment) if moment else "", last_run_at=reference, last_job_uid=job_uid, run_count=int(schedule.get("run_count") or 0) + 1, ) self.log(f"Scheduled backup '{schedule.get('name')}' enqueued ({job_uid})")