use crate::{ExternalBindings, ManagementConfig, StackSettings};
use bon::Builder;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::{
is_false, ComputeBackend, DeletionPolicy, DomainMetadata, EnvironmentVariablesSnapshot,
};
#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeploymentConfig {
#[serde(skip)]
#[cfg_attr(feature = "openapi", schema(ignore))]
#[builder(default)]
pub setup_run: bool,
#[serde(skip)]
#[cfg_attr(feature = "openapi", schema(ignore))]
pub setup_run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deployment_name: Option<String>,
#[serde(default)]
pub stack_settings: StackSettings,
#[serde(skip_serializing_if = "Option::is_none")]
pub management_config: Option<ManagementConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gcp_federation_scope: Option<crate::GcpFederationScope>,
pub environment_variables: EnvironmentVariablesSnapshot,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
#[builder(default)]
pub input_values: HashMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub stored_secret_input_ids: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "is_false")]
pub allow_frozen_changes: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub compute_backend: Option<ComputeBackend>,
#[serde(default)]
pub external_bindings: ExternalBindings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_platform: Option<crate::Platform>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label_domain: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observe_label_selector: Option<String>,
#[serde(default)]
#[builder(default)]
pub observe_all_namespaces: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_endpoints: Option<HashMap<String, HashMap<String, String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_metadata: Option<DomainMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub monitoring: Option<OtlpConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manager_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deployment_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native_image_host: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub volume_restores: Vec<VolumeRestoreRequest>,
#[serde(skip)]
#[cfg_attr(feature = "openapi", schema(ignore))]
#[builder(default)]
pub deletion_policy: DeletionPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct VolumeRestoreRequest {
pub request_id: String,
pub resource_id: String,
pub ordinal: u32,
pub snapshot_id: String,
}
pub const ALIEN_SYSTEM_RESOURCE_ATTRIBUTE: &str = "alien.system";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct OtlpConfig {
pub logs_endpoint: String,
pub logs_auth_header: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub metrics_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metrics_auth_header: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub resource_attributes: HashMap<String, String>,
}
#[cfg(test)]
mod input_values_tests {
use super::*;
#[test]
fn input_values_default_to_empty_and_stay_off_the_wire() {
let json = serde_json::json!({
"stackSettings": serde_json::to_value(crate::StackSettings::default()).unwrap(),
"environmentVariables": {
"variables": [],
"hash": "hash",
"createdAt": "2026-01-01T00:00:00Z",
},
"externalBindings": {},
});
let config: DeploymentConfig =
serde_json::from_value(json).expect("old shape deserializes");
assert!(config.input_values.is_empty());
assert!(config.stored_secret_input_ids.is_none());
let round = serde_json::to_value(&config).expect("serializes");
assert!(round.get("storedSecretInputIds").is_none());
assert!(
round.get("inputValues").is_none(),
"empty map must not serialize"
);
}
#[test]
fn stored_secret_presence_roundtrips_without_values_or_gate_answers() {
let mut config = DeploymentConfig::builder()
.stack_settings(StackSettings::default())
.environment_variables(EnvironmentVariablesSnapshot {
variables: vec![],
hash: String::new(),
created_at: String::new(),
})
.allow_frozen_changes(false)
.external_bindings(ExternalBindings::default())
.build();
assert!(config.stored_secret_input_ids.is_none());
config.stored_secret_input_ids = Some(Vec::new());
let empty = serde_json::to_value(&config).unwrap();
assert_eq!(empty["storedSecretInputIds"], serde_json::json!([]));
assert_eq!(
serde_json::from_value::<DeploymentConfig>(empty)
.unwrap()
.stored_secret_input_ids,
Some(Vec::new())
);
config.stored_secret_input_ids =
Some(vec!["apiKey".to_string(), "enableFeature".to_string()]);
let wire = serde_json::to_value(&config).unwrap();
assert_eq!(
wire["storedSecretInputIds"],
serde_json::json!(["apiKey", "enableFeature"])
);
assert!(wire.get("inputValues").is_none());
let decoded: DeploymentConfig = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(
decoded.stored_secret_input_ids,
config.stored_secret_input_ids
);
assert_eq!(serde_json::to_value(decoded.clone()).unwrap(), wire);
assert!(decoded.input_values.is_empty());
for default in [false, true] {
let input: crate::StackInputDefinition = serde_json::from_value(serde_json::json!({
"id": "enableFeature", "kind": "boolean", "providedBy": ["deployer"],
"required": false, "label": "Enable feature", "description": "", "default": crate::StackInputDefaultValue::Boolean(default)
}))
.unwrap();
assert_eq!(
crate::gate_resolves_true(
&[input],
"enableFeature",
&decoded.input_values,
"worker",
crate::Platform::Aws
)
.unwrap(),
default
);
}
assert!(crate::gate_resolves_true(
&[],
"enableFeature",
&decoded.input_values,
"worker",
crate::Platform::Aws
)
.is_err());
}
fn deployment_config_with_cluster(
cluster_image: Option<serde_json::Value>,
) -> serde_json::Value {
let config = DeploymentConfig::builder()
.stack_settings(StackSettings::default())
.environment_variables(EnvironmentVariablesSnapshot {
variables: vec![],
hash: String::new(),
created_at: String::new(),
})
.allow_frozen_changes(false)
.external_bindings(ExternalBindings::default())
.compute_backend(crate::ComputeBackend::Horizon(crate::HorizonConfig {
url: "https://horizon.example.com".to_string(),
horizon_machine_image: None,
clusters: HashMap::from([(
"compute".to_string(),
crate::HorizonClusterConfig {
cluster_id: "cluster-1".to_string(),
management_token: "hm_test".to_string(),
horizon_machine_image: cluster_image
.map(|image| serde_json::from_value(image).unwrap()),
},
)]),
}))
.build();
serde_json::to_value(&config).unwrap()
}
fn machine_image(version: &str) -> serde_json::Value {
serde_json::json!({
"channel": "stable",
"machineImageVersion": version,
"horizondVersion": version,
"gitSha": "abc123",
"createdAt": "2026-01-01T00:00:00Z",
"baseImage": { "name": "linux", "version": "1" },
"horizondArtifacts": {}
})
}
#[test]
fn deployment_config_without_cluster_machine_image_reads_as_before() {
let wire = deployment_config_with_cluster(None);
let cluster = &wire["computeBackend"]["clusters"]["compute"];
assert_eq!(
cluster,
&serde_json::json!({ "clusterId": "cluster-1", "managementToken": "hm_test" })
);
let config: DeploymentConfig = serde_json::from_value(wire).unwrap();
let Some(crate::ComputeBackend::Horizon(horizon)) = config.compute_backend else {
panic!("the Horizon backend reads back");
};
assert_eq!(horizon.clusters["compute"].horizon_machine_image, None);
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PreviousDeploymentConfig {
compute_backend: Option<PreviousComputeBackend>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum PreviousComputeBackend {
Horizon(PreviousHorizonConfig),
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PreviousHorizonConfig {
clusters: HashMap<String, PreviousHorizonClusterConfig>,
}
#[derive(Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct PreviousHorizonClusterConfig {
cluster_id: String,
management_token: String,
}
#[test]
fn deployment_config_with_cluster_machine_image_reads_in_older_readers() {
let wire = deployment_config_with_cluster(Some(machine_image("1")));
assert!(wire["computeBackend"]["clusters"]["compute"]["horizonMachineImage"].is_object());
let previous: PreviousDeploymentConfig = serde_json::from_value(wire.clone())
.expect("an older reader must accept a cluster that carries its own image");
let Some(PreviousComputeBackend::Horizon(horizon)) = previous.compute_backend else {
panic!("the Horizon backend reads back");
};
assert_eq!(
horizon.clusters["compute"],
PreviousHorizonClusterConfig {
cluster_id: "cluster-1".to_string(),
management_token: "hm_test".to_string(),
}
);
let current: DeploymentConfig = serde_json::from_value(wire).unwrap();
let Some(crate::ComputeBackend::Horizon(horizon)) = current.compute_backend else {
panic!("the Horizon backend reads back");
};
assert_eq!(
horizon
.machine_image_for("compute")
.map(|image| image.machine_image_version.as_str()),
Some("1")
);
}
}