Files
Patryk Gensch f4aa7caaa9 Containerise: Postgres + Redis/RQ + API + Ghidra worker
Brings up the documented target architecture as a docker-compose stack — a
modular monolith with the Ghidra step split into its own async worker.

- worker/: RQ queue (lazy redis import) + run_acquisition task (Job status
  queued→started→finished/failed, drives ams.acquire with sink=db)
- Job model + JobOut schema; Snapshot.data is JSONB on Postgres
- POST/GET /jobs: stream an upload to a shared volume, enqueue, poll status
- docker/api.Dockerfile (slim) + docker/worker.Dockerfile (JDK21 + Ghidra
  fetched at build, overridable via GHIDRA_URL) + docker-compose.yml
- ghidra.py: AMS_GHIDRA_SCRIPTS override for in-container script path
- pyproject: [worker] extra (rq/redis/psycopg), python-multipart in [api]
- tests: 4 new (task success/failure + endpoint enqueue/503) -> 22/22

Verified: API image builds, container serves /health + /ui + /jobs; compose
config validates. Worker image (downloads ~1 GB Ghidra) not built here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:24:47 +02:00

71 lines
2.4 KiB
Python

"""Acquisition jobs: upload a game archive/DLL, hand it to the Ghidra worker, poll status.
The upload is streamed to a shared volume ($AMS_UPLOAD_DIR) that the worker container also
mounts; only the path travels through Redis, not the bytes."""
from __future__ import annotations
import os
import shutil
import uuid
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import models, schemas
from ..db import get_db
router = APIRouter(prefix="/jobs", tags=["jobs"])
UPLOAD_DIR = os.environ.get("AMS_UPLOAD_DIR", "./uploads")
def _save_upload(upload: UploadFile) -> tuple[str, str]:
"""Stream the upload to the shared dir under a unique name; return (path, original_name)."""
os.makedirs(UPLOAD_DIR, exist_ok=True)
original = os.path.basename(upload.filename or "upload.bin")
dest = os.path.join(UPLOAD_DIR, "{0}_{1}".format(uuid.uuid4().hex[:8], original))
with open(dest, "wb") as fh:
shutil.copyfileobj(upload.file, fh)
return dest, original
@router.post("", response_model=schemas.JobOut, status_code=202)
def create_job(
file: UploadFile = File(..., description="an ISO/ZIP archive or a loose engine DLL"),
game: str | None = Form(None, description="link the resulting snapshot to this game"),
db: Session = Depends(get_db),
) -> models.Job:
path, original = _save_upload(file)
job = models.Job(source_name=original, source_path=path, game_name=game, status="queued")
db.add(job)
db.commit()
db.refresh(job)
try:
from ...worker.queue import enqueue_acquisition
job.rq_id = enqueue_acquisition(path, game, job.id)
db.commit()
db.refresh(job)
except Exception as exc: # Redis/RQ down or missing — surface, don't leave a phantom job
job.status = "failed"
job.error = "enqueue failed: {0}".format(exc)
db.commit()
raise HTTPException(503, job.error)
return job
@router.get("", response_model=list[schemas.JobOut])
def list_jobs(db: Session = Depends(get_db)) -> list[models.Job]:
return list(db.scalars(select(models.Job).order_by(models.Job.id.desc())))
@router.get("/{job_id}", response_model=schemas.JobOut)
def get_job(job_id: int, db: Session = Depends(get_db)) -> models.Job:
job = db.get(models.Job, job_id)
if job is None:
raise HTTPException(404, "job not found")
return job