feat: inventory storage infrastructure dependencies
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/files-webui",
|
||||
"version": "0.1.23",
|
||||
"version": "0.1.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
Reference in New Issue
Block a user