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 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, )