SIENTIAPDE-1973
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.
This commit is contained in:
@@ -2,108 +2,113 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from ingestor.connection.servers import CONNECTORS, OPC_SERVERS, ServerConfigResolver
|
||||
from ingestor.connection.servers import CONNECTORS, ServerConfigResolver
|
||||
|
||||
LOOKED_UP = {
|
||||
'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'},
|
||||
}
|
||||
# A connector as the aggregation returns it ($lookup on opc_certificates)
|
||||
CONNECTOR = {
|
||||
'connector_id': 1,
|
||||
'type': 'opc_ua',
|
||||
'status': 'Active',
|
||||
'name': 'kepserver',
|
||||
'connector_id': 'kepserver',
|
||||
'url': 'opc.tcp://kepserver:49320',
|
||||
'server_uri': 'urn:kepserver',
|
||||
'use_security': True,
|
||||
'subscription_period_ms': 500,
|
||||
'security': {
|
||||
'mode': 'SignAndEncrypt',
|
||||
'policy': 'Basic256Sha256',
|
||||
'client_certificate_id': 1,
|
||||
},
|
||||
'certificates': [LOOKED_UP],
|
||||
}
|
||||
OPC_SERVER = {
|
||||
'id': '1',
|
||||
'server_name': 'kepserver',
|
||||
'url': 'opc.tcp://legacy:49320',
|
||||
'uri': 'urn:legacy',
|
||||
'cert_path': '/app/certs/cert.pem',
|
||||
# The same server in the slot v2 format
|
||||
CONFIG = {
|
||||
'server_id': 'kepserver',
|
||||
'url': 'opc.tcp://kepserver:49320',
|
||||
'subscription_period_ms': 500,
|
||||
'security': {'mode': 'SignAndEncrypt', 'policy': 'Basic256Sha256'},
|
||||
'certificate': LOOKED_UP,
|
||||
}
|
||||
|
||||
|
||||
def make_resolver(connector=None, opc_server=None, pooled=None, ttl_s=60.0):
|
||||
def make_resolver(connector=None, pooled=None, ttl_s=60.0):
|
||||
pool = MagicMock()
|
||||
pool.config_for = MagicMock(return_value=pooled)
|
||||
mongo = MagicMock()
|
||||
|
||||
async def find_one(collection, _filters, _metadata):
|
||||
return {CONNECTORS: connector, OPC_SERVERS: opc_server}[collection]
|
||||
|
||||
mongo.find_one = AsyncMock(side_effect=find_one)
|
||||
mongo.aggregate = AsyncMock(return_value=[connector] if connector is not None else [])
|
||||
return ServerConfigResolver(pool, mongo, MagicMock(), ttl_s=ttl_s)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_connection_overrides_every_other_source():
|
||||
resolver = make_resolver(connector=CONNECTOR)
|
||||
|
||||
cfg = await resolver.resolve('1', {'url': 'opc.tcp://h:1', 'server_uri': 'urn:h'})
|
||||
|
||||
assert cfg == {'url': 'opc.tcp://h:1', 'server_uri': 'urn:h', 'use_security': False}
|
||||
resolver.mongo.find_one.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_pooled_connection_wins_over_mongo():
|
||||
"""Writes reuse exactly the settings the read path opened the connection with."""
|
||||
pooled = {'url': 'opc.tcp://read:1', 'server_uri': 'urn:read', 'use_security': True}
|
||||
pooled = {'url': 'opc.tcp://read:1', 'security': CONFIG['security'], 'certificate': None}
|
||||
resolver = make_resolver(connector=CONNECTOR, pooled=pooled)
|
||||
|
||||
assert await resolver.resolve('1', {}) == pooled
|
||||
resolver.pool.config_for.assert_called_once_with('1')
|
||||
resolver.mongo.find_one.assert_not_awaited()
|
||||
assert await resolver.resolve('kepserver') == pooled
|
||||
resolver.pool.config_for.assert_called_once_with('kepserver')
|
||||
resolver.mongo.aggregate.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_database_connector_first():
|
||||
resolver = make_resolver(connector=CONNECTOR, opc_server=OPC_SERVER)
|
||||
async def test_connector_with_its_certificate_in_the_slot_format():
|
||||
resolver = make_resolver(connector=CONNECTOR)
|
||||
|
||||
cfg = await resolver.resolve('1', {})
|
||||
|
||||
assert cfg == {
|
||||
'url': 'opc.tcp://kepserver:49320',
|
||||
'server_uri': 'urn:kepserver',
|
||||
'use_security': True,
|
||||
}
|
||||
collection, filters, _ = resolver.mongo.find_one.await_args_list[0].args
|
||||
assert await resolver.resolve('kepserver') == CONFIG
|
||||
collection, pipeline, _ = resolver.mongo.aggregate.await_args.args
|
||||
assert collection == CONNECTORS
|
||||
# connector_id is an int in the tag database; the command carries a string.
|
||||
assert filters == {'connector_id': {'$in': ['1', 1]}, 'type': 'opc_ua', 'status': 'Active'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_opc_servers_fallback_without_tag_database():
|
||||
resolver = make_resolver(connector=None, opc_server=OPC_SERVER)
|
||||
|
||||
cfg = await resolver.resolve('1', {})
|
||||
|
||||
# Same rule as the orchestrator's read slot: a cert_path means security.
|
||||
assert cfg == {
|
||||
'url': 'opc.tcp://legacy:49320',
|
||||
'server_uri': 'urn:legacy',
|
||||
'use_security': True,
|
||||
assert pipeline[0] == {
|
||||
'$match': {'connector_id': 'kepserver', 'type': 'opc_ua', 'status': 'Active'}
|
||||
}
|
||||
collection, filters, _ = resolver.mongo.find_one.await_args_list[1].args
|
||||
assert collection == OPC_SERVERS
|
||||
assert filters == {'id': {'$in': ['1', 1]}}
|
||||
# The same rule as the orchestrator: only an active client certificate (D5)
|
||||
[lookup] = [stage['$lookup'] for stage in pipeline if '$lookup' in stage]
|
||||
assert lookup['from'] == 'opc_certificates'
|
||||
assert lookup['pipeline'][0]['$match']['status'] == 'active'
|
||||
assert lookup['pipeline'][0]['$match']['kind'] == 'client'
|
||||
resolver.logger.warning.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_opc_server_without_cert_path_is_insecure():
|
||||
resolver = make_resolver(opc_server={**OPC_SERVER, 'cert_path': ''})
|
||||
async def test_connector_without_certificate_id_connects_without_security():
|
||||
connector = {
|
||||
**CONNECTOR,
|
||||
'security': {'mode': 'None', 'policy': 'None', 'client_certificate_id': None},
|
||||
'certificates': [],
|
||||
}
|
||||
resolver = make_resolver(connector=connector)
|
||||
|
||||
assert (await resolver.resolve('1', {}))['use_security'] is False
|
||||
cfg = await resolver.resolve('kepserver')
|
||||
|
||||
assert cfg['certificate'] is None
|
||||
assert cfg['security'] == {'mode': 'None', 'policy': 'None'}
|
||||
resolver.logger.warning.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_numeric_server_id_is_looked_up_as_is():
|
||||
resolver = make_resolver(opc_server={**OPC_SERVER, 'id': 'kep-a'})
|
||||
async def test_certificate_id_without_an_active_client_certificate_warns():
|
||||
resolver = make_resolver(connector={**CONNECTOR, 'certificates': []})
|
||||
|
||||
await resolver.resolve('kep-a', {})
|
||||
cfg = await resolver.resolve('kepserver')
|
||||
|
||||
_, filters, _ = resolver.mongo.find_one.await_args_list[0].args
|
||||
assert filters['connector_id'] == {'$in': ['kep-a']}
|
||||
assert cfg == {**CONFIG, 'certificate': None}
|
||||
assert 'client_certificate_id 1' in resolver.logger.warning.call_args.args[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'connector',
|
||||
[
|
||||
pytest.param({**CONNECTOR, 'url': ''}, id='no-url'),
|
||||
pytest.param({**CONNECTOR, 'security': {'mode': 'Sign'}}, id='no-policy'),
|
||||
pytest.param({k: v for k, v in CONNECTOR.items() if k != 'security'}, id='no-security'),
|
||||
],
|
||||
)
|
||||
async def test_incomplete_connectors_are_not_found(connector):
|
||||
resolver = make_resolver(connector=connector)
|
||||
|
||||
assert await resolver.resolve('kepserver') is None
|
||||
resolver.logger.warning.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -113,39 +118,32 @@ async def test_unknown_server_is_cached_briefly():
|
||||
resolver.clock = MagicMock()
|
||||
resolver.clock.elapsed.side_effect = [0.0, 5.0, 11.0]
|
||||
|
||||
assert await resolver.resolve('9', {}) is None
|
||||
assert await resolver.resolve('9', {}) is None # within missing_ttl_s: no lookup
|
||||
assert await resolver.resolve('9', {}) is None # expired: looked up again
|
||||
assert resolver.mongo.find_one.await_count == 4 # 2 lookups (connectors + opc-servers)
|
||||
assert await resolver.resolve('9') is None
|
||||
assert await resolver.resolve('9') is None # within missing_ttl_s: no lookup
|
||||
assert await resolver.resolve('9') is None # expired: looked up again
|
||||
assert resolver.mongo.aggregate.await_count == 2
|
||||
assert resolver.logger.warning.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_documents_without_url_are_ignored():
|
||||
resolver = make_resolver(connector={**CONNECTOR, 'url': ''}, opc_server=OPC_SERVER)
|
||||
|
||||
assert (await resolver.resolve('1', {}))['url'] == 'opc.tcp://legacy:49320'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookups_are_cached_for_the_ttl():
|
||||
resolver = make_resolver(connector=CONNECTOR, ttl_s=60.0)
|
||||
resolver.clock = MagicMock()
|
||||
resolver.clock.elapsed.side_effect = [0.0, 30.0, 61.0]
|
||||
|
||||
first = await resolver.resolve('1', {})
|
||||
first = await resolver.resolve('kepserver')
|
||||
first['url'] = 'mutated by the caller'
|
||||
second = await resolver.resolve('1', {}) # within the TTL: cached, and a copy
|
||||
await resolver.resolve('1', {}) # expired: looked up again
|
||||
second = await resolver.resolve('kepserver') # within the TTL: cached, and a copy
|
||||
await resolver.resolve('kepserver') # expired: looked up again
|
||||
|
||||
assert second['url'] == 'opc.tcp://kepserver:49320'
|
||||
assert resolver.mongo.find_one.await_count == 2
|
||||
assert resolver.mongo.aggregate.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mongo_errors_propagate_to_the_caller():
|
||||
resolver = make_resolver()
|
||||
resolver.mongo.find_one = AsyncMock(side_effect=RuntimeError('mongo down'))
|
||||
resolver.mongo.aggregate = AsyncMock(side_effect=RuntimeError('mongo down'))
|
||||
|
||||
with pytest.raises(RuntimeError, match='mongo down'):
|
||||
await resolver.resolve('1', {})
|
||||
await resolver.resolve('kepserver')
|
||||
|
||||
Reference in New Issue
Block a user