Files
wyndham-ARR/monthly_reports/publishing.py
2026-07-31 15:11:42 +08:00

407 lines
15 KiB
Python

"""Artifact-tool adapter and recoverable atomic monthly-report publication."""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import subprocess
import tempfile
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Mapping, Optional
from monthly_reports.contracts import (
RESULT_SCHEMA_VERSION,
XLSX_MIME,
ErrorCode,
MonthlyReport,
)
from monthly_reports.repository import (
FileMetadata,
ReportRepository,
RepositoryError,
ReservedReport,
)
class BuildError(RuntimeError):
def __init__(self, code: str, safe_message: str):
super().__init__(safe_message)
self.code = code
self.safe_message = safe_message
class PublicationError(RuntimeError):
def __init__(self, code: str, safe_message: str):
super().__init__(safe_message)
self.code = code
self.safe_message = safe_message
@dataclass(frozen=True)
class BuiltWorkbook:
path: Path
sha256: str
byte_size: int
summary: Mapping[str, Any]
@dataclass(frozen=True)
class PublicationOutcome:
latest_path: Path
latest_result_path: Path
archive_path: Path
result_path: Path
artifact: FileMetadata
result_json: FileMetadata
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _private_json(path: Path, payload: Mapping[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, sort_keys=True, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
except Exception:
try:
os.close(descriptor)
except OSError:
pass
raise
class ArtifactToolBuilder:
def __init__(
self,
builder_script: Path,
node_binary: Optional[str] = None,
artifact_tool_module: Optional[Path] = None,
timeout_seconds: int = 180,
) -> None:
configured = (node_binary or os.environ.get("MONTHLY_REPORT_NODE_BINARY", "")).strip()
self._node_binary = configured or shutil.which("node") or ""
self._builder_script = builder_script.resolve()
self._artifact_tool_module = (
artifact_tool_module.resolve() if artifact_tool_module else None
)
self._timeout_seconds = timeout_seconds
def build(self, report: MonthlyReport, work_dir: Path) -> BuiltWorkbook:
if not self._node_binary or not self._builder_script.is_file():
raise BuildError(
ErrorCode.OUTPUT_VALIDATION_FAILED,
"the monthly XLSX builder runtime is unavailable",
)
work_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
os.chmod(work_dir, 0o700)
payload_path = work_dir / "workbook-payload.json"
output_path = work_dir / report.filename
preview_dir = work_dir / "previews"
summary_path = work_dir / "workbook-summary.json"
_private_json(payload_path, report.to_workbook_payload())
environment = os.environ.copy()
if self._artifact_tool_module is not None:
environment["MONTHLY_REPORT_ARTIFACT_TOOL_MODULE"] = str(
self._artifact_tool_module
)
try:
completed = subprocess.run(
[
self._node_binary,
str(self._builder_script),
str(payload_path),
str(output_path),
str(preview_dir),
str(summary_path),
],
cwd=self._builder_script.parent,
capture_output=True,
text=True,
timeout=self._timeout_seconds,
env=environment,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
raise BuildError(
ErrorCode.OUTPUT_VALIDATION_FAILED,
"the monthly XLSX builder did not complete",
) from None
if completed.returncode != 0:
raise BuildError(
ErrorCode.OUTPUT_VALIDATION_FAILED,
"the monthly XLSX builder rejected the generated payload",
)
try:
summary = json.loads(summary_path.read_text(encoding="utf-8"))
expected_names = [channel.worksheet for channel in report.channels]
expected_rows = [len(channel.rows) for channel in report.channels]
if (
summary.get("status") != "success"
or summary.get("schema_version") != RESULT_SCHEMA_VERSION
or summary.get("filename") != report.filename
or summary.get("report_year") != report.report_year
or summary.get("report_month") != report.report_month
or summary.get("as_of_date") != report.as_of_date.isoformat()
or summary.get("sheet_names") != expected_names
or summary.get("row_counts") != expected_rows
or summary.get("formula_count") != report.row_count
or summary.get("preview_count") != len(report.channels)
or not isinstance(summary.get("semantic_sha256"), str)
or len(summary["semantic_sha256"]) != 64
or not output_path.is_file()
or output_path.stat().st_size <= 0
):
raise ValueError("builder summary mismatch")
except (OSError, ValueError, TypeError, json.JSONDecodeError, KeyError):
raise BuildError(
ErrorCode.OUTPUT_VALIDATION_FAILED,
"the monthly XLSX builder result could not be validated",
) from None
os.chmod(output_path, 0o600)
return BuiltWorkbook(
path=output_path,
sha256=sha256_file(output_path),
byte_size=output_path.stat().st_size,
summary=summary,
)
class AtomicReportPublisher:
def __init__(self, project_root: Path, output_root: Path) -> None:
self._project_root = project_root.resolve()
self._output_root = output_root.resolve()
try:
self._output_root.relative_to(self._project_root)
except ValueError:
raise ValueError("output_root must be inside project_root") from None
@staticmethod
def _fsync_parent(path: Path) -> None:
descriptor = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
@classmethod
def _atomic_copy(cls, source: Path, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
temporary = target.parent / f".{target.name}.tmp-{uuid.uuid4().hex}"
try:
shutil.copyfile(source, temporary)
os.chmod(temporary, 0o600)
with temporary.open("rb") as handle:
os.fsync(handle.fileno())
os.replace(temporary, target)
cls._fsync_parent(target)
finally:
temporary.unlink(missing_ok=True)
@classmethod
def _atomic_json(cls, target: Path, payload: Mapping[str, Any]) -> None:
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
temporary = target.parent / f".{target.name}.tmp-{uuid.uuid4().hex}"
try:
_private_json(temporary, payload)
os.replace(temporary, target)
cls._fsync_parent(target)
finally:
temporary.unlink(missing_ok=True)
@classmethod
def _install_once(cls, source: Path, target: Path, expected_sha256: str) -> bool:
if target.exists():
if sha256_file(target) != expected_sha256:
raise PublicationError(
ErrorCode.PUBLISH_FAILED,
"an immutable monthly archive path contains different content",
)
return False
cls._atomic_copy(source, target)
return True
def _storage_key(self, path: Path) -> str:
try:
return path.resolve().relative_to(self._project_root).as_posix()
except ValueError:
raise PublicationError(
ErrorCode.PUBLISH_FAILED,
"a monthly artifact escaped the controlled project root",
) from None
@staticmethod
def _success_result(
report: MonthlyReport,
reservation: ReservedReport,
artifact_storage_key: str,
artifact_sha256: str,
semantic_sha256: str,
) -> Dict[str, Any]:
return {
"schema_version": RESULT_SCHEMA_VERSION,
"status": "success",
"report_year": report.report_year,
"report_month": report.report_month,
"as_of_date": report.as_of_date.isoformat(),
"report_version_id": reservation.report_version_id,
"version_no": reservation.version_no,
"artifact": {
"filename": report.filename,
"storage_key": artifact_storage_key,
"sha256": artifact_sha256,
"semantic_sha256": semantic_sha256,
},
"row_count": report.row_count,
"channel_manifest": [
{
"worksheet": worksheet,
"worksheet_order": order,
"row_count": row_count,
}
for worksheet, order, row_count in report.channel_manifest
],
"daily_version_count": len(report.daily_versions),
"warnings": [],
"errors": [],
}
def publish(
self,
report: MonthlyReport,
reservation: ReservedReport,
built: BuiltWorkbook,
repository: ReportRepository,
work_dir: Path,
) -> PublicationOutcome:
month_root = self._output_root / f"{report.report_year:04d}" / f"{report.report_month:02d}"
version_root = month_root / "archive" / f"v{reservation.version_no:04d}"
archive_path = version_root / report.filename
result_path = version_root / "result.json"
latest_path = month_root / "latest.xlsx"
latest_result_path = month_root / "latest.result.json"
workbook_backup = work_dir / "previous-latest.xlsx"
result_backup = work_dir / "previous-latest.result.json"
latest_existed = latest_path.is_file()
latest_result_existed = latest_result_path.is_file()
archive_created = False
result_created = False
latest_replaced = False
latest_result_replaced = False
try:
month_root.mkdir(parents=True, exist_ok=True, mode=0o700)
if latest_existed:
shutil.copyfile(latest_path, workbook_backup)
os.chmod(workbook_backup, 0o600)
if latest_result_existed:
shutil.copyfile(latest_result_path, result_backup)
os.chmod(result_backup, 0o600)
archive_created = self._install_once(built.path, archive_path, built.sha256)
artifact_storage_key = self._storage_key(archive_path)
result_payload = self._success_result(
report,
reservation,
artifact_storage_key,
built.sha256,
str(built.summary.get("semantic_sha256", "")),
)
if result_path.exists():
expected = json.dumps(
result_payload,
ensure_ascii=False,
sort_keys=True,
indent=2,
) + "\n"
if result_path.read_text(encoding="utf-8") != expected:
raise PublicationError(
ErrorCode.PUBLISH_FAILED,
"an immutable monthly result path contains different content",
)
else:
self._atomic_json(result_path, result_payload)
result_created = True
self._atomic_copy(built.path, latest_path)
latest_replaced = True
self._atomic_json(latest_result_path, result_payload)
latest_result_replaced = True
artifact = FileMetadata(
file_kind="monthly_xlsx",
original_filename=report.filename,
storage_key=artifact_storage_key,
sha256=built.sha256,
byte_size=built.byte_size,
mime_type=XLSX_MIME,
)
result_json = FileMetadata(
file_kind="result_json",
original_filename=result_path.name,
storage_key=self._storage_key(result_path),
sha256=sha256_file(result_path),
byte_size=result_path.stat().st_size,
mime_type="application/json",
)
repository.activate_report(
reservation,
artifact,
result_json,
str(built.summary.get("semantic_sha256", "")),
)
return PublicationOutcome(
latest_path=latest_path,
latest_result_path=latest_result_path,
archive_path=archive_path,
result_path=result_path,
artifact=artifact,
result_json=result_json,
)
except Exception as error:
if latest_result_replaced:
if latest_result_existed and result_backup.is_file():
self._atomic_copy(result_backup, latest_result_path)
else:
latest_result_path.unlink(missing_ok=True)
if latest_replaced:
if latest_existed and workbook_backup.is_file():
self._atomic_copy(workbook_backup, latest_path)
else:
latest_path.unlink(missing_ok=True)
if result_created:
result_path.unlink(missing_ok=True)
if archive_created:
archive_path.unlink(missing_ok=True)
code = getattr(error, "code", ErrorCode.PUBLISH_FAILED)
safe_message = getattr(error, "safe_message", "monthly report publication failed")
try:
repository.mark_failed(reservation, code, safe_message)
except Exception:
pass
if isinstance(error, PublicationError):
raise
if isinstance(error, RepositoryError):
raise PublicationError(error.code, error.safe_message) from None
raise PublicationError(
ErrorCode.PUBLISH_FAILED,
"monthly report publication failed",
) from None
def private_staging_directory(parent: Path):
parent.mkdir(parents=True, exist_ok=True, mode=0o700)
return tempfile.TemporaryDirectory(prefix="monthly-report-", dir=parent)