Skip to main content

soma_fleet/
topology.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::PathBuf;
3
4use serde::{Deserialize, Deserializer, Serialize};
5
6use crate::{CapabilityName, HostEndpoint, HostId, PoolKey, TopologyRevision};
7
8const MAX_LABEL_CHARS: usize = 128;
9
10/// Product-neutral managed host record.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12pub struct HostRecord {
13    id: HostId,
14    endpoint: HostEndpoint,
15    revision: TopologyRevision,
16    labels: BTreeSet<String>,
17    capabilities: BTreeSet<CapabilityName>,
18}
19
20impl HostRecord {
21    /// Creates a host and derives its revision from endpoint material.
22    #[must_use]
23    pub fn new(id: HostId, endpoint: HostEndpoint) -> Self {
24        let revision = endpoint.revision();
25        Self {
26            id,
27            endpoint,
28            revision,
29            labels: BTreeSet::new(),
30            capabilities: BTreeSet::new(),
31        }
32    }
33
34    /// Adds a normalized host label.
35    pub fn with_label(mut self, label: impl Into<String>) -> Result<Self, TopologyError> {
36        let label = label.into();
37        validate_label(&label)?;
38        self.labels.insert(label);
39        Ok(self)
40    }
41
42    /// Adds an advertised capability.
43    #[must_use]
44    pub fn with_capability(mut self, capability: CapabilityName) -> Self {
45        self.capabilities.insert(capability);
46        self
47    }
48
49    /// Returns the host identity.
50    #[must_use]
51    pub fn id(&self) -> &HostId {
52        &self.id
53    }
54
55    /// Returns the transport endpoint.
56    #[must_use]
57    pub fn endpoint(&self) -> &HostEndpoint {
58        &self.endpoint
59    }
60
61    /// Returns the transport-affecting topology revision.
62    #[must_use]
63    pub fn revision(&self) -> &TopologyRevision {
64        &self.revision
65    }
66
67    /// Returns the connection-cache key.
68    #[must_use]
69    pub fn pool_key(&self) -> PoolKey {
70        PoolKey::new(self.id.clone(), self.revision.clone())
71    }
72
73    /// Iterates over sorted labels.
74    pub fn labels(&self) -> impl Iterator<Item = &str> {
75        self.labels.iter().map(String::as_str)
76    }
77
78    /// Iterates over advertised capabilities.
79    pub fn capabilities(&self) -> impl Iterator<Item = &CapabilityName> {
80        self.capabilities.iter()
81    }
82}
83
84impl<'de> Deserialize<'de> for HostRecord {
85    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
86    where
87        D: Deserializer<'de>,
88    {
89        let wire = HostRecordWire::deserialize(deserializer)?;
90        let mut record = Self::new(wire.id, wire.endpoint);
91        if record.revision != wire.revision {
92            return Err(serde::de::Error::custom(TopologyError::RevisionMismatch));
93        }
94        for label in wire.labels {
95            record = record.with_label(label).map_err(serde::de::Error::custom)?;
96        }
97        record.capabilities = wire.capabilities;
98        Ok(record)
99    }
100}
101
102#[derive(Deserialize)]
103struct HostRecordWire {
104    id: HostId,
105    endpoint: HostEndpoint,
106    revision: TopologyRevision,
107    #[serde(default)]
108    labels: BTreeSet<String>,
109    #[serde(default)]
110    capabilities: BTreeSet<CapabilityName>,
111}
112
113/// Immutable topology snapshot with unique host identities.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct TopologySnapshot {
116    revision: TopologyRevision,
117    hosts: BTreeMap<HostId, HostRecord>,
118}
119
120impl TopologySnapshot {
121    /// Builds a snapshot and rejects duplicate host identities.
122    pub fn new<I>(hosts: I) -> Result<Self, TopologyError>
123    where
124        I: IntoIterator<Item = HostRecord>,
125    {
126        let mut indexed = BTreeMap::new();
127        for host in hosts {
128            let id = host.id.clone();
129            if indexed.insert(id.clone(), host).is_some() {
130                return Err(TopologyError::DuplicateHost(id));
131            }
132        }
133        let material = indexed
134            .iter()
135            .map(|(id, host)| format!("{}:{}\n", id, host.revision))
136            .collect::<String>();
137        Ok(Self {
138            revision: TopologyRevision::from_material(material),
139            hosts: indexed,
140        })
141    }
142
143    /// Returns the snapshot revision.
144    #[must_use]
145    pub fn revision(&self) -> &TopologyRevision {
146        &self.revision
147    }
148
149    /// Returns a host by stable identity.
150    #[must_use]
151    pub fn get(&self, id: &HostId) -> Option<&HostRecord> {
152        self.hosts.get(id)
153    }
154
155    /// Iterates over hosts in identity order.
156    pub fn hosts(&self) -> impl Iterator<Item = &HostRecord> {
157        self.hosts.values()
158    }
159
160    /// Returns the number of hosts.
161    #[must_use]
162    pub fn len(&self) -> usize {
163        self.hosts.len()
164    }
165
166    /// Returns whether the snapshot has no hosts.
167    #[must_use]
168    pub fn is_empty(&self) -> bool {
169        self.hosts.is_empty()
170    }
171}
172
173/// Invalid host endpoint or topology snapshot.
174#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
175#[non_exhaustive]
176pub enum TopologyError {
177    /// Endpoint text was empty, oversized, or contained control characters.
178    #[error("invalid {field}")]
179    InvalidEndpointText {
180        /// Invalid endpoint field.
181        field: &'static str,
182    },
183    /// Port zero is never valid.
184    #[error("fleet endpoint port must be greater than zero")]
185    InvalidPort,
186    /// A security-sensitive path was not absolute and normalized.
187    #[error("fleet path must be absolute and contain no parent traversal: {0}")]
188    InvalidAbsolutePath(PathBuf),
189    /// HTTP endpoint was not plain HTTP(S) or contained embedded credentials.
190    #[error("invalid HTTP fleet endpoint")]
191    InvalidHttpEndpoint,
192    /// Label was empty, oversized, or contained control characters.
193    #[error("invalid host label: {0}")]
194    InvalidLabel(String),
195    /// Host identity appeared more than once.
196    #[error("duplicate host identity: {0}")]
197    DuplicateHost(HostId),
198    /// Serialized endpoint material did not match its claimed revision.
199    #[error("host topology revision does not match endpoint material")]
200    RevisionMismatch,
201}
202
203fn validate_label(label: &str) -> Result<(), TopologyError> {
204    let count = label.chars().count();
205    if count == 0 || count > MAX_LABEL_CHARS || label.chars().any(char::is_control) {
206        Err(TopologyError::InvalidLabel(label.to_owned()))
207    } else {
208        Ok(())
209    }
210}
211
212#[cfg(test)]
213#[path = "topology_tests.rs"]
214mod tests;