Skip to main content

alien_core/deployment/
compute.rs

1//! Compute backend configuration for container orchestration.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7/// Configuration for a single container worker cluster.
8///
9/// Contains the cluster ID and management token needed to interact with
10/// the managed container control plane API for container operations.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[serde(rename_all = "camelCase")]
14pub struct HorizonClusterConfig {
15    /// Cluster ID (deterministic: workspace/project/deployment/resourceid)
16    pub cluster_id: String,
17
18    /// Management token for API access (hm_...)
19    /// Used by alien-deployment controllers to create/update containers
20    pub management_token: String,
21
22    /// Machine image this cluster's machines run. When set, it replaces
23    /// [`HorizonConfig::horizon_machine_image`] for this cluster only, so one
24    /// cluster can stay on the image it runs while others move to a newer one.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub horizon_machine_image: Option<HorizonMachineImage>,
27}
28
29/// Machine image architecture.
30#[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    /// Linux arm64 / aarch64 machine image.
35    #[serde(rename = "arm64")]
36    Arm64,
37    /// Linux amd64 / x86_64 machine image.
38    #[serde(rename = "amd64")]
39    Amd64,
40}
41
42/// AWS machine image catalog.
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
45#[serde(rename_all = "camelCase")]
46pub struct HorizonAwsMachineImages {
47    /// AMI IDs by architecture, then AWS region.
48    pub amis: HashMap<HorizonMachineArchitecture, HashMap<String, String>>,
49}
50
51/// GCP machine image entry.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
54#[serde(rename_all = "camelCase")]
55pub struct HorizonGcpMachineImage {
56    /// Source image self link or image-family URL.
57    pub source_image: String,
58}
59
60/// GCP machine image catalog.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
63#[serde(rename_all = "camelCase")]
64pub struct HorizonGcpMachineImages {
65    /// Images by architecture.
66    pub images: HashMap<HorizonMachineArchitecture, HorizonGcpMachineImage>,
67}
68
69/// Azure machine image entry.
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
72#[serde(rename_all = "camelCase")]
73pub struct HorizonAzureMachineImage {
74    /// Azure Compute Gallery image version ID.
75    pub image_version_id: String,
76}
77
78/// Base image metadata for the machine image.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
81#[serde(rename_all = "camelCase")]
82pub struct HorizonMachineBaseImage {
83    /// Base OS image name.
84    pub name: String,
85    /// Base OS image version or channel.
86    pub version: String,
87}
88
89/// Azure machine image catalog.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
92#[serde(rename_all = "camelCase")]
93pub struct HorizonAzureMachineImages {
94    /// Images by architecture.
95    pub images: HashMap<HorizonMachineArchitecture, HorizonAzureMachineImage>,
96}
97
98/// Machine agent download for one release platform.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
101#[serde(rename_all = "camelCase")]
102pub struct HorizondArtifact {
103    /// Runtime isolation capability generation of the immutable artifact, not live readiness.
104    #[serde(default)]
105    pub runtime_isolation_generation: u32,
106    /// HTTPS URL for the artifact.
107    pub url: String,
108    /// SHA-256 digest for the artifact payload.
109    pub sha256: String,
110}
111
112/// Machine image catalog.
113///
114/// Platform resolves concrete provider images from this catalog during rollout.
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
117#[serde(rename_all = "camelCase")]
118pub struct HorizonMachineImage {
119    /// Runtime isolation capability generation of the immutable artifact, not live readiness.
120    #[serde(default)]
121    pub runtime_isolation_generation: u32,
122    /// Logical image channel, such as prod, staging, or canary.
123    pub channel: String,
124    /// Published immutable machine image version.
125    pub machine_image_version: String,
126    /// Machine agent version baked into the image.
127    pub horizond_version: String,
128    /// Git commit SHA used to build the image.
129    pub git_sha: String,
130    /// Image manifest creation timestamp.
131    pub created_at: String,
132    /// Base OS image metadata.
133    pub base_image: HorizonMachineBaseImage,
134    /// Per-architecture machine agent downloads by release-platform key.
135    pub horizond_artifacts: HashMap<String, HorizondArtifact>,
136    /// AWS image catalog.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub aws: Option<HorizonAwsMachineImages>,
139    /// GCP image catalog.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub gcp: Option<HorizonGcpMachineImages>,
142    /// Azure image catalog.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub azure: Option<HorizonAzureMachineImages>,
145}
146
147/// Machine control-plane configuration for container orchestration.
148///
149/// Contains all the information needed for Alien to interact with managed
150/// container clusters during deployment. Each ComputeCluster resource gets its own
151/// entry in the clusters map.
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
154#[serde(rename_all = "camelCase")]
155pub struct HorizonConfig {
156    /// Machine control-plane API base URL.
157    pub url: String,
158
159    /// Machine image catalog.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub horizon_machine_image: Option<HorizonMachineImage>,
162
163    /// Cluster configurations (one per ComputeCluster resource)
164    /// Key: ComputeCluster resource ID from stack
165    /// Value: Cluster ID and management token for that cluster
166    pub clusters: HashMap<String, HorizonClusterConfig>,
167}
168
169impl HorizonConfig {
170    /// The machine image for the cluster of the ComputeCluster resource
171    /// `resource_id`: its own image when it has one, otherwise the catalog's.
172    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/// Compute backend for Container and Worker resources.
181///
182/// Determines how compute workloads are orchestrated on cloud platforms.
183/// When None, the platform default is used for cloud platforms.
184#[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    /// VM-backed container orchestration (default for cloud platforms)
189    Horizon(HorizonConfig),
190    // Future backends:
191    // /// Deploy to existing Kubernetes cluster (EKS/GKE/AKS)
192    // Kubernetes(KubernetesCredentials),
193    // /// AWS ECS Fargate (serverless containers)
194    // EcsFargate,
195}
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        // Writing it back adds nothing, so readers that predate the field see the same document.
319        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    /// The cluster and catalog types as they were before clusters could carry their own image.
353    /// Nothing on the path from the deployment config to a cluster rejects unknown fields, so an
354    /// older reader keeps working when a newer writer sets the image.
355    #[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}