feat: validate receipt-bound storage configuration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user