Skip to main content

bhtune_runtime/
gateway.rs

1//! Shared opcda-bridge compatibility checks for live mutations and inspection surfaces.
2//!
3//! Live mutations refuse an incompatible core. Read-only discovery, browse, and read surfaces
4//! must not call [`require_live_gateway_compatible`]: they degrade instead of failing.
5
6use anyhow::Context as _;
7use bhtune_driver::{OpcDaGatewayCompatibility, check_gateway_compatibility};
8
9/// Checks the gateway and refuses only an incompatible core protocol range.
10///
11/// `Partial` and `Unknown` results are logged and returned so the caller can proceed and
12/// persist the snapshot. A transport or RPC failure is an error: a live mutation must not
13/// start when compatibility cannot be classified.
14pub async fn require_live_gateway_compatible(
15    bridge_host: &str,
16    server: Option<&str>,
17) -> anyhow::Result<OpcDaGatewayCompatibility> {
18    let report = check_gateway_compatibility(bridge_host, server)
19        .await
20        .with_context(|| format!("failed to check opcda-bridge compatibility at {bridge_host}"))?;
21    if let Some(refusal) = report.live_mutation_refusal() {
22        anyhow::bail!(refusal);
23    }
24    if let Some(warning) = report.warning_message() {
25        tracing::warn!("{warning}");
26    }
27    Ok(report)
28}
29
30/// Serializes a compatibility snapshot for `tune_runs.gateway_compatibility_json`.
31#[must_use]
32pub fn compatibility_json(report: &OpcDaGatewayCompatibility) -> String {
33    serde_json::to_string(report).unwrap_or_default()
34}
35
36/// Parses a stored compatibility snapshot.
37pub fn parse_stored_compatibility(json: &str) -> anyhow::Result<OpcDaGatewayCompatibility> {
38    serde_json::from_str(json).context("stored gateway compatibility snapshot could not be parsed")
39}
40
41#[cfg(test)]
42mod tests {
43    use opcda_bridge_proto::bridge::{
44        GetGatewayInfoResponse, ProtocolFeature, ProtocolFeatureKind,
45    };
46
47    use super::{compatibility_json, parse_stored_compatibility, require_live_gateway_compatible};
48    use crate::test_support::{MockBridgeService, start_mock_server};
49    use bhtune_driver::OpcDaCompatibilityStatus;
50
51    fn protocol_feature(
52        kind: ProtocolFeatureKind,
53        min_version: u32,
54        max_version: u32,
55    ) -> ProtocolFeature {
56        ProtocolFeature {
57            kind: kind as i32,
58            min_version,
59            max_version,
60        }
61    }
62
63    fn gateway_info(features: Vec<ProtocolFeature>) -> GetGatewayInfoResponse {
64        GetGatewayInfoResponse {
65            application_version: "0.5.9".into(),
66            compatibility_schema_version: 1,
67            features,
68        }
69    }
70
71    #[tokio::test]
72    async fn require_live_gateway_compatible_refuses_only_an_incompatible_core() {
73        let incompatible = start_mock_server(MockBridgeService {
74            gateway_info_response: gateway_info(vec![protocol_feature(
75                ProtocolFeatureKind::Core,
76                9,
77                9,
78            )]),
79            ..Default::default()
80        })
81        .await;
82        let refused = require_live_gateway_compatible(&incompatible.0, Some("Plant.Server"))
83            .await
84            .expect_err("an incompatible core must refuse the live mutation");
85        assert!(refused.to_string().contains("incompatible"));
86
87        let partial = start_mock_server(MockBridgeService {
88            gateway_info_response: gateway_info(vec![protocol_feature(
89                ProtocolFeatureKind::Core,
90                1,
91                1,
92            )]),
93            ..Default::default()
94        })
95        .await;
96        let partial_report = require_live_gateway_compatible(&partial.0, None)
97            .await
98            .unwrap();
99        assert_eq!(partial_report.status, OpcDaCompatibilityStatus::Partial);
100
101        let unknown = start_mock_server(MockBridgeService::default()).await;
102        let unknown_report = require_live_gateway_compatible(&unknown.0, None)
103            .await
104            .unwrap();
105        assert_eq!(unknown_report.status, OpcDaCompatibilityStatus::Unknown);
106
107        let full = start_mock_server(MockBridgeService {
108            gateway_info_response: gateway_info(vec![
109                protocol_feature(ProtocolFeatureKind::Core, 1, 1),
110                protocol_feature(ProtocolFeatureKind::Namespace, 2, 3),
111                protocol_feature(ProtocolFeatureKind::IndexedSearch, 2, 2),
112            ]),
113            ..Default::default()
114        })
115        .await;
116        let full_report = require_live_gateway_compatible(&full.0, None)
117            .await
118            .unwrap();
119        assert_eq!(full_report.status, OpcDaCompatibilityStatus::Full);
120        assert!(compatibility_json(&full_report).contains("\"status\":\"full\""));
121    }
122
123    #[test]
124    fn parse_stored_compatibility_rejects_an_unreadable_snapshot() {
125        assert!(parse_stored_compatibility("not-a-report").is_err());
126        assert!(parse_stored_compatibility(r#"{"status":"unknown"}"#).is_err());
127    }
128}