Skip to main content

a3s_runtime/contract/
network.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{btree_map::Entry, BTreeMap, BTreeSet};
3use std::net::{IpAddr, Ipv4Addr, SocketAddr};
4
5use super::observation::RuntimeObservation;
6
7const SERVICE_ENDPOINT_CLAIM_PREFIX: &str = "a3s.runtime.service-endpoint.";
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum NetworkMode {
12    None,
13    Outbound,
14    Service,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum TransportProtocol {
20    Tcp,
21    Udp,
22}
23
24impl TransportProtocol {
25    pub const fn as_str(self) -> &'static str {
26        match self {
27            Self::Tcp => "tcp",
28            Self::Udp => "udp",
29        }
30    }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct RuntimePort {
36    pub name: String,
37    pub container_port: u16,
38    pub protocol: TransportProtocol,
39}
40
41impl RuntimePort {
42    pub(crate) fn validate(&self) -> Result<(), String> {
43        validate_service_port_name(&self.name)?;
44        if self.container_port == 0 {
45            return Err("container_port must be positive".into());
46        }
47        Ok(())
48    }
49}
50
51/// One provider-published, generation-bound loopback endpoint for a declared
52/// Runtime Service port.
53///
54/// The value is encoded inside the existing Runtime evidence claim map so the
55/// endpoint remains bound to the observation's provider build and spec digest.
56/// Providers own endpoint lifecycle; callers consume this type instead of
57/// defining product-specific claim prefixes or endpoint registries.
58#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct RuntimeServiceEndpoint {
61    pub port_name: String,
62    pub protocol: TransportProtocol,
63    pub address: IpAddr,
64    pub port: u16,
65}
66
67impl RuntimeServiceEndpoint {
68    pub fn new(
69        port_name: impl Into<String>,
70        protocol: TransportProtocol,
71        address: IpAddr,
72        port: u16,
73    ) -> Result<Self, String> {
74        let endpoint = Self {
75            port_name: port_name.into(),
76            protocol,
77            address,
78            port,
79        };
80        endpoint.validate()?;
81        Ok(endpoint)
82    }
83
84    pub fn node_local_tcp(port_name: impl Into<String>, port: u16) -> Result<Self, String> {
85        Self::new(
86            port_name,
87            TransportProtocol::Tcp,
88            IpAddr::V4(Ipv4Addr::LOCALHOST),
89            port,
90        )
91    }
92
93    pub fn validate(&self) -> Result<(), String> {
94        validate_service_port_name(&self.port_name)?;
95        if !self.address.is_loopback() || self.port == 0 {
96            return Err(
97                "Runtime service endpoint must use an explicit positive loopback socket".into(),
98            );
99        }
100        Ok(())
101    }
102
103    pub fn socket_addr(&self) -> SocketAddr {
104        SocketAddr::new(self.address, self.port)
105    }
106
107    pub fn claim_key(&self) -> String {
108        format!("{SERVICE_ENDPOINT_CLAIM_PREFIX}{}", self.port_name)
109    }
110
111    pub fn claim_value(&self) -> String {
112        format!("{}://{}", self.protocol.as_str(), self.socket_addr())
113    }
114
115    pub fn insert_claim(&self, claims: &mut BTreeMap<String, String>) -> Result<(), String> {
116        self.validate()?;
117        let key = self.claim_key();
118        let value = self.claim_value();
119        match claims.entry(key) {
120            Entry::Vacant(entry) => {
121                entry.insert(value);
122                Ok(())
123            }
124            Entry::Occupied(entry) if entry.get() == &value => Ok(()),
125            Entry::Occupied(_) => Err(format!(
126                "Runtime evidence contains conflicting service endpoint {:?}",
127                self.port_name
128            )),
129        }
130    }
131
132    pub fn from_observation(
133        observation: &RuntimeObservation,
134        port_name: &str,
135    ) -> Result<Self, String> {
136        observation.validate()?;
137        validate_service_port_name(port_name)?;
138        let key = format!("{SERVICE_ENDPOINT_CLAIM_PREFIX}{port_name}");
139        let value = observation
140            .evidence
141            .as_ref()
142            .and_then(|evidence| evidence.claims.get(&key))
143            .ok_or_else(|| {
144                format!("Runtime observation has no service endpoint for port {port_name:?}")
145            })?;
146        Self::from_claim(port_name, value)
147    }
148
149    pub(crate) fn from_claims(claims: &BTreeMap<String, String>) -> Result<Vec<Self>, String> {
150        claims
151            .iter()
152            .filter_map(|(key, value)| {
153                key.strip_prefix(SERVICE_ENDPOINT_CLAIM_PREFIX)
154                    .map(|port_name| Self::from_claim(port_name, value))
155            })
156            .collect()
157    }
158
159    pub(crate) fn remove_claims(claims: &mut BTreeMap<String, String>) {
160        claims.retain(|key, _| !key.starts_with(SERVICE_ENDPOINT_CLAIM_PREFIX));
161    }
162
163    fn from_claim(port_name: &str, value: &str) -> Result<Self, String> {
164        let (protocol, socket) = if let Some(socket) = value.strip_prefix("tcp://") {
165            (TransportProtocol::Tcp, socket)
166        } else if let Some(socket) = value.strip_prefix("udp://") {
167            (TransportProtocol::Udp, socket)
168        } else {
169            return Err("Runtime service endpoint claim has an unsupported protocol".into());
170        };
171        let socket = socket
172            .parse::<SocketAddr>()
173            .map_err(|error| format!("Runtime service endpoint claim is invalid: {error}"))?;
174        let endpoint = Self::new(port_name, protocol, socket.ip(), socket.port())?;
175        if endpoint.claim_value() != value {
176            return Err("Runtime service endpoint claim is not canonical".into());
177        }
178        Ok(endpoint)
179    }
180}
181
182fn validate_service_port_name(value: &str) -> Result<(), String> {
183    super::validate_name("service port name", value)?;
184    if SERVICE_ENDPOINT_CLAIM_PREFIX.len() + value.len() > 255 {
185        return Err("Runtime service port name exceeds the endpoint evidence key bound".into());
186    }
187    Ok(())
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct RuntimeNetworkSpec {
193    pub mode: NetworkMode,
194    pub ports: Vec<RuntimePort>,
195}
196
197impl RuntimeNetworkSpec {
198    pub(crate) fn validate(&self) -> Result<(), String> {
199        if self.ports.len() > 64 {
200            return Err("Runtime unit declares more than 64 ports".into());
201        }
202        if self.mode != NetworkMode::Service && !self.ports.is_empty() {
203            return Err("declared ports require service network mode".into());
204        }
205        let mut names = BTreeSet::new();
206        let mut sockets = BTreeSet::new();
207        for port in &self.ports {
208            port.validate()?;
209            if !names.insert(&port.name) {
210                return Err(format!("duplicate Runtime port name {:?}", port.name));
211            }
212            if !sockets.insert((port.container_port, port.protocol)) {
213                return Err(format!(
214                    "duplicate Runtime port socket {}/{}",
215                    port.container_port,
216                    match port.protocol {
217                        TransportProtocol::Tcp => "tcp",
218                        TransportProtocol::Udp => "udp",
219                    }
220                ));
221            }
222        }
223        Ok(())
224    }
225
226    pub(crate) fn has_port(&self, name: &str) -> bool {
227        self.ports.iter().any(|port| port.name == name)
228    }
229}