use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct HorizonClusterConfig {
pub cluster_id: String,
pub management_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub horizon_machine_image: Option<HorizonMachineImage>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub enum HorizonMachineArchitecture {
#[serde(rename = "arm64")]
Arm64,
#[serde(rename = "amd64")]
Amd64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct HorizonAwsMachineImages {
pub amis: HashMap<HorizonMachineArchitecture, HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct HorizonGcpMachineImage {
pub source_image: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct HorizonGcpMachineImages {
pub images: HashMap<HorizonMachineArchitecture, HorizonGcpMachineImage>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct HorizonAzureMachineImage {
pub image_version_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct HorizonMachineBaseImage {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct HorizonAzureMachineImages {
pub images: HashMap<HorizonMachineArchitecture, HorizonAzureMachineImage>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct HorizondArtifact {
#[serde(default)]
pub runtime_isolation_generation: u32,
pub url: String,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct HorizonMachineImage {
#[serde(default)]
pub runtime_isolation_generation: u32,
pub channel: String,
pub machine_image_version: String,
pub horizond_version: String,
pub git_sha: String,
pub created_at: String,
pub base_image: HorizonMachineBaseImage,
pub horizond_artifacts: HashMap<String, HorizondArtifact>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aws: Option<HorizonAwsMachineImages>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gcp: Option<HorizonGcpMachineImages>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub azure: Option<HorizonAzureMachineImages>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct HorizonConfig {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub horizon_machine_image: Option<HorizonMachineImage>,
pub clusters: HashMap<String, HorizonClusterConfig>,
}
impl HorizonConfig {
pub fn machine_image_for(&self, resource_id: &str) -> Option<&HorizonMachineImage> {
self.clusters
.get(resource_id)
.and_then(|cluster| cluster.horizon_machine_image.as_ref())
.or(self.horizon_machine_image.as_ref())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ComputeBackend {
Horizon(HorizonConfig),
}
#[cfg(test)]
mod tests {
use serde_json::{json, Value};
use std::collections::HashMap;
use serde::Deserialize;
use super::{HorizonClusterConfig, HorizonConfig, HorizonMachineImage, HorizondArtifact};
fn legacy_catalog() -> Value {
json!({
"channel": "stable",
"machineImageVersion": "1",
"horizondVersion": "1",
"gitSha": "abc123",
"createdAt": "2026-01-01T00:00:00Z",
"baseImage": { "name": "linux", "version": "1" },
"horizondArtifacts": {
"linux-arm64": { "url": "https://example.com/arm64", "sha256": "a".repeat(64) },
"linux-amd64": { "url": "https://example.com/amd64", "sha256": "b".repeat(64) }
},
"aws": { "amis": { "arm64": { "us-east-1": "ami-example" } } },
"gcp": { "images": { "arm64": { "sourceImage": "example-image" } } },
"azure": { "images": { "arm64": { "imageVersionId": "example-version" } } }
})
}
#[test]
fn runtime_isolation_generation_legacy_catalog_defaults_to_zero() {
let mut expected = legacy_catalog();
let catalog: HorizonMachineImage = serde_json::from_value(expected.clone()).unwrap();
assert_eq!(catalog.runtime_isolation_generation, 0);
expected["runtimeIsolationGeneration"] = json!(0);
for (arch, artifact) in &catalog.horizond_artifacts {
assert_eq!(artifact.runtime_isolation_generation, 0);
let decoded: HorizondArtifact =
serde_json::from_value(expected["horizondArtifacts"][arch].clone()).unwrap();
assert_eq!(&decoded, artifact);
expected["horizondArtifacts"][arch]["runtimeIsolationGeneration"] = json!(0);
assert_eq!(
serde_json::to_value(decoded).unwrap(),
expected["horizondArtifacts"][arch]
);
}
assert_eq!(serde_json::to_value(catalog).unwrap(), expected);
}
#[test]
fn runtime_isolation_generation_roundtrips_catalog_and_each_artifact() {
let mut expected = legacy_catalog();
expected["runtimeIsolationGeneration"] = json!(1);
for artifact in expected["horizondArtifacts"]
.as_object_mut()
.unwrap()
.values_mut()
{
artifact["runtimeIsolationGeneration"] = json!(1);
}
let catalog: HorizonMachineImage = serde_json::from_value(expected.clone()).unwrap();
assert_eq!(catalog.runtime_isolation_generation, 1);
for (arch, artifact) in &catalog.horizond_artifacts {
assert_eq!(artifact.runtime_isolation_generation, 1);
let encoded = serde_json::to_value(artifact).unwrap();
assert_eq!(encoded, expected["horizondArtifacts"][arch]);
assert_eq!(
serde_json::from_value::<HorizondArtifact>(encoded).unwrap(),
*artifact
);
}
assert_eq!(serde_json::to_value(catalog).unwrap(), expected);
}
#[test]
fn runtime_isolation_generation_rejects_invalid_numbers_and_types() {
for invalid in [
json!(-1),
json!(1.5),
json!("1"),
json!(null),
json!(4294967296_u64),
] {
let mut catalog = legacy_catalog();
catalog["runtimeIsolationGeneration"] = invalid.clone();
assert!(serde_json::from_value::<HorizonMachineImage>(catalog).is_err());
for arch in ["linux-arm64", "linux-amd64"] {
let mut catalog = legacy_catalog();
catalog["horizondArtifacts"][arch]["runtimeIsolationGeneration"] = invalid.clone();
assert!(serde_json::from_value::<HorizondArtifact>(
catalog["horizondArtifacts"][arch].clone()
)
.is_err());
assert!(serde_json::from_value::<HorizonMachineImage>(catalog).is_err());
}
}
}
fn catalog_version(version: &str) -> Value {
let mut catalog = legacy_catalog();
catalog["machineImageVersion"] = json!(version);
catalog
}
#[test]
fn cluster_without_its_own_machine_image_reads_as_before() {
let config: HorizonConfig = serde_json::from_value(json!({
"url": "https://horizon.example.com",
"horizonMachineImage": catalog_version("2"),
"clusters": {
"compute": { "clusterId": "cluster-1", "managementToken": "hm_test" }
}
}))
.expect("a config written before the field existed must deserialize");
assert_eq!(config.clusters["compute"].horizon_machine_image, None);
assert_eq!(
config
.machine_image_for("compute")
.map(|image| image.machine_image_version.as_str()),
Some("2")
);
let written = serde_json::to_value(&config.clusters["compute"]).unwrap();
assert_eq!(
written,
json!({ "clusterId": "cluster-1", "managementToken": "hm_test" })
);
}
#[test]
fn each_cluster_uses_its_own_machine_image_when_it_has_one() {
let config: HorizonConfig = serde_json::from_value(json!({
"url": "https://horizon.example.com",
"horizonMachineImage": catalog_version("2"),
"clusters": {
"held": {
"clusterId": "cluster-1",
"managementToken": "hm_test",
"horizonMachineImage": catalog_version("1")
},
"moving": { "clusterId": "cluster-2", "managementToken": "hm_test" }
}
}))
.unwrap();
let version = |resource_id| {
config
.machine_image_for(resource_id)
.map(|image| image.machine_image_version.clone())
};
assert_eq!(version("held").as_deref(), Some("1"));
assert_eq!(version("moving").as_deref(), Some("2"));
assert_eq!(version("unknown").as_deref(), Some("2"));
}
#[derive(Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct PreviousHorizonClusterConfig {
cluster_id: String,
management_token: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PreviousHorizonConfig {
#[allow(dead_code)]
url: String,
#[serde(default)]
horizon_machine_image: Option<HorizonMachineImage>,
clusters: HashMap<String, PreviousHorizonClusterConfig>,
}
#[test]
fn readers_that_predate_cluster_machine_images_ignore_them() {
let config = HorizonConfig {
url: "https://horizon.example.com".to_string(),
horizon_machine_image: Some(serde_json::from_value(catalog_version("2")).unwrap()),
clusters: HashMap::from([(
"compute".to_string(),
HorizonClusterConfig {
cluster_id: "cluster-1".to_string(),
management_token: "hm_test".to_string(),
horizon_machine_image: Some(
serde_json::from_value(catalog_version("1")).unwrap(),
),
},
)]),
};
let written = serde_json::to_value(&config).unwrap();
assert!(written["clusters"]["compute"]["horizonMachineImage"].is_object());
let previous: PreviousHorizonConfig = serde_json::from_value(written)
.expect("an older reader must accept a cluster that carries its own image");
assert_eq!(
previous.clusters["compute"],
PreviousHorizonClusterConfig {
cluster_id: "cluster-1".to_string(),
management_token: "hm_test".to_string(),
}
);
assert_eq!(
previous
.horizon_machine_image
.map(|image| image.machine_image_version),
Some("2".to_string())
);
}
}