use axum::{extract::State, http::HeaderMap, http::StatusCode, Json}; use chrono::Utc; use deployer_types::{ CommissionState, EnrollmentRuleSpec, HardwareFacts, InstallPlan, InstallState, NodeClassSpec, NodeConfig, NodeInfo, NodePoolSpec, NodeState, PhoneHomeRequest, PhoneHomeResponse, PowerState, }; use sha2::{Digest, Sha256}; use std::sync::Arc; use tracing::{debug, error, info, warn}; use crate::auth::require_bootstrap_auth; use crate::cluster::ClusterNodeRecord; use crate::state::AppState; use crate::validation::{validate_identifier, validate_ip}; fn merge_install_plan( preferred: Option<&InstallPlan>, fallback: Option<&InstallPlan>, ) -> Option { InstallPlan::from_layers(preferred, fallback) } fn merge_hardware_summary_metadata( metadata: &mut std::collections::HashMap, hardware_facts: Option<&HardwareFacts>, ) { let Some(hardware_facts) = hardware_facts else { return; }; if let Some(cpu_threads) = hardware_facts.cpu_threads { metadata.insert("hardware.cpu_threads".to_string(), cpu_threads.to_string()); } if let Some(cpu_cores) = hardware_facts.cpu_cores { metadata.insert("hardware.cpu_cores".to_string(), cpu_cores.to_string()); } if let Some(memory_bytes) = hardware_facts.memory_bytes { metadata.insert("hardware.memory_bytes".to_string(), memory_bytes.to_string()); } metadata.insert( "hardware.disk_count".to_string(), hardware_facts.disks.len().to_string(), ); metadata.insert( "hardware.nic_count".to_string(), hardware_facts.nics.len().to_string(), ); if let Some(architecture) = hardware_facts.architecture.as_deref() { metadata.insert("hardware.architecture".to_string(), architecture.to_string()); } } fn inventory_hash(hardware_facts: Option<&HardwareFacts>) -> Option { let hardware_facts = hardware_facts?; let payload = serde_json::to_vec(hardware_facts).ok()?; let mut hasher = Sha256::new(); hasher.update(payload); Some(format!("{:x}", hasher.finalize())) } /// POST /api/v1/phone-home /// /// Handles node registration during first boot. /// Nodes send their machine-id, and Deployer returns: /// - Node configuration (hostname, role, IP, services) /// - SSH host key /// - TLS certificates (optional) /// /// Uses ChainFire storage when available, falls back to in-memory. pub async fn phone_home( State(state): State>, headers: HeaderMap, Json(request): Json, ) -> Result, (StatusCode, String)> { require_bootstrap_auth(&state, &headers)?; validate_identifier(&request.machine_id, "machine_id")?; if let Some(node_id) = request.node_id.as_ref() { validate_identifier(node_id, "node_id")?; } if let Some(ip) = request.ip.as_ref() { validate_ip(ip, "ip")?; } info!( machine_id = %request.machine_id, "Phone home request received" ); // Lookup node configuration (ChainFire or fallback) let (node_id, mut node_config) = match lookup_node_config(&state, &request.machine_id).await { Some((id, config)) => (id, config), None => { if let Some((id, config)) = resolve_enrollment_config(&state, &request).await? { info!( machine_id = %request.machine_id, node_id = %id, "Resolved unknown machine through enrollment rules" ); (id, config) } else { if !state.config.allow_unknown_nodes { warn!( machine_id = %request.machine_id, "Unknown machine-id rejected (pre-registration required)" ); return Err(( StatusCode::FORBIDDEN, "machine-id not registered".to_string(), )); } warn!( machine_id = %request.machine_id, "Unknown machine-id, assigning default configuration (unsafe)" ); // Assign default configuration for unknown machines (dev-only). // Prefer explicit node_id, then DHCP-provided hostname, then machine-id suffix. let node_id = request .node_id .as_ref() .map(|v| v.trim()) .filter(|v| !v.is_empty()) .map(|v| v.to_string()) .or_else(|| { request .hostname .as_ref() .map(|v| v.trim()) .filter(|v| !v.is_empty()) .map(|v| v.to_string()) }) .unwrap_or_else(|| { let max_suffix_len = 128usize.saturating_sub("node-".len()); let suffix_len = std::cmp::min(max_suffix_len, request.machine_id.len()); format!("node-{}", &request.machine_id[..suffix_len]) }); let config = NodeConfig { hostname: node_id.clone(), role: "worker".to_string(), ip: request.ip.clone().unwrap_or_default(), services: vec![], ssh_authorized_keys: vec![], labels: std::collections::HashMap::new(), pool: None, node_class: None, failure_domain: request.metadata.get("failure_domain").cloned(), nix_profile: None, install_plan: None, }; (node_id, config) } } }; if let Some(request_ip) = request.ip.as_ref() { if !node_config.ip.is_empty() && node_config.ip != *request_ip { warn!( machine_id = %request.machine_id, requested_ip = %request_ip, expected_ip = %node_config.ip, "Node IP mismatch in phone-home" ); return Err((StatusCode::BAD_REQUEST, "node ip mismatch".to_string())); } } if let Some(requested_id) = request.node_id.as_ref() { if requested_id != &node_id { warn!( machine_id = %request.machine_id, requested_id = %requested_id, expected_id = %node_id, "Node ID mismatch in phone-home" ); return Err((StatusCode::BAD_REQUEST, "node_id mismatch".to_string())); } } if node_config.hostname.is_empty() { if let Some(hostname) = request.hostname.as_ref() { node_config.hostname = hostname.clone(); } else { node_config.hostname = node_id.clone(); } } if node_config.ip.is_empty() { if let Some(ip) = request.ip.clone() { node_config.ip = ip; } else { warn!( machine_id = %request.machine_id, node_id = %node_id, "Node config missing IP; refusing registration" ); return Err((StatusCode::BAD_REQUEST, "node ip missing".to_string())); } } validate_ip(&node_config.ip, "node_config.ip")?; // Ensure metadata contains authoritative role/service info let mut metadata = request.metadata.clone(); metadata.insert("role".to_string(), node_config.role.clone()); metadata.insert("services".to_string(), node_config.services.join(",")); merge_hardware_summary_metadata(&mut metadata, request.hardware_facts.as_ref()); // Create NodeInfo for tracking let node_info = NodeInfo { id: node_id.clone(), machine_id: Some(request.machine_id.clone()), hostname: node_config.hostname.clone(), ip: node_config.ip.clone(), state: NodeState::Provisioning, cluster_config_hash: request.cluster_config_hash.unwrap_or_default(), last_heartbeat: Utc::now(), metadata, }; // Persist config mapping for this machine (best-effort) if let Err(e) = persist_node_config(&state, &request.machine_id, &node_id, &node_config).await { warn!( machine_id = %request.machine_id, node_id = %node_id, error = %e, "Failed to persist node configuration" ); } // Store in ChainFire or in-memory match store_node_info(&state, &node_info).await { Ok(_) => { let storage = if state.has_local_storage() { "local" } else if state.has_storage() { "chainfire" } else { "in-memory" }; info!( node_id = %node_info.id, hostname = %node_info.hostname, role = %node_config.role, storage = storage, "Node registered successfully" ); if let Err(e) = store_cluster_node_if_configured( &state, &node_info, &node_config, &request.machine_id, request.hardware_facts.as_ref(), ) .await { warn!( node_id = %node_info.id, error = %e, "Failed to store cluster node state" ); } let ssh_host_key = if let Some(local_storage) = &state.local_storage { let mut storage = local_storage.lock().await; match storage.get_or_generate_ssh_host_key(&node_info.id) { Ok(key) => Some(key), Err(e) => { warn!(error = %e, "Failed to generate ssh host key"); None } } } else { None }; let (tls_cert, tls_key) = if state.config.tls_self_signed || (state.config.tls_ca_cert_path.is_some() && state.config.tls_ca_key_path.is_some()) { if let Some(local_storage) = &state.local_storage { let mut storage = local_storage.lock().await; match storage.get_or_generate_tls_cert( &node_info.id, &node_config.hostname, &node_config.ip, state.config.tls_ca_cert_path.as_deref(), state.config.tls_ca_key_path.as_deref(), ) { Ok((cert, key)) => (Some(cert), Some(key)), Err(e) => { warn!(error = %e, "Failed to issue node TLS certificate"); (None, None) } } } else { match crate::tls::issue_node_cert( &node_info.id, &node_config.hostname, &node_config.ip, state.config.tls_ca_cert_path.as_deref(), state.config.tls_ca_key_path.as_deref(), ) { Ok((cert, key)) => (Some(cert), Some(key)), Err(e) => { warn!(error = %e, "Failed to issue node TLS certificate"); (None, None) } } } } else { (None, None) }; Ok(Json(PhoneHomeResponse { success: true, message: Some(format!("Node {} registered successfully", node_info.id)), node_id: node_id.clone(), state: NodeState::Provisioning, node_config: Some(node_config), ssh_host_key, tls_cert, tls_key, })) } Err(e) => { error!( machine_id = %request.machine_id, error = %e, "Failed to store node info" ); Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to register node: {}", e), )) } } } /// Lookup node configuration by machine-id /// /// Tries ChainFire first, then falls back to in-memory storage. pub(crate) async fn lookup_node_config( state: &AppState, machine_id: &str, ) -> Option<(String, NodeConfig)> { debug!(machine_id = %machine_id, "Looking up node configuration"); // Try local storage first if let Some(local_storage) = &state.local_storage { let storage = local_storage.lock().await; if let Some((node_id, config)) = storage.get_node_config(machine_id) { debug!( machine_id = %machine_id, node_id = %node_id, "Found config in local storage" ); return Some((node_id, config)); } } // Try ChainFire storage first if let Some(storage_mutex) = &state.storage { let mut storage = storage_mutex.lock().await; match storage.get_node_config(machine_id).await { Ok(Some((node_id, config))) => { debug!( machine_id = %machine_id, node_id = %node_id, "Found config in ChainFire" ); return Some((node_id, config)); } Ok(None) => { debug!(machine_id = %machine_id, "Not found in ChainFire"); } Err(e) => { warn!( machine_id = %machine_id, error = %e, "ChainFire lookup failed, trying fallback" ); } } } // Fallback to in-memory storage let configs = state.machine_configs.read().await; if let Some((node_id, config)) = configs.get(machine_id) { debug!( machine_id = %machine_id, node_id = %node_id, "Found config in in-memory storage" ); return Some((node_id.clone(), config.clone())); } // Hardcoded test mappings (for development/testing) if state.config.allow_test_mappings { match machine_id { "test-machine-01" => { return Some(( "node01".to_string(), NodeConfig { hostname: "node01".to_string(), role: "control-plane".to_string(), ip: "10.0.1.10".to_string(), services: vec!["chainfire".to_string(), "flaredb".to_string()], ssh_authorized_keys: vec![], labels: std::collections::HashMap::new(), pool: None, node_class: None, failure_domain: None, nix_profile: None, install_plan: None, }, )); } "test-machine-02" => { return Some(( "node02".to_string(), NodeConfig { hostname: "node02".to_string(), role: "worker".to_string(), ip: "10.0.1.11".to_string(), services: vec!["chainfire".to_string()], ssh_authorized_keys: vec![], labels: std::collections::HashMap::new(), pool: None, node_class: None, failure_domain: None, nix_profile: None, install_plan: None, }, )); } _ => {} } } None } async fn resolve_enrollment_config( state: &AppState, request: &PhoneHomeRequest, ) -> Result, (StatusCode, String)> { let Some(cluster_id) = state.config.cluster_id.as_deref() else { return Ok(None); }; let Some(storage_mutex) = &state.storage else { return Ok(None); }; let cluster_namespace = state.config.cluster_namespace.trim(); if cluster_namespace.is_empty() { return Ok(None); } let mut storage = storage_mutex.lock().await; let mut rules = storage .list_enrollment_rules(cluster_namespace, cluster_id) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("failed to load enrollment rules: {}", e), ) })?; if rules.is_empty() { return Ok(None); } let node_classes = storage .list_node_classes(cluster_namespace, cluster_id) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("failed to load node classes: {}", e), ) })?; let pools = storage .list_pools(cluster_namespace, cluster_id) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("failed to load pools: {}", e), ) })?; drop(storage); rules.sort_by(|lhs, rhs| { rhs.priority .cmp(&lhs.priority) .then_with(|| lhs.name.cmp(&rhs.name)) }); let Some(rule) = rules .iter() .find(|rule| enrollment_rule_matches(rule, request)) else { return Ok(None); }; Ok(Some(build_node_config_from_rule( rule, request, &node_classes, &pools, ))) } fn enrollment_rule_matches(rule: &EnrollmentRuleSpec, request: &PhoneHomeRequest) -> bool { if let Some(prefix) = rule.match_hostname_prefix.as_deref() { let Some(hostname) = request.hostname.as_deref() else { return false; }; if !hostname.starts_with(prefix) { return false; } } if !rule.match_ip_prefixes.is_empty() { let Some(ip) = request.ip.as_deref() else { return false; }; if !rule .match_ip_prefixes .iter() .any(|prefix| ip.starts_with(prefix)) { return false; } } rule.match_labels .iter() .all(|(key, value)| request.metadata.get(key) == Some(value)) } fn build_node_config_from_rule( rule: &EnrollmentRuleSpec, request: &PhoneHomeRequest, node_classes: &[NodeClassSpec], pools: &[NodePoolSpec], ) -> (String, NodeConfig) { let requested_id = request .node_id .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) .map(str::to_string) .or_else(|| { request .hostname .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) .map(str::to_string) }); let node_id = requested_id.unwrap_or_else(|| { let prefix = rule.node_id_prefix.as_deref().unwrap_or("node"); let suffix_len = std::cmp::min(12usize, request.machine_id.len()); format!("{prefix}-{}", &request.machine_id[..suffix_len]) }); let pool = rule.pool.clone(); let pool_spec = pool .as_deref() .and_then(|name| pools.iter().find(|pool| pool.name == name)); let node_class = rule .node_class .clone() .or_else(|| pool_spec.and_then(|pool| pool.node_class.clone())); let node_class_spec = node_class.as_deref().and_then(|name| { node_classes .iter() .find(|node_class| node_class.name == name) }); let role = rule .role .clone() .or_else(|| node_class_spec.and_then(|node_class| node_class.roles.first().cloned())) .unwrap_or_else(|| "worker".to_string()); let mut labels = std::collections::HashMap::new(); if let Some(node_class) = node_class_spec { labels.extend(node_class.labels.clone()); } if let Some(pool) = pool_spec { for (key, value) in &pool.labels { labels.entry(key.clone()).or_insert_with(|| value.clone()); } } for (key, value) in &rule.labels { labels.insert(key.clone(), value.clone()); } if let Some(pool_name) = pool.as_deref() { labels .entry("pool".to_string()) .or_insert_with(|| pool_name.to_string()); } if let Some(node_class_name) = node_class.as_deref() { labels .entry("node_class".to_string()) .or_insert_with(|| node_class_name.to_string()); } let failure_domain = request .metadata .get("failure_domain") .cloned() .or_else(|| request.metadata.get("topology.kubernetes.io/zone").cloned()); ( node_id.clone(), NodeConfig { hostname: request .hostname .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| node_id.clone()), role, ip: request.ip.clone().unwrap_or_default(), services: rule.services.clone(), ssh_authorized_keys: rule.ssh_authorized_keys.clone(), labels, pool, node_class, failure_domain, nix_profile: rule .nix_profile .clone() .or_else(|| node_class_spec.and_then(|node_class| node_class.nix_profile.clone())), install_plan: merge_install_plan( rule.install_plan.as_ref(), node_class_spec.and_then(|node_class| node_class.install_plan.as_ref()), ), }, ) } /// Store NodeInfo in ChainFire or in-memory async fn store_node_info(state: &AppState, node_info: &NodeInfo) -> anyhow::Result<()> { let mut stored = false; // Prefer local storage when configured. if let Some(local_storage) = &state.local_storage { let mut storage = local_storage.lock().await; match storage.store_node_info(node_info) { Ok(()) => { stored = true; debug!(node_id = %node_info.id, "Stored node info in local storage"); } Err(e) => { warn!(error = %e, "Failed to store node info in local storage"); } } } // Also try ChainFire if available. if let Some(storage_mutex) = &state.storage { let mut chainfire = storage_mutex.lock().await; match chainfire.store_node_info(node_info).await { Ok(()) => { stored = true; debug!(node_id = %node_info.id, "Stored node info in ChainFire"); } Err(e) => { warn!(error = %e, "Failed to store node info in ChainFire"); } } } if stored { return Ok(()); } // Fallback to in-memory storage when all configured backends fail. state .nodes .write() .await .insert(node_info.id.clone(), node_info.clone()); debug!( node_id = %node_info.id, "Stored node info in-memory (all backends unavailable)" ); Ok(()) } /// Persist node config mapping in ChainFire and in-memory fallback async fn persist_node_config( state: &AppState, machine_id: &str, node_id: &str, config: &NodeConfig, ) -> anyhow::Result<()> { if let Some(local_storage) = &state.local_storage { let mut storage = local_storage.lock().await; if let Err(e) = storage.register_node(machine_id, node_id, config) { warn!( machine_id = %machine_id, node_id = %node_id, error = %e, "Failed to persist node config to local storage" ); } } if let Some(storage_mutex) = &state.storage { let mut storage = storage_mutex.lock().await; if let Err(e) = storage.register_node(machine_id, node_id, config).await { warn!( machine_id = %machine_id, node_id = %node_id, error = %e, "Failed to persist node config to ChainFire" ); } } // Keep in-memory mapping in sync as a fallback cache { let mut map = state.machine_configs.write().await; if let Some((existing_node, _)) = map.get(machine_id) { if existing_node != node_id { warn!( machine_id = %machine_id, existing_node = %existing_node, requested_node = %node_id, "Skipping in-memory mapping update due to conflict" ); return Ok(()); } } map.insert( machine_id.to_string(), (node_id.to_string(), config.clone()), ); } Ok(()) } async fn store_cluster_node_if_configured( state: &AppState, node_info: &NodeInfo, node_config: &NodeConfig, machine_id: &str, hardware_facts: Option<&HardwareFacts>, ) -> anyhow::Result<()> { let Some(cluster_id) = state.config.cluster_id.as_deref() else { debug!("cluster_id not configured; skipping cluster node state write"); return Ok(()); }; if cluster_id.trim().is_empty() { debug!("cluster_id is empty; skipping cluster node state write"); return Ok(()); } let cluster_namespace = state.config.cluster_namespace.trim(); if cluster_namespace.is_empty() { debug!("cluster_namespace is empty; skipping cluster node state write"); return Ok(()); } let mut labels = node_config.labels.clone(); for (key, value) in &node_info.metadata { labels.insert(key.clone(), value.clone()); } labels.remove("role"); labels.remove("services"); let mut roles = Vec::new(); if !node_config.role.trim().is_empty() { roles.push(node_config.role.clone()); } else if let Some(role) = node_info.metadata.get("role") { if !role.trim().is_empty() { roles.push(role.clone()); } } let record = ClusterNodeRecord { node_id: node_info.id.clone(), machine_id: Some(machine_id.to_string()), ip: node_info.ip.clone(), hostname: node_info.hostname.clone(), roles, labels, pool: node_config.pool.clone(), node_class: node_config.node_class.clone(), failure_domain: node_config.failure_domain.clone(), nix_profile: node_config.nix_profile.clone(), install_plan: node_config.install_plan.clone(), hardware_facts: hardware_facts.cloned(), state: Some(format!("{:?}", node_info.state).to_lowercase()), commission_state: hardware_facts.map(|_| CommissionState::Discovered), install_state: node_config.install_plan.as_ref().map(|_| InstallState::Pending), commissioned_at: None, last_inventory_hash: inventory_hash(hardware_facts), power_state: node_info .metadata .get("power_state") .and_then(|value| match value.as_str() { "on" => Some(PowerState::On), "off" => Some(PowerState::Off), "cycling" => Some(PowerState::Cycling), "unknown" => Some(PowerState::Unknown), _ => None, }), bmc_ref: node_info.metadata.get("bmc_ref").cloned(), last_heartbeat: Some(node_info.last_heartbeat), }; if let Some(local_storage) = &state.local_storage { let mut storage = local_storage.lock().await; if let Err(e) = storage.store_cluster_node(cluster_namespace, cluster_id, &node_info.id, &record) { warn!(error = %e, "Failed to store cluster node in local storage"); } } if let Some(storage_mutex) = &state.storage { let mut storage = storage_mutex.lock().await; if let Err(e) = storage .store_cluster_node(cluster_namespace, cluster_id, &node_info.id, &record) .await { warn!(error = %e, "Failed to store cluster node in ChainFire"); } } else if state.local_storage.is_none() { debug!("ChainFire storage unavailable; skipping cluster node state write"); } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::config::Config; use crate::state::AppState; use axum::http::HeaderMap; use std::collections::HashMap; fn test_headers() -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert("x-deployer-token", "test-token".parse().unwrap()); headers } fn test_state() -> Arc { let mut config = Config::default(); config.bootstrap_token = Some("test-token".to_string()); Arc::new(AppState::with_config(config)) } #[tokio::test] async fn test_phone_home_known_machine() { let state = test_state(); // Pre-register a machine let config = NodeConfig { hostname: "node01".to_string(), role: "control-plane".to_string(), ip: "10.0.1.10".to_string(), services: vec!["chainfire".to_string(), "flaredb".to_string()], ssh_authorized_keys: vec![], labels: HashMap::new(), pool: None, node_class: None, failure_domain: None, nix_profile: None, install_plan: None, }; state.machine_configs.write().await.insert( "test-machine-01".to_string(), ("node01".to_string(), config), ); let request = PhoneHomeRequest { machine_id: "test-machine-01".to_string(), node_id: None, hostname: None, ip: None, cluster_config_hash: None, metadata: HashMap::new(), hardware_facts: None, }; let result = phone_home(State(state.clone()), test_headers(), Json(request)).await; assert!(result.is_ok()); let response = result.unwrap().0; assert!(response.success); assert_eq!(response.node_id, "node01"); assert_eq!(response.state, NodeState::Provisioning); assert!(response.node_config.is_some()); assert!(response.ssh_host_key.is_none()); let config = response.node_config.unwrap(); assert_eq!(config.hostname, "node01"); assert_eq!(config.role, "control-plane"); // Verify node was stored let nodes = state.nodes.read().await; assert!(nodes.contains_key("node01")); } #[tokio::test] async fn test_phone_home_unknown_machine() { let mut config = Config::default(); config.bootstrap_token = Some("test-token".to_string()); config.allow_unknown_nodes = true; let state = Arc::new(AppState::with_config(config)); let request = PhoneHomeRequest { machine_id: "unknown-machine-xyz".to_string(), node_id: None, hostname: None, ip: Some("10.0.1.100".to_string()), cluster_config_hash: None, metadata: HashMap::new(), hardware_facts: None, }; let result = phone_home(State(state.clone()), test_headers(), Json(request)).await; assert!(result.is_ok()); let response = result.unwrap().0; assert!(response.success); assert!(response.node_id.starts_with("node-")); assert_eq!(response.state, NodeState::Provisioning); assert!(response.node_config.is_some()); let config = response.node_config.unwrap(); assert_eq!(config.role, "worker"); // Default role } #[tokio::test] async fn test_phone_home_with_preregistered_config() { let state = test_state(); // Pre-register a machine let config = NodeConfig { hostname: "my-node".to_string(), role: "storage".to_string(), ip: "10.0.2.50".to_string(), services: vec!["lightningstor".to_string()], ssh_authorized_keys: vec![], labels: HashMap::new(), pool: None, node_class: None, failure_domain: None, nix_profile: None, install_plan: None, }; state.machine_configs.write().await.insert( "preregistered-123".to_string(), ("my-node".to_string(), config), ); let request = PhoneHomeRequest { machine_id: "preregistered-123".to_string(), node_id: None, hostname: None, ip: None, cluster_config_hash: None, metadata: HashMap::new(), hardware_facts: None, }; let result = phone_home(State(state.clone()), test_headers(), Json(request)).await; assert!(result.is_ok()); let response = result.unwrap().0; assert!(response.success); assert_eq!(response.node_id, "my-node"); let config = response.node_config.unwrap(); assert_eq!(config.role, "storage"); assert_eq!(config.ip, "10.0.2.50"); } #[test] fn test_enrollment_rule_matching() { let rule = EnrollmentRuleSpec { name: "gpu".to_string(), priority: 10, match_labels: HashMap::from([("sku".to_string(), "gpu".to_string())]), match_hostname_prefix: Some("gpu-".to_string()), match_ip_prefixes: vec!["10.0.3.".to_string()], pool: Some("gpu".to_string()), node_class: Some("gpu-worker".to_string()), role: None, labels: HashMap::new(), nix_profile: None, install_plan: None, services: vec![], ssh_authorized_keys: vec![], node_id_prefix: Some("gpu".to_string()), }; let request = PhoneHomeRequest { machine_id: "machine-1".to_string(), node_id: None, hostname: Some("gpu-node-01".to_string()), ip: Some("10.0.3.25".to_string()), cluster_config_hash: None, metadata: HashMap::from([("sku".to_string(), "gpu".to_string())]), hardware_facts: None, }; assert!(enrollment_rule_matches(&rule, &request)); } #[test] fn test_build_node_config_from_rule_inherits_class_and_pool() { let rule = EnrollmentRuleSpec { name: "gpu".to_string(), priority: 10, match_labels: HashMap::new(), match_hostname_prefix: None, match_ip_prefixes: vec![], pool: Some("gpu".to_string()), node_class: None, role: None, labels: HashMap::from([("accelerator".to_string(), "nvidia".to_string())]), nix_profile: None, install_plan: None, services: vec!["gpu-agent".to_string()], ssh_authorized_keys: vec!["ssh-ed25519 test".to_string()], node_id_prefix: Some("gpu".to_string()), }; let request = PhoneHomeRequest { machine_id: "abcdef123456".to_string(), node_id: None, hostname: Some("gpu-dyn-01".to_string()), ip: Some("10.0.9.10".to_string()), cluster_config_hash: None, metadata: HashMap::from([( "topology.kubernetes.io/zone".to_string(), "rack-z".to_string(), )]), hardware_facts: None, }; let node_classes = vec![NodeClassSpec { name: "gpu-worker".to_string(), description: None, nix_profile: Some("profiles/gpu-worker".to_string()), install_plan: Some(InstallPlan { nixos_configuration: Some("gpu-worker".to_string()), disko_config_path: Some("profiles/gpu-worker/disko.nix".to_string()), target_disk: Some("/dev/disk/by-id/nvme-gpu-worker".to_string()), target_disk_by_id: None, }), roles: vec!["worker".to_string()], labels: HashMap::from([("tier".to_string(), "gpu".to_string())]), }]; let pools = vec![NodePoolSpec { name: "gpu".to_string(), description: None, node_class: Some("gpu-worker".to_string()), min_size: None, max_size: None, labels: HashMap::from([("pool-kind".to_string(), "accelerated".to_string())]), }]; let (node_id, config) = build_node_config_from_rule(&rule, &request, &node_classes, &pools); assert_eq!(node_id, "gpu-dyn-01"); assert_eq!(config.role, "worker"); assert_eq!(config.pool.as_deref(), Some("gpu")); assert_eq!(config.node_class.as_deref(), Some("gpu-worker")); assert_eq!(config.nix_profile.as_deref(), Some("profiles/gpu-worker")); let install_plan = config .install_plan .expect("install_plan should inherit from class"); assert_eq!( install_plan.nixos_configuration.as_deref(), Some("gpu-worker") ); assert_eq!( install_plan.disko_config_path.as_deref(), Some("profiles/gpu-worker/disko.nix") ); assert_eq!(config.labels.get("tier").map(String::as_str), Some("gpu")); assert_eq!( config.labels.get("pool-kind").map(String::as_str), Some("accelerated") ); assert_eq!( config.labels.get("accelerator").map(String::as_str), Some("nvidia") ); assert_eq!(config.failure_domain.as_deref(), Some("rack-z")); } }