feat: govern shared campaign artifact storage

This commit is contained in:
2026-08-01 17:48:24 +02:00
parent 82ddc0c34c
commit cec3d17bff
15 changed files with 518 additions and 159 deletions
+6
View File
@@ -1,5 +1,11 @@
# GovOPlaN Campaign Codex Guide # GovOPlaN Campaign Codex Guide
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Campaign internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Scope ## Scope
This repository owns the `campaigns` module: campaign authoring, validation, message building, attachment resolution, queue/review/send control, reports, campaign module manifest, and `@govoplan/campaign-webui`. This repository owns the `campaigns` module: campaign authoring, validation, message building, attachment resolution, queue/review/send control, reports, campaign module manifest, and `@govoplan/campaign-webui`.
+7
View File
@@ -23,6 +23,13 @@ primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an
optional platform module for tenant administration and tenant resolver behavior. optional platform module for tenant administration and tenant resolver behavior.
Files and mail own their respective storage and transport capabilities. Files and mail own their respective storage and transport capabilities.
Generated EML is durable execution material, not a node-local runtime cache.
Campaign stores it through Core's shared object-storage contract under opaque
Campaign-owned keys; database job rows retain the expected size, digest, and
Message-ID. Workers resolve and verify the object before delivery. Build
failure compensates objects written before database commit, and retention keeps
the database reference when object deletion fails so cleanup can be retried.
## Dependencies ## Dependencies
The module has one required runtime dependency: The module has one required runtime dependency:
+23
View File
@@ -134,6 +134,29 @@ production process manager. Repeat the worker-loss drill under the selected
systemd, container, Kubernetes, or other production supervisor and the target systemd, container, Kubernetes, or other production supervisor and the target
Redis/SMTP infrastructure before deployment approval. Redis/SMTP infrastructure before deployment approval.
## Shared Build Artifacts
Generated EML is stored through Core's configured object-storage backend under
opaque Campaign-owned keys. The job records expected byte size, SHA-256 digest,
and Message-ID. A worker on another node must retrieve and verify those values
before attempting delivery.
- Do not expose object keys to ordinary campaign users or copy them into
business fields.
- A build failure deletes objects written before the database transaction can
commit.
- Retention clears metadata only after object deletion succeeds; an unavailable
backend leaves the reference in place for a later retry.
- A hard process loss between object creation and metadata commit can leave an
orphan object. Reconcile only within the Campaign build prefix and verify that
no job references the object before deleting it.
- Restore Campaign rows, object storage, and the encryption key to one
coordinated recovery point before resuming workers.
For a scaled-runtime drill, build on one API replica, consume from another
worker, compare the stored evidence, and inject storage failures during build
and retention.
## Reporting Checks ## Reporting Checks
- Partial delivery must show accepted, failed, and unknown counts separately. - Partial delivery must show accepted, failed, and unknown counts separately.
+45
View File
@@ -28,6 +28,7 @@ from govoplan_core.core.modules import (
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.operations import OperationalCheckProviderRegistration from govoplan_core.core.operations import OperationalCheckProviderRegistration
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.core.postbox import ( from govoplan_core.core.postbox import (
@@ -718,6 +719,37 @@ manifest = ModuleManifest(
], ],
}, },
), ),
DocumentationTopic(
id="campaigns.reference.shared-build-artifacts",
title="Operate Campaign build artifacts across workers",
summary="Generated messages use shared object storage and are verified before delivery.",
body="Campaign stores generated EML under opaque shared object keys and records expected size, SHA-256 digest, and Message-ID in each job. A worker may run on another node and verifies that evidence before delivery. Failed builds compensate newly written objects; retention retains metadata when deletion fails. Never copy or edit runtime object keys as business data.",
layer="evidence",
documentation_types=("admin",),
audience=("campaign_operator", "platform_operator", "release_reviewer"),
order=53,
conditions=(
DocumentationCondition(
required_modules=("campaigns",),
any_scopes=("campaigns:diagnostic:read", "system:settings:read"),
),
),
links=(
DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"),
DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"),
),
related_modules=("files", "ops", "mail"),
metadata={
"kind": "reference",
"route": "/campaigns/queue",
"screen": "Campaign operator queue",
"verification": "Build on one replica, deliver from another, verify object digest/size evidence, and exercise storage failure during build and retention.",
"limitations": [
"A hard process loss between object creation and database commit can leave an orphan object until an inventory reconciler removes it.",
"Database, object storage, and encryption keys require a coordinated deployment backup and restore procedure.",
],
},
),
), ),
documentation_providers=(documentation_topics,), documentation_providers=(documentation_topics,),
ownership_providers=( ownership_providers=(
@@ -762,6 +794,19 @@ manifest = ModuleManifest(
cache_seconds=60, cache_seconds=60,
), ),
), ),
architecture=declared_module_architecture(
layer="communication_participation",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/CAMPAIGN_HANDBOOK.md",
test_ref="tests/test_execution_snapshot_integrity.py",
known_limits=("Target-environment recovery and accessibility evidence is incomplete for reference-ready maturity.",),
owned_concepts=("campaign", "campaign version", "recipient snapshot", "delivery job", "delivery report"),
non_owned_concepts=("mail transport", "postbox", "durable address directory", "file storage"),
recovery_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",),
security_docs=("docs/ACCESS_EXPLANATION_COVERAGE.md",),
operations_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",),
),
) )
@@ -1,55 +1,56 @@
from __future__ import annotations from __future__ import annotations
import os
import secrets import secrets
from pathlib import Path
from govoplan_core.core.object_storage import (
StorageBackendError,
configured_storage_backend,
)
from govoplan_core.core.operations import OperationalCheck from govoplan_core.core.operations import OperationalCheck
from govoplan_campaign.backend.persistence.campaigns import BUILD_OUTPUT_DIR from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.runtime import get_settings
def generated_eml_storage_check() -> OperationalCheck: def generated_eml_storage_check() -> OperationalCheck:
"""Verify the current EML evidence path and report its node-local boundary.""" """Verify Campaign evidence against the deployment object-store boundary."""
root = Path(BUILD_OUTPUT_DIR) storage = configured_storage_backend(get_settings() or core_settings)
probe = root / ".govoplan-health" / f"{secrets.token_hex(16)}.probe" probe = f"campaign-artifacts/.health/{secrets.token_hex(16)}.probe"
payload = secrets.token_bytes(64) payload = secrets.token_bytes(64)
try: try:
probe.parent.mkdir(parents=True, mode=0o700, exist_ok=True) storage.put_bytes(probe, payload, content_type="application/octet-stream")
with probe.open("xb") as handle: if storage.get_bytes(probe) != payload:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
if probe.read_bytes() != payload:
raise OSError("generated EML persistence returned different bytes") raise OSError("generated EML persistence returned different bytes")
except OSError as exc: except (OSError, StorageBackendError) as exc:
return OperationalCheck( return OperationalCheck(
id="campaign.generated_eml_storage", id="campaign.generated_eml_storage",
label="Generated Campaign EML evidence", label="Generated Campaign EML evidence",
state="error", state="error",
detail=( detail=(
"The generated EML evidence path failed a bounded durable-write probe " "The generated EML object store failed a bounded write/read probe "
f"({type(exc).__name__})." f"({type(exc).__name__})."
), ),
readiness_critical=True, readiness_critical=True,
metrics={"backend": "node_local_filesystem"}, metrics={"backend": storage.name},
) )
finally: finally:
try: try:
probe.unlink(missing_ok=True) storage.delete(probe)
probe.parent.rmdir() except StorageBackendError:
except OSError:
pass pass
node_local = storage.name == "local"
return OperationalCheck( return OperationalCheck(
id="campaign.generated_eml_storage", id="campaign.generated_eml_storage",
label="Generated Campaign EML evidence", label="Generated Campaign EML evidence",
state="warning", state="warning" if node_local else "ok",
detail=( detail=(
"Generated EML passed a local write/fsync/read probe, but remains node-local. " "Generated EML passed the object-store write/read/delete probe. "
"Use one shared runtime volume and include it in coordinated backups until " + (
"Campaign evidence is migrated to managed object storage." "The configured backend is node-local and is suitable only for a single-node profile."
if node_local
else "The configured backend is shared across application and worker nodes."
)
), ),
metrics={"backend": "node_local_filesystem"}, metrics={"backend": storage.name},
) )
@@ -2,18 +2,24 @@ from __future__ import annotations
import copy import copy
import hashlib import hashlib
import json from dataclasses import dataclass
import os
from datetime import UTC, datetime from datetime import UTC, datetime
from email import policy from email import policy
from email.parser import BytesParser from email.parser import BytesParser
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session 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 ( from govoplan_campaign.backend.db.models import (
Campaign, Campaign,
CampaignIssue, CampaignIssue,
@@ -52,20 +58,19 @@ from govoplan_campaign.backend.integrations import (
postbox_integration, postbox_integration,
) )
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
from govoplan_campaign.backend.runtime import get_settings
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots"
BUILD_OUTPUT_DIR = RUNTIME_DIR / "generated_eml"
class CampaignPersistenceError(RuntimeError): class CampaignPersistenceError(RuntimeError):
pass pass
def _ensure_dirs() -> None: @dataclass(frozen=True, slots=True)
for directory in (CAMPAIGN_SNAPSHOT_DIR, BUILD_OUTPUT_DIR): class _StoredEmlArtifact:
directory.mkdir(parents=True, mode=0o700, exist_ok=True) storage_key: str
directory.chmod(0o700) 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) return CampaignConfig.model_validate(materialized)
def _write_campaign_snapshot(version: CampaignVersion) -> Path: def _campaign_reference_path(version: CampaignVersion) -> Path:
_ensure_dirs() """Return a path anchor without persisting a second configuration copy."""
path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json"
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC source_base_path = str(version.source_base_path or "").strip()
if hasattr(os, "O_NOFOLLOW"): base = Path(source_base_path).expanduser().resolve() if source_base_path else Path.cwd()
flags |= os.O_NOFOLLOW return base / "campaign.json"
descriptor = os.open(path, flags, 0o600)
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: try:
os.fchmod(descriptor, 0o600) for built in built_messages:
with os.fdopen(descriptor, "w", encoding="utf-8") as stream: message = built.draft
descriptor = -1 if not message.eml_path:
json.dump(version.raw_json, stream, ensure_ascii=False, indent=2) continue
finally: path = Path(message.eml_path)
if descriptor >= 0: if not path.is_file():
os.close(descriptor) raise CampaignPersistenceError(
return path "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: 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. """Resolve paths for an explicitly trusted, file-oriented import.
The CLI naturally resolves relative paths against the campaign.json file. The CLI naturally resolves relative paths against the campaign.json file.
Once the campaign is stored in the database, the JSON snapshot lives in Once the campaign is stored in the database, its JSON is authoritative.
app/mailer/runtime/campaign_snapshots. To keep existing file-based To keep existing file-based campaigns working, relative file paths are
campaigns working, relative file paths are normalized to absolute paths at normalized to absolute paths at import time when a source_base_path is
import time when a source_base_path is known. HTTP/API callers are rejected known. HTTP/API callers are rejected by
by ``assert_server_safe_campaign_paths`` before they can reach this helper. ``assert_server_safe_campaign_paths`` before they can reach this helper.
""" """
base = Path(source_base_path).expanduser().resolve() if source_base_path else None base = Path(source_base_path).expanduser().resolve() if source_base_path else None
data = copy.deepcopy(raw_json) data = copy.deepcopy(raw_json)
@@ -232,7 +297,6 @@ def create_campaign_version_from_json(
campaign.current_version_id = version.id campaign.current_version_id = version.id
session.add(campaign) session.add(campaign)
if commit: if commit:
_write_campaign_snapshot(version)
session.commit() session.commit()
else: else:
session.flush() session.flush()
@@ -299,7 +363,7 @@ def load_version_config(session: Session, version_id: str):
raw_json, raw_json,
managed_files_available=files_integration().available, 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) 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.workflow_state = CampaignVersionWorkflowState.APPROVED.value
version.is_complete = True version.is_complete = True
if lock_on_success and version.locked_at is None: if lock_on_success and version.locked_at is None:
from datetime import UTC, datetime
version.locked_at = datetime.now(UTC) version.locked_at = datetime.now(UTC)
version.locked_by_user_id = user_id version.locked_by_user_id = user_id
else: else:
@@ -426,9 +488,13 @@ def _job_from_message(
version_id: str, version_id: str,
message: MessageDraft, message: MessageDraft,
resolved_postbox_targets: list[dict[str, Any]] | None = None, resolved_postbox_targets: list[dict[str, Any]] | None = None,
stored_eml: _StoredEmlArtifact | None = None,
) -> CampaignJob: ) -> CampaignJob:
recipient_email = message.to[0].email if message.to else None recipient_email = message.to[0].email if message.to else None
eml_sha256, message_id_header = _eml_evidence(message.eml_path) 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( return CampaignJob(
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
@@ -438,8 +504,9 @@ def _job_from_message(
recipient_email=recipient_email, recipient_email=recipient_email,
subject=message.subject, subject=message.subject,
message_id_header=message_id_header, message_id_header=message_id_header,
eml_local_path=message.eml_path, eml_storage_key=stored_eml.storage_key if stored_eml else None,
eml_size_bytes=message.eml_size_bytes, 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, eml_sha256=eml_sha256,
build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status), 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), validation_status=_job_validation_status(message.validation_status.value),
@@ -531,7 +598,18 @@ def _replace_version_jobs(
version_id: str, version_id: str,
built_messages: list[Any], built_messages: list[Any],
postbox_targets_by_index: dict[int, list[dict[str, 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( session.query(CampaignIssue).filter(
CampaignIssue.campaign_version_id == version_id, CampaignIssue.campaign_version_id == version_id,
CampaignIssue.job_id.is_not(None), CampaignIssue.job_id.is_not(None),
@@ -547,11 +625,12 @@ def _replace_version_jobs(
version_id=version_id, version_id=version_id,
message=built.draft, message=built.draft,
resolved_postbox_targets=postbox_targets_by_index.get(built.draft.entry_index, []), 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) session.add(job)
pairs.append((job, built.draft)) pairs.append((job, built.draft))
session.flush() session.flush()
return pairs return pairs, old_storage_keys
def _mail_execution_profile( def _mail_execution_profile(
@@ -664,71 +743,112 @@ def build_campaign_version(
raise CampaignPersistenceError("Campaign version must be successfully validated before messages are built") raise CampaignPersistenceError("Campaign version must be successfully validated before messages are built")
_ensure_version_validated_and_locked(version) _ensure_version_validated_and_locked(version)
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
files = files_integration() files = files_integration()
with files.prepared_campaign_snapshot( storage = _object_storage()
session, build_id = uuid4().hex
tenant_id=tenant_id, with TemporaryDirectory(prefix="govoplan-campaign-build-") as output_directory:
campaign_id=campaign.id, with files.prepared_campaign_snapshot(
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(
session, session,
tenant_id=tenant_id, 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, built_messages=result.built_messages,
entries_by_index=entries_by_index,
) )
report_json = _campaign_build_report(result, files) new_storage_keys = [item.storage_key for item in stored_eml_by_index.values()]
report_json["built_by_user_id"] = user_id try:
version.build_summary = report_json report_json = _campaign_build_report(result, files)
editor_state = copy.deepcopy(version.editor_state or {}) report_json["built_by_user_id"] = user_id
editor_state.pop("review_send", None) version.build_summary = report_json
version.editor_state = editor_state 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( job_build_pairs, old_storage_keys = _replace_version_jobs(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign.id, campaign_id=campaign.id,
version_id=version.id, version_id=version.id,
built_messages=result.built_messages, built_messages=result.built_messages,
postbox_targets_by_index=resolved_postbox_targets_by_index, 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") jobs = [job for job, _message in job_build_pairs]
_store_execution_snapshot(session, version=version, config=managed_config, jobs=jobs, build_summary=report_json) files.record_campaign_attachment_uses_for_jobs(
_store_job_issues( session,
session, jobs,
tenant_id=tenant_id, stage="built",
campaign_id=campaign.id, )
version_id=version.id, _store_execution_snapshot(
job_build_pairs=job_build_pairs, session,
) version=version,
_apply_campaign_build_state( config=managed_config,
campaign, jobs=jobs,
version, build_summary=report_json,
needs_review_count=result.report.needs_review_count, )
blocked_count=result.report.blocked_count, _store_job_issues(
queueable_count=result.report.queueable_count, 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(version)
session.add(campaign) session.add(campaign)
session.commit() session.commit()
except Exception:
session.rollback()
_delete_storage_keys(storage, new_storage_keys)
raise
_delete_storage_keys(storage, old_storage_keys)
return report_json return report_json
@@ -38,7 +38,6 @@ from govoplan_campaign.backend.integrations import files_integration, mail_integ
from govoplan_campaign.backend.persistence.campaigns import ( from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError, CampaignPersistenceError,
_next_version_number, _next_version_number,
_write_campaign_snapshot,
normalize_campaign_paths, normalize_campaign_paths,
) )
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
@@ -250,7 +249,6 @@ def create_minimal_campaign(
campaign.current_version_id = version.id campaign.current_version_id = version.id
session.add(campaign) session.add(campaign)
if commit: if commit:
_write_campaign_snapshot(version)
session.commit() session.commit()
else: else:
session.flush() session.flush()
@@ -435,7 +433,6 @@ def _persist_forked_version(
campaign.status = CampaignStatus.DRAFT.value campaign.status = CampaignStatus.DRAFT.value
session.add(campaign) session.add(campaign)
if commit: if commit:
_write_campaign_snapshot(version)
session.commit() session.commit()
else: else:
session.flush() session.flush()
@@ -705,7 +702,6 @@ def _persist_updated_version(
session.add(campaign) session.add(campaign)
session.flush() session.flush()
if commit: if commit:
_write_campaign_snapshot(version)
session.commit() session.commit()
else: else:
session.flush() session.flush()
+32 -1
View File
@@ -9,7 +9,15 @@ from typing import Any, Callable
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.object_storage import (
StorageBackend,
StorageBackendError,
StorageObjectMissing,
configured_storage_backend,
)
from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
from govoplan_campaign.backend.runtime import get_settings
FINAL_VERSION_STATES = { FINAL_VERSION_STATES = {
"completed", "completed",
@@ -111,8 +119,16 @@ def _apply_eml_retention(
dry_run: bool, dry_run: bool,
now: datetime, now: datetime,
policy_for_campaign_id: Callable[[str | None], object], policy_for_campaign_id: Callable[[str | None], object],
storage: StorageBackend | None = None,
) -> dict[str, int]: ) -> dict[str, int]:
result = {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0} result = {
"eligible": 0,
"metadata_cleared": 0,
"files_deleted": 0,
"files_missing": 0,
"delete_failed": 0,
"skipped_not_final": 0,
}
jobs = ( jobs = (
session.query(CampaignJob) session.query(CampaignJob)
.filter((CampaignJob.eml_local_path.is_not(None)) | (CampaignJob.eml_storage_key.is_not(None))) .filter((CampaignJob.eml_local_path.is_not(None)) | (CampaignJob.eml_storage_key.is_not(None)))
@@ -137,6 +153,21 @@ def _apply_eml_retention(
result["eligible"] += 1 result["eligible"] += 1
if dry_run: if dry_run:
continue continue
if job.eml_storage_key:
active_storage = storage or configured_storage_backend(
get_settings() or core_settings
)
try:
if active_storage.exists(job.eml_storage_key):
active_storage.delete(job.eml_storage_key)
result["files_deleted"] += 1
else:
result["files_missing"] += 1
except StorageObjectMissing:
result["files_missing"] += 1
except StorageBackendError:
result["delete_failed"] += 1
continue
if job.eml_local_path: if job.eml_local_path:
path = Path(job.eml_local_path) path = Path(job.eml_local_path)
if path.exists(): if path.exists():
+3 -11
View File
@@ -645,17 +645,9 @@ def _clear_current_version_mail_profile_for_owner_transfer(
def _write_current_version_snapshot_if_available(version: CampaignVersion) -> None: def _write_current_version_snapshot_if_available(version: CampaignVersion) -> None:
try: # Kept as a compatibility no-op for callers outside this package. Campaign
from govoplan_campaign.backend.persistence.campaigns import ( # JSON is database-authoritative and no longer mirrored onto an API node.
_write_campaign_snapshot, del version
)
_write_campaign_snapshot(version)
except Exception:
# The database state is authoritative for the WebUI. Snapshot writing is
# best-effort here because ownership changes should not fail due to an
# unavailable local runtime directory.
return
def bounded_query_rows(query, *, limit: int, label: str): def bounded_query_rows(query, *, limit: int, label: str):
+17 -2
View File
@@ -16,6 +16,10 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
from govoplan_core.core.object_storage import (
StorageBackendError,
configured_storage_backend,
)
from govoplan_core.audit.logging import audit_event from govoplan_core.audit.logging import audit_event
from govoplan_core.security.redaction import redact_secret_values from govoplan_core.security.redaction import redact_secret_values
from govoplan_core.settings import settings as core_settings from govoplan_core.settings import settings as core_settings
@@ -53,7 +57,7 @@ from govoplan_campaign.backend.sending.postbox_delivery import (
PostboxChannelOutcome, PostboxChannelOutcome,
deliver_campaign_job_to_postboxes, deliver_campaign_job_to_postboxes,
) )
from govoplan_campaign.backend.runtime import get_registry from govoplan_campaign.backend.runtime import get_registry, get_settings
from govoplan_campaign.backend.integrations import ( from govoplan_campaign.backend.integrations import (
ImapAppendError, ImapAppendError,
ImapConfigurationError, ImapConfigurationError,
@@ -2389,6 +2393,17 @@ def _verify_eml_evidence(job: CampaignJob, payload: bytes) -> None:
def _load_eml_bytes_for_job(job: CampaignJob) -> bytes: def _load_eml_bytes_for_job(job: CampaignJob) -> bytes:
if job.eml_storage_key:
try:
payload = configured_storage_backend(
get_settings() or core_settings
).get_bytes(job.eml_storage_key)
except StorageBackendError as exc:
raise SendJobError(
f"Generated EML object could not be read for job {job.id}: {exc}"
) from exc
_verify_eml_evidence(job, payload)
return payload
if job.eml_local_path: if job.eml_local_path:
path = Path(job.eml_local_path) path = Path(job.eml_local_path)
if not path.exists(): if not path.exists():
@@ -2396,7 +2411,7 @@ def _load_eml_bytes_for_job(job: CampaignJob) -> bytes:
payload = path.read_bytes() payload = path.read_bytes()
_verify_eml_evidence(job, payload) _verify_eml_evidence(job, payload)
return payload return payload
raise SendJobError("Only local EML paths are supported for sending in this implementation step") raise SendJobError("Generated EML evidence is not available for this job")
def _load_eml_for_job(job: CampaignJob): def _load_eml_for_job(job: CampaignJob):
@@ -92,9 +92,6 @@ class CampaignOptimisticConcurrencyTests(unittest.TestCase):
"govoplan_campaign.backend.persistence.versions._updated_runtime_json", "govoplan_campaign.backend.persistence.versions._updated_runtime_json",
side_effect=lambda _session, **kwargs: kwargs["raw_json"], side_effect=lambda _session, **kwargs: kwargs["raw_json"],
), ),
patch(
"govoplan_campaign.backend.persistence.versions._write_campaign_snapshot"
),
): ):
return update_campaign_version( return update_campaign_version(
session, session,
+3 -3
View File
@@ -2,17 +2,17 @@ from __future__ import annotations
from unittest.mock import patch from unittest.mock import patch
from govoplan_core.core.object_storage import LocalFilesystemStorageBackend
from govoplan_campaign.backend.operational_checks import generated_eml_storage_check from govoplan_campaign.backend.operational_checks import generated_eml_storage_check
def test_generated_eml_probe_reports_node_local_boundary(tmp_path) -> None: def test_generated_eml_probe_reports_node_local_boundary(tmp_path) -> None:
with patch( with patch(
"govoplan_campaign.backend.operational_checks.BUILD_OUTPUT_DIR", "govoplan_campaign.backend.operational_checks.configured_storage_backend",
tmp_path, return_value=LocalFilesystemStorageBackend(tmp_path),
): ):
result = generated_eml_storage_check() result = generated_eml_storage_check()
assert result.state == "warning" assert result.state == "warning"
assert "node-local" in result.detail assert "node-local" in result.detail
assert list(tmp_path.rglob("*.probe")) == [] assert list(tmp_path.rglob("*.probe")) == []
+8 -11
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import stat
import unittest import unittest
from datetime import UTC, datetime from datetime import UTC, datetime
from types import SimpleNamespace from types import SimpleNamespace
@@ -307,20 +306,18 @@ def test_diagnostics_permission_is_operator_only_by_default() -> None:
assert "campaigns:diagnostic:read" not in role_permissions["campaign_reviewer"] assert "campaigns:diagnostic:read" not in role_permissions["campaign_reviewer"]
def test_campaign_snapshot_with_inline_credentials_is_owner_only(tmp_path, monkeypatch) -> None: def test_database_campaign_config_is_not_mirrored_to_node_local_disk(tmp_path) -> None:
from govoplan_campaign.backend.persistence import campaigns as persistence from govoplan_campaign.backend.persistence import campaigns as persistence
snapshots = tmp_path / "snapshots" path = persistence._campaign_reference_path( # type: ignore[arg-type]
output = tmp_path / "generated" SimpleNamespace(
monkeypatch.setattr(persistence, "CAMPAIGN_SNAPSHOT_DIR", snapshots) source_base_path=str(tmp_path),
monkeypatch.setattr(persistence, "BUILD_OUTPUT_DIR", output) raw_json={"server": {"smtp": {"password": "secret"}}},
path = persistence._write_campaign_snapshot( # type: ignore[arg-type] )
SimpleNamespace(id="version-secret", raw_json={"server": {"smtp": {"password": "secret"}}})
) )
assert stat.S_IMODE(path.stat().st_mode) == 0o600 assert path == tmp_path / "campaign.json"
assert stat.S_IMODE(snapshots.stat().st_mode) == 0o700 assert list(tmp_path.iterdir()) == []
assert stat.S_IMODE(output.stat().st_mode) == 0o700
class ResponseSecurityTests(unittest.TestCase): class ResponseSecurityTests(unittest.TestCase):
+2 -2
View File
@@ -79,13 +79,13 @@ class ServerCampaignPathSecurityTests(unittest.TestCase):
"govoplan_campaign.backend.persistence.campaigns.files_integration", "govoplan_campaign.backend.persistence.campaigns.files_integration",
return_value=SimpleNamespace(available=True), return_value=SimpleNamespace(available=True),
), ),
patch("govoplan_campaign.backend.persistence.campaigns._write_campaign_snapshot") as write_snapshot, patch("govoplan_campaign.backend.persistence.campaigns._campaign_reference_path") as reference_path,
patch("govoplan_campaign.backend.persistence.campaigns.load_campaign_config_from_json") as load_config, patch("govoplan_campaign.backend.persistence.campaigns.load_campaign_config_from_json") as load_config,
): ):
with self.assertRaisesRegex(CampaignPathSecurityError, "template source paths"): with self.assertRaisesRegex(CampaignPathSecurityError, "template source paths"):
load_version_config(session, "version-1") # type: ignore[arg-type] load_version_config(session, "version-1") # type: ignore[arg-type]
write_snapshot.assert_not_called() reference_path.assert_not_called()
load_config.assert_not_called() load_config.assert_not_called()
def test_source_base_symlink_escape_is_rejected(self) -> None: def test_source_base_symlink_escape_is_rejected(self) -> None:
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
import hashlib
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from govoplan_core.core.object_storage import (
LocalFilesystemStorageBackend,
StorageBackendError,
)
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
_persist_built_eml_artifacts,
)
from govoplan_campaign.backend.retention import _apply_eml_retention
from govoplan_campaign.backend.sending.jobs import _load_eml_bytes_for_job
class _FailingStorage:
name = "test"
def __init__(self) -> None:
self.objects: dict[str, bytes] = {}
self.deleted: list[str] = []
def put_bytes(self, key: str, data: bytes, **_kwargs) -> None:
if self.objects:
raise StorageBackendError("object service unavailable")
self.objects[key] = data
def delete(self, key: str) -> None:
self.deleted.append(key)
self.objects.pop(key, None)
def test_partial_campaign_artifact_write_is_compensated(tmp_path: Path) -> None:
first = tmp_path / "first.eml"
second = tmp_path / "second.eml"
first.write_bytes(b"Message-ID: <first@example.test>\r\n\r\nfirst")
second.write_bytes(b"Message-ID: <second@example.test>\r\n\r\nsecond")
messages = [
SimpleNamespace(
draft=SimpleNamespace(
entry_index=1,
eml_path=str(first),
eml_size_bytes=None,
)
),
SimpleNamespace(
draft=SimpleNamespace(
entry_index=2,
eml_path=str(second),
eml_size_bytes=None,
)
),
]
storage = _FailingStorage()
with pytest.raises(CampaignPersistenceError, match="could not be persisted"):
_persist_built_eml_artifacts(
storage=storage, # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
version_id="version-1",
build_id="build-1",
built_messages=messages,
)
assert storage.objects == {}
assert len(storage.deleted) == 1
def test_worker_loads_and_verifies_campaign_eml_from_object_storage(
tmp_path: Path,
) -> None:
payload = b"Message-ID: <message@example.test>\r\n\r\nbody"
storage = LocalFilesystemStorageBackend(tmp_path)
storage.put_bytes("campaign-artifacts/message.eml", payload)
job = SimpleNamespace(
id="job-1",
eml_storage_key="campaign-artifacts/message.eml",
eml_local_path=None,
eml_size_bytes=len(payload),
eml_sha256=hashlib.sha256(payload).hexdigest(),
message_id_header="<message@example.test>",
)
with patch(
"govoplan_campaign.backend.sending.jobs.configured_storage_backend",
return_value=storage,
):
assert _load_eml_bytes_for_job(job) == payload
def test_retention_preserves_reference_when_object_store_is_unavailable() -> None:
job = SimpleNamespace(
campaign_id="campaign-1",
updated_at=datetime.now(timezone.utc) - timedelta(days=10),
queue_status="draft",
send_status="smtp_accepted",
imap_status="appended",
eml_local_path=None,
eml_storage_key="campaign-artifacts/message.eml",
)
session = MagicMock()
session.query.return_value.filter.return_value.order_by.return_value.all.return_value = [
job
]
storage = MagicMock()
storage.exists.side_effect = StorageBackendError("object service unavailable")
result = _apply_eml_retention(
session,
dry_run=False,
now=datetime.now(timezone.utc),
policy_for_campaign_id=lambda _campaign_id: SimpleNamespace(
generated_eml_retention_days=1
),
storage=storage,
)
assert result["delete_failed"] == 1
assert result["metadata_cleared"] == 0
assert job.eml_storage_key == "campaign-artifacts/message.eml"
session.add.assert_not_called()