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
23#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
25#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
26#[serde(rename_all = "camelCase")]
27pub enum HorizonMachineArchitecture {
28 #[serde(rename = "arm64")]
30 Arm64,
31 #[serde(rename = "amd64")]
33 Amd64,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
39#[serde(rename_all = "camelCase")]
40pub struct HorizonAwsMachineImages {
41 pub amis: HashMap<HorizonMachineArchitecture, HashMap<String, String>>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
48#[serde(rename_all = "camelCase")]
49pub struct HorizonGcpMachineImage {
50 pub source_image: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
57#[serde(rename_all = "camelCase")]
58pub struct HorizonGcpMachineImages {
59 pub images: HashMap<HorizonMachineArchitecture, HorizonGcpMachineImage>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
66#[serde(rename_all = "camelCase")]
67pub struct HorizonAzureMachineImage {
68 pub image_version_id: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
75#[serde(rename_all = "camelCase")]
76pub struct HorizonMachineBaseImage {
77 pub name: String,
79 pub version: String,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
86#[serde(rename_all = "camelCase")]
87pub struct HorizonAzureMachineImages {
88 pub images: HashMap<HorizonMachineArchitecture, HorizonAzureMachineImage>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
95#[serde(rename_all = "camelCase")]
96pub struct HorizondArtifact {
97 #[serde(default)]
99 pub runtime_isolation_generation: u32,
100 pub url: String,
102 pub sha256: String,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
111#[serde(rename_all = "camelCase")]
112pub struct HorizonMachineImage {
113 #[serde(default)]
115 pub runtime_isolation_generation: u32,
116 pub channel: String,
118 pub machine_image_version: String,
120 pub horizond_version: String,
122 pub git_sha: String,
124 pub created_at: String,
126 pub base_image: HorizonMachineBaseImage,
128 pub horizond_artifacts: HashMap<String, HorizondArtifact>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub aws: Option<HorizonAwsMachineImages>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub gcp: Option<HorizonGcpMachineImages>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub azure: Option<HorizonAzureMachineImages>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
148#[serde(rename_all = "camelCase")]
149pub struct HorizonConfig {
150 pub url: String,
152
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub horizon_machine_image: Option<HorizonMachineImage>,
156
157 pub clusters: HashMap<String, HorizonClusterConfig>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
168#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
169#[serde(tag = "type", rename_all = "camelCase")]
170pub enum ComputeBackend {
171 Horizon(HorizonConfig),
173 }
179
180#[cfg(test)]
181mod tests {
182 use serde_json::{json, Value};
183
184 use super::{HorizonMachineImage, HorizondArtifact};
185
186 fn legacy_catalog() -> Value {
187 json!({
188 "channel": "stable",
189 "machineImageVersion": "1",
190 "horizondVersion": "1",
191 "gitSha": "abc123",
192 "createdAt": "2026-01-01T00:00:00Z",
193 "baseImage": { "name": "linux", "version": "1" },
194 "horizondArtifacts": {
195 "linux-arm64": { "url": "https://example.com/arm64", "sha256": "a".repeat(64) },
196 "linux-amd64": { "url": "https://example.com/amd64", "sha256": "b".repeat(64) }
197 },
198 "aws": { "amis": { "arm64": { "us-east-1": "ami-example" } } },
199 "gcp": { "images": { "arm64": { "sourceImage": "example-image" } } },
200 "azure": { "images": { "arm64": { "imageVersionId": "example-version" } } }
201 })
202 }
203
204 #[test]
205 fn runtime_isolation_generation_legacy_catalog_defaults_to_zero() {
206 let mut expected = legacy_catalog();
207 let catalog: HorizonMachineImage = serde_json::from_value(expected.clone()).unwrap();
208 assert_eq!(catalog.runtime_isolation_generation, 0);
209 expected["runtimeIsolationGeneration"] = json!(0);
210 for (arch, artifact) in &catalog.horizond_artifacts {
211 assert_eq!(artifact.runtime_isolation_generation, 0);
212 let decoded: HorizondArtifact =
213 serde_json::from_value(expected["horizondArtifacts"][arch].clone()).unwrap();
214 assert_eq!(&decoded, artifact);
215 expected["horizondArtifacts"][arch]["runtimeIsolationGeneration"] = json!(0);
216 assert_eq!(
217 serde_json::to_value(decoded).unwrap(),
218 expected["horizondArtifacts"][arch]
219 );
220 }
221 assert_eq!(serde_json::to_value(catalog).unwrap(), expected);
222 }
223
224 #[test]
225 fn runtime_isolation_generation_roundtrips_catalog_and_each_artifact() {
226 let mut expected = legacy_catalog();
227 expected["runtimeIsolationGeneration"] = json!(1);
228 for artifact in expected["horizondArtifacts"]
229 .as_object_mut()
230 .unwrap()
231 .values_mut()
232 {
233 artifact["runtimeIsolationGeneration"] = json!(1);
234 }
235 let catalog: HorizonMachineImage = serde_json::from_value(expected.clone()).unwrap();
236 assert_eq!(catalog.runtime_isolation_generation, 1);
237 for (arch, artifact) in &catalog.horizond_artifacts {
238 assert_eq!(artifact.runtime_isolation_generation, 1);
239 let encoded = serde_json::to_value(artifact).unwrap();
240 assert_eq!(encoded, expected["horizondArtifacts"][arch]);
241 assert_eq!(
242 serde_json::from_value::<HorizondArtifact>(encoded).unwrap(),
243 *artifact
244 );
245 }
246 assert_eq!(serde_json::to_value(catalog).unwrap(), expected);
247 }
248
249 #[test]
250 fn runtime_isolation_generation_rejects_invalid_numbers_and_types() {
251 for invalid in [
252 json!(-1),
253 json!(1.5),
254 json!("1"),
255 json!(null),
256 json!(4294967296_u64),
257 ] {
258 let mut catalog = legacy_catalog();
259 catalog["runtimeIsolationGeneration"] = invalid.clone();
260 assert!(serde_json::from_value::<HorizonMachineImage>(catalog).is_err());
261 for arch in ["linux-arm64", "linux-amd64"] {
262 let mut catalog = legacy_catalog();
263 catalog["horizondArtifacts"][arch]["runtimeIsolationGeneration"] = invalid.clone();
264 assert!(serde_json::from_value::<HorizondArtifact>(
265 catalog["horizondArtifacts"][arch].clone()
266 )
267 .is_err());
268 assert!(serde_json::from_value::<HorizonMachineImage>(catalog).is_err());
269 }
270 }
271 }
272}