- config: GatewayConfig.certs_from_env() reads only CERT_BUCKET/CERT_CACHE_DIR; IngestorManager uses it, so a malformed write/gRPC env var (GRPC_PORT, WRITE_MIN_PAUSE_S, IDLE_CONN_MAX_S) can no longer stop the reads. - certs: CertProvider raises CertificateNotConfiguredError with an actionable message when no platform_certificates document exists; downloads once under an asyncio.Lock, caches the paths, writes atomically (tmp + os.replace); invalidate() forces a fresh download. - pool: server_id normalized to str (1 and '1' share a session); warns when a get() asks for other url/server_uri/use_security than the pooled client was opened with (client reused, the slot config wins); invalidates the cert cache when a secured connect fails. - SubscriptionEngine: tracks nodes per subscription and forgets them on unsubscribe (no more cycle alarms / tag count for removed tags), keeping addresses still monitored by another slot. - ServerReader.shutdown(evict) / IngestorManager.remove_server(evict): a server that only left this pod's slots keeps the shared connection for NATS writes/gRPC; lost servers and config changes still evict. - IngestorManager warns once per server when a slot still sets server_cert_path (server certificate pinning is no longer applied). - slots: CONNECTION_FIELDS exported and reused by ServerReader. Accepted, not changed: read metrics label server_name with the server_id (same as the write path, which only knows the id); a malformed slot is notified each lease attempt (alerts are deduplicated by notification_id). Tests: 236 unit tests pass (+18); ruff clean; local e2e against an asyncua server incl. slot moved (connection kept and reused by the write path) and lost-server eviction/re-initialization. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
4.7 KiB
Python
149 lines
4.7 KiB
Python
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from ingestor.connection.certs import CertProvider
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_returns_local_paths(tmp_path):
|
|
mongo = AsyncMock()
|
|
mongo.find_one.return_value = {
|
|
'client_cert': {'minio_object_name': 'platform/certificates/client_cert_c.pem'},
|
|
'private_key': {'minio_object_name': 'platform/certificates/private_key_k.pem'},
|
|
}
|
|
minio = AsyncMock()
|
|
minio.download_file.side_effect = [b'cert-bytes', b'key-bytes']
|
|
|
|
provider = CertProvider(mongo, minio, cache_dir=str(tmp_path), bucket='sientia-dataops')
|
|
cert, key = await provider.fetch()
|
|
|
|
assert cert.endswith('c.pem') and key.endswith('k.pem')
|
|
assert minio.download_file.await_count == 2
|
|
mongo.find_one.assert_awaited_once_with('platform_certificates', {}, {})
|
|
minio.download_file.assert_any_await(
|
|
'platform/certificates/client_cert_c.pem', 'sientia-dataops', {}
|
|
)
|
|
assert (tmp_path / 'client_cert_c.pem').read_bytes() == b'cert-bytes'
|
|
assert (tmp_path / 'private_key_k.pem').read_bytes() == b'key-bytes'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lazy_minio_builds_client_once_on_first_download():
|
|
from unittest.mock import MagicMock
|
|
|
|
from ingestor.connection.certs import LazyMinio
|
|
|
|
client = AsyncMock()
|
|
client.download_file.return_value = b'bytes'
|
|
factory = MagicMock(return_value=client)
|
|
minio = LazyMinio(factory)
|
|
|
|
factory.assert_not_called() # no network at construction
|
|
assert await minio.download_file('a.pem', 'bucket', {}) == b'bytes'
|
|
await minio.download_file('b.pem', 'bucket', {})
|
|
|
|
factory.assert_called_once()
|
|
assert client.download_file.await_count == 2
|
|
|
|
|
|
def test_build_cert_provider_is_lazy_and_uses_config(monkeypatch):
|
|
from unittest.mock import MagicMock
|
|
|
|
from ingestor.config import GatewayConfig
|
|
from ingestor.connection import certs
|
|
|
|
minio_cls = MagicMock()
|
|
monkeypatch.setattr(certs, 'MinioRepository', minio_cls)
|
|
mongo = MagicMock()
|
|
|
|
provider = certs.build_cert_provider(
|
|
mongo=mongo,
|
|
config=GatewayConfig(cert_bucket='streamlit-connectors', cert_cache_dir='certs-cache'),
|
|
logger=MagicMock(),
|
|
notification_handler=MagicMock(),
|
|
metrics_controller=MagicMock(),
|
|
)
|
|
|
|
assert provider.mongo is mongo
|
|
assert provider.bucket == 'streamlit-connectors'
|
|
assert provider.cache_dir == 'certs-cache'
|
|
assert isinstance(provider.minio, certs.LazyMinio)
|
|
minio_cls.assert_not_called() # MinIO is only reached when a cert is needed
|
|
|
|
|
|
def _provider(tmp_path, doc=None, data=b'pem'):
|
|
from unittest.mock import MagicMock
|
|
|
|
mongo = MagicMock()
|
|
mongo.find_one = AsyncMock(
|
|
return_value=doc
|
|
if doc is not None
|
|
else {
|
|
'client_cert': {'minio_object_name': 'platform_certificates/c.pem'},
|
|
'private_key': {'minio_object_name': 'platform_certificates/k.pem'},
|
|
}
|
|
)
|
|
minio = MagicMock()
|
|
minio.download_file = AsyncMock(return_value=data)
|
|
return CertProvider(mongo=mongo, minio=minio, cache_dir=str(tmp_path), bucket='b'), minio
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
'doc',
|
|
[
|
|
None,
|
|
{},
|
|
{'client_cert': {'minio_object_name': 'c.pem'}},
|
|
{'client_cert': None, 'private_key': {}},
|
|
],
|
|
)
|
|
async def test_fetch_without_platform_certificate_raises_clear_error(tmp_path, doc):
|
|
from ingestor.connection.certs import CertificateNotConfiguredError
|
|
|
|
provider, minio = _provider(tmp_path)
|
|
provider.mongo.find_one.return_value = doc
|
|
|
|
with pytest.raises(CertificateNotConfiguredError, match='platform_certificates'):
|
|
await provider.fetch()
|
|
minio.download_file.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_is_cached_until_invalidated(tmp_path):
|
|
provider, minio = _provider(tmp_path)
|
|
|
|
first = await provider.fetch()
|
|
second = await provider.fetch()
|
|
assert first == second
|
|
assert minio.download_file.await_count == 2 # cert + key, once
|
|
|
|
provider.invalidate()
|
|
await provider.fetch()
|
|
assert minio.download_file.await_count == 4
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_fetches_download_once(tmp_path):
|
|
import asyncio
|
|
|
|
provider, minio = _provider(tmp_path)
|
|
|
|
results = await asyncio.gather(*(provider.fetch() for _ in range(5)))
|
|
|
|
assert len(set(results)) == 1
|
|
assert minio.download_file.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_replaces_file_atomically(tmp_path):
|
|
provider, minio = _provider(tmp_path, data=b'new')
|
|
(tmp_path / 'c.pem').write_bytes(b'old')
|
|
|
|
cert_path, _ = await provider.fetch()
|
|
|
|
assert (tmp_path / 'c.pem').read_bytes() == b'new'
|
|
assert cert_path == str(tmp_path / 'c.pem')
|
|
assert not list(tmp_path.glob('*.tmp'))
|