crates/flotilla-protocol/src/query.rs
Line | Count | Source |
1 | | use std::{collections::HashMap, path::PathBuf}; |
2 | | |
3 | | use serde::{Deserialize, Serialize}; |
4 | | |
5 | | use crate::{ |
6 | | snapshot::{ProviderError, WorkItem}, |
7 | | EnvironmentInfo, HostName, HostSummary, PeerConnectionState, |
8 | | }; |
9 | | |
10 | | /// Provider health across categories. Outer key: category (e.g. "vcs", |
11 | | /// "change_request"). Inner key: provider name. Value: healthy. |
12 | | pub type ProviderHealthMap = HashMap<String, HashMap<String, bool>>; |
13 | | |
14 | | // --- status --- |
15 | | |
16 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
17 | | pub struct StatusResponse { |
18 | | pub repos: Vec<RepoSummary>, |
19 | | } |
20 | | |
21 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
22 | | pub struct RepoSummary { |
23 | | pub path: PathBuf, |
24 | | pub slug: Option<String>, |
25 | | pub provider_health: ProviderHealthMap, |
26 | | pub work_item_count: usize, |
27 | | pub error_count: usize, |
28 | | } |
29 | | |
30 | | // --- repo detail --- |
31 | | |
32 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
33 | | pub struct RepoDetailResponse { |
34 | | pub path: PathBuf, |
35 | | pub slug: Option<String>, |
36 | | pub provider_health: ProviderHealthMap, |
37 | | pub work_items: Vec<WorkItem>, |
38 | | pub errors: Vec<ProviderError>, |
39 | | } |
40 | | |
41 | | // --- repo providers --- |
42 | | |
43 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
44 | | pub struct RepoProvidersResponse { |
45 | | pub path: PathBuf, |
46 | | pub slug: Option<String>, |
47 | | pub host_discovery: Vec<DiscoveryEntry>, |
48 | | pub repo_discovery: Vec<DiscoveryEntry>, |
49 | | pub providers: Vec<ProviderInfo>, |
50 | | pub unmet_requirements: Vec<UnmetRequirementInfo>, |
51 | | } |
52 | | |
53 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
54 | | pub struct DiscoveryEntry { |
55 | | pub kind: String, |
56 | | pub detail: HashMap<String, String>, |
57 | | } |
58 | | |
59 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
60 | | pub struct ProviderInfo { |
61 | | pub category: String, |
62 | | pub name: String, |
63 | | pub healthy: bool, |
64 | | } |
65 | | |
66 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
67 | | pub struct UnmetRequirementInfo { |
68 | | pub factory: String, |
69 | | pub kind: String, |
70 | | #[serde(skip_serializing_if = "Option::is_none")] |
71 | | pub value: Option<String>, |
72 | | } |
73 | | |
74 | | // --- repo work --- |
75 | | |
76 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
77 | | pub struct RepoWorkResponse { |
78 | | pub path: PathBuf, |
79 | | pub slug: Option<String>, |
80 | | pub work_items: Vec<WorkItem>, |
81 | | } |
82 | | |
83 | | // --- host / topology --- |
84 | | |
85 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
86 | | pub struct HostListResponse { |
87 | | pub hosts: Vec<HostListEntry>, |
88 | | } |
89 | | |
90 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
91 | | pub struct HostListEntry { |
92 | | pub host: HostName, |
93 | | pub is_local: bool, |
94 | | /// `true` only for non-local hosts that appear in `hosts.toml`. |
95 | | pub configured: bool, |
96 | | pub connection_status: PeerConnectionState, |
97 | | /// Indicates whether `get_host_status` would be able to return a |
98 | | /// non-`None` summary for this host. |
99 | | pub has_summary: bool, |
100 | | pub repo_count: usize, |
101 | | pub work_item_count: usize, |
102 | | } |
103 | | |
104 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
105 | | pub struct HostStatusResponse { |
106 | | pub host: HostName, |
107 | | pub is_local: bool, |
108 | | /// `true` only for non-local hosts that appear in `hosts.toml`. |
109 | | pub configured: bool, |
110 | | pub connection_status: PeerConnectionState, |
111 | | #[serde(skip_serializing_if = "Option::is_none")] |
112 | | pub summary: Option<HostSummary>, |
113 | | #[serde(default)] |
114 | | pub visible_environments: Vec<EnvironmentInfo>, |
115 | | pub repo_count: usize, |
116 | | pub work_item_count: usize, |
117 | | } |
118 | | |
119 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
120 | | pub struct HostProvidersResponse { |
121 | | pub host: HostName, |
122 | | pub is_local: bool, |
123 | | /// `true` only for non-local hosts that appear in `hosts.toml`. |
124 | | pub configured: bool, |
125 | | pub connection_status: PeerConnectionState, |
126 | | pub summary: HostSummary, |
127 | | #[serde(default)] |
128 | | pub visible_environments: Vec<EnvironmentInfo>, |
129 | | } |
130 | | |
131 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
132 | | pub struct TopologyResponse { |
133 | | pub local_host: HostName, |
134 | | pub routes: Vec<TopologyRoute>, |
135 | | } |
136 | | |
137 | | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
138 | | pub struct TopologyRoute { |
139 | | pub target: HostName, |
140 | | pub next_hop: HostName, |
141 | | pub direct: bool, |
142 | | pub connected: bool, |
143 | | #[serde(default)] |
144 | | pub fallbacks: Vec<HostName>, |
145 | | } |
146 | | |
147 | | #[cfg(test)] |
148 | | mod tests { |
149 | | use serde_json::json; |
150 | | |
151 | | use super::{ |
152 | | HostListEntry, HostListResponse, HostProvidersResponse, HostStatusResponse, TopologyResponse, TopologyRoute, UnmetRequirementInfo, |
153 | | }; |
154 | | use crate::{ |
155 | | test_helpers::assert_roundtrip, EnvironmentId, EnvironmentInfo, EnvironmentStatus, HostEnvironment, HostName, HostProviderStatus, |
156 | | HostSummary, ImageId, PeerConnectionState, SystemInfo, ToolInventory, |
157 | | }; |
158 | | |
159 | | #[test] |
160 | 1 | fn unmet_requirement_info_omits_none_value_when_serialized() { |
161 | 1 | let without_value = UnmetRequirementInfo { factory: "git".into(), kind: "no_vcs_checkout".into(), value: None }; |
162 | 1 | let with_value = UnmetRequirementInfo { factory: "github".into(), kind: "missing_binary".into(), value: Some("gh".into()) }; |
163 | | |
164 | 1 | assert_eq!( |
165 | 1 | serde_json::to_value(&without_value).expect("serialize without value"), |
166 | 1 | json!({ |
167 | 1 | "factory": "git", |
168 | 1 | "kind": "no_vcs_checkout" |
169 | | }) |
170 | | ); |
171 | 1 | assert_eq!( |
172 | 1 | serde_json::to_value(&with_value).expect("serialize with value"), |
173 | 1 | json!({ |
174 | 1 | "factory": "github", |
175 | 1 | "kind": "missing_binary", |
176 | 1 | "value": "gh" |
177 | | }) |
178 | | ); |
179 | 1 | } |
180 | | |
181 | 3 | fn sample_host_summary() -> HostSummary { |
182 | 3 | HostSummary { |
183 | 3 | host_name: HostName::new("desktop"), |
184 | 3 | system: SystemInfo { |
185 | 3 | home_dir: Some("/home/dev".into()), |
186 | 3 | os: Some("linux".into()), |
187 | 3 | arch: Some("aarch64".into()), |
188 | 3 | cpu_count: Some(8), |
189 | 3 | memory_total_mb: Some(16384), |
190 | 3 | environment: HostEnvironment::Container, |
191 | 3 | }, |
192 | 3 | inventory: ToolInventory::default(), |
193 | 3 | providers: vec![HostProviderStatus { category: "vcs".into(), name: "Git".into(), implementation: "git".into(), healthy: true }], |
194 | 3 | environments: vec![], |
195 | 3 | } |
196 | 3 | } |
197 | | |
198 | 2 | fn sample_visible_environments() -> Vec<EnvironmentInfo> { |
199 | 2 | vec![ |
200 | 2 | EnvironmentInfo::Direct { |
201 | 2 | id: EnvironmentId::new("direct-env"), |
202 | 2 | display_name: Some("direct".into()), |
203 | 2 | host_id: None, |
204 | 2 | status: EnvironmentStatus::Running, |
205 | 2 | }, |
206 | 2 | EnvironmentInfo::Provisioned { |
207 | 2 | id: EnvironmentId::new("provisioned-env"), |
208 | 2 | display_name: Some("provisioned".into()), |
209 | 2 | image: ImageId::new("mock:image"), |
210 | 2 | status: EnvironmentStatus::Running, |
211 | 2 | }, |
212 | | ] |
213 | 2 | } |
214 | | |
215 | | #[test] |
216 | 1 | fn host_list_response_roundtrips_without_summary_data() { |
217 | 1 | let response = HostListResponse { |
218 | 1 | hosts: vec![HostListEntry { |
219 | 1 | host: HostName::new("remote"), |
220 | 1 | is_local: false, |
221 | 1 | configured: true, |
222 | 1 | connection_status: PeerConnectionState::Disconnected, |
223 | 1 | has_summary: false, |
224 | 1 | repo_count: 0, |
225 | 1 | work_item_count: 0, |
226 | 1 | }], |
227 | 1 | }; |
228 | | |
229 | 1 | assert_roundtrip(&response); |
230 | 1 | } |
231 | | |
232 | | #[test] |
233 | 1 | fn host_status_response_roundtrips_with_summary() { |
234 | 1 | let response = HostStatusResponse { |
235 | 1 | host: HostName::new("desktop"), |
236 | 1 | is_local: true, |
237 | 1 | configured: true, |
238 | 1 | connection_status: PeerConnectionState::Connected, |
239 | 1 | summary: Some(sample_host_summary()), |
240 | 1 | visible_environments: sample_visible_environments(), |
241 | 1 | repo_count: 2, |
242 | 1 | work_item_count: 5, |
243 | 1 | }; |
244 | | |
245 | 1 | assert_roundtrip(&response); |
246 | 1 | } |
247 | | |
248 | | #[test] |
249 | 1 | fn host_providers_response_roundtrips_summary() { |
250 | 1 | let response = HostProvidersResponse { |
251 | 1 | host: HostName::new("desktop"), |
252 | 1 | is_local: true, |
253 | 1 | configured: true, |
254 | 1 | connection_status: PeerConnectionState::Connected, |
255 | 1 | summary: sample_host_summary(), |
256 | 1 | visible_environments: sample_visible_environments(), |
257 | 1 | }; |
258 | | |
259 | 1 | assert_roundtrip(&response); |
260 | 1 | } |
261 | | |
262 | | #[test] |
263 | 1 | fn host_status_response_defaults_missing_visible_environments() { |
264 | 1 | let mut value = serde_json::to_value(HostStatusResponse { |
265 | 1 | host: HostName::new("desktop"), |
266 | 1 | is_local: true, |
267 | 1 | configured: true, |
268 | 1 | connection_status: PeerConnectionState::Connected, |
269 | 1 | summary: Some(sample_host_summary()), |
270 | 1 | visible_environments: vec![], |
271 | 1 | repo_count: 2, |
272 | 1 | work_item_count: 5, |
273 | 1 | }) |
274 | 1 | .expect("serialize host status"); |
275 | 1 | value.as_object_mut().expect("object").remove("visible_environments"); |
276 | | |
277 | 1 | let decoded: HostStatusResponse = serde_json::from_value(value).expect("deserialize without visible environments"); |
278 | 1 | assert!(decoded.visible_environments.is_empty()); |
279 | 1 | } |
280 | | |
281 | | #[test] |
282 | 1 | fn topology_response_roundtrips_fallbacks() { |
283 | 1 | let response = TopologyResponse { |
284 | 1 | local_host: HostName::new("desktop"), |
285 | 1 | routes: vec![TopologyRoute { |
286 | 1 | target: HostName::new("worker"), |
287 | 1 | next_hop: HostName::new("relay"), |
288 | 1 | direct: false, |
289 | 1 | connected: true, |
290 | 1 | fallbacks: vec![HostName::new("backup-relay")], |
291 | 1 | }], |
292 | 1 | }; |
293 | | |
294 | 1 | assert_roundtrip(&response); |
295 | 1 | } |
296 | | } |