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.
168 lines
5.4 KiB
Python
168 lines
5.4 KiB
Python
from unittest.mock import AsyncMock, MagicMock, call
|
|
|
|
import pytest
|
|
|
|
import ingestor.metrics as metrics
|
|
from ingestor.managers.server_reader import ServerReader
|
|
|
|
SERVER_CONFIG = {
|
|
'name': 'kepserver',
|
|
'server_id': 1, # the slot may carry it as int; the pool keys by str
|
|
'url': 'opc.tcp://kepserver:49320',
|
|
'subscription_period_ms': 15000.0,
|
|
'security': {'mode': 'SignAndEncrypt', 'policy': 'Basic256Sha256'},
|
|
'certificate': {
|
|
'certificate_id': 1,
|
|
'application_uri': 'urn:sientia:opc-ingestor',
|
|
'cert': {'bucket': 'opc-certificates', 'object_name': 'client/p/cert.pem'},
|
|
'private_key': {'bucket': 'opc-certificates', 'object_name': 'client/p/key.pem'},
|
|
},
|
|
}
|
|
|
|
|
|
def make_reader():
|
|
pool = MagicMock(get=AsyncMock(), evict=AsyncMock())
|
|
reader = ServerReader(
|
|
name='kepserver',
|
|
server_config=SERVER_CONFIG,
|
|
pool=pool,
|
|
sink=AsyncMock(),
|
|
logger=MagicMock(),
|
|
notification_handler=MagicMock(),
|
|
metrics_controller=AsyncMock(),
|
|
metadata={},
|
|
)
|
|
reader.engine = MagicMock(
|
|
subscriptions={},
|
|
pod_id='pod',
|
|
create_subscription=AsyncMock(),
|
|
subscribe=AsyncMock(),
|
|
unsubscribe=AsyncMock(),
|
|
check_cycles=AsyncMock(),
|
|
check_opc_listenning=AsyncMock(return_value=False),
|
|
emit_metric=AsyncMock(),
|
|
)
|
|
return reader, pool
|
|
|
|
|
|
def test_server_cfg_keeps_only_connection_fields():
|
|
reader, _ = make_reader()
|
|
|
|
assert reader.server_id == '1'
|
|
assert reader.server_cfg == {
|
|
key: SERVER_CONFIG[key]
|
|
for key in ('url', 'subscription_period_ms', 'security', 'certificate')
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_opens_pooled_connection():
|
|
reader, pool = make_reader()
|
|
|
|
await reader.connect()
|
|
|
|
# With a certificate, the pool fetches it from MinIO (CertProvider)
|
|
pool.get.assert_awaited_once_with('1', reader.server_cfg)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_propagates_failure():
|
|
reader, pool = make_reader()
|
|
pool.get.side_effect = ConnectionError('unreachable')
|
|
|
|
with pytest.raises(ConnectionError):
|
|
await reader.connect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subscription_calls_delegate_to_engine_per_slot():
|
|
reader, _ = make_reader()
|
|
nodes = {'ns=2;i=2': {'frequency': '1000', 'names': [{'collection': 'raw_a', 'tag_name': 'a'}]}}
|
|
|
|
await reader.create_subscription('1')
|
|
await reader.subscribe('1', nodes, 10)
|
|
await reader.unsubscribe('1')
|
|
|
|
reader.engine.create_subscription.assert_awaited_once_with('1', reader.server_cfg, '1')
|
|
reader.engine.subscribe.assert_awaited_once_with('1', nodes, 10)
|
|
reader.engine.unsubscribe.assert_awaited_once_with('1')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_supervision_delegates_to_engine():
|
|
reader, _ = make_reader()
|
|
reader.engine.check_opc_listenning.return_value = True
|
|
|
|
await reader.check_cycles()
|
|
lost = await reader.check_opc_listenning()
|
|
|
|
reader.engine.check_cycles.assert_awaited_once()
|
|
reader.engine.check_opc_listenning.assert_awaited_once_with('1')
|
|
assert lost is True
|
|
|
|
|
|
def test_subscriptions_are_the_engine_ones():
|
|
reader, _ = make_reader()
|
|
reader.engine.subscriptions['1'] = MagicMock()
|
|
|
|
assert reader.subscriptions is reader.engine.subscriptions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_unsubscribes_all_slots_evicts_and_zeroes_metrics():
|
|
reader, pool = make_reader()
|
|
reader.engine.subscriptions.update({'1': MagicMock(), '2': MagicMock()})
|
|
|
|
await reader.shutdown()
|
|
|
|
reader.engine.unsubscribe.assert_has_awaits([call('1'), call('2')])
|
|
# Dropping the pooled connection makes the next initialization reconnect.
|
|
pool.evict.assert_awaited_once_with('1')
|
|
reader.engine.emit_metric.assert_any_await(
|
|
metric_object=metrics.OPC_CONNECTION_STATUS,
|
|
method='set',
|
|
value=0,
|
|
tags={'pod_id': 'pod', 'server_name': '1', 'server_url': 'opc.tcp://kepserver:49320'},
|
|
)
|
|
reader.engine.emit_metric.assert_any_await(
|
|
metric_object=metrics.OPC_TAGS_SUBSCRIBED,
|
|
method='set',
|
|
value=0,
|
|
tags={'pod_id': 'pod', 'server_name': '1'},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_without_evict_keeps_the_shared_connection():
|
|
"""Slot moved to another pod: drop the subscriptions, keep the pooled
|
|
connection for NATS writes/gRPC (review #7)."""
|
|
reader, pool = make_reader()
|
|
reader.engine.subscriptions.update({'1': MagicMock()})
|
|
|
|
await reader.shutdown(evict=False)
|
|
|
|
reader.engine.unsubscribe.assert_awaited_once_with('1')
|
|
pool.evict.assert_not_awaited()
|
|
for c in reader.engine.emit_metric.await_args_list:
|
|
assert c.kwargs['metric_object'] is not metrics.OPC_CONNECTION_STATUS
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_unsubscribes_every_slot_while_engine_pops_them():
|
|
"""The real unsubscribe() pops from engine.subscriptions: shutdown must iterate a
|
|
snapshot (list()), or the loop breaks with "dictionary changed size during
|
|
iteration" and the remaining slots stay subscribed (SonarQube S7504 does not apply)."""
|
|
reader, pool = make_reader()
|
|
subs = {'1': MagicMock(), '2': MagicMock(), '3': MagicMock()}
|
|
reader.engine.subscriptions = subs
|
|
|
|
async def pop(slot):
|
|
subs.pop(slot)
|
|
|
|
reader.engine.unsubscribe = AsyncMock(side_effect=pop)
|
|
|
|
await reader.shutdown()
|
|
|
|
assert subs == {}
|
|
assert reader.engine.unsubscribe.await_count == 3
|