diff --git a/nexus/src/app/background/plan_execution.rs b/nexus/src/app/background/plan_execution.rs index add9a8ccfd5..98272d88da6 100644 --- a/nexus/src/app/background/plan_execution.rs +++ b/nexus/src/app/background/plan_execution.rs @@ -15,7 +15,7 @@ use nexus_types::blueprint::{Blueprint, OmicronZonesConfig}; use serde_json::json; use slog::Logger; use std::collections::BTreeMap; -use std::net::SocketAddrV6; +use std::net::SocketAddr; use std::sync::Arc; use tokio::sync::watch; use uuid::Uuid; @@ -27,6 +27,8 @@ pub struct PlanExecutor { } impl PlanExecutor { + // Temporary until we wire up the background task + #[allow(unused)] pub fn new( rx_blueprint: watch::Receiver>>, ) -> PlanExecutor { @@ -87,7 +89,7 @@ async fn realize_blueprint( async fn deploy_zones( log: &Logger, - zones: &BTreeMap, + zones: &BTreeMap, ) -> Result<(), Vec> { let errors: Vec<_> = stream::iter(zones.clone()) .filter_map(|(sled_id, (addr, config))| async move { @@ -132,7 +134,15 @@ async fn deploy_zones( mod test { use super::*; use crate::app::background::common::BackgroundTask; + use httptest::matchers::{all_of, json_decoded, request}; + use httptest::responders::status_code; + use httptest::Expectation; use nexus_test_utils_macros::nexus_test; + use nexus_types::inventory::{ + OmicronZoneConfig, OmicronZoneDataset, OmicronZoneType, + }; + use serde::Deserialize; + use sled_agent_client::types::Generation; type ControlPlaneTestContext = nexus_test_utils::ControlPlaneTestContext; @@ -155,8 +165,163 @@ mod test { // Get a success (empty) result back when the blueprint has an empty set of zones let blueprint = Arc::new(Blueprint::OmicronZones(BTreeMap::new())); + blueprint_tx.send(Some(blueprint)).unwrap(); let value = task.activate(&opctx).await; + assert_eq!(value, json!({})); + + // Create some fake sled-agent servers to respond to zone puts + let mut s1 = httptest::Server::run(); + let mut s2 = httptest::Server::run(); + + // The particular dataset doesn't matter for this test. + // We re-use the same one to not obfuscate things + // TODO: Why do we even need to allocate that zone dataset from nexus in + // the first place? + let dataset = OmicronZoneDataset { + pool_name: format!("oxp_{}", Uuid::new_v4()).parse().unwrap(), + }; + + // Zones are updated in a particular order, but each request contains + // the full set of zones that must be running. + // See https://github.com/oxidecomputer/omicron/blob/main/sled-agent/src/rack_setup/service.rs#L976-L998 + // for more details. + let mut zones = OmicronZonesConfig { + generation: Generation(1), + zones: vec![OmicronZoneConfig { + id: Uuid::new_v4(), + underlay_address: "::1".parse().unwrap(), + zone_type: OmicronZoneType::InternalDns { + dataset, + dns_address: "oh-hello-internal-dns".into(), + gz_address: "::1".parse().unwrap(), + gz_address_index: 0, + http_address: "some-ipv6-address".into(), + }, + }], + }; + + // Create a blueprint with only the `InternalDns` zone for both servers + // We reuse the same `OmicronZonesConfig` because the details don't + // matter for this test. + let sled_id1 = Uuid::new_v4(); + let sled_id2 = Uuid::new_v4(); + + let blueprint = Arc::new(Blueprint::OmicronZones(BTreeMap::from([ + (sled_id1, (s1.addr(), zones.clone())), + (sled_id2, (s2.addr(), zones.clone())), + ]))); + + // Send the blueprint with the first set of zones to the task blueprint_tx.send(Some(blueprint)).unwrap(); + + // Check that the initial requests were sent to the fake sled-agents + for s in [&mut s1, &mut s2] { + s.expect( + Expectation::matching(all_of![ + request::method_path("PUT", "/omicron-zones",), + // Our generation number should be 1 and there should + // be only a single zone. + request::body(json_decoded(|c: &OmicronZonesConfig| { + c.generation == Generation(1) && c.zones.len() == 1 + })) + ]) + .respond_with(status_code(204)), + ); + } + + // Activate the task to trigger zone configuration on the sled-agents + let value = task.activate(&opctx).await; + assert_eq!(value, json!({})); + s1.verify_and_clear(); + s2.verify_and_clear(); + + // Do it again. This should trigger the same request. + for s in [&mut s1, &mut s2] { + s.expect( + Expectation::matching(request::method_path( + "PUT", + "/omicron-zones", + )) + .respond_with(status_code(204)), + ); + } + let value = task.activate(&opctx).await; + assert_eq!(value, json!({})); + s1.verify_and_clear(); + s2.verify_and_clear(); + + // Take another lap, but this time, have one server fail the request and + // try again. + s1.expect( + Expectation::matching(request::method_path( + "PUT", + "/omicron-zones", + )) + .respond_with(status_code(204)), + ); + s2.expect( + Expectation::matching(request::method_path( + "PUT", + "/omicron-zones", + )) + .respond_with(status_code(500)), + ); + + // Define a type we can use to pick stuff out of error objects. + #[derive(Deserialize)] + struct ErrorResult { + errors: Vec, + } + + let value = task.activate(&opctx).await; + println!("{:?}", value); + let result: ErrorResult = serde_json::from_value(value).unwrap(); + assert_eq!(result.errors.len(), 1); + assert!( + result.errors[0].starts_with("Failed to put OmicronZonesConfig") + ); + s1.verify_and_clear(); + s2.verify_and_clear(); + + // Add an `InternalNtp` zone for our next update + zones.generation = Generation(2); + zones.zones.push(OmicronZoneConfig { + id: Uuid::new_v4(), + underlay_address: "::1".parse().unwrap(), + zone_type: OmicronZoneType::InternalNtp { + address: "::1".into(), + dns_servers: vec!["::1".parse().unwrap()], + domain: None, + ntp_servers: vec!["some-ntp-server-addr".into()], + }, + }); + + // Update our watch channel + let blueprint = Arc::new(Blueprint::OmicronZones(BTreeMap::from([ + (sled_id1, (s1.addr(), zones.clone())), + (sled_id2, (s2.addr(), zones.clone())), + ]))); + blueprint_tx.send(Some(blueprint)).unwrap(); + + // Set our new expectations + for s in [&mut s1, &mut s2] { + s.expect( + Expectation::matching(all_of![ + request::method_path("PUT", "/omicron-zones",), + // Our generation number should be bumped and there should + // be two zones. + request::body(json_decoded(|c: &OmicronZonesConfig| { + c.generation == Generation(2) && c.zones.len() == 2 + })) + ]) + .respond_with(status_code(204)), + ); + } + + // Activate the task + let value = task.activate(&opctx).await; assert_eq!(value, json!({})); + s1.verify_and_clear(); + s2.verify_and_clear(); } } diff --git a/nexus/types/src/blueprint.rs b/nexus/types/src/blueprint.rs index 74bf2677904..9aaac7137df 100644 --- a/nexus/types/src/blueprint.rs +++ b/nexus/types/src/blueprint.rs @@ -5,7 +5,7 @@ //! Deployment details crated by the update planner pub use sled_agent_client::types::OmicronZonesConfig; -use std::{collections::BTreeMap, net::SocketAddrV6}; +use std::{collections::BTreeMap, net::SocketAddr}; use uuid::Uuid; /// An individual decision made by the update planner in order to drive the @@ -46,7 +46,7 @@ use uuid::Uuid; #[derive(Debug, PartialEq, Eq)] pub enum Blueprint { // Mapping from Sled UUID to (sled-agent address, zone config) pair - OmicronZones(BTreeMap), + OmicronZones(BTreeMap), //Dns(SomeDnsMapping) }