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:
@@ -1,11 +1,24 @@
|
||||
from ingestor.io.slots import parse_slot
|
||||
import pytest
|
||||
|
||||
from ingestor.io.slots import CONNECTION_FIELDS, parse_slot
|
||||
|
||||
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'},
|
||||
}
|
||||
NAME = {'collection': 'c', 'tag_name': 't', 'model_name': 'm', 'model_id': '1'}
|
||||
|
||||
RAW = {
|
||||
'server1': {
|
||||
'server_id': '1',
|
||||
'name': 'server1',
|
||||
'url': 'opc.tcp://h:4840',
|
||||
'use_security': True,
|
||||
'subscription_period_ms': 1000,
|
||||
'security': SECURITY,
|
||||
'certificate': CERTIFICATE,
|
||||
'tags': {
|
||||
'ns=2;i=2': {
|
||||
'frequency': '15000',
|
||||
@@ -30,6 +43,17 @@ RAW = {
|
||||
}
|
||||
|
||||
|
||||
def _server(**fields):
|
||||
server = {
|
||||
'server_id': '1',
|
||||
'url': 'u',
|
||||
'security': {'mode': 'None', 'policy': 'None'},
|
||||
'certificate': None,
|
||||
'tags': {'ns=2;i=1': {'frequency': '1000', 'names': [NAME]}},
|
||||
}
|
||||
return {'s': {**server, **fields}}
|
||||
|
||||
|
||||
def test_parse_slot_multi_name():
|
||||
slot = parse_slot(RAW)
|
||||
tag = slot['server1']['tags']['ns=2;i=2']
|
||||
@@ -37,127 +61,71 @@ def test_parse_slot_multi_name():
|
||||
assert tag['active_read']['cycle_multiple'] == 3
|
||||
|
||||
|
||||
def test_parse_slot_defaults_active_read_disabled():
|
||||
raw = {
|
||||
's': {
|
||||
'server_id': '1',
|
||||
'url': 'u',
|
||||
'tags': {
|
||||
'ns=2;i=1': {
|
||||
'frequency': '1000',
|
||||
'names': [
|
||||
{'collection': 'c', 'tag_name': 't', 'model_name': 'm', 'model_id': '1'}
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
def test_parse_slot_keeps_security_and_certificate():
|
||||
server = parse_slot(RAW)['server1']
|
||||
|
||||
assert server['security'] == SECURITY
|
||||
assert server['certificate'] == CERTIFICATE
|
||||
assert {field: server[field] for field in CONNECTION_FIELDS} == {
|
||||
'url': 'opc.tcp://h:4840',
|
||||
'subscription_period_ms': 1000,
|
||||
'security': SECURITY,
|
||||
'certificate': CERTIFICATE,
|
||||
}
|
||||
tag = parse_slot(raw)['s']['tags']['ns=2;i=1']
|
||||
assert tag['active_read'] == {'enabled': False}
|
||||
|
||||
|
||||
def test_parse_slot_rejects_tag_without_names():
|
||||
raw = {'s': {'server_id': '1', 'url': 'u', 'tags': {'ns=2;i=1': {'frequency': '1000'}}}}
|
||||
import pytest
|
||||
|
||||
with pytest.raises((KeyError, ValueError)):
|
||||
parse_slot(raw)
|
||||
|
||||
|
||||
RAW_V1 = {
|
||||
'kepserver': {
|
||||
'name': 'kepserver',
|
||||
'server_id': '1',
|
||||
'url': 'opc.tcp://kepserver:49320',
|
||||
'server_uri': 'urn:sientia:opc-ingestor',
|
||||
'cert_path': '/app/certs/opc-ingestor-cert.pem',
|
||||
'private_key_path': '/app/certs/opc-ingestor-key.pem',
|
||||
'server_cert_path': None,
|
||||
'subscription_period_ms': 15000.0,
|
||||
'tags': {
|
||||
'ns=2;s=A.TargetSin': {
|
||||
'aggr_func': 'lts',
|
||||
'frequency': 30000,
|
||||
'server_id': '1',
|
||||
'tag_address': 'ns=2;s=A.TargetSin',
|
||||
'tag_name': 'TargetSin',
|
||||
'topics': ['raw_scouter-sin-approx', 'raw_other-model'],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_parse_slot_converts_v1_topics_to_names():
|
||||
"""Slot v1 (current Orchestrator) is read as v2: one name per topic, no model_id,
|
||||
so the documents stay identical to the legacy ones (SIENTIAPDE-1974 G1)."""
|
||||
server = parse_slot(RAW_V1)['kepserver']
|
||||
tag = server['tags']['ns=2;s=A.TargetSin']
|
||||
|
||||
assert tag['names'] == [
|
||||
{'collection': 'raw_scouter-sin-approx', 'tag_name': 'TargetSin'},
|
||||
{'collection': 'raw_other-model', 'tag_name': 'TargetSin'},
|
||||
]
|
||||
assert tag['frequency'] == 30000
|
||||
assert tag['active_read'] == {'enabled': False}
|
||||
|
||||
|
||||
def test_parse_slot_v1_cert_path_means_security_but_path_is_dropped():
|
||||
server = parse_slot(RAW_V1)['kepserver']
|
||||
|
||||
assert server['use_security'] is True
|
||||
assert server['name'] == 'kepserver'
|
||||
assert server['server_id'] == '1'
|
||||
assert server['subscription_period_ms'] == 15000.0
|
||||
# the certificate always comes from MinIO now: no file path survives the parse
|
||||
assert 'cert_path' not in server
|
||||
assert 'private_key_path' not in server
|
||||
|
||||
|
||||
def test_parse_slot_v1_without_cert_path_is_insecure():
|
||||
raw = {
|
||||
's': {
|
||||
'server_id': '2',
|
||||
'url': 'u',
|
||||
'cert_path': None,
|
||||
'tags': {'ns=2;i=1': {'tag_name': 't', 'frequency': '1000', 'topics': ['raw_c']}},
|
||||
}
|
||||
}
|
||||
def test_parse_slot_without_certificate_key_means_no_certificate():
|
||||
raw = _server()
|
||||
del raw['s']['certificate']
|
||||
|
||||
server = parse_slot(raw)['s']
|
||||
|
||||
assert server['use_security'] is False
|
||||
assert server['certificate'] is None
|
||||
assert server['name'] == 's' # falls back to the slot key
|
||||
|
||||
|
||||
def test_parse_slot_v2_explicit_use_security_wins():
|
||||
raw = {
|
||||
's': {
|
||||
'server_id': '1',
|
||||
'url': 'u',
|
||||
'use_security': False,
|
||||
'cert_path': '/legacy/path',
|
||||
'tags': {
|
||||
'ns=2;i=1': {
|
||||
'frequency': '1000',
|
||||
'names': [{'collection': 'c', 'tag_name': 't', 'model_id': '1'}],
|
||||
def test_parse_slot_defaults_active_read_disabled():
|
||||
tag = parse_slot(_server())['s']['tags']['ns=2;i=1']
|
||||
assert tag['active_read'] == {'enabled': False}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'raw',
|
||||
[
|
||||
pytest.param(_server(tags={'ns=2;i=1': {'frequency': '1000'}}), id='tag-without-names'),
|
||||
pytest.param(
|
||||
_server(tags={'ns=2;i=1': {'frequency': '1', 'names': [{'collection': 'c'}]}}),
|
||||
id='name-without-tag-name',
|
||||
),
|
||||
pytest.param(
|
||||
_server(
|
||||
tags={
|
||||
'ns=2;i=1': {
|
||||
'frequency': '1',
|
||||
'names': [{'collection': 'c', 'tag_name': 't'}],
|
||||
}
|
||||
}
|
||||
),
|
||||
id='name-without-model-id',
|
||||
),
|
||||
pytest.param(_server(security=None), id='no-security'),
|
||||
# Slot v1 (an Orchestrator before SIENTIAPDE-1974): no longer accepted
|
||||
pytest.param(
|
||||
{
|
||||
'kepserver': {
|
||||
'server_id': '1',
|
||||
'url': 'opc.tcp://kepserver:49320',
|
||||
'server_uri': 'urn:sientia:opc-ingestor',
|
||||
'cert_path': '/app/certs/opc-ingestor-cert.pem',
|
||||
'tags': {
|
||||
'ns=2;s=A': {'tag_name': 'A', 'frequency': 30000, 'topics': ['raw_x']}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
assert parse_slot(raw)['s']['use_security'] is False
|
||||
|
||||
|
||||
def test_parse_slot_rejects_v1_tag_without_tag_name():
|
||||
raw = {
|
||||
's': {
|
||||
'server_id': '1',
|
||||
'url': 'u',
|
||||
'tags': {'ns=2;i=1': {'frequency': '1', 'topics': ['c']}},
|
||||
}
|
||||
}
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
id='slot-v1',
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parse_slot_rejects_invalid_slots(raw):
|
||||
with pytest.raises((KeyError, ValueError)):
|
||||
parse_slot(raw)
|
||||
|
||||
Reference in New Issue
Block a user