diff --git a/docs/FILES_HANDBOOK.md b/docs/FILES_HANDBOOK.md index 51aa49b..5a7d22b 100644 --- a/docs/FILES_HANDBOOK.md +++ b/docs/FILES_HANDBOOK.md @@ -62,6 +62,28 @@ The main domain objects are: | Campaign attachment use | Evidence connecting a campaign job or entry to an exact asset, version, blob, checksum, and stage | Retained for campaign execution evidence | | Form evidence upload grant | A one-use, hash-only bearer grant tied to an exact Form instance/revision, purpose, custodian, size, and media-type policy | Issued for at most 15 minutes, consumed by one managed upload, then retained as evidence provenance | +## Deployment configuration packages + +Files registers the `files.configuration` capability for `managed_storage` +fragments. Managed content storage is deployment-owned: the installer selects +local persistent storage, managed Garage, or an external S3-compatible service +and mounts a validated non-secret infrastructure capability receipt. Files +does not copy that endpoint or its credentials into module-owned tables. + +Preflight compares the receipt's `files.storage` capability with the effective +runtime backend. It validates backend kind, sanitized S3 endpoint, bucket, +Garage management marker or external trust marker, an absolute persistent path +for local storage, and the presence of Files-owned `env:` secret references. +Secret values are never read into a plan, diagnostic, export, or fragment. + +When runtime and receipt agree, the plan reports `skip`: the desired binding is +already effective, and repeated apply is a no-op. A mismatch blocks import and +explains which deployment setting must be reconciled. The provider deliberately +does not mutate process environment, migrate stored objects, probe remote +storage, or reinterpret an infrastructure replacement as safe. Use the Files +integrity and Ops checks after deployment and complete migration/recovery review +before changing an active backend. + ## User tasks The Files page is available at `/files`. Actions appear only when the current diff --git a/src/govoplan_files/backend/configuration_provider.py b/src/govoplan_files/backend/configuration_provider.py new file mode 100644 index 0000000..24337ef --- /dev/null +++ b/src/govoplan_files/backend/configuration_provider.py @@ -0,0 +1,600 @@ +from __future__ import annotations + +from collections.abc import Mapping +import os +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +from govoplan_core.core.configuration_packages import ( + ConfigurationApplyResult, + ConfigurationDiagnostic, + ConfigurationExportResult, + ConfigurationExportSelection, + ConfigurationPackageFragment, + ConfigurationPlanItem, + ConfigurationPreflightContext, + ConfigurationPreflightResult, + ConfigurationProvider, + ConfigurationProviderDescription, +) +from govoplan_core.core.infrastructure_capabilities import ( + InfrastructureCapability, + InfrastructureCapabilityReceipt, +) +from govoplan_files.backend.runtime import settings as runtime_settings + + +FILES_CONFIGURATION_CAPABILITY = "files.configuration" +MANAGED_STORAGE_FRAGMENT = "managed_storage" +_PAYLOAD_KEYS = frozenset( + {"capability_id", "expected_backend", "expected_source"} +) + + +class FilesConfigurationProvider(ConfigurationProvider): + module_id = "files" + + def __init__( + self, + *, + settings: object | None = None, + environment: Mapping[str, str] | None = None, + ) -> None: + self._settings = runtime_settings if settings is None else settings + self._environment = os.environ if environment is None else environment + + def describe(self) -> ConfigurationProviderDescription: + return ConfigurationProviderDescription( + module_id=self.module_id, + fragment_types=(MANAGED_STORAGE_FRAGMENT,), + schema_refs={ + MANAGED_STORAGE_FRAGMENT: "govoplan/files/configuration/managed-storage.v1" + }, + exported_scopes=("system",), + ) + + def preflight( + self, + fragment: ConfigurationPackageFragment, + context: ConfigurationPreflightContext, + ) -> ConfigurationPreflightResult: + if fragment.fragment_type != MANAGED_STORAGE_FRAGMENT: + return ConfigurationPreflightResult( + diagnostics=(_unsupported(fragment),), + plan=(_blocked_plan(fragment, "Fragment type is unsupported."),), + ) + diagnostics, binding_ref = self._binding_diagnostics(fragment, context) + blocked = any(item.severity == "blocker" for item in diagnostics) + return ConfigurationPreflightResult( + diagnostics=tuple(diagnostics), + plan=( + ConfigurationPlanItem( + action="blocked" if blocked else "skip", + module_id="files", + fragment_type=MANAGED_STORAGE_FRAGMENT, + fragment_id=fragment.fragment_id or binding_ref, + summary=( + "Managed storage does not match the deployment receipt." + if blocked + else "Deployment-owned managed storage already matches the receipt; no module state is rewritten." + ), + ), + ), + ) + + def apply( + self, + fragment: ConfigurationPackageFragment, + supplied_data: Mapping[str, Any], + context: ConfigurationPreflightContext, + ) -> ConfigurationApplyResult: + del supplied_data + if fragment.fragment_type != MANAGED_STORAGE_FRAGMENT: + return ConfigurationApplyResult(diagnostics=(_unsupported(fragment),)) + diagnostics, _binding_ref = self._binding_diagnostics(fragment, context) + return ConfigurationApplyResult( + diagnostics=tuple( + item for item in diagnostics if item.severity == "blocker" + ) + ) + + def export( + self, + selection: ConfigurationExportSelection, + context: ConfigurationPreflightContext, + ) -> ConfigurationExportResult: + del selection + receipt = context.infrastructure_receipt + if context.infrastructure_receipt_error: + return ConfigurationExportResult( + diagnostics=( + _receipt_error( + context.infrastructure_receipt_error, + object_ref="files.storage", + ), + ) + ) + if receipt is None: + return ConfigurationExportResult( + diagnostics=( + ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_receipt_missing", + message="Files managed-storage export requires the deployment capability receipt.", + module_id="files", + object_ref="files.storage", + resolution="Mount the installer-generated receipt before exporting deployment configuration.", + ), + ) + ) + capability = receipt.capability("files.storage") + if capability is None: + return ConfigurationExportResult( + diagnostics=( + ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_capability_missing", + message="The deployment receipt does not declare managed file storage.", + module_id="files", + object_ref="files.storage", + ), + ) + ) + return ConfigurationExportResult( + fragments=( + ConfigurationPackageFragment( + module_id="files", + fragment_type=MANAGED_STORAGE_FRAGMENT, + fragment_id=f"{receipt.installation_id}:files.storage", + payload={ + "capability_id": "files.storage", + "expected_backend": _expected_backend(capability.source), + "expected_source": capability.source, + }, + ), + ) + ) + + def health( + self, + import_result: ConfigurationApplyResult, + context: ConfigurationPreflightContext, + ) -> tuple[ConfigurationDiagnostic, ...]: + del context + return tuple( + item for item in import_result.diagnostics if item.severity == "blocker" + ) + + def _binding_diagnostics( + self, + fragment: ConfigurationPackageFragment, + context: ConfigurationPreflightContext, + ) -> tuple[list[ConfigurationDiagnostic], str]: + diagnostics: list[ConfigurationDiagnostic] = [] + payload = _files_fragment_payload(fragment, diagnostics) + if payload is None: + return diagnostics, fragment.fragment_id or "files.storage" + receipt_state = _files_receipt_capability( + fragment, + context, + payload, + diagnostics, + ) + if receipt_state is None: + return diagnostics, fragment.fragment_id or "files.storage" + _receipt, capability, binding_ref = receipt_state + expected_backend = _expected_backend(capability.source) + diagnostics.extend( + _package_expectation_diagnostics( + payload, + capability, + expected_backend, + binding_ref, + ) + ) + active_backend = _normalized_backend( + getattr(self._settings, "file_storage_backend", "local") + ) + if active_backend != expected_backend: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="files_storage_runtime_mismatch", + message=( + f"Files runtime uses {active_backend!r}, while the deployment receipt declares {expected_backend!r}." + ), + module_id="files", + object_ref=binding_ref, + resolution="Reconcile deployment environment and receipt before starting or importing Files configuration.", + ) + ) + return diagnostics, binding_ref + diagnostics.extend( + self._runtime_storage_diagnostics( + expected_backend, + capability, + binding_ref, + ) + ) + if "files" not in capability.dependent_modules: + diagnostics.append( + ConfigurationDiagnostic( + severity="info", + code="infrastructure_consumer_not_declared", + message="Files is active but was not selected as a receipt consumer when the deployment plan was generated.", + module_id="files", + object_ref=binding_ref, + resolution="Regenerate the deployment plan so removal-impact inventory includes Files.", + ) + ) + return diagnostics, binding_ref + + def _runtime_storage_diagnostics( + self, + expected_backend: str, + capability: InfrastructureCapability, + binding_ref: str, + ) -> list[ConfigurationDiagnostic]: + if expected_backend == "s3": + return self._s3_diagnostics( + capability.endpoint, + capability.secret_refs, + capability.source, + binding_ref, + ) + root = Path( + str(getattr(self._settings, "file_storage_local_root", "") or "") + ) + if root.is_absolute(): + return [] + return [ + ConfigurationDiagnostic( + severity="blocker", + code="files_storage_local_root_not_durable", + message="Receipt-bound local file storage requires an absolute deployment-managed path.", + module_id="files", + object_ref=binding_ref, + resolution="Set FILE_STORAGE_LOCAL_ROOT to the mounted persistent-volume path.", + ) + ] + + def _s3_diagnostics( + self, + endpoint: Mapping[str, object], + secret_refs: tuple[str, ...], + source: str, + object_ref: str, + ) -> list[ConfigurationDiagnostic]: + diagnostics: list[ConfigurationDiagnostic] = [] + endpoint_url = str( + getattr(self._settings, "file_storage_s3_endpoint_url", "") + or getattr(self._settings, "s3_endpoint_url", "") + or "" + ).strip() + runtime_endpoint = _redacted_endpoint( + endpoint_url, + default_port=int(endpoint.get("port") or 443), + ) + receipt_endpoint = { + key: endpoint.get(key) for key in ("scheme", "host", "port") if endpoint.get(key) is not None + } + if receipt_endpoint and runtime_endpoint != receipt_endpoint: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="files_storage_endpoint_mismatch", + message="Files S3 endpoint does not match the sanitized deployment receipt endpoint.", + module_id="files", + object_ref=object_ref, + resolution="Reconcile FILE_STORAGE_S3_ENDPOINT_URL and the deployment plan before apply.", + ) + ) + bucket = str( + getattr(self._settings, "file_storage_s3_bucket", "") + or getattr(self._settings, "s3_bucket", "") + or "" + ).strip() + if not bucket: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="files_storage_bucket_missing", + message="Files S3 storage has no configured bucket.", + module_id="files", + object_ref=object_ref, + resolution="Set FILE_STORAGE_S3_BUCKET in the deployment environment.", + ) + ) + for reference in secret_refs: + variable = reference.removeprefix("env:") + if not variable.startswith("FILE_STORAGE_"): + continue + if not str(self._environment.get(variable, "")).strip(): + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="files_storage_secret_reference_unresolved", + message=f"Required storage secret reference {reference} is not available to the runtime.", + module_id="files", + object_ref=object_ref, + resolution="Provide the referenced environment secret without copying its value into the package.", + ) + ) + deployment_managed = bool( + getattr( + self._settings, + "file_storage_s3_deployment_managed", + False, + ) + ) + endpoint_trusted = bool( + getattr(self._settings, "file_storage_s3_endpoint_trusted", False) + ) + if source == "installer-managed-garage" and not deployment_managed: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="files_storage_management_boundary_mismatch", + message="Garage is installer-managed in the receipt but not marked deployment-managed in Files runtime.", + module_id="files", + object_ref=object_ref, + resolution="Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=true for the installer-managed Garage endpoint.", + ) + ) + if source == "operator-supplied-s3" and not endpoint_trusted: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="files_storage_trust_boundary_missing", + message="External S3 storage must be explicitly marked as a trusted deployment endpoint.", + module_id="files", + object_ref=object_ref, + resolution="Review the endpoint and set FILE_STORAGE_S3_ENDPOINT_TRUSTED=true in deployment configuration.", + ) + ) + return diagnostics + + +def _files_fragment_payload( + fragment: ConfigurationPackageFragment, + diagnostics: list[ConfigurationDiagnostic], +) -> Mapping[str, Any] | None: + payload = fragment.payload + if not isinstance(payload, Mapping): + diagnostics.append( + _invalid(fragment, "Managed-storage payload must be an object.") + ) + return None + unknown = sorted(set(payload) - _PAYLOAD_KEYS) + if not unknown: + return payload + secret_like = any( + marker in key.casefold() + for key in unknown + for marker in ("password", "secret", "token", "credential", "access_key") + ) + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code=( + "files_configuration_secret_forbidden" + if secret_like + else "files_configuration_payload_invalid" + ), + message=( + "Managed-storage fragments accept receipt references and non-secret expectations only." + if secret_like + else f"Managed-storage payload contains unsupported fields: {', '.join(unknown)}." + ), + module_id="files", + object_ref=fragment.fragment_id or MANAGED_STORAGE_FRAGMENT, + resolution="Keep storage credentials in deployment environment references, never in a configuration package.", + ) + ) + return payload + + +def _files_receipt_capability( + fragment: ConfigurationPackageFragment, + context: ConfigurationPreflightContext, + payload: Mapping[str, Any], + diagnostics: list[ConfigurationDiagnostic], +) -> tuple[ + InfrastructureCapabilityReceipt, + InfrastructureCapability, + str, +] | None: + if context.infrastructure_receipt_error: + diagnostics.append( + _receipt_error( + context.infrastructure_receipt_error, + object_ref=fragment.fragment_id or "files.storage", + ) + ) + return None + receipt = context.infrastructure_receipt + if receipt is None: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_receipt_missing", + message="Files managed-storage configuration requires the deployment capability receipt.", + module_id="files", + object_ref=fragment.fragment_id or "files.storage", + resolution="Mount the installer-generated receipt and rerun preflight.", + ) + ) + return None + binding_ref = fragment.fragment_id or f"{receipt.installation_id}:files.storage" + capability_id = _text(payload.get("capability_id")) or "files.storage" + if capability_id != "files.storage": + diagnostics.append( + _invalid( + fragment, + "Files managed-storage fragments must reference capability 'files.storage'.", + ) + ) + return None + capability = receipt.capability(capability_id) + if capability is None: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_capability_missing", + message="The deployment receipt does not declare managed file storage.", + module_id="files", + object_ref=binding_ref, + resolution="Regenerate the receipt from a deployment profile that declares managed storage.", + ) + ) + return None + state_diagnostic = _storage_capability_state_diagnostic(capability, binding_ref) + if state_diagnostic is not None: + diagnostics.append(state_diagnostic) + return None + return receipt, capability, binding_ref + + +def _storage_capability_state_diagnostic( + capability: InfrastructureCapability, + binding_ref: str, +) -> ConfigurationDiagnostic | None: + if capability.state == "unavailable": + return ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_capability_unavailable", + message="The deployment receipt states that managed file storage is unavailable.", + module_id="files", + object_ref=binding_ref, + resolution="Select local, managed Garage, or external S3 storage in the deployment profile.", + ) + if capability.state == "available_unconfigured": + return ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_capability_unconfigured", + message="Managed file storage is available but has not been bound by the deployment runtime.", + module_id="files", + object_ref=binding_ref, + resolution="Complete the deployment-owned storage configuration before importing Files configuration.", + ) + return None + + +def _package_expectation_diagnostics( + payload: Mapping[str, Any], + capability: InfrastructureCapability, + expected_backend: str, + binding_ref: str, +) -> list[ConfigurationDiagnostic]: + diagnostics: list[ConfigurationDiagnostic] = [] + package_backend = ( + _text(payload.get("expected_backend")) or expected_backend + ).casefold() + if package_backend != expected_backend: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_backend_mismatch", + message="The package storage backend expectation conflicts with the deployment receipt.", + module_id="files", + object_ref=binding_ref, + resolution="Use the receipt backend or regenerate the deployment package after review.", + ) + ) + package_source = _text(payload.get("expected_source")) + if package_source and package_source != capability.source: + diagnostics.append( + ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_source_mismatch", + message="The package storage source expectation conflicts with the deployment receipt.", + module_id="files", + object_ref=binding_ref, + resolution="Review the provider replacement and regenerate the package from the active receipt.", + ) + ) + return diagnostics + + +def _expected_backend(source: str) -> str: + return "local" if source == "host-local" else "s3" + + +def _normalized_backend(value: object) -> str: + clean = str(value or "local").strip().casefold() + if clean in {"local", "filesystem", "fs"}: + return "local" + if clean in {"s3", "garage"}: + return "s3" + return clean + + +def _redacted_endpoint(value: str, *, default_port: int) -> dict[str, object]: + try: + parsed = urlsplit(value) + host = parsed.hostname + if not parsed.scheme or not host: + return {"reference": "unresolved"} + port = parsed.port or default_port + except ValueError: + return {"reference": "unresolved"} + return {"scheme": parsed.scheme, "host": host, "port": port} + + +def _text(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _receipt_error(error: str, *, object_ref: str) -> ConfigurationDiagnostic: + return ConfigurationDiagnostic( + severity="blocker", + code="infrastructure_receipt_invalid", + message=f"The deployment capability receipt is invalid: {error}", + module_id="files", + object_ref=object_ref, + resolution="Repair or regenerate the deployment receipt before importing Files configuration.", + ) + + +def _invalid( + fragment: ConfigurationPackageFragment, + message: str, +) -> ConfigurationDiagnostic: + return ConfigurationDiagnostic( + severity="blocker", + code="files_configuration_payload_invalid", + message=message, + module_id="files", + object_ref=fragment.fragment_id or fragment.fragment_type, + resolution="Review the Files configuration-package fragment and rerun preflight.", + ) + + +def _unsupported(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic: + return ConfigurationDiagnostic( + severity="blocker", + code="fragment_type_unsupported", + message=f"Files configuration does not support fragment type {fragment.fragment_type!r}.", + module_id="files", + object_ref=fragment.fragment_id or fragment.fragment_type, + ) + + +def _blocked_plan( + fragment: ConfigurationPackageFragment, + summary: str, +) -> ConfigurationPlanItem: + return ConfigurationPlanItem( + action="blocked", + module_id="files", + fragment_type=fragment.fragment_type, + fragment_id=fragment.fragment_id, + summary=summary, + ) + + +__all__ = ["FILES_CONFIGURATION_CAPABILITY", "FilesConfigurationProvider"] diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index db75ab4..85a87ee 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -48,6 +48,9 @@ from govoplan_core.core.search import SearchSourceProviderRegistration from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base from govoplan_files.backend.change_tracking import register_files_change_tracking +from govoplan_files.backend.configuration_provider import ( + FILES_CONFIGURATION_CAPABILITY, +) from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata from govoplan_files.backend.documentation import documentation_topics from govoplan_files.backend.form_evidence import ( @@ -85,6 +88,15 @@ _files_table_retirement_provider = drop_table_retirement_provider( ) +def _configuration_provider(context: ModuleContext) -> object: + del context + from govoplan_files.backend.configuration_provider import ( + FilesConfigurationProvider, + ) + + return FilesConfigurationProvider() + + def _files_retirement_provider(session: object | None, module_id: str): plan = _files_table_retirement_provider(session, module_id) base_executor = plan.destroy_data_executor @@ -568,6 +580,57 @@ manifest = ModuleManifest( ), ), documentation=( + DocumentationTopic( + id="files.configuration-package.managed-storage", + title="Validate deployment-managed file storage", + summary="Compare Files runtime storage with the non-secret deployment receipt without rewriting infrastructure settings.", + body=( + "The Files configuration provider validates the files.storage capability against the effective local or S3 runtime. " + "It checks the backend, sanitized endpoint, bucket, trust or management marker, durable local path, and presence of referenced environment secrets. " + "Matching configuration is already effective and therefore reports skip on every apply. Drift blocks the package; Files never copies secret values, rewrites process environment, " + "or treats a storage replacement as an implicit migration. Run Files integrity and Ops checks after deployment changes." + ), + layer="configured", + documentation_types=("admin",), + audience=("file_manager", "administrator", "operator"), + related_modules=("core", "ops"), + conditions=( + DocumentationCondition( + required_modules=("files", "access"), + any_scopes=("admin:settings:read", "system:settings:read"), + ), + ), + links=( + DocumentationLink( + label="Configuration packages", + href="/admin?section=configuration-packages", + kind="runtime", + ), + DocumentationLink( + label="Files handbook", + href="govoplan-files/docs/FILES_HANDBOOK.md", + kind="repository", + ), + ), + translations={ + "de": { + "title": "Bereitstellungsverwalteten Dateispeicher prüfen", + "summary": "Den Laufzeitspeicher von Files mit dem nicht geheimen Bereitstellungsnachweis vergleichen, ohne Infrastruktureinstellungen umzuschreiben.", + "body": ( + "Der Files-Konfigurationsprovider prüft die Fähigkeit files.storage gegen die wirksame lokale oder S3-Laufzeitkonfiguration. " + "Geprüft werden Backend, bereinigter Endpunkt, Bucket, Vertrauens- oder Verwaltungskennzeichen, dauerhafter lokaler Pfad sowie das Vorhandensein referenzierter Umgebungsgeheimnisse. " + "Eine passende Konfiguration ist bereits wirksam und meldet deshalb bei jeder Anwendung skip. Abweichungen blockieren das Paket; Files kopiert keine Geheimwerte, verändert keine Prozessumgebung " + "und behandelt einen Speicherwechsel nicht als stillschweigende Migration. Nach Bereitstellungsänderungen sind die Integritäts- und Ops-Prüfungen auszuführen." + ), + } + }, + metadata={ + "kind": "workflow", + "route": "/admin?section=configuration-packages", + "help_contexts": ["admin.configuration-packages", "files.admin.tenant-integrity"], + }, + order=4, + ), DocumentationTopic( id="files.quick-access-and-product-area", title="Files in Records and documents and Quick Access", @@ -1407,6 +1470,7 @@ manifest = ModuleManifest( ), ), capability_factories={ + FILES_CONFIGURATION_CAPABILITY: _configuration_provider, CAPABILITY_FILES_ACCESS: lambda context: __import__( "govoplan_files.backend.capabilities", fromlist=["access_capability"] ).access_capability(context), diff --git a/tests/test_configuration_provider.py b/tests/test_configuration_provider.py new file mode 100644 index 0000000..7783655 --- /dev/null +++ b/tests/test_configuration_provider.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from types import SimpleNamespace +import unittest + +from govoplan_core.core.configuration_packages import ( + ConfigurationPackageFragment, + ConfigurationPreflightContext, +) +from govoplan_core.core.infrastructure_capabilities import ( + infrastructure_capability_receipt_from_mapping, +) +from govoplan_files.backend.configuration_provider import ( + FILES_CONFIGURATION_CAPABILITY, + FilesConfigurationProvider, +) +from govoplan_files.backend.manifest import manifest + + +def _receipt(*, source: str = "host-local", state: str = "configured"): + endpoint = ( + {"kind": "filesystem", "reference": "volume:files-data"} + if source == "host-local" + else {"scheme": "http", "host": "garage", "port": 3900} + ) + secret_refs = ( + [] + if source == "host-local" + else [ + "env:FILE_STORAGE_S3_ACCESS_KEY_ID", + "env:FILE_STORAGE_S3_SECRET_ACCESS_KEY", + "env:GARAGE_RPC_SECRET", + ] + ) + return infrastructure_capability_receipt_from_mapping( + { + "schema_version": 1, + "installation_id": "files-provider-test", + "profile": "evaluation", + "capabilities": [ + { + "id": "files.storage", + "label": "Managed file content storage", + "state": state, + "source": source, + "detail": "Deployment-owned storage binding.", + "endpoint": endpoint, + "secret_refs": secret_refs, + "dependent_modules": ["files"], + } + ], + "post_install_tasks": [], + } + ) + + +def _local_settings(**overrides): + values = { + "file_storage_backend": "local", + "file_storage_local_root": "/var/lib/govoplan/files", + } + values.update(overrides) + return SimpleNamespace(**values) + + +def _s3_settings(**overrides): + values = { + "file_storage_backend": "s3", + "file_storage_s3_endpoint_url": "http://garage:3900", + "file_storage_s3_bucket": "files", + "file_storage_s3_deployment_managed": True, + "file_storage_s3_endpoint_trusted": False, + } + values.update(overrides) + return SimpleNamespace(**values) + + +class FilesConfigurationProviderTests(unittest.TestCase): + def test_provider_is_registered(self) -> None: + self.assertIn(FILES_CONFIGURATION_CAPABILITY, manifest.capability_factories) + + def test_matching_local_storage_is_an_idempotent_noop(self) -> None: + provider = FilesConfigurationProvider( + settings=_local_settings(), + environment={}, + ) + context = ConfigurationPreflightContext( + infrastructure_receipt=_receipt(), + ) + fragment = ConfigurationPackageFragment( + module_id="files", + fragment_type="managed_storage", + fragment_id="files-storage", + payload={ + "expected_backend": "local", + "expected_source": "host-local", + }, + ) + + first = provider.preflight(fragment, context) + applied = provider.apply(fragment, {}, context) + second = provider.preflight(fragment, context) + + self.assertEqual("skip", first.plan[0].action) + self.assertEqual("skip", second.plan[0].action) + self.assertEqual((), applied.diagnostics) + self.assertEqual({}, applied.created_refs) + self.assertEqual({}, applied.updated_refs) + + def test_runtime_backend_mismatch_blocks_without_rewriting_settings(self) -> None: + settings = _local_settings() + provider = FilesConfigurationProvider(settings=settings, environment={}) + context = ConfigurationPreflightContext( + infrastructure_receipt=_receipt(source="installer-managed-garage"), + ) + fragment = ConfigurationPackageFragment( + module_id="files", + fragment_type="managed_storage", + payload={}, + ) + + result = provider.preflight(fragment, context) + + self.assertEqual("blocked", result.plan[0].action) + self.assertIn( + "files_storage_runtime_mismatch", + {item.code for item in result.diagnostics}, + ) + self.assertEqual("local", settings.file_storage_backend) + + def test_garage_binding_requires_only_files_secret_references(self) -> None: + fragment = ConfigurationPackageFragment( + module_id="files", + fragment_type="managed_storage", + payload={}, + ) + context = ConfigurationPreflightContext( + infrastructure_receipt=_receipt(source="installer-managed-garage"), + ) + missing = FilesConfigurationProvider( + settings=_s3_settings(), + environment={}, + ).preflight(fragment, context) + available = FilesConfigurationProvider( + settings=_s3_settings(), + environment={ + "FILE_STORAGE_S3_ACCESS_KEY_ID": "reference-resolved", + "FILE_STORAGE_S3_SECRET_ACCESS_KEY": "reference-resolved", + }, + ).preflight(fragment, context) + + self.assertEqual("blocked", missing.plan[0].action) + self.assertEqual( + 2, + sum( + item.code == "files_storage_secret_reference_unresolved" + for item in missing.diagnostics + ), + ) + self.assertEqual("skip", available.plan[0].action) + + def test_operator_supplied_s3_requires_explicit_endpoint_trust(self) -> None: + receipt = _receipt(source="operator-supplied-s3", state="externally_supplied") + fragment = ConfigurationPackageFragment( + module_id="files", + fragment_type="managed_storage", + payload={}, + ) + context = ConfigurationPreflightContext(infrastructure_receipt=receipt) + environment = { + "FILE_STORAGE_S3_ACCESS_KEY_ID": "reference-resolved", + "FILE_STORAGE_S3_SECRET_ACCESS_KEY": "reference-resolved", + } + + blocked = FilesConfigurationProvider( + settings=_s3_settings( + file_storage_s3_deployment_managed=False, + file_storage_s3_endpoint_trusted=False, + ), + environment=environment, + ).preflight(fragment, context) + ready = FilesConfigurationProvider( + settings=_s3_settings( + file_storage_s3_deployment_managed=False, + file_storage_s3_endpoint_trusted=True, + ), + environment=environment, + ).preflight(fragment, context) + + self.assertEqual("blocked", blocked.plan[0].action) + self.assertIn( + "files_storage_trust_boundary_missing", + {item.code for item in blocked.diagnostics}, + ) + self.assertEqual("skip", ready.plan[0].action) + + def test_inline_storage_secret_is_rejected(self) -> None: + provider = FilesConfigurationProvider( + settings=_local_settings(), + environment={}, + ) + result = provider.preflight( + ConfigurationPackageFragment( + module_id="files", + fragment_type="managed_storage", + payload={"secret_access_key": "inline"}, + ), + ConfigurationPreflightContext(infrastructure_receipt=_receipt()), + ) + + self.assertEqual("blocked", result.plan[0].action) + self.assertIn( + "files_configuration_secret_forbidden", + {item.code for item in result.diagnostics}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest_documentation.py b/tests/test_manifest_documentation.py index 759ba6d..b4448bd 100644 --- a/tests/test_manifest_documentation.py +++ b/tests/test_manifest_documentation.py @@ -4,6 +4,7 @@ import unittest STATIC_TOPIC_IDS = { + "files.configuration-package.managed-storage", "files.quick-access-and-product-area", "files.search.managed-content", "files.workflow.organize-managed-files",