1use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[serde(rename_all = "camelCase")]
14pub struct HorizonClusterConfig {
15 pub cluster_id: String,
17
18 pub management_token: String,
21
22 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub horizon_machine_image: Option<HorizonMachineImage>,
27}
28
29#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
31#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
32#[serde(rename_all = "camelCase")]
33pub enum HorizonMachineArchitecture {
34 #[serde(rename = "arm64")]
36 Arm64,
37 #[serde(rename = "amd64")]
39 Amd64,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
45#[serde(rename_all = "camelCase")]
46pub struct HorizonAwsMachineImages {
47 pub amis: HashMap<HorizonMachineArchitecture, HashMap<String, String>>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
54#[serde(rename_all = "camelCase")]
55pub struct HorizonGcpMachineImage {
56 pub source_image: String,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
63#[serde(rename_all = "camelCase")]
64pub struct HorizonGcpMachineImages {
65 pub images: HashMap<HorizonMachineArchitecture, HorizonGcpMachineImage>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
72#[serde(rename_all = "camelCase")]
73pub struct HorizonAzureMachineImage {
74 pub image_version_id: String,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
81#[serde(rename_all = "camelCase")]
82pub struct HorizonMachineBaseImage {
83 pub name: String,
85 pub version: String,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
92#[serde(rename_all = "camelCase")]
93pub struct HorizonAzureMachineImages {
94 pub images: HashMap<HorizonMachineArchitecture, HorizonAzureMachineImage>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
101#[serde(rename_all = "camelCase")]
102pub struct HorizondArtifact {
103 #[serde(default)]
105 pub runtime_isolation_generation: u32,
106 pub url: String,
108 pub sha256: String,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
117#[serde(rename_all = "camelCase")]
118pub struct HorizonMachineImage {
119 #[serde(default)]
121 pub runtime_isolation_generation: u32,
122 pub channel: String,
124 pub machine_image_version: String,
126 pub horizond_version: String,
128 pub git_sha: String,
130 pub created_at: String,
132 pub base_image: HorizonMachineBaseImage,
134 pub horizond_artifacts: HashMap<String, HorizondArtifact>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub aws: Option<HorizonAwsMachineImages>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub gcp: Option<HorizonGcpMachineImages>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub azure: Option<HorizonAzureMachineImages>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
154#[serde(rename_all = "camelCase")]
155pub struct HorizonConfig {
156 pub url: String,
158
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub horizon_machine_image: Option<HorizonMachineImage>,
162
163 pub clusters: HashMap<String, HorizonClusterConfig>,
167}
168
169impl HorizonConfig {
170 pub fn machine_image_for(&self, resource_id: &str) -> Option<&HorizonMachineImage> {
173 self.clusters
174 .get(resource_id)
175 .and_then(|cluster| cluster.horizon_machine_image.as_ref())
176 .or(self.horizon_machine_image.as_ref())
177 }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
186#[serde(tag = "type", rename_all = "camelCase")]
187pub enum ComputeBackend {
188 Horizon(HorizonConfig),
190 }
196
197#[cfg(test)]
198mod tests {
199 use serde_json::{json, Value};
200
201 use std::collections::HashMap;
202
203 use serde::Deserialize;
204
205 use super::{HorizonClusterConfig, HorizonConfig, HorizonMachineImage, HorizondArtifact};
206
207 fn legacy_catalog() -> Value {
208 json!({
209 "channel": "stable",
210 "machineImageVersion": "1",
211 "horizondVersion": "1",
212 "gitSha": "abc123",
213 "createdAt": "2026-01-01T00:00:00Z",
214 "baseImage": { "name": "linux", "version": "1" },
215 "horizondArtifacts": {
216 "linux-arm64": { "url": "https://example.com/arm64", "sha256": "a".repeat(64) },
217 "linux-amd64": { "url": "https://example.com/amd64", "sha256": "b".repeat(64) }
218 },
219 "aws": { "amis": { "arm64": { "us-east-1": "ami-example" } } },
220 "gcp": { "images": { "arm64": { "sourceImage": "example-image" } } },
221 "azure": { "images": { "arm64": { "imageVersionId": "example-version" } } }
222 })
223 }
224
225 #[test]
226 fn runtime_isolation_generation_legacy_catalog_defaults_to_zero() {
227 let mut expected = legacy_catalog();
228 let catalog: HorizonMachineImage = serde_json::from_value(expected.clone()).unwrap();
229 assert_eq!(catalog.runtime_isolation_generation, 0);
230 expected["runtimeIsolationGeneration"] = json!(0);
231 for (arch, artifact) in &catalog.horizond_artifacts {
232 assert_eq!(artifact.runtime_isolation_generation, 0);
233 let decoded: HorizondArtifact =
234 serde_json::from_value(expected["horizondArtifacts"][arch].clone()).unwrap();
235 assert_eq!(&decoded, artifact);
236 expected["horizondArtifacts"][arch]["runtimeIsolationGeneration"] = json!(0);
237 assert_eq!(
238 serde_json::to_value(decoded).unwrap(),
239 expected["horizondArtifacts"][arch]
240 );
241 }
242 assert_eq!(serde_json::to_value(catalog).unwrap(), expected);
243 }
244
245 #[test]
246 fn runtime_isolation_generation_roundtrips_catalog_and_each_artifact() {
247 let mut expected = legacy_catalog();
248 expected["runtimeIsolationGeneration"] = json!(1);
249 for artifact in expected["horizondArtifacts"]
250 .as_object_mut()
251 .unwrap()
252 .values_mut()
253 {
254 artifact["runtimeIsolationGeneration"] = json!(1);
255 }
256 let catalog: HorizonMachineImage = serde_json::from_value(expected.clone()).unwrap();
257 assert_eq!(catalog.runtime_isolation_generation, 1);
258 for (arch, artifact) in &catalog.horizond_artifacts {
259 assert_eq!(artifact.runtime_isolation_generation, 1);
260 let encoded = serde_json::to_value(artifact).unwrap();
261 assert_eq!(encoded, expected["horizondArtifacts"][arch]);
262 assert_eq!(
263 serde_json::from_value::<HorizondArtifact>(encoded).unwrap(),
264 *artifact
265 );
266 }
267 assert_eq!(serde_json::to_value(catalog).unwrap(), expected);
268 }
269
270 #[test]
271 fn runtime_isolation_generation_rejects_invalid_numbers_and_types() {
272 for invalid in [
273 json!(-1),
274 json!(1.5),
275 json!("1"),
276 json!(null),
277 json!(4294967296_u64),
278 ] {
279 let mut catalog = legacy_catalog();
280 catalog["runtimeIsolationGeneration"] = invalid.clone();
281 assert!(serde_json::from_value::<HorizonMachineImage>(catalog).is_err());
282 for arch in ["linux-arm64", "linux-amd64"] {
283 let mut catalog = legacy_catalog();
284 catalog["horizondArtifacts"][arch]["runtimeIsolationGeneration"] = invalid.clone();
285 assert!(serde_json::from_value::<HorizondArtifact>(
286 catalog["horizondArtifacts"][arch].clone()
287 )
288 .is_err());
289 assert!(serde_json::from_value::<HorizonMachineImage>(catalog).is_err());
290 }
291 }
292 }
293
294 fn catalog_version(version: &str) -> Value {
295 let mut catalog = legacy_catalog();
296 catalog["machineImageVersion"] = json!(version);
297 catalog
298 }
299
300 #[test]
301 fn cluster_without_its_own_machine_image_reads_as_before() {
302 let config: HorizonConfig = serde_json::from_value(json!({
303 "url": "https://horizon.example.com",
304 "horizonMachineImage": catalog_version("2"),
305 "clusters": {
306 "compute": { "clusterId": "cluster-1", "managementToken": "hm_test" }
307 }
308 }))
309 .expect("a config written before the field existed must deserialize");
310
311 assert_eq!(config.clusters["compute"].horizon_machine_image, None);
312 assert_eq!(
313 config
314 .machine_image_for("compute")
315 .map(|image| image.machine_image_version.as_str()),
316 Some("2")
317 );
318 let written = serde_json::to_value(&config.clusters["compute"]).unwrap();
320 assert_eq!(
321 written,
322 json!({ "clusterId": "cluster-1", "managementToken": "hm_test" })
323 );
324 }
325
326 #[test]
327 fn each_cluster_uses_its_own_machine_image_when_it_has_one() {
328 let config: HorizonConfig = serde_json::from_value(json!({
329 "url": "https://horizon.example.com",
330 "horizonMachineImage": catalog_version("2"),
331 "clusters": {
332 "held": {
333 "clusterId": "cluster-1",
334 "managementToken": "hm_test",
335 "horizonMachineImage": catalog_version("1")
336 },
337 "moving": { "clusterId": "cluster-2", "managementToken": "hm_test" }
338 }
339 }))
340 .unwrap();
341
342 let version = |resource_id| {
343 config
344 .machine_image_for(resource_id)
345 .map(|image| image.machine_image_version.clone())
346 };
347 assert_eq!(version("held").as_deref(), Some("1"));
348 assert_eq!(version("moving").as_deref(), Some("2"));
349 assert_eq!(version("unknown").as_deref(), Some("2"));
350 }
351
352 #[derive(Debug, Deserialize, PartialEq)]
356 #[serde(rename_all = "camelCase")]
357 struct PreviousHorizonClusterConfig {
358 cluster_id: String,
359 management_token: String,
360 }
361
362 #[derive(Debug, Deserialize)]
363 #[serde(rename_all = "camelCase")]
364 struct PreviousHorizonConfig {
365 #[allow(dead_code)]
366 url: String,
367 #[serde(default)]
368 horizon_machine_image: Option<HorizonMachineImage>,
369 clusters: HashMap<String, PreviousHorizonClusterConfig>,
370 }
371
372 #[test]
373 fn readers_that_predate_cluster_machine_images_ignore_them() {
374 let config = HorizonConfig {
375 url: "https://horizon.example.com".to_string(),
376 horizon_machine_image: Some(serde_json::from_value(catalog_version("2")).unwrap()),
377 clusters: HashMap::from([(
378 "compute".to_string(),
379 HorizonClusterConfig {
380 cluster_id: "cluster-1".to_string(),
381 management_token: "hm_test".to_string(),
382 horizon_machine_image: Some(
383 serde_json::from_value(catalog_version("1")).unwrap(),
384 ),
385 },
386 )]),
387 };
388 let written = serde_json::to_value(&config).unwrap();
389 assert!(written["clusters"]["compute"]["horizonMachineImage"].is_object());
390
391 let previous: PreviousHorizonConfig = serde_json::from_value(written)
392 .expect("an older reader must accept a cluster that carries its own image");
393 assert_eq!(
394 previous.clusters["compute"],
395 PreviousHorizonClusterConfig {
396 cluster_id: "cluster-1".to_string(),
397 management_token: "hm_test".to_string(),
398 }
399 );
400 assert_eq!(
401 previous
402 .horizon_machine_image
403 .map(|image| image.machine_image_version),
404 Some("2".to_string())
405 );
406 }
407}