Skip to content

Commit 6f9ae31

Browse files
committed
Complete deterministic Phase 6 reporting
1 parent 29fdb23 commit 6f9ae31

22 files changed

Lines changed: 2347 additions & 29 deletions

backend/app/api.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
44
from fastapi.encoders import jsonable_encoder
5-
from fastapi.responses import JSONResponse
5+
from fastapi.responses import FileResponse, JSONResponse
66
from sqlalchemy import func, select
77
from sqlalchemy.orm import Session
88

@@ -19,6 +19,7 @@
1919
from app.intelligence import runner as intelligence_runner
2020
from app.inventory import get_asset, list_assets, list_observations, list_services
2121
from app.models import Asset, Dockyard, EvidenceRecord, Finding, FindingEvidence, Service
22+
from app.reporting import runner as reporting_runner
2223
from app.schemas import (
2324
AdapterRead,
2425
AssetDetailRead,
@@ -45,6 +46,8 @@
4546
ObservationRead,
4647
ProfileRead,
4748
RedPathGraphRead,
49+
ReportCreate,
50+
ReportRunRead,
4851
ScopeEntryCreate,
4952
ScopeEntryRead,
5053
ScopeEvaluateRequest,
@@ -453,6 +456,90 @@ def approve_intelligence_run(
453456
raise HTTPException(status_code=code, detail=message) from error
454457

455458

459+
@router.get("/dockyards/{dockyard_id}/reports", response_model=list[ReportRunRead])
460+
def read_reports(
461+
dockyard_id: int, limit: int = ListLimit, session: Session = Depends(get_session)
462+
) -> list[ReportRunRead]:
463+
require_dockyard(dockyard_id, session)
464+
return reporting_runner.list_runs(session, dockyard_id, limit)
465+
466+
467+
@router.post(
468+
"/dockyards/{dockyard_id}/reports",
469+
response_model=ReportRunRead,
470+
status_code=status.HTTP_201_CREATED,
471+
)
472+
def create_report(
473+
dockyard_id: int,
474+
payload: ReportCreate,
475+
session: Session = Depends(get_session),
476+
) -> ReportRunRead:
477+
"""Snapshot all retained state; the empty request chooses no target or path."""
478+
require_dockyard(dockyard_id, session)
479+
try:
480+
return reporting_runner.start_report(session, dockyard_id)
481+
except reporting_runner.ReportRejected as error:
482+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
483+
484+
485+
@router.get("/dockyards/{dockyard_id}/reports/{run_id}", response_model=ReportRunRead)
486+
def read_report(
487+
dockyard_id: int, run_id: int, session: Session = Depends(get_session)
488+
) -> ReportRunRead:
489+
require_dockyard(dockyard_id, session)
490+
run = reporting_runner.get_run(session, dockyard_id, run_id)
491+
if run is None:
492+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report run not found")
493+
return run
494+
495+
496+
def _report_artifact(dockyard_id: int, run_id: int, artifact: str, session: Session):
497+
require_dockyard(dockyard_id, session)
498+
run = reporting_runner.get_run(session, dockyard_id, run_id)
499+
if run is None:
500+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report run not found")
501+
try:
502+
return reporting_runner.artifact_path(run, artifact)
503+
except reporting_runner.ReportRejected as error:
504+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
505+
506+
507+
@router.get("/dockyards/{dockyard_id}/reports/{run_id}/technical")
508+
def read_technical_report(
509+
dockyard_id: int, run_id: int, session: Session = Depends(get_session)
510+
):
511+
path = _report_artifact(dockyard_id, run_id, "technical", session)
512+
return FileResponse(path, media_type="text/markdown; charset=utf-8")
513+
514+
515+
@router.get("/dockyards/{dockyard_id}/reports/{run_id}/executive")
516+
def read_executive_report(
517+
dockyard_id: int, run_id: int, session: Session = Depends(get_session)
518+
):
519+
path = _report_artifact(dockyard_id, run_id, "executive", session)
520+
return FileResponse(path, media_type="text/markdown; charset=utf-8")
521+
522+
523+
@router.get("/dockyards/{dockyard_id}/reports/{run_id}/manifest")
524+
def read_report_manifest(
525+
dockyard_id: int, run_id: int, session: Session = Depends(get_session)
526+
):
527+
path = _report_artifact(dockyard_id, run_id, "manifest", session)
528+
return FileResponse(path, media_type="application/json")
529+
530+
531+
@router.get("/dockyards/{dockyard_id}/reports/{run_id}/dockpack")
532+
def download_dockpack(
533+
dockyard_id: int, run_id: int, session: Session = Depends(get_session)
534+
):
535+
path = _report_artifact(dockyard_id, run_id, "dockpack", session)
536+
return FileResponse(
537+
path,
538+
media_type="application/zip",
539+
filename=f"reddock-dockyard-{dockyard_id}-report-{run_id}.dockpack.zip",
540+
)
541+
542+
456543
@router.get("/dockyards/{dockyard_id}/validations", response_model=list[ValidationRunRead])
457544
def read_validations(
458545
dockyard_id: int, limit: int = ListLimit, session: Session = Depends(get_session)

backend/app/config.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ class Settings(BaseModel):
99
"""Runtime settings kept intentionally small for the local foundation."""
1010

1111
app_name: str = "RedDock"
12-
version: str = "0.6.0"
13-
phase: str = "Phase 5Intelligence"
12+
version: str = "0.7.0"
13+
phase: str = "Phase 6Reporting"
1414
database_url: str = "sqlite:///./data/reddock.db"
1515
evidence_dir: str = "./data/evidence"
1616
nmap_path: str | None = None
@@ -63,6 +63,16 @@ class Settings(BaseModel):
6363
max_intelligence_input_bytes: int = 512 * 1024
6464
max_intelligence_runs_per_dockyard: int = 200
6565

66+
# Reporting packages only database-referenced retained files. Fixed limits
67+
# keep a local export from becoming an unbounded filesystem copy operation.
68+
max_report_runs_per_dockyard: int = 200
69+
max_report_assets: int = 2_000
70+
max_report_services: int = 20_000
71+
max_report_findings: int = 5_000
72+
max_report_evidence_links: int = 20_000
73+
max_report_evidence_files: int = 2_000
74+
max_dockpack_bytes: int = 64 * 1024 * 1024
75+
6676

6777
@lru_cache
6878
def get_settings() -> Settings:

backend/app/evidence.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
evidence/<dockyard-id>/validation/<validation-run-id>/
1616
evidence/<dockyard-id>/correlation/<correlation-run-id>/
1717
evidence/<dockyard-id>/intelligence/<intelligence-run-id>/
18+
evidence/<dockyard-id>/reporting/<report-run-id>/
1819
"""
1920

2021
import json
@@ -35,6 +36,7 @@
3536
VALIDATION_SCOPE = "validation"
3637
CORRELATION_SCOPE = "correlation"
3738
INTELLIGENCE_SCOPE = "intelligence"
39+
REPORTING_SCOPE = "reporting"
3840

3941
_ARTIFACT_NAME = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
4042
#: A closed set, so a scope can never become a path fragment an operator chose.
@@ -45,6 +47,7 @@
4547
VALIDATION_SCOPE,
4648
CORRELATION_SCOPE,
4749
INTELLIGENCE_SCOPE,
50+
REPORTING_SCOPE,
4851
}
4952
)
5053

@@ -128,6 +131,30 @@ def write_metadata(
128131
scope,
129132
)
130133

134+
def write_export(
135+
self,
136+
dockyard_id: int,
137+
run_id: int,
138+
name: str,
139+
media_type: str,
140+
content: bytes,
141+
) -> StoredArtifact:
142+
"""Write one complete bounded Phase 6 export without evidence truncation."""
143+
if not _ARTIFACT_NAME.match(name):
144+
raise EvidenceError(f"Unsafe evidence artifact name: {name!r}")
145+
if len(content) > get_settings().max_dockpack_bytes:
146+
raise EvidenceError("Reporting artifact exceeds the fixed DockPack size limit")
147+
return self._write(
148+
dockyard_id,
149+
run_id,
150+
"export",
151+
f"raw/{name}",
152+
media_type,
153+
content,
154+
False,
155+
REPORTING_SCOPE,
156+
)
157+
131158
def _write(
132159
self,
133160
dockyard_id: int,

backend/app/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from app.detection.runner import recover_interrupted_runs as recover_interrupted_detections
1414
from app.discovery.runner import recover_interrupted_runs
1515
from app.intelligence.runner import recover_interrupted_runs as recover_interrupted_intelligence
16+
from app.reporting.runner import recover_interrupted_runs as recover_interrupted_reports
1617

1718
STATIC_DIRECTORY = Path(__file__).resolve().parents[2] / "static"
1819

@@ -30,6 +31,7 @@ async def lifespan(_: FastAPI):
3031
detections = recover_interrupted_detections(session)
3132
correlations = recover_interrupted_correlations(session)
3233
intelligence = recover_interrupted_intelligence(session)
34+
reports = recover_interrupted_reports(session)
3335
if interrupted:
3436
logger.warning("Marked %s discovery run(s) as interrupted by restart", interrupted)
3537
if detections:
@@ -38,6 +40,8 @@ async def lifespan(_: FastAPI):
3840
logger.warning("Marked %s correlation run(s) as interrupted by restart", correlations)
3941
if intelligence:
4042
logger.warning("Marked %s intelligence run(s) as interrupted by restart", intelligence)
43+
if reports:
44+
logger.warning("Marked %s report run(s) as interrupted by restart", reports)
4145
yield
4246

4347

backend/app/models.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,3 +549,33 @@ class IntelligenceRun(Base):
549549
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
550550
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
551551
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
552+
553+
554+
class ReportRun(Base):
555+
"""One immutable, reproducible reporting snapshot and DockPack export.
556+
557+
Report artifacts are derived only from stored database state and retained
558+
evidence. The database keeps their hashes and summary rather than mutable
559+
report bodies; downloads are re-verified against these hashes.
560+
"""
561+
562+
__tablename__ = "report_runs"
563+
__table_args__ = (Index("ix_report_dockyard_status", "dockyard_id", "status"),)
564+
565+
id: Mapped[int] = mapped_column(primary_key=True)
566+
dockyard_id: Mapped[int] = mapped_column(
567+
ForeignKey("dockyards.id", ondelete="CASCADE"), index=True, nullable=False
568+
)
569+
status: Mapped[str] = mapped_column(String(16), nullable=False)
570+
report_schema: Mapped[str] = mapped_column(String(32), nullable=False)
571+
snapshot_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
572+
technical_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
573+
executive_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
574+
manifest_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
575+
dockpack_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
576+
dockpack_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
577+
evidence_path: Mapped[str | None] = mapped_column(String(255), nullable=True)
578+
source_counts: Mapped[dict | None] = mapped_column(JSON, nullable=True)
579+
error: Mapped[str | None] = mapped_column(String(500), nullable=True)
580+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
581+
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

backend/app/reporting/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Phase 6 deterministic reports and portable, evidence-linked DockPacks."""

0 commit comments

Comments
 (0)