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#[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 #[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 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 #[must_use]
44 pub fn with_capability(mut self, capability: CapabilityName) -> Self {
45 self.capabilities.insert(capability);
46 self
47 }
48
49 #[must_use]
51 pub fn id(&self) -> &HostId {
52 &self.id
53 }
54
55 #[must_use]
57 pub fn endpoint(&self) -> &HostEndpoint {
58 &self.endpoint
59 }
60
61 #[must_use]
63 pub fn revision(&self) -> &TopologyRevision {
64 &self.revision
65 }
66
67 #[must_use]
69 pub fn pool_key(&self) -> PoolKey {
70 PoolKey::new(self.id.clone(), self.revision.clone())
71 }
72
73 pub fn labels(&self) -> impl Iterator<Item = &str> {
75 self.labels.iter().map(String::as_str)
76 }
77
78 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct TopologySnapshot {
116 revision: TopologyRevision,
117 hosts: BTreeMap<HostId, HostRecord>,
118}
119
120impl TopologySnapshot {
121 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 #[must_use]
145 pub fn revision(&self) -> &TopologyRevision {
146 &self.revision
147 }
148
149 #[must_use]
151 pub fn get(&self, id: &HostId) -> Option<&HostRecord> {
152 self.hosts.get(id)
153 }
154
155 pub fn hosts(&self) -> impl Iterator<Item = &HostRecord> {
157 self.hosts.values()
158 }
159
160 #[must_use]
162 pub fn len(&self) -> usize {
163 self.hosts.len()
164 }
165
166 #[must_use]
168 pub fn is_empty(&self) -> bool {
169 self.hosts.is_empty()
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
175#[non_exhaustive]
176pub enum TopologyError {
177 #[error("invalid {field}")]
179 InvalidEndpointText {
180 field: &'static str,
182 },
183 #[error("fleet endpoint port must be greater than zero")]
185 InvalidPort,
186 #[error("fleet path must be absolute and contain no parent traversal: {0}")]
188 InvalidAbsolutePath(PathBuf),
189 #[error("invalid HTTP fleet endpoint")]
191 InvalidHttpEndpoint,
192 #[error("invalid host label: {0}")]
194 InvalidLabel(String),
195 #[error("duplicate host identity: {0}")]
197 DuplicateHost(HostId),
198 #[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;