diff --git a/docs/FILES_HANDBOOK.md b/docs/FILES_HANDBOOK.md index 7d7ff19..fe8679b 100644 --- a/docs/FILES_HANDBOOK.md +++ b/docs/FILES_HANDBOOK.md @@ -85,6 +85,14 @@ 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. +The Files infrastructure dependency provider makes that review concrete. Its +authorized, non-secret Ops inventory reports the active runtime binding and +aggregates persisted `FileBlob` rows by storage backend with blob counts and +byte totals. A host apply that changes `files.storage` requires a fresh, +complete inventory from the same installation and shows the migration and +checksum-verification action before any service is replaced. Object keys, +tenant identifiers and storage credentials are not exported. + ## User tasks The Files page is available at `/files`. Actions appear only when the current diff --git a/package.json b/package.json index 5ef0900..76e4a32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/files-webui", - "version": "0.1.23", + "version": "0.1.24", "private": true, "type": "module", "main": "webui/src/index.ts", diff --git a/pyproject.toml b/pyproject.toml index 27c58f8..d8ef77d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-files" -version = "0.1.23" +version = "0.1.24" description = "GovOPlaN files module with backend and WebUI integration." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.20", + "govoplan-core>=0.1.42", "defusedxml>=0.7,<1", "pyzipper>=0.3.6,<1", "python-multipart>=0.0.31,<1", diff --git a/src/govoplan_files/backend/configuration_provider.py b/src/govoplan_files/backend/configuration_provider.py index 24337ef..00e3b85 100644 --- a/src/govoplan_files/backend/configuration_provider.py +++ b/src/govoplan_files/backend/configuration_provider.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import Any from urllib.parse import urlsplit +from sqlalchemy import func, select + from govoplan_core.core.configuration_packages import ( ConfigurationApplyResult, ConfigurationDiagnostic, @@ -21,19 +23,30 @@ from govoplan_core.core.configuration_packages import ( from govoplan_core.core.infrastructure_capabilities import ( InfrastructureCapability, InfrastructureCapabilityReceipt, + InfrastructureDependency, + InfrastructureDependencyProvider, ) +from govoplan_core.db.session import get_database +from govoplan_files.backend.db.models import FileBlob from govoplan_files.backend.runtime import settings as runtime_settings FILES_CONFIGURATION_CAPABILITY = "files.configuration" +FILES_INFRASTRUCTURE_DEPENDENCY_CAPABILITY = ( + "infrastructure.dependency_inventory.files" +) MANAGED_STORAGE_FRAGMENT = "managed_storage" _PAYLOAD_KEYS = frozenset( {"capability_id", "expected_backend", "expected_source"} ) -class FilesConfigurationProvider(ConfigurationProvider): +class FilesConfigurationProvider( + ConfigurationProvider, + InfrastructureDependencyProvider, +): module_id = "files" + capability_ids = ("files.storage",) def __init__( self, @@ -166,6 +179,61 @@ class FilesConfigurationProvider(ConfigurationProvider): item for item in import_result.diagnostics if item.severity == "blocker" ) + def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]: + active_backend = _normalized_backend( + getattr(self._settings, "file_storage_backend", "local") + ) + dependencies = [ + InfrastructureDependency( + capability_id="files.storage", + module_id="files", + dependency_type="runtime_storage_binding", + dependency_ref=f"files-storage:{active_backend}", + state="runtime_binding", + scope="system", + summary=( + "The active Files runtime is bound to this deployment storage backend." + ), + metrics={}, + required_action=( + "Provision and verify the replacement backend before rebinding the Files runtime." + ), + ) + ] + with get_database().session() as session: + rows = session.execute( + select( + FileBlob.storage_backend, + func.count(FileBlob.id), + func.coalesce(func.sum(FileBlob.size_bytes), 0), + ) + .group_by(FileBlob.storage_backend) + .order_by(FileBlob.storage_backend) + ) + for backend, blob_count, size_bytes in rows: + normalized_backend = _normalized_backend(str(backend or "local")) + dependencies.append( + InfrastructureDependency( + capability_id="files.storage", + module_id="files", + dependency_type="stored_blob_set", + dependency_ref=f"file-blobs:{normalized_backend}", + state="data_present", + scope="all-tenants", + summary=( + "Persisted Files blob metadata references content in this storage backend." + ), + metrics={ + "blob_count": int(blob_count or 0), + "content_bytes": int(size_bytes or 0), + }, + required_action=( + "Copy and checksum-verify every referenced blob, switch the runtime binding, and retain rollback evidence before removing or replacing storage." + ), + ) + ) + return tuple(dependencies) + def _binding_diagnostics( self, fragment: ConfigurationPackageFragment, diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index 1883c3b..e998988 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -55,6 +55,7 @@ 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, + FILES_INFRASTRUCTURE_DEPENDENCY_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 @@ -458,7 +459,7 @@ def _dsar_provider(context: ModuleContext) -> object: manifest = ModuleManifest( id="files", name="Files", - version="0.1.23", + version="0.1.24", required_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, @@ -720,7 +721,8 @@ manifest = ModuleManifest( "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." + "or treats a storage replacement as an implicit migration. Before files.storage changes, Files reports the active runtime binding plus persisted blob counts and byte totals per backend " + "through the non-secret Ops dependency inventory. Missing, stale, or incomplete inventory blocks host apply. Run Files integrity and Ops checks before and after deployment changes." ), layer="configured", documentation_types=("admin",), @@ -752,7 +754,8 @@ manifest = ModuleManifest( "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." + "und behandelt einen Speicherwechsel nicht als stillschweigende Migration. Vor einer Änderung von files.storage meldet Files die aktive Laufzeitbindung sowie gespeicherte Blob-Anzahlen und Byte-Summen je Backend " + "im nicht geheimen Ops-Abhängigkeitsinventar. Ein fehlendes, veraltetes oder unvollständiges Inventar blockiert die Host-Anwendung. Die Integritäts- und Ops-Prüfungen sind vor und nach der Bereitstellungsänderung auszuführen." ), } }, @@ -1784,6 +1787,7 @@ manifest = ModuleManifest( ), capability_factories={ FILES_CONFIGURATION_CAPABILITY: _configuration_provider, + FILES_INFRASTRUCTURE_DEPENDENCY_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 index 7783655..dd8536e 100644 --- a/tests/test_configuration_provider.py +++ b/tests/test_configuration_provider.py @@ -1,8 +1,12 @@ from __future__ import annotations +from pathlib import Path from types import SimpleNamespace +import tempfile import unittest +from sqlalchemy import create_engine + from govoplan_core.core.configuration_packages import ( ConfigurationPackageFragment, ConfigurationPreflightContext, @@ -10,10 +14,14 @@ from govoplan_core.core.configuration_packages import ( from govoplan_core.core.infrastructure_capabilities import ( infrastructure_capability_receipt_from_mapping, ) +from govoplan_core.db.base import Base +from govoplan_core.db.session import configure_database, reset_database from govoplan_files.backend.configuration_provider import ( FILES_CONFIGURATION_CAPABILITY, + FILES_INFRASTRUCTURE_DEPENDENCY_CAPABILITY, FilesConfigurationProvider, ) +from govoplan_files.backend.db.models import FileBlob from govoplan_files.backend.manifest import manifest @@ -78,6 +86,67 @@ def _s3_settings(**overrides): class FilesConfigurationProviderTests(unittest.TestCase): def test_provider_is_registered(self) -> None: self.assertIn(FILES_CONFIGURATION_CAPABILITY, manifest.capability_factories) + self.assertIn( + FILES_INFRASTRUCTURE_DEPENDENCY_CAPABILITY, + manifest.capability_factories, + ) + + def test_inventory_reports_runtime_binding_and_persisted_blob_aggregate(self) -> None: + with tempfile.TemporaryDirectory(prefix="govoplan-files-inventory-") as root: + database_path = Path(root) / "files.sqlite3" + engine = create_engine(f"sqlite:///{database_path}") + Base.metadata.create_all(engine, tables=(FileBlob.__table__,)) + configure_database( + f"sqlite:///{database_path}", + engine=engine, + dispose_previous=True, + ) + try: + with engine.begin() as connection: + connection.execute( + FileBlob.__table__.insert(), + [ + { + "id": "blob-1", + "tenant_id": "tenant-1", + "storage_backend": "local", + "storage_key": "tenant-1/a", + "checksum_sha256": "a" * 64, + "size_bytes": 7, + "protection_discriminator": "plaintext", + "ref_count": 1, + "integrity_status": "unchecked", + }, + { + "id": "blob-2", + "tenant_id": "tenant-1", + "storage_backend": "local", + "storage_key": "tenant-1/b", + "checksum_sha256": "b" * 64, + "size_bytes": 11, + "protection_discriminator": "plaintext", + "ref_count": 1, + "integrity_status": "unchecked", + }, + ], + ) + provider = FilesConfigurationProvider( + settings=_local_settings(), + environment={}, + ) + + dependencies = provider.infrastructure_dependencies() + finally: + reset_database() + engine.dispose() + + self.assertEqual( + ["runtime_storage_binding", "stored_blob_set"], + [item.dependency_type for item in dependencies], + ) + blob_set = dependencies[1] + self.assertEqual(2, blob_set.metrics["blob_count"]) + self.assertEqual(18, blob_set.metrics["content_bytes"]) def test_matching_local_storage_is_an_idempotent_noop(self) -> None: provider = FilesConfigurationProvider( diff --git a/webui/package.json b/webui/package.json index 7c6f64f..cafa306 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/files-webui", - "version": "0.1.23", + "version": "0.1.24", "private": true, "type": "module", "main": "src/index.ts",