SIENTIAPDE-1973: Implement per-connector OPC client certificates and remove legacy read engine. This update overhauls certificate management, transitioning from a single platform certificate to individual client certificates defined in the slot configuration. The CertProvider now downloads certificates from MinIO based on certificate_id, applying dynamic security policies (mode, policy) from the slot. The legacy READ_ENGINE=legacy option and OpcManager have been entirely removed, standardizing on the ServerReader with strict slot v2 enforcement. Write command resolution (ServerConfigResolver) now consistently uses connectors joined with opc_certificates. The CERT_BUCKET environment variable is no longer used.
140 lines
6.1 KiB
Python
140 lines
6.1 KiB
Python
import asyncio
|
|
import os
|
|
from os import getenv
|
|
from pathlib import Path
|
|
|
|
from sientia_do.repository.minio_repository import MinioRepository
|
|
|
|
from ingestor.config import _env_bool
|
|
|
|
# The two objects of a client certificate, and the local file each one is saved as
|
|
CERTIFICATE_OBJECTS = (('cert', 'cert'), ('private_key', 'key'))
|
|
|
|
|
|
class CertificateNotConfiguredError(RuntimeError):
|
|
"""The certificate of a server does not name its MinIO objects (opc_certificates)."""
|
|
|
|
|
|
def _objects(certificate: dict) -> tuple[tuple[str, str], ...]:
|
|
"""(bucket, object_name) of the cert and the private key, in CERTIFICATE_OBJECTS order."""
|
|
try:
|
|
return tuple(
|
|
(str(certificate[field]['bucket']), str(certificate[field]['object_name']))
|
|
for field, _ in CERTIFICATE_OBJECTS
|
|
)
|
|
except (KeyError, TypeError) as e:
|
|
raise CertificateNotConfiguredError(
|
|
f'OPC client certificate {certificate!r} does not name the MinIO bucket and '
|
|
f'object of its cert and private_key (opc_certificates)'
|
|
) from e
|
|
|
|
|
|
def _certificate_id(certificate: dict) -> str:
|
|
"""certificate_id as the name of its cache folder: an integer, never a path."""
|
|
try:
|
|
return str(int(certificate['certificate_id']))
|
|
except (KeyError, TypeError, ValueError) as e:
|
|
raise CertificateNotConfiguredError(
|
|
f'OPC client certificate {certificate!r} has no integer certificate_id'
|
|
) from e
|
|
|
|
|
|
class CertProvider:
|
|
"""Downloads the client cert + private key of a connection (the MinIO objects its
|
|
slot server or write lookup names, SIENTIAPDE-1974) into a writable cache dir — pod
|
|
root FS is read-only (§12), so `cache_dir` must be an emptyDir/tmpfs mount.
|
|
|
|
Each certificate (`certificate_id`) has its own folder (`<cache_dir>/<certificate_id>/`),
|
|
cache entry and lock, so two connections never rewrite the same files at the same
|
|
time. A cached certificate is reused while it names the same objects; when the slot
|
|
names other objects for the same id (the certificate was replaced), it is downloaded
|
|
again. `invalidate(certificate_id)` forces a fresh download (the pool calls it when a
|
|
secured connection fails, e.g. after the objects were replaced in MinIO)."""
|
|
|
|
def __init__(self, minio, cache_dir: str, metadata: dict | None = None):
|
|
self.minio = minio
|
|
self.cache_dir = cache_dir
|
|
self.metadata = metadata or {}
|
|
# ponytail: one Lock per certificate, never cleaned up — the set is small.
|
|
self._locks: dict[str, asyncio.Lock] = {}
|
|
self._paths: dict[str, tuple[tuple[tuple[str, str], ...], tuple[str, str]]] = {}
|
|
|
|
async def fetch(self, certificate: dict) -> tuple[str, str]:
|
|
"""Local paths of the cert and private key of `certificate` (slot format)."""
|
|
certificate_id = _certificate_id(certificate)
|
|
objects = _objects(certificate)
|
|
async with self._locks.setdefault(certificate_id, asyncio.Lock()):
|
|
cached = self._paths.get(certificate_id)
|
|
if cached is None or cached[0] != objects:
|
|
self._paths[certificate_id] = (
|
|
objects,
|
|
await self._download_all(certificate_id, objects),
|
|
)
|
|
return self._paths[certificate_id][1]
|
|
|
|
def invalidate(self, certificate_id) -> None:
|
|
self._paths.pop(str(certificate_id), None)
|
|
|
|
async def _download_all(
|
|
self, certificate_id: str, objects: tuple[tuple[str, str], ...]
|
|
) -> tuple[str, str]:
|
|
folder = Path(self.cache_dir) / certificate_id
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
(cert_bucket, cert_object), (key_bucket, key_object) = objects
|
|
cert_path = await self._download(cert_bucket, cert_object, folder / 'cert')
|
|
key_path = await self._download(key_bucket, key_object, folder / 'key')
|
|
return cert_path, key_path
|
|
|
|
async def _download(self, bucket: str, object_name: str, stem: Path) -> str:
|
|
data = await self.minio.download_file(object_name, bucket, self.metadata)
|
|
# The extension tells asyncua the format (.pem / .der)
|
|
local_path = stem.with_suffix(Path(object_name).suffix)
|
|
# Atomic replace: a connection loading the previous file never sees it half-written.
|
|
tmp_path = local_path.with_name(local_path.name + '.tmp')
|
|
tmp_path.write_bytes(data)
|
|
os.replace(tmp_path, local_path)
|
|
return str(local_path)
|
|
|
|
|
|
class LazyMinio:
|
|
"""Builds one MinIO client per bucket on first use. MinioRepository's constructor
|
|
hits the network (bucket check), so building it eagerly would let a MinIO outage at
|
|
pod start take the whole read path down. Built lazily, only the servers that need a
|
|
certificate fail — and they are retried on the next loop. One client per bucket,
|
|
because the constructor creates its bucket when missing: it is only ever given a
|
|
bucket a certificate names."""
|
|
|
|
def __init__(self, factory):
|
|
self._factory = factory
|
|
self._clients: dict[str, MinioRepository] = {}
|
|
|
|
async def download_file(self, object_name: str, bucket: str, metadata: dict):
|
|
if bucket not in self._clients:
|
|
self._clients[bucket] = self._factory(bucket)
|
|
return await self._clients[bucket].download_file(object_name, bucket, metadata)
|
|
|
|
|
|
def build_cert_provider(
|
|
config, logger, notification_handler, metrics_controller, metadata=None
|
|
) -> CertProvider:
|
|
"""CertProvider shared by the read path (IngestorManager) and the write path
|
|
(GatewayRuntime): one certificate cache for every connection."""
|
|
|
|
def minio_factory(bucket: str) -> MinioRepository:
|
|
return MinioRepository(
|
|
endpoint=getenv('MINIO_ENDPOINT', 'localhost:9000'),
|
|
access_key=getenv('MINIO_ACCESS_KEY', 'minioadmin'),
|
|
secret_key=getenv('MINIO_SECRET_KEY', 'minioadmin'),
|
|
secure=_env_bool('MINIO_SECURE', True),
|
|
bucket=bucket,
|
|
logger=logger,
|
|
notification_handler=notification_handler,
|
|
metrics_controller=metrics_controller,
|
|
)
|
|
|
|
return CertProvider(
|
|
minio=LazyMinio(minio_factory),
|
|
cache_dir=config.cert_cache_dir,
|
|
metadata=metadata,
|
|
)
|