feat: govern shared campaign artifact storage
This commit is contained in:
@@ -2,18 +2,24 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.object_storage import (
|
||||
StorageBackend,
|
||||
StorageBackendError,
|
||||
configured_storage_backend,
|
||||
)
|
||||
from govoplan_core.settings import settings as core_settings
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
@@ -52,20 +58,19 @@ from govoplan_campaign.backend.integrations import (
|
||||
postbox_integration,
|
||||
)
|
||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
||||
|
||||
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
|
||||
CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots"
|
||||
BUILD_OUTPUT_DIR = RUNTIME_DIR / "generated_eml"
|
||||
from govoplan_campaign.backend.runtime import get_settings
|
||||
|
||||
|
||||
class CampaignPersistenceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _ensure_dirs() -> None:
|
||||
for directory in (CAMPAIGN_SNAPSHOT_DIR, BUILD_OUTPUT_DIR):
|
||||
directory.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
directory.chmod(0o700)
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _StoredEmlArtifact:
|
||||
storage_key: str
|
||||
size_bytes: int
|
||||
sha256: str
|
||||
message_id_header: str | None
|
||||
|
||||
|
||||
|
||||
@@ -107,22 +112,82 @@ def load_campaign_config_from_json(
|
||||
return CampaignConfig.model_validate(materialized)
|
||||
|
||||
|
||||
def _write_campaign_snapshot(version: CampaignVersion) -> Path:
|
||||
_ensure_dirs()
|
||||
path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json"
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
def _campaign_reference_path(version: CampaignVersion) -> Path:
|
||||
"""Return a path anchor without persisting a second configuration copy."""
|
||||
|
||||
source_base_path = str(version.source_base_path or "").strip()
|
||||
base = Path(source_base_path).expanduser().resolve() if source_base_path else Path.cwd()
|
||||
return base / "campaign.json"
|
||||
|
||||
|
||||
def _object_storage() -> StorageBackend:
|
||||
return configured_storage_backend(get_settings() or core_settings)
|
||||
|
||||
|
||||
def _persist_built_eml_artifacts(
|
||||
*,
|
||||
storage: StorageBackend,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
build_id: str,
|
||||
built_messages: list[Any],
|
||||
) -> dict[int, _StoredEmlArtifact]:
|
||||
artifacts: dict[int, _StoredEmlArtifact] = {}
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||
descriptor = -1
|
||||
json.dump(version.raw_json, stream, ensure_ascii=False, indent=2)
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
return path
|
||||
for built in built_messages:
|
||||
message = built.draft
|
||||
if not message.eml_path:
|
||||
continue
|
||||
path = Path(message.eml_path)
|
||||
if not path.is_file():
|
||||
raise CampaignPersistenceError(
|
||||
"Generated EML evidence is missing for entry "
|
||||
f"{message.entry_index}"
|
||||
)
|
||||
payload = path.read_bytes()
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
parsed = BytesParser(policy=policy.default).parsebytes(payload)
|
||||
message_id = parsed.get("Message-ID")
|
||||
storage_key = (
|
||||
f"campaign-artifacts/{tenant_id}/{campaign_id}/{version_id}/"
|
||||
f"{build_id}/{message.entry_index:08d}-{digest}.eml"
|
||||
)
|
||||
try:
|
||||
storage.put_bytes(
|
||||
storage_key,
|
||||
payload,
|
||||
content_type="message/rfc822",
|
||||
)
|
||||
except StorageBackendError as exc:
|
||||
raise CampaignPersistenceError(
|
||||
f"Generated EML evidence could not be persisted: {exc}"
|
||||
) from exc
|
||||
artifacts[message.entry_index] = _StoredEmlArtifact(
|
||||
storage_key=storage_key,
|
||||
size_bytes=len(payload),
|
||||
sha256=digest,
|
||||
message_id_header=str(message_id) if message_id else None,
|
||||
)
|
||||
message.eml_path = None
|
||||
message.eml_size_bytes = len(payload)
|
||||
except Exception:
|
||||
_delete_storage_keys(
|
||||
storage,
|
||||
[artifact.storage_key for artifact in artifacts.values()],
|
||||
)
|
||||
raise
|
||||
return artifacts
|
||||
|
||||
|
||||
def _delete_storage_keys(storage: StorageBackend, keys: list[str]) -> None:
|
||||
for key in keys:
|
||||
try:
|
||||
storage.delete(key)
|
||||
except StorageBackendError:
|
||||
# A committed build remains authoritative. Reconciliation can
|
||||
# remove an orphaned superseded object later.
|
||||
continue
|
||||
|
||||
|
||||
def _next_version_number(session: Session, campaign_id: str) -> int:
|
||||
@@ -143,11 +208,11 @@ def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | P
|
||||
"""Resolve paths for an explicitly trusted, file-oriented import.
|
||||
|
||||
The CLI naturally resolves relative paths against the campaign.json file.
|
||||
Once the campaign is stored in the database, the JSON snapshot lives in
|
||||
app/mailer/runtime/campaign_snapshots. To keep existing file-based
|
||||
campaigns working, relative file paths are normalized to absolute paths at
|
||||
import time when a source_base_path is known. HTTP/API callers are rejected
|
||||
by ``assert_server_safe_campaign_paths`` before they can reach this helper.
|
||||
Once the campaign is stored in the database, its JSON is authoritative.
|
||||
To keep existing file-based campaigns working, relative file paths are
|
||||
normalized to absolute paths at import time when a source_base_path is
|
||||
known. HTTP/API callers are rejected by
|
||||
``assert_server_safe_campaign_paths`` before they can reach this helper.
|
||||
"""
|
||||
base = Path(source_base_path).expanduser().resolve() if source_base_path else None
|
||||
data = copy.deepcopy(raw_json)
|
||||
@@ -232,7 +297,6 @@ def create_campaign_version_from_json(
|
||||
campaign.current_version_id = version.id
|
||||
session.add(campaign)
|
||||
if commit:
|
||||
_write_campaign_snapshot(version)
|
||||
session.commit()
|
||||
else:
|
||||
session.flush()
|
||||
@@ -299,7 +363,7 @@ def load_version_config(session: Session, version_id: str):
|
||||
raw_json,
|
||||
managed_files_available=files_integration().available,
|
||||
)
|
||||
path = _write_campaign_snapshot(version)
|
||||
path = _campaign_reference_path(version)
|
||||
return version, path, load_campaign_config_from_json(session, tenant_id=campaign.tenant_id, raw_json=raw_json, campaign_id=campaign.id)
|
||||
|
||||
|
||||
@@ -391,8 +455,6 @@ def validate_campaign_version(
|
||||
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
||||
version.is_complete = True
|
||||
if lock_on_success and version.locked_at is None:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
version.locked_at = datetime.now(UTC)
|
||||
version.locked_by_user_id = user_id
|
||||
else:
|
||||
@@ -426,9 +488,13 @@ def _job_from_message(
|
||||
version_id: str,
|
||||
message: MessageDraft,
|
||||
resolved_postbox_targets: list[dict[str, Any]] | None = None,
|
||||
stored_eml: _StoredEmlArtifact | None = None,
|
||||
) -> CampaignJob:
|
||||
recipient_email = message.to[0].email if message.to else None
|
||||
eml_sha256, message_id_header = _eml_evidence(message.eml_path)
|
||||
if stored_eml is not None:
|
||||
eml_sha256 = stored_eml.sha256
|
||||
message_id_header = stored_eml.message_id_header
|
||||
return CampaignJob(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
@@ -438,8 +504,9 @@ def _job_from_message(
|
||||
recipient_email=recipient_email,
|
||||
subject=message.subject,
|
||||
message_id_header=message_id_header,
|
||||
eml_local_path=message.eml_path,
|
||||
eml_size_bytes=message.eml_size_bytes,
|
||||
eml_storage_key=stored_eml.storage_key if stored_eml else None,
|
||||
eml_local_path=message.eml_path if stored_eml is None else None,
|
||||
eml_size_bytes=stored_eml.size_bytes if stored_eml else message.eml_size_bytes,
|
||||
eml_sha256=eml_sha256,
|
||||
build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status),
|
||||
validation_status=_job_validation_status(message.validation_status.value),
|
||||
@@ -531,7 +598,18 @@ def _replace_version_jobs(
|
||||
version_id: str,
|
||||
built_messages: list[Any],
|
||||
postbox_targets_by_index: dict[int, list[dict[str, Any]]],
|
||||
) -> list[tuple[CampaignJob, MessageDraft]]:
|
||||
stored_eml_by_index: dict[int, _StoredEmlArtifact],
|
||||
) -> tuple[list[tuple[CampaignJob, MessageDraft]], list[str]]:
|
||||
old_storage_keys = [
|
||||
str(key)
|
||||
for (key,) in session.query(CampaignJob.eml_storage_key)
|
||||
.filter(
|
||||
CampaignJob.campaign_version_id == version_id,
|
||||
CampaignJob.eml_storage_key.is_not(None),
|
||||
)
|
||||
.all()
|
||||
if key
|
||||
]
|
||||
session.query(CampaignIssue).filter(
|
||||
CampaignIssue.campaign_version_id == version_id,
|
||||
CampaignIssue.job_id.is_not(None),
|
||||
@@ -547,11 +625,12 @@ def _replace_version_jobs(
|
||||
version_id=version_id,
|
||||
message=built.draft,
|
||||
resolved_postbox_targets=postbox_targets_by_index.get(built.draft.entry_index, []),
|
||||
stored_eml=stored_eml_by_index.get(built.draft.entry_index),
|
||||
)
|
||||
session.add(job)
|
||||
pairs.append((job, built.draft))
|
||||
session.flush()
|
||||
return pairs
|
||||
return pairs, old_storage_keys
|
||||
|
||||
|
||||
def _mail_execution_profile(
|
||||
@@ -664,71 +743,112 @@ def build_campaign_version(
|
||||
raise CampaignPersistenceError("Campaign version must be successfully validated before messages are built")
|
||||
_ensure_version_validated_and_locked(version)
|
||||
|
||||
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
|
||||
files = files_integration()
|
||||
with files.prepared_campaign_snapshot(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||
include_bytes=True,
|
||||
prefix="govoplan-managed-build-",
|
||||
) as prepared:
|
||||
managed_raw = load_campaign_json(prepared.path)
|
||||
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
|
||||
result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml)
|
||||
files.annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
|
||||
entries_by_index = {
|
||||
index: entry
|
||||
for index, entry in enumerate(
|
||||
load_campaign_entries(
|
||||
managed_config,
|
||||
campaign_file=prepared.path,
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
}
|
||||
resolved_postbox_targets_by_index = _resolve_built_postbox_targets(
|
||||
storage = _object_storage()
|
||||
build_id = uuid4().hex
|
||||
with TemporaryDirectory(prefix="govoplan-campaign-build-") as output_directory:
|
||||
with files.prepared_campaign_snapshot(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
config=managed_config,
|
||||
campaign_id=campaign.id,
|
||||
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||
include_bytes=True,
|
||||
prefix="govoplan-managed-build-",
|
||||
) as prepared:
|
||||
managed_raw = load_campaign_json(prepared.path)
|
||||
managed_config = load_campaign_config_from_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
raw_json=managed_raw,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
result = build_campaign_messages(
|
||||
managed_config,
|
||||
campaign_file=prepared.path,
|
||||
output_dir=Path(output_directory),
|
||||
write_eml=write_eml,
|
||||
)
|
||||
files.annotate_built_messages_with_managed_files(
|
||||
result.built_messages,
|
||||
prepared.managed_files_by_local_path,
|
||||
)
|
||||
entries_by_index = {
|
||||
index: entry
|
||||
for index, entry in enumerate(
|
||||
load_campaign_entries(
|
||||
managed_config,
|
||||
campaign_file=prepared.path,
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
}
|
||||
resolved_postbox_targets_by_index = _resolve_built_postbox_targets(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
config=managed_config,
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
)
|
||||
stored_eml_by_index = _persist_built_eml_artifacts(
|
||||
storage=storage,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
build_id=build_id,
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
)
|
||||
report_json = _campaign_build_report(result, files)
|
||||
report_json["built_by_user_id"] = user_id
|
||||
version.build_summary = report_json
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state.pop("review_send", None)
|
||||
version.editor_state = editor_state
|
||||
new_storage_keys = [item.storage_key for item in stored_eml_by_index.values()]
|
||||
try:
|
||||
report_json = _campaign_build_report(result, files)
|
||||
report_json["built_by_user_id"] = user_id
|
||||
version.build_summary = report_json
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state.pop("review_send", None)
|
||||
version.editor_state = editor_state
|
||||
|
||||
job_build_pairs = _replace_version_jobs(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
built_messages=result.built_messages,
|
||||
postbox_targets_by_index=resolved_postbox_targets_by_index,
|
||||
)
|
||||
jobs = [job for job, _message in job_build_pairs]
|
||||
files.record_campaign_attachment_uses_for_jobs(session, jobs, stage="built")
|
||||
_store_execution_snapshot(session, version=version, config=managed_config, jobs=jobs, build_summary=report_json)
|
||||
_store_job_issues(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
job_build_pairs=job_build_pairs,
|
||||
)
|
||||
_apply_campaign_build_state(
|
||||
campaign,
|
||||
version,
|
||||
needs_review_count=result.report.needs_review_count,
|
||||
blocked_count=result.report.blocked_count,
|
||||
queueable_count=result.report.queueable_count,
|
||||
)
|
||||
job_build_pairs, old_storage_keys = _replace_version_jobs(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
built_messages=result.built_messages,
|
||||
postbox_targets_by_index=resolved_postbox_targets_by_index,
|
||||
stored_eml_by_index=stored_eml_by_index,
|
||||
)
|
||||
jobs = [job for job, _message in job_build_pairs]
|
||||
files.record_campaign_attachment_uses_for_jobs(
|
||||
session,
|
||||
jobs,
|
||||
stage="built",
|
||||
)
|
||||
_store_execution_snapshot(
|
||||
session,
|
||||
version=version,
|
||||
config=managed_config,
|
||||
jobs=jobs,
|
||||
build_summary=report_json,
|
||||
)
|
||||
_store_job_issues(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
job_build_pairs=job_build_pairs,
|
||||
)
|
||||
_apply_campaign_build_state(
|
||||
campaign,
|
||||
version,
|
||||
needs_review_count=result.report.needs_review_count,
|
||||
blocked_count=result.report.blocked_count,
|
||||
queueable_count=result.report.queueable_count,
|
||||
)
|
||||
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
_delete_storage_keys(storage, new_storage_keys)
|
||||
raise
|
||||
_delete_storage_keys(storage, old_storage_keys)
|
||||
return report_json
|
||||
|
||||
Reference in New Issue
Block a user