198 lines
7.1 KiB
Python
198 lines
7.1 KiB
Python
"""Repair software-job outputs written under the duplicated legacy user path.
|
|
|
|
The command is dry-run by default. It reads its database URL only from the
|
|
explicit ``ZCBOT_MIGRATION_DB_URL`` environment variable and never loads .env.
|
|
Run schema migration 0033 before applying this repair.
|
|
"""
|
|
# ruff: noqa: I001
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import create_engine, select
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy.orm.attributes import flag_modified
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from core.software_jobs import (
|
|
SOFTWARE_JOB_METADATA_IDS,
|
|
software_job_output_path,
|
|
)
|
|
from core.storage.models import Artifact, SoftwareJob, Task
|
|
from core.paths import from_db_path
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RepairPlan:
|
|
job_id: UUID
|
|
user_root: Path
|
|
working_dir: Path
|
|
legacy_dir: Path
|
|
output_dir: Path
|
|
move_legacy_dir: bool
|
|
metadata_moves: tuple[tuple[Path, Path], ...]
|
|
|
|
|
|
def _user_root(working_dir: Path, user_id: UUID) -> Path:
|
|
expected = str(user_id)
|
|
for candidate in (working_dir, *working_dir.parents):
|
|
if candidate.name == expected and candidate.parent.name == "users":
|
|
return candidate.resolve()
|
|
raise RuntimeError(f"task working_dir has no user root for {user_id}")
|
|
|
|
|
|
def _within(root: Path, target: Path) -> Path:
|
|
resolved = target.resolve()
|
|
resolved.relative_to(root.resolve())
|
|
return resolved
|
|
|
|
|
|
def build_plan(job: SoftwareJob, task: Task) -> RepairPlan | None:
|
|
working_dir = from_db_path(task.working_dir).resolve()
|
|
user_root = _user_root(working_dir, job.user_id)
|
|
output_dir = _within(user_root, working_dir / "origin" / str(job.job_id))
|
|
legacy_dir = _within(
|
|
user_root,
|
|
user_root / Path(task.working_dir) / "origin" / str(job.job_id),
|
|
)
|
|
legacy_exists = legacy_dir.is_dir()
|
|
output_exists = output_dir.is_dir()
|
|
if legacy_exists and output_exists:
|
|
raise RuntimeError(f"job {job.job_id}: legacy and target output directories both exist")
|
|
if not legacy_exists and not output_exists:
|
|
print(f"[WARN] job {job.job_id}: output directory is missing")
|
|
return None
|
|
source_dir = legacy_dir if legacy_exists else output_dir
|
|
metadata_moves: list[tuple[Path, Path]] = []
|
|
for item in job.artifact_manifest or []:
|
|
output_id = str((item or {}).get("source_artifact_id") or "")
|
|
if output_id not in SOFTWARE_JOB_METADATA_IDS:
|
|
continue
|
|
source = source_dir / str((item or {}).get("filename") or "")
|
|
destination = source_dir / software_job_output_path(output_id)
|
|
if source == destination or not source.exists():
|
|
continue
|
|
if destination.exists():
|
|
raise RuntimeError(f"job {job.job_id}: metadata destination already exists")
|
|
metadata_moves.append((source, destination))
|
|
return RepairPlan(
|
|
job_id=job.job_id,
|
|
user_root=user_root,
|
|
working_dir=working_dir,
|
|
legacy_dir=legacy_dir,
|
|
output_dir=output_dir,
|
|
move_legacy_dir=legacy_exists,
|
|
metadata_moves=tuple(metadata_moves),
|
|
)
|
|
|
|
|
|
def apply_files(plan: RepairPlan) -> None:
|
|
for _, destination in plan.metadata_moves:
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
for source, destination in plan.metadata_moves:
|
|
os.replace(source, destination)
|
|
if plan.move_legacy_dir:
|
|
plan.output_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
os.replace(plan.legacy_dir, plan.output_dir)
|
|
|
|
|
|
def update_rows(session: Session, job: SoftwareJob, task: Task, plan: RepairPlan) -> int:
|
|
artifact_count = 0
|
|
manifest = [dict(item) for item in (job.artifact_manifest or [])]
|
|
task_prefix = plan.working_dir.relative_to(plan.user_root).as_posix()
|
|
for item in manifest:
|
|
output_id = str(item.get("source_artifact_id") or "")
|
|
if not output_id:
|
|
continue
|
|
path = f"origin/{job.job_id}/{software_job_output_path(output_id)}"
|
|
item["path"] = path
|
|
raw_artifact_id = item.get("artifact_id")
|
|
if not raw_artifact_id:
|
|
continue
|
|
try:
|
|
artifact_id = UUID(str(raw_artifact_id))
|
|
except ValueError:
|
|
continue
|
|
artifact = session.get(Artifact, artifact_id)
|
|
if artifact is None or artifact.user_id != job.user_id:
|
|
raise RuntimeError(f"job {job.job_id}: artifact {artifact_id} is missing")
|
|
artifact.current_path = f"{task_prefix}/{path}"
|
|
if output_id not in SOFTWARE_JOB_METADATA_IDS:
|
|
artifact.software_job_id = job.job_id
|
|
artifact_count += 1
|
|
job.artifact_manifest = manifest
|
|
flag_modified(job, "artifact_manifest")
|
|
return artifact_count
|
|
|
|
|
|
def repair(session: Session, *, apply: bool, job_id: UUID | None = None) -> tuple[int, int]:
|
|
statement = (
|
|
select(SoftwareJob, Task)
|
|
.join(Task, Task.task_id == SoftwareJob.task_id)
|
|
.where(SoftwareJob.status == "succeeded")
|
|
.order_by(SoftwareJob.created_at, SoftwareJob.job_id)
|
|
)
|
|
if job_id is not None:
|
|
statement = statement.where(SoftwareJob.job_id == job_id)
|
|
rows = session.execute(statement).all()
|
|
prepared: list[tuple[SoftwareJob, Task, RepairPlan]] = []
|
|
for job, task in rows:
|
|
plan = build_plan(job, task)
|
|
if plan is None:
|
|
continue
|
|
print(
|
|
f"[INFO] job={job.job_id} move_dir={plan.move_legacy_dir} "
|
|
f"metadata_moves={len(plan.metadata_moves)} target={plan.output_dir}"
|
|
)
|
|
prepared.append((job, task, plan))
|
|
if not apply:
|
|
return len(prepared), 0
|
|
updated_artifacts = 0
|
|
for job, task, plan in prepared:
|
|
apply_files(plan)
|
|
updated_artifacts += update_rows(session, job, task, plan)
|
|
session.flush()
|
|
return len(prepared), updated_artifacts
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--apply", action="store_true", help="apply filesystem and database changes")
|
|
parser.add_argument("--job-id", type=UUID, help="limit repair to one software job")
|
|
args = parser.parse_args()
|
|
database_url = os.environ.get("ZCBOT_MIGRATION_DB_URL", "").strip()
|
|
if not database_url:
|
|
print("[ERR] ZCBOT_MIGRATION_DB_URL is required", file=sys.stderr)
|
|
return 2
|
|
engine = create_engine(database_url, pool_pre_ping=True, future=True)
|
|
try:
|
|
with Session(engine, future=True) as session:
|
|
try:
|
|
jobs, artifacts = repair(
|
|
session, apply=args.apply, job_id=args.job_id
|
|
)
|
|
if args.apply:
|
|
session.commit()
|
|
else:
|
|
session.rollback()
|
|
except Exception:
|
|
session.rollback()
|
|
raise
|
|
finally:
|
|
engine.dispose()
|
|
mode = "applied" if args.apply else "validated"
|
|
print(f"[OK] {mode} jobs: {jobs}")
|
|
print(f"[OK] updated artifacts: {artifacts}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|