diff --git a/Cargo.lock b/Cargo.lock index eaefe9d54d1..84ec9b702db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8016,6 +8016,7 @@ dependencies = [ "http", "iddqd", "ipnetwork", + "itertools 0.14.0", "libc", "macaddr", "mg-admin-client", diff --git a/clients/nexus-lockstep-client/src/lib.rs b/clients/nexus-lockstep-client/src/lib.rs index 14ab369959a..16748df18fc 100644 --- a/clients/nexus-lockstep-client/src/lib.rs +++ b/clients/nexus-lockstep-client/src/lib.rs @@ -132,11 +132,11 @@ impl From for types::Ipv6Range { } } -impl From<&omicron_common::api::internal::shared::SourceNatConfig> - for types::SourceNatConfig +impl From<&omicron_common::api::internal::shared::SourceNatConfigGeneric> + for types::SourceNatConfigGeneric { fn from( - r: &omicron_common::api::internal::shared::SourceNatConfig, + r: &omicron_common::api::internal::shared::SourceNatConfigGeneric, ) -> Self { let (first_port, last_port) = r.port_range_raw(); Self { ip: r.ip, first_port, last_port } diff --git a/clients/sled-agent-client/src/lib.rs b/clients/sled-agent-client/src/lib.rs index 341fde5c749..3d06fd47b54 100644 --- a/clients/sled-agent-client/src/lib.rs +++ b/clients/sled-agent-client/src/lib.rs @@ -56,6 +56,9 @@ progenitor::generate_api!( DiskManagementError = omicron_common::disk::DiskManagementError, DiskVariant = omicron_common::disk::DiskVariant, ExternalIpGatewayMap = omicron_common::api::internal::shared::ExternalIpGatewayMap, + ExternalIpConfig = omicron_common::api::internal::shared::ExternalIpConfig, + ExternalIpv4Config = omicron_common::api::internal::shared::ExternalIpv4Config, + ExternalIpv6Config = omicron_common::api::internal::shared::ExternalIpv6Config, Generation = omicron_common::api::external::Generation, Hostname = omicron_common::api::external::Hostname, ImportExportPolicy = omicron_common::api::external::ImportExportPolicy, @@ -83,7 +86,7 @@ progenitor::generate_api!( RouterTarget = omicron_common::api::internal::shared::RouterTarget, RouterVersion = omicron_common::api::internal::shared::RouterVersion, SledRole = nexus_sled_agent_shared::inventory::SledRole, - SourceNatConfig = omicron_common::api::internal::shared::SourceNatConfig, + SourceNatConfigGeneric = omicron_common::api::internal::shared::SourceNatConfigGeneric, SwitchLocation = omicron_common::api::external::SwitchLocation, Vni = omicron_common::api::external::Vni, VpcFirewallIcmpFilter = omicron_common::api::external::VpcFirewallIcmpFilter, diff --git a/common/Cargo.toml b/common/Cargo.toml index 1ba384196fa..f62f78b4207 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -27,6 +27,7 @@ hex.workspace = true http.workspace = true iddqd.workspace = true ipnetwork.workspace = true +itertools.workspace = true lldp_protocol.workspace = true macaddr.workspace = true mg-admin-client.workspace = true diff --git a/common/src/api/internal/shared/external_ip/mod.rs b/common/src/api/internal/shared/external_ip/mod.rs new file mode 100644 index 00000000000..99b872bec4c --- /dev/null +++ b/common/src/api/internal/shared/external_ip/mod.rs @@ -0,0 +1,871 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! External IP types. + +pub mod v1; + +use crate::address::NUM_SOURCE_NAT_PORTS; +use daft::Diffable; +use itertools::Either; +use itertools::Itertools as _; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; + +/// Trait for any IP address type. +/// +/// This is used to constrain the external addressing types below. +pub trait Ip: + Clone + + Copy + + std::fmt::Debug + + Diffable + + Eq + + JsonSchema + + std::hash::Hash + + PartialOrd + + PartialEq + + Ord + + Serialize + + SnatSchema +{ +} +impl Ip for Ipv4Addr {} +impl Ip for Ipv6Addr {} +impl Ip for IpAddr {} + +/// Helper trait specifying the name of the JSON Schema for a `SourceNatConfig`. +/// +/// This exists so we can use a generic type and have the names of the concrete +/// type aliases be the same as the name of schema object. +pub trait SnatSchema { + fn json_schema_name() -> String; +} + +impl SnatSchema for Ipv4Addr { + fn json_schema_name() -> String { + String::from("SourceNatConfigV4") + } +} + +impl SnatSchema for Ipv6Addr { + fn json_schema_name() -> String { + String::from("SourceNatConfigV6") + } +} + +impl SnatSchema for IpAddr { + fn json_schema_name() -> String { + String::from("SourceNatConfigGeneric") + } +} + +/// An IP address of a specific version, IPv4 or IPv6. +pub trait ConcreteIp: Ip { + fn into_ipaddr(self) -> IpAddr; +} + +impl ConcreteIp for Ipv4Addr { + fn into_ipaddr(self) -> IpAddr { + IpAddr::V4(self) + } +} + +impl ConcreteIp for Ipv6Addr { + fn into_ipaddr(self) -> IpAddr { + IpAddr::V6(self) + } +} + +/// Helper trait specifying the name of the JSON Schema for an +/// `ExternalIpConfig` object. +/// +/// This exists so we can use a generic type and have the names of the concrete +/// type aliases be the same as the name of the schema oject. +pub trait ExternalIpSchema { + fn json_schema_name() -> String; +} + +impl ExternalIpSchema for Ipv4Addr { + fn json_schema_name() -> String { + String::from("ExternalIpv4Config") + } +} + +impl ExternalIpSchema for Ipv6Addr { + fn json_schema_name() -> String { + String::from("ExternalIpv6Config") + } +} + +/// An IP address and port range used for source NAT, i.e., making +/// outbound network connections from guests or services. +// Note that `Deserialize` is manually implemented; if you make any changes to +// the fields of this structure, you must make them to that implementation too. +#[derive( + Debug, + Clone, + Copy, + Serialize, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Diffable, +)] +pub struct SourceNatConfig { + /// The external address provided to the instance or service. + pub ip: T, + /// The first port used for source NAT, inclusive. + first_port: u16, + /// The last port used for source NAT, also inclusive. + last_port: u16, +} + +/// An IP address and port range used for source NAT, i.e., making +/// outbound network connections from guests or services. +// Private type only used for deriving the actual JSON schema object itself, +// and for checked deserialization. +// +// The fields of `SourceNatConfigShadow` should exactly match the fields +// of `SourceNatConfig`. We're not really using serde's remote derive, +// but by adding the attribute we get compile-time checking that all the +// field names and types match. (It doesn't check the _order_, but that +// should be fine as long as we're using JSON or similar formats.) +#[derive(Deserialize, JsonSchema)] +#[serde(remote = "SourceNatConfig")] +struct SourceNatConfigShadow { + /// The external address provided to the instance or service. + ip: T, + /// The first port used for source NAT, inclusive. + first_port: u16, + /// The last port used for source NAT, also inclusive. + last_port: u16, +} + +pub type SourceNatConfigV4 = SourceNatConfig; +pub type SourceNatConfigV6 = SourceNatConfig; +pub type SourceNatConfigGeneric = SourceNatConfig; + +impl JsonSchema for SourceNatConfig +where + T: Ip, +{ + fn schema_name() -> String { + ::json_schema_name() + } + + fn json_schema( + generator: &mut schemars::r#gen::SchemaGenerator, + ) -> schemars::schema::Schema { + SourceNatConfigShadow::::json_schema(generator) + } +} + +// We implement `Deserialize` manually to add validity checking on the port +// range. +impl<'de, T: Ip + Deserialize<'de>> Deserialize<'de> for SourceNatConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + let shadow = SourceNatConfigShadow::deserialize(deserializer)?; + SourceNatConfig::new(shadow.ip, shadow.first_port, shadow.last_port) + .map_err(D::Error::custom) + } +} + +impl SourceNatConfig { + /// Construct a `SourceNatConfig` with the given port range, both inclusive. + /// + /// # Errors + /// + /// Fails if `(first_port, last_port)` is not aligned to + /// [`NUM_SOURCE_NAT_PORTS`]. + pub fn new( + ip: T, + first_port: u16, + last_port: u16, + ) -> Result { + if first_port % NUM_SOURCE_NAT_PORTS == 0 + && last_port + .checked_sub(first_port) + .and_then(|diff| diff.checked_add(1)) + == Some(NUM_SOURCE_NAT_PORTS) + { + Ok(Self { ip, first_port, last_port }) + } else { + Err(SourceNatConfigError::UnalignedPortPair { + first_port, + last_port, + }) + } + } + + /// Get the port range. + /// + /// Guaranteed to be aligned to [`NUM_SOURCE_NAT_PORTS`]. + pub fn port_range(&self) -> std::ops::RangeInclusive { + self.first_port..=self.last_port + } + + /// Get the port range as a raw tuple; both values are inclusive. + /// + /// Guaranteed to be aligned to [`NUM_SOURCE_NAT_PORTS`]. + pub fn port_range_raw(&self) -> (u16, u16) { + self.port_range().into_inner() + } +} + +impl SourceNatConfigGeneric { + /// Try to convert this to a concrete IPv4 configuration. + /// + /// Return None if this is an IPv6 configuration. + pub fn try_as_ipv4(&self) -> Option { + let IpAddr::V4(ip) = self.ip else { + return None; + }; + Some(SourceNatConfig { + ip, + first_port: self.first_port, + last_port: self.last_port, + }) + } + + /// Try to convert this to a concrete IPv6 configuration. + /// + /// Return None if this is an IPv4 configuration. + pub fn try_as_ipv6(&self) -> Option { + let IpAddr::V6(ip) = self.ip else { + return None; + }; + Some(SourceNatConfig { + ip, + first_port: self.first_port, + last_port: self.last_port, + }) + } +} + +impl TryFrom> for v1::SourceNatConfig +where + T: Ip, + IpAddr: From, +{ + type Error = SourceNatConfigError; + + fn try_from(value: SourceNatConfig) -> Result { + v1::SourceNatConfig::new( + value.ip.into(), + value.first_port, + value.last_port, + ) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SourceNatConfigError { + #[error( + "snat port range is not aligned to {NUM_SOURCE_NAT_PORTS}: \ + ({first_port}, {last_port})" + )] + UnalignedPortPair { first_port: u16, last_port: u16 }, +} + +/// External IP address configuration. +/// +/// This encapsulates all the external addresses of a single IP version, +/// including source NAT, Ephemeral, and Floating IPs. Note that not all of +/// these need to be specified, but this type can only be constructed if _at +/// least one_ of them is. +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct ExternalIps +where + T: ConcreteIp, +{ + /// Source NAT configuration, for outbound-only connectivity. + source_nat: Option>, + /// An Ephemeral address for in- and outbound connectivity. + ephemeral_ip: Option, + /// Additional Floating IPs for in- and outbound connectivity. + floating_ips: Vec, +} + +impl ExternalIps +where + T: ConcreteIp, +{ + /// Return a reference to the SNAT address, if it exists. + pub fn source_nat(&self) -> &Option> { + &self.source_nat + } + + /// Return a reference to the Ephemeral adress, if it exists. + pub fn ephemeral_ip(&self) -> &Option { + &self.ephemeral_ip + } + + /// Return a mutable reference to the Ephemeral address, if it exists. + pub fn ephemeral_ip_mut(&mut self) -> &mut Option { + &mut self.ephemeral_ip + } + + /// Return the list of Floating IP addresses. + /// + /// The slice will be empty if no such addresses exist. + pub fn floating_ips(&self) -> &[T] { + &self.floating_ips + } + + /// Return a mutable reference to the list of Floating IP addresses. + /// + /// The slice will be empty if no such addresses exist. + pub fn floating_ips_mut(&mut self) -> &mut Vec { + &mut self.floating_ips + } +} + +pub type ExternalIpv4Config = ExternalIps; +pub type ExternalIpv6Config = ExternalIps; + +/// External IP address configuration. +/// +/// This encapsulates all the external addresses of a single IP version, +/// including source NAT, Ephemeral, and Floating IPs. Note that not all of +/// these need to be specified, but this type can only be constructed if _at +/// least one_ of them is. +#[derive(Deserialize, JsonSchema)] +#[serde(remote = "ExternalIps")] +struct ExternalIpsShadow +where + T: ConcreteIp, +{ + /// Source NAT configuration, for outbound-only connectivity. + source_nat: Option>, + /// An Ephemeral address for in- and outbound connectivity. + ephemeral_ip: Option, + /// Additional Floating IPs for in- and outbound connectivity. + floating_ips: Vec, +} + +impl JsonSchema for ExternalIpv4Config { + fn schema_name() -> String { + String::from("ExternalIpv4Config") + } + + fn json_schema( + generator: &mut schemars::r#gen::SchemaGenerator, + ) -> schemars::schema::Schema { + ExternalIpsShadow::::json_schema(generator) + } +} + +impl JsonSchema for ExternalIpv6Config { + fn schema_name() -> String { + String::from("ExternalIpv6Config") + } + + fn json_schema( + generator: &mut schemars::r#gen::SchemaGenerator, + ) -> schemars::schema::Schema { + ExternalIpsShadow::::json_schema(generator) + } +} + +// We implement `Deserialize` manually to ensure we have at least one of the +// SNAT, ephemeral, and floating IPs in the input data. +impl<'de, T> Deserialize<'de> for ExternalIps +where + T: ConcreteIp + Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + let ExternalIps { source_nat, ephemeral_ip, floating_ips } = + ExternalIpsShadow::deserialize(deserializer)?; + let mut builder = ExternalIpConfigBuilder::new(); + if let Some(snat) = source_nat { + builder = builder.with_source_nat(snat); + } + if let Some(ip) = ephemeral_ip { + builder = builder.with_ephemeral_ip(ip); + } + builder + .with_floating_ips(floating_ips) + .build() + .map_err(D::Error::custom) + } +} + +/// A builder for `ExternalIps` and the concrete type aliases. +/// +/// This enforces the constraint that `ExternalIps` always has _at least one_ IP +/// address. +pub struct ExternalIpConfigBuilder { + source_nat: Option>, + ephemeral_ip: Option, + floating_ips: Vec, +} + +impl ExternalIpConfigBuilder +where + T: ConcreteIp, +{ + /// Construct a new builder. + pub fn new() -> Self { + Self { source_nat: None, ephemeral_ip: None, floating_ips: Vec::new() } + } + + /// Add a source NAT address. + /// + /// Note that this overwrites any previous such information. + pub fn with_source_nat(mut self, source_nat: SourceNatConfig) -> Self { + self.source_nat = Some(source_nat); + self + } + + /// Add an Ephemeral IP address. + /// + /// Note that this overwrites any previous such information. + pub fn with_ephemeral_ip(mut self, ip: T) -> Self { + self.ephemeral_ip = Some(ip); + self + } + + /// Add floating IPs. + /// + /// Note that this overwrites any previous such information. + pub fn with_floating_ips(mut self, ips: Vec) -> Self { + self.floating_ips = ips; + self + } + + /// Attempt to build an `ExternalIps`. + /// + /// This will fail if no addresses at all have been specified. + pub fn build(self) -> Result, ExternalIpsError> { + let ExternalIpConfigBuilder { source_nat, ephemeral_ip, floating_ips } = + self; + if source_nat.is_none() + && ephemeral_ip.is_none() + && floating_ips.is_empty() + { + return Err(ExternalIpsError::NoIps); + } + Ok(ExternalIps { source_nat, ephemeral_ip, floating_ips }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ExternalIpsError { + #[error( + "Must specify at least one SNAT, ephemeral, or floating IP address" + )] + NoIps, + #[error( + "SNAT, ephemeral, and floating IPs must all be of the same IP version" + )] + MixedIpVersions, + #[error(transparent)] + SourceNat(#[from] SourceNatConfigError), +} + +/// A single- or dual-stack external IP configuration. +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "snake_case", tag = "type", content = "value")] +pub enum ExternalIpConfig { + /// Single-stack IPv4 external IP configuration. + V4(ExternalIpv4Config), + /// Single-stack IPv6 external IP configuration. + V6(ExternalIpv6Config), + /// Both IPv4 and IPv6 external IP configuration. + DualStack { v4: ExternalIpv4Config, v6: ExternalIpv6Config }, +} + +impl From for ExternalIpConfig { + fn from(cfg: ExternalIpv4Config) -> Self { + Self::V4(cfg) + } +} + +impl From for ExternalIpConfig { + fn from(cfg: ExternalIpv6Config) -> Self { + Self::V6(cfg) + } +} + +impl ExternalIpConfig { + /// Attempt to convert from generic IP addressing information. + /// + /// This is used to convert previous versions of the external addressing + /// information, which used version-agnostic IP address types, like + /// `IpAddr`, rather than concrete `Ipv4Addr` or `Ipv6Addr`. This handles + /// all the possible flavors of addresses for SNAT, Ephemeral, and Floating + /// IPs. + /// + /// This returns an error if: + /// + /// - There are no addresses at all in the input parameters + /// - The input parameters specify mixed IP versions + pub fn try_from_generic( + source_nat: Option, + ephemeral_ip: Option, + floating_ips: Vec, + ) -> Result { + // This is a terrible conversion method. We need to handle all the + // possible IP versions for three different sources: SNAT, Ephemeral, + // and Floating IPs. We also need to handle the possibility that the + // floating IPs are not all the same version. + // + // This proceeds in 3 steps: + // + // - Convert the provided SNAT configuration into either an SNATv4 or + // SNATv6 configuration. At most one of these should be non-none. + // - Extract all the floating IPs into arrays of one or the other + // version. At most one of these should be non-empty. + // - Match on all of the possible states, which is huge and annoying. + + // Populate one or the other SNAT configuration. + let snat_v4; + let snat_v6; + match source_nat { + Some(snat) => { + let (first_port, last_port) = snat.port_range_raw(); + match snat.ip { + IpAddr::V4(ipv4) => { + snat_v6 = None; + snat_v4 = Some(SourceNatConfig::new( + ipv4, first_port, last_port, + )?); + } + IpAddr::V6(ipv6) => { + snat_v6 = Some(SourceNatConfig::new( + ipv6, first_port, last_port, + )?); + snat_v4 = None; + } + } + } + None => { + snat_v4 = None; + snat_v6 = None; + } + }; + + // Populate one or the other arrays of floating IPs. + let (fips_v4, fips_v6): (Vec<_>, Vec<_>) = + floating_ips.into_iter().partition_map(|fip| match fip { + IpAddr::V4(v4) => Either::Left(v4), + IpAddr::V6(v6) => Either::Right(v6), + }); + + match (snat_v4, snat_v6, ephemeral_ip) { + (None, None, None) => { + // Only floating IPs. + match (fips_v4.is_empty(), fips_v6.is_empty()) { + (true, true) => Err(ExternalIpsError::NoIps), + (true, false) => ExternalIpConfigBuilder::new() + .with_floating_ips(fips_v6) + .build() + .map(Into::into), + (false, true) => ExternalIpConfigBuilder::new() + .with_floating_ips(fips_v4) + .build() + .map(Into::into), + (false, false) => Err(ExternalIpsError::MixedIpVersions), + } + } + (None, None, Some(IpAddr::V4(v4))) => { + // Ephemeral IPv4, ensure we have no v6 floating IPs. + if !fips_v6.is_empty() { + return Err(ExternalIpsError::MixedIpVersions); + } + ExternalIpConfigBuilder::new() + .with_ephemeral_ip(v4) + .with_floating_ips(fips_v4) + .build() + .map(Into::into) + } + (None, None, Some(IpAddr::V6(v6))) => { + // Ephemeral IPv6, ensure we have no v4 floating IPs. + if !fips_v4.is_empty() { + return Err(ExternalIpsError::MixedIpVersions); + } + ExternalIpConfigBuilder::new() + .with_ephemeral_ip(v6) + .with_floating_ips(fips_v6) + .build() + .map(Into::into) + } + (None, Some(snat_v6), None) => { + // IPv6 source nat, ensure we have no v4 floating IPs. + if !fips_v4.is_empty() { + return Err(ExternalIpsError::MixedIpVersions); + } + ExternalIpConfigBuilder::new() + .with_source_nat(snat_v6) + .with_floating_ips(fips_v6) + .build() + .map(Into::into) + } + (None, Some(snat_v6), Some(IpAddr::V6(eip_v6))) => { + // IPv6 source nat and ephemeral, ensure we have no v4 floating + // IPs. + if !fips_v4.is_empty() { + return Err(ExternalIpsError::MixedIpVersions); + } + ExternalIpConfigBuilder::new() + .with_source_nat(snat_v6) + .with_ephemeral_ip(eip_v6) + .with_floating_ips(fips_v6) + .build() + .map(Into::into) + } + (Some(snat_v4), None, None) => { + // IPv4 source nat, ensure we have no v6 floating IPs. + if !fips_v6.is_empty() { + return Err(ExternalIpsError::MixedIpVersions); + } + ExternalIpConfigBuilder::new() + .with_source_nat(snat_v4) + .with_floating_ips(fips_v4) + .build() + .map(Into::into) + } + (Some(snat_v4), None, Some(IpAddr::V4(eip_v4))) => { + // IPv4 source nat and ephemeral, ensure we have no v6 floating + // IPs. + if !fips_v6.is_empty() { + return Err(ExternalIpsError::MixedIpVersions); + } + ExternalIpConfigBuilder::new() + .with_source_nat(snat_v4) + .with_ephemeral_ip(eip_v4) + .with_floating_ips(fips_v4) + .build() + .map(Into::into) + } + (Some(_), None, Some(IpAddr::V6(_))) + | (None, Some(_), Some(IpAddr::V4(_))) => { + Err(ExternalIpsError::MixedIpVersions) + } + (Some(_), Some(_), None) | (Some(_), Some(_), Some(_)) => { + // Both SNAT v4 and v6 configurations. Shouldn't be possible, + // but not valid. + Err(ExternalIpsError::MixedIpVersions) + } + } + } + + /// Return the IPv4 configuration, if it exists. + pub fn ipv4_config(&self) -> Option<&ExternalIpv4Config> { + match self { + ExternalIpConfig::V4(v4) + | ExternalIpConfig::DualStack { v4, .. } => Some(v4), + ExternalIpConfig::V6(_) => None, + } + } + + /// Return a mutable reference to the IPv4 configuration, if it exists. + pub fn ipv4_config_mut(&mut self) -> Option<&mut ExternalIpv4Config> { + match self { + ExternalIpConfig::V4(v4) + | ExternalIpConfig::DualStack { v4, .. } => Some(v4), + ExternalIpConfig::V6(_) => None, + } + } + + /// Return the IPv6 configuration, if it exists. + pub fn ipv6_config(&self) -> Option<&ExternalIpv6Config> { + match self { + ExternalIpConfig::V6(v6) + | ExternalIpConfig::DualStack { v6, .. } => Some(v6), + ExternalIpConfig::V4(_) => None, + } + } + + /// Return a mutable reference to the IPv6 configuration, if it exists. + pub fn ipv6_config_mut(&mut self) -> Option<&mut ExternalIpv6Config> { + match self { + ExternalIpConfig::V6(v6) + | ExternalIpConfig::DualStack { v6, .. } => Some(v6), + ExternalIpConfig::V4(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn try_from_generic_fails_with_no_ips() { + assert!( + ExternalIpConfig::try_from_generic(None, None, vec![]).is_err(), + "Should fail to construct an ExternalIpConfig from no IP addresses" + ); + } + + #[test] + fn try_from_generic_fails_with_mixed_address_versions() { + let v4 = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); + let v6 = IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)); + let snat_v4 = v1::SourceNatConfig::new(v4, 0, 16383).unwrap(); + let snat_v6 = v1::SourceNatConfig::new(v6, 0, 16383).unwrap(); + + for (snat, ephemeral, floating) in [ + (Some(snat_v4), Some(v6), vec![]), + (Some(snat_v4), None, vec![v6]), + (Some(snat_v6), Some(v4), vec![]), + (Some(snat_v6), None, vec![v4]), + (None, Some(v4), vec![v6]), + (None, Some(v6), vec![v4]), + ] { + assert!( + ExternalIpConfig::try_from_generic(snat, ephemeral, floating,) + .is_err(), + "Should fail to construct an ExternalIpConfig with mixed IP versions" + ); + } + } + + #[test] + fn try_from_generic_successes() { + let ipv4 = Ipv4Addr::new(1, 1, 1, 1); + let v4 = IpAddr::V4(ipv4); + let ipv6 = Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1); + let v6 = IpAddr::V6(ipv6); + let snat_v4 = v1::SourceNatConfig::new(v4, 0, 16383).unwrap(); + let snat_v6 = v1::SourceNatConfig::new(v6, 0, 16383).unwrap(); + + for (ipv4_snat, ipv4_ephemeral, ipv4_floating) in [ + (Some(snat_v4), None, vec![]), + (None, Some(v4), vec![]), + (None, None, vec![v4]), + (Some(snat_v4), Some(v4), vec![]), + (Some(snat_v4), None, vec![v4]), + (None, Some(v4), vec![v4]), + (Some(snat_v4), Some(v4), vec![v4]), + ] { + let config = ExternalIpConfig::try_from_generic( + ipv4_snat, + ipv4_ephemeral, + ipv4_floating.clone(), + ) + .unwrap(); + assert!(config.ipv6_config().is_none()); + let ipv4_config = config.ipv4_config().unwrap(); + + match (ipv4_config.source_nat(), ipv4_snat) { + (None, None) => {} + (Some(actual), Some(expected)) => { + let IpAddr::V4(addr) = expected.ip else { + unreachable!("Should have an IPv4 address"); + }; + assert_eq!(actual.ip, addr); + let (expected_first, expected_last) = + expected.port_range_raw(); + assert_eq!(actual.first_port, expected_first); + assert_eq!(actual.last_port, expected_last); + } + (None, Some(_)) | (Some(_), None) => { + panic!("IPv4 SNAT was not carried over correctly",) + } + } + + match (ipv4_config.ephemeral_ip(), ipv4_ephemeral) { + (None, None) => {} + (Some(actual), Some(expected)) => { + let IpAddr::V4(addr) = expected else { + unreachable!("Should have an IPv4 address"); + }; + assert_eq!(actual, &addr); + } + (None, Some(_)) | (Some(_), None) => { + panic!("IPv4 Ephemeral IP not carried over correctly") + } + } + + let actual_fips = ipv4_config.floating_ips(); + assert_eq!(actual_fips.len(), ipv4_floating.len()); + for (actual, expected) in + actual_fips.iter().zip(ipv4_floating.iter()) + { + let IpAddr::V4(expected) = expected else { + unreachable!("Should have an IPv4 address"); + }; + assert_eq!(actual, expected); + } + } + + for (ipv6_snat, ipv6_ephemeral, ipv6_floating) in [ + (Some(snat_v6), None, vec![]), + (None, Some(v6), vec![]), + (None, None, vec![v6]), + (Some(snat_v6), Some(v6), vec![]), + (Some(snat_v6), None, vec![v6]), + (None, Some(v6), vec![v6]), + (Some(snat_v6), Some(v6), vec![v6]), + ] { + let config = ExternalIpConfig::try_from_generic( + ipv6_snat, + ipv6_ephemeral, + ipv6_floating.clone(), + ) + .unwrap(); + assert!(config.ipv4_config().is_none()); + let ipv6_config = config.ipv6_config().unwrap(); + + match (ipv6_config.source_nat(), ipv6_snat) { + (None, None) => {} + (Some(actual), Some(expected)) => { + let IpAddr::V6(addr) = expected.ip else { + unreachable!("Should have an IPv6 address"); + }; + assert_eq!(actual.ip, addr); + let (expected_first, expected_last) = + expected.port_range_raw(); + assert_eq!(actual.first_port, expected_first); + assert_eq!(actual.last_port, expected_last); + } + (None, Some(_)) | (Some(_), None) => { + panic!("IPv6 SNAT was not carried over correctly",) + } + } + + match (ipv6_config.ephemeral_ip(), ipv6_ephemeral) { + (None, None) => {} + (Some(actual), Some(expected)) => { + let IpAddr::V6(addr) = expected else { + unreachable!("Should have an IPv6 address"); + }; + assert_eq!(actual, &addr); + } + (None, Some(_)) | (Some(_), None) => { + panic!("IPv6 Ephemeral IP not carried over correctly") + } + } + + let actual_fips = ipv6_config.floating_ips(); + assert_eq!(actual_fips.len(), ipv6_floating.len()); + for (actual, expected) in + actual_fips.iter().zip(ipv6_floating.iter()) + { + let IpAddr::V6(expected) = expected else { + unreachable!("Should have an IPv6 address"); + }; + assert_eq!(actual, expected); + } + } + } +} diff --git a/common/src/api/internal/shared/external_ip/v1.rs b/common/src/api/internal/shared/external_ip/v1.rs new file mode 100644 index 00000000000..b87f24be282 --- /dev/null +++ b/common/src/api/internal/shared/external_ip/v1.rs @@ -0,0 +1,108 @@ +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Version 1 of the external IP types. + +use super::SourceNatConfigError; +use crate::address::NUM_SOURCE_NAT_PORTS; +use daft::Diffable; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::net::IpAddr; + +/// An IP address and port range used for source NAT, i.e., making +/// outbound network connections from guests or services. +// Note that `Deserialize` is manually implemented; if you make any changes to +// the fields of this structure, you must make them to that implementation too. +#[derive( + Debug, + Clone, + Copy, + Serialize, + JsonSchema, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Diffable, +)] +pub struct SourceNatConfig { + /// The external address provided to the instance or service. + pub ip: IpAddr, + /// The first port used for source NAT, inclusive. + first_port: u16, + /// The last port used for source NAT, also inclusive. + last_port: u16, +} + +// We implement `Deserialize` manually to add validity checking on the port +// range. +impl<'de> Deserialize<'de> for SourceNatConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + // The fields of `SourceNatConfigShadow` should exactly match the fields + // of `SourceNatConfig`. We're not really using serde's remote derive, + // but by adding the attribute we get compile-time checking that all the + // field names and types match. (It doesn't check the _order_, but that + // should be fine as long as we're using JSON or similar formats.) + #[derive(Deserialize)] + #[serde(remote = "SourceNatConfig")] + struct SourceNatConfigShadow { + ip: IpAddr, + first_port: u16, + last_port: u16, + } + + let shadow = SourceNatConfigShadow::deserialize(deserializer)?; + SourceNatConfig::new(shadow.ip, shadow.first_port, shadow.last_port) + .map_err(D::Error::custom) + } +} + +impl SourceNatConfig { + /// Construct a `SourceNatConfig` with the given port range, both inclusive. + /// + /// # Errors + /// + /// Fails if `(first_port, last_port)` is not aligned to + /// [`NUM_SOURCE_NAT_PORTS`]. + pub fn new( + ip: IpAddr, + first_port: u16, + last_port: u16, + ) -> Result { + if first_port % NUM_SOURCE_NAT_PORTS == 0 + && last_port + .checked_sub(first_port) + .and_then(|diff| diff.checked_add(1)) + == Some(NUM_SOURCE_NAT_PORTS) + { + Ok(Self { ip, first_port, last_port }) + } else { + Err(SourceNatConfigError::UnalignedPortPair { + first_port, + last_port, + }) + } + } + + /// Get the port range. + /// + /// Guaranteed to be aligned to [`NUM_SOURCE_NAT_PORTS`]. + pub fn port_range(&self) -> std::ops::RangeInclusive { + self.first_port..=self.last_port + } + + /// Get the port range as a raw tuple; both values are inclusive. + /// + /// Guaranteed to be aligned to [`NUM_SOURCE_NAT_PORTS`]. + pub fn port_range_raw(&self) -> (u16, u16) { + self.port_range().into_inner() + } +} diff --git a/common/src/api/internal/shared/mod.rs b/common/src/api/internal/shared/mod.rs index 43d45fa453b..3113f41c0d6 100644 --- a/common/src/api/internal/shared/mod.rs +++ b/common/src/api/internal/shared/mod.rs @@ -6,7 +6,6 @@ use super::nexus::HostIdentifier; use crate::{ - address::NUM_SOURCE_NAT_PORTS, api::external::{self, BfdMode, ImportExportPolicy, Name, Vni}, disk::DatasetName, zpool_name::ZpoolName, @@ -26,116 +25,23 @@ use std::{ use strum::EnumCount; use uuid::Uuid; +pub mod external_ip; pub mod network_interface; // Re-export latest version of all NIC-related types. pub use network_interface::NetworkInterfaceKind; pub use network_interface::*; -/// An IP address and port range used for source NAT, i.e., making -/// outbound network connections from guests or services. -// Note that `Deserialize` is manually implemented; if you make any changes to -// the fields of this structure, you must make them to that implementation too. -#[derive( - Debug, - Clone, - Copy, - Serialize, - JsonSchema, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - Diffable, -)] -pub struct SourceNatConfig { - /// The external address provided to the instance or service. - pub ip: IpAddr, - /// The first port used for source NAT, inclusive. - first_port: u16, - /// The last port used for source NAT, also inclusive. - last_port: u16, -} - -// We implement `Deserialize` manually to add validity checking on the port -// range. -impl<'de> Deserialize<'de> for SourceNatConfig { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - use serde::de::Error; - - // The fields of `SourceNatConfigShadow` should exactly match the fields - // of `SourceNatConfig`. We're not really using serde's remote derive, - // but by adding the attribute we get compile-time checking that all the - // field names and types match. (It doesn't check the _order_, but that - // should be fine as long as we're using JSON or similar formats.) - #[derive(Deserialize)] - #[serde(remote = "SourceNatConfig")] - struct SourceNatConfigShadow { - ip: IpAddr, - first_port: u16, - last_port: u16, - } - - let shadow = SourceNatConfigShadow::deserialize(deserializer)?; - SourceNatConfig::new(shadow.ip, shadow.first_port, shadow.last_port) - .map_err(D::Error::custom) - } -} - -impl SourceNatConfig { - /// Construct a `SourceNatConfig` with the given port range, both inclusive. - /// - /// # Errors - /// - /// Fails if `(first_port, last_port)` is not aligned to - /// [`NUM_SOURCE_NAT_PORTS`]. - pub fn new( - ip: IpAddr, - first_port: u16, - last_port: u16, - ) -> Result { - if first_port % NUM_SOURCE_NAT_PORTS == 0 - && last_port - .checked_sub(first_port) - .and_then(|diff| diff.checked_add(1)) - == Some(NUM_SOURCE_NAT_PORTS) - { - Ok(Self { ip, first_port, last_port }) - } else { - Err(SourceNatConfigError::UnalignedPortPair { - first_port, - last_port, - }) - } - } - - /// Get the port range. - /// - /// Guaranteed to be aligned to [`NUM_SOURCE_NAT_PORTS`]. - pub fn port_range(&self) -> std::ops::RangeInclusive { - self.first_port..=self.last_port - } - - /// Get the port range as a raw tuple; both values are inclusive. - /// - /// Guaranteed to be aligned to [`NUM_SOURCE_NAT_PORTS`]. - pub fn port_range_raw(&self) -> (u16, u16) { - self.port_range().into_inner() - } -} - -#[derive(Debug, thiserror::Error)] -pub enum SourceNatConfigError { - #[error( - "snat port range is not aligned to {NUM_SOURCE_NAT_PORTS}: \ - ({first_port}, {last_port})" - )] - UnalignedPortPair { first_port: u16, last_port: u16 }, -} +// Re-export latest version of the external IP types. +pub use external_ip::ExternalIpConfig; +pub use external_ip::ExternalIpConfigBuilder; +pub use external_ip::ExternalIps; +pub use external_ip::ExternalIpv4Config; +pub use external_ip::ExternalIpv6Config; +pub use external_ip::SourceNatConfigError; +pub use external_ip::SourceNatConfigGeneric; +pub use external_ip::SourceNatConfigV4; +pub use external_ip::SourceNatConfigV6; // We alias [`PortConfig`] to the current version of the protocol, so // that we can convert between versions as necessary. diff --git a/illumos-utils/src/opte/port_manager.rs b/illumos-utils/src/opte/port_manager.rs index 93cb56a7d86..32d0b31b17f 100644 --- a/illumos-utils/src/opte/port_manager.rs +++ b/illumos-utils/src/opte/port_manager.rs @@ -18,7 +18,10 @@ use macaddr::MacAddr6; use omicron_common::address::IPV4_MULTICAST_RANGE; use omicron_common::address::IPV6_MULTICAST_RANGE; use omicron_common::api::external; +use omicron_common::api::internal::shared::ExternalIpConfig; use omicron_common::api::internal::shared::ExternalIpGatewayMap; +use omicron_common::api::internal::shared::ExternalIpv4Config; +use omicron_common::api::internal::shared::ExternalIpv6Config; use omicron_common::api::internal::shared::InternetGatewayRouterTarget; use omicron_common::api::internal::shared::NetworkInterface; use omicron_common::api::internal::shared::NetworkInterfaceKind; @@ -33,7 +36,6 @@ use omicron_common::api::internal::shared::RouterId; use omicron_common::api::internal::shared::RouterKind; use omicron_common::api::internal::shared::RouterTarget as ApiRouterTarget; use omicron_common::api::internal::shared::RouterVersion; -use omicron_common::api::internal::shared::SourceNatConfig; use omicron_common::api::internal::shared::VirtualNetworkInterfaceHost; use oxide_vpc::api::AddRouterEntryReq; use oxide_vpc::api::DelRouterEntryReq; @@ -131,14 +133,7 @@ impl PortManagerInner { /// Parameters needed to create and configure an OPTE port. pub struct PortCreateParams<'a> { pub nic: &'a NetworkInterface, - // TODO-completeness: These should ideally be grouped together into a type - // that ensures they're all of the same IP version, and that the IP stack - // for the external and VPC-private addresses match. - // - // See https://github.com/oxidecomputer/omicron/issues/9318. - pub source_nat: Option, - pub ephemeral_ip: Option, - pub floating_ips: &'a [IpAddr], + pub external_ips: &'a Option, pub firewall_rules: &'a [ResolvedVpcFirewallRule], pub dhcp_config: DhcpCfg, } @@ -147,180 +142,147 @@ impl<'a> TryFrom<&PortCreateParams<'a>> for IpCfg { type Error = Error; fn try_from(params: &PortCreateParams) -> Result { - match ¶ms.nic.ip_config { - PrivateIpConfig::V4(v4) => { - build_opte_ipv4_config( - v4, - params.source_nat.as_ref(), - params.ephemeral_ip.as_ref(), - ¶ms.floating_ips, - /* is_ipv4_only = */ true, - ) - .map(IpCfg::Ipv4) - } - PrivateIpConfig::V6(v6) => { - build_opte_ipv6_config( - v6, - params.source_nat.as_ref(), - params.ephemeral_ip.as_ref(), - ¶ms.floating_ips, - /* is_ipv6_only = */ true, - ) - .map(IpCfg::Ipv6) - } - PrivateIpConfig::DualStack { v4, v6 } => { - let ipv4 = build_opte_ipv4_config( - v4, - params.source_nat.as_ref(), - params.ephemeral_ip.as_ref(), - ¶ms.floating_ips, - /* is_ipv4_only = */ false, - )?; - let ipv6 = build_opte_ipv6_config( - v6, - params.source_nat.as_ref(), - params.ephemeral_ip.as_ref(), - ¶ms.floating_ips, - /* is_ipv6_only = */ false, - )?; - Ok(IpCfg::DualStack { ipv4, ipv6 }) - } - } + omicron_to_opte_ip_config(¶ms.nic.ip_config, params.external_ips) } } +fn omicron_to_opte_ip_config( + private_ips: &PrivateIpConfig, + external_ips: &Option, +) -> Result { + let cfg = match (private_ips, external_ips) { + // Private and public IPv4 configuration + ( + PrivateIpConfig::V4(private_v4), + Some(ExternalIpConfig::V4(external_v4)), + ) => IpCfg::Ipv4(build_opte_ipv4_config(private_v4, Some(external_v4))), + // Only private IPv4 configuration + (PrivateIpConfig::V4(private_v4), None) => { + IpCfg::Ipv4(build_opte_ipv4_config(private_v4, None)) + } + // Private and public IPv6 configuration + ( + PrivateIpConfig::V6(private_v6), + Some(ExternalIpConfig::V6(external_v6)), + ) => IpCfg::Ipv6(build_opte_ipv6_config(private_v6, Some(external_v6))), + // Only private IPv6 configuration + (PrivateIpConfig::V6(private_v6), None) => { + IpCfg::Ipv6(build_opte_ipv6_config(private_v6, None)) + } + // Private and public dual-stack configuration + ( + PrivateIpConfig::DualStack { v4: private_v4, v6: private_v6 }, + Some(ExternalIpConfig::DualStack { + v4: external_v4, + v6: external_v6, + }), + ) => { + let ipv4 = build_opte_ipv4_config(private_v4, Some(external_v4)); + let ipv6 = build_opte_ipv6_config(private_v6, Some(external_v6)); + IpCfg::DualStack { ipv4, ipv6 } + } + // Only private dual-stack configuration. + ( + PrivateIpConfig::DualStack { v4: private_v4, v6: private_v6 }, + None, + ) => { + let ipv4 = build_opte_ipv4_config(private_v4, None); + let ipv6 = build_opte_ipv6_config(private_v6, None); + IpCfg::DualStack { ipv4, ipv6 } + } + // Private dual-stack, public IPv4 configuration. + ( + PrivateIpConfig::DualStack { v4: private_v4, v6: private_v6 }, + Some(ExternalIpConfig::V4(external_v4)), + ) => { + let ipv4 = build_opte_ipv4_config(private_v4, Some(external_v4)); + let ipv6 = build_opte_ipv6_config(private_v6, None); + IpCfg::DualStack { ipv4, ipv6 } + } + // Private dual-stack, public IPv6 configuration. + ( + PrivateIpConfig::DualStack { v4: private_v4, v6: private_v6 }, + Some(ExternalIpConfig::V6(external_v6)), + ) => { + let ipv4 = build_opte_ipv4_config(private_v4, None); + let ipv6 = build_opte_ipv6_config(private_v6, Some(external_v6)); + IpCfg::DualStack { ipv4, ipv6 } + } + // Cannot have external config of a different version, either + // because we are single-stack but of the wrong version, or + // dual-stack public, but single-stack private. + (PrivateIpConfig::V6(_), Some(ExternalIpConfig::V4(_))) + | (PrivateIpConfig::V4(_), Some(ExternalIpConfig::V6(_))) + | ( + PrivateIpConfig::V6(_) | PrivateIpConfig::V4(_), + Some(ExternalIpConfig::DualStack { .. }), + ) => return Err(Error::InvalidPortIpConfig), + }; + Ok(cfg) +} + fn build_opte_ipv4_config( - v4: &PrivateIpv4Config, - source_nat: Option<&SourceNatConfig>, - ephemeral_ip: Option<&IpAddr>, - floating_ips: &[IpAddr], - is_ipv4_only: bool, -) -> Result { - let gateway_ip = v4.opte_gateway().into(); - let vpc_subnet = Ipv4Cidr::from(Ipv4Network::from(*v4.subnet())); - let private_ip = (*v4.ip()).into(); - let external_ips = build_external_ipv4_config( - source_nat, - ephemeral_ip, - floating_ips, - is_ipv4_only, - )?; - Ok(Ipv4Cfg { vpc_subnet, private_ip, gateway_ip, external_ips }) + private_v4: &PrivateIpv4Config, + external_v4: Option<&ExternalIpv4Config>, +) -> Ipv4Cfg { + let gateway_ip = private_v4.opte_gateway().into(); + let vpc_subnet = Ipv4Cidr::from(Ipv4Network::from(*private_v4.subnet())); + let private_ip = (*private_v4.ip()).into(); + let external_ips = build_external_ipv4_config(external_v4); + Ipv4Cfg { vpc_subnet, private_ip, gateway_ip, external_ips } } fn build_opte_ipv6_config( - v6: &PrivateIpv6Config, - source_nat: Option<&SourceNatConfig>, - ephemeral_ip: Option<&IpAddr>, - floating_ips: &[IpAddr], - is_ipv6_only: bool, -) -> Result { - let gateway_ip = v6.opte_gateway().into(); - let vpc_subnet = Ipv6Cidr::from(Ipv6Network::from(*v6.subnet())); - let private_ip = (*v6.ip()).into(); - let external_ips = build_external_ipv6_config( - source_nat, - ephemeral_ip, - floating_ips, - is_ipv6_only, - )?; - Ok(Ipv6Cfg { vpc_subnet, private_ip, gateway_ip, external_ips }) + private_v6: &PrivateIpv6Config, + external_v6: Option<&ExternalIpv6Config>, +) -> Ipv6Cfg { + let gateway_ip = private_v6.opte_gateway().into(); + let vpc_subnet = Ipv6Cidr::from(Ipv6Network::from(*private_v6.subnet())); + let private_ip = (*private_v6.ip()).into(); + let external_ips = build_external_ipv6_config(external_v6); + Ipv6Cfg { vpc_subnet, private_ip, gateway_ip, external_ips } } // Build an ExternalIpCfg from parameters. fn build_external_ipv4_config( - source_nat: Option<&SourceNatConfig>, - ephemeral_ip: Option<&IpAddr>, - floating_ips: &[IpAddr], - is_ipv4_only: bool, -) -> Result, Error> { - let snat = match source_nat { - None => None, - Some(snat) => match snat.ip { - IpAddr::V4(ipv4) => Some(SNat4Cfg { - external_ip: ipv4.into(), - ports: snat.port_range(), - }), - IpAddr::V6(_) => { - if is_ipv4_only { - return Err(Error::InvalidPortIpConfig); - } - None - } - }, - }; - let ephemeral_ip = match ephemeral_ip { - Some(IpAddr::V4(ipv4)) => Some((*ipv4).into()), - Some(IpAddr::V6(_)) => { - if is_ipv4_only { - return Err(Error::InvalidPortIpConfig); - } - None - } - None => None, + external_v4: Option<&ExternalIpv4Config>, +) -> ExternalIpCfg { + let Some(v4) = external_v4 else { + return ExternalIpCfg { + snat: None, + ephemeral_ip: None, + floating_ips: vec![], + }; }; - let floating_ips = floating_ips - .iter() - .filter_map(|ip| match ip { - IpAddr::V4(ipv4) => Some(Ok((*ipv4).into())), - IpAddr::V6(_) => { - if is_ipv4_only { - Some(Err(Error::InvalidPortIpConfig)) - } else { - None - } - } - }) - .collect::>()?; - Ok(ExternalIpCfg { snat, ephemeral_ip, floating_ips }) + let snat = v4.source_nat().map(|snat| SNat4Cfg { + external_ip: snat.ip.into(), + ports: snat.port_range(), + }); + let ephemeral_ip = v4.ephemeral_ip().as_ref().copied().map(Into::into); + let floating_ips = + v4.floating_ips().iter().copied().map(Into::into).collect(); + ExternalIpCfg { snat, ephemeral_ip, floating_ips } } // Build an OPTE External IPv6 configuration from parameters. fn build_external_ipv6_config( - source_nat: Option<&SourceNatConfig>, - ephemeral_ip: Option<&IpAddr>, - floating_ips: &[IpAddr], - is_ipv6_only: bool, -) -> Result, Error> { - let snat = match source_nat { - None => None, - Some(snat) => match snat.ip { - IpAddr::V6(ipv6) => Some(SNat6Cfg { - external_ip: ipv6.into(), - ports: snat.port_range(), - }), - IpAddr::V4(_) => { - if is_ipv6_only { - return Err(Error::InvalidPortIpConfig); - } - None - } - }, - }; - let ephemeral_ip = match ephemeral_ip { - Some(IpAddr::V6(ipv6)) => Some((*ipv6).into()), - Some(IpAddr::V4(_)) => { - if is_ipv6_only { - return Err(Error::InvalidPortIpConfig); - } - None - } - None => None, + external_v6: Option<&ExternalIpv6Config>, +) -> ExternalIpCfg { + let Some(v6) = external_v6 else { + return ExternalIpCfg { + snat: None, + ephemeral_ip: None, + floating_ips: vec![], + }; }; - let floating_ips = floating_ips - .iter() - .filter_map(|ip| match ip { - IpAddr::V6(ipv6) => Some(Ok((*ipv6).into())), - IpAddr::V4(_) => { - if is_ipv6_only { - Some(Err(Error::InvalidPortIpConfig)) - } else { - None - } - } - }) - .collect::>()?; - Ok(ExternalIpCfg { snat, ephemeral_ip, floating_ips }) + let snat = v6.source_nat().map(|snat| SNat6Cfg { + external_ip: snat.ip.into(), + ports: snat.port_range(), + }); + let ephemeral_ip = v6.ephemeral_ip().as_ref().copied().map(Into::into); + let floating_ips = + v6.floating_ips().iter().copied().map(Into::into).collect(); + ExternalIpCfg { snat, ephemeral_ip, floating_ips } } /// The port manager controls all OPTE ports on a single host. @@ -354,14 +316,8 @@ impl PortManager { params: PortCreateParams, ) -> Result<(Port, PortTicket), Error> { let ip_cfg = IpCfg::try_from(¶ms)?; - let PortCreateParams { - nic, - source_nat, - ephemeral_ip, - floating_ips, - firewall_rules, - dhcp_config, - } = params; + let PortCreateParams { nic, external_ips, firewall_rules, dhcp_config } = + params; let is_service = matches!(nic.kind, NetworkInterfaceKind::Service { .. }); let is_instance = @@ -438,13 +394,9 @@ impl PortManager { // `Instance::refresh_external_ips_inner`), and to prevent updates // racing with nexus before an instance/port are reachable from their // respective managers. - self.external_ips_ensure_port( - &port, - nic.id, - source_nat, - ephemeral_ip, - floating_ips, - )?; + if let Some(eips) = external_ips { + self.external_ips_ensure_port(&port, nic.id, eips)?; + } } (port, ticket) }; @@ -732,22 +684,14 @@ impl PortManager { &self, nic_id: Uuid, nic_kind: NetworkInterfaceKind, - source_nat: Option, - ephemeral_ip: Option, - floating_ips: &[IpAddr], + external_ips: &ExternalIpConfig, ) -> Result<(), Error> { let ports = self.inner.ports.lock().unwrap(); let port = ports.get(&(nic_id, nic_kind)).ok_or_else(|| { Error::ExternalIpUpdateMissingPort(nic_id, nic_kind) })?; - self.external_ips_ensure_port( - port, - nic_id, - source_nat, - ephemeral_ip, - floating_ips, - ) + self.external_ips_ensure_port(port, nic_id, external_ips) } /// Ensure external IPs for an OPTE port are up to date. @@ -755,56 +699,28 @@ impl PortManager { &self, port: &Port, nic_id: Uuid, - source_nat: Option, - ephemeral_ip: Option, - floating_ips: &[IpAddr], + external_ips: &ExternalIpConfig, ) -> Result<(), Error> { let egw_lock = self.inner.eip_gateways.lock().unwrap(); let inet_gw_map = egw_lock.get(&nic_id).cloned(); drop(egw_lock); - let has_ipv4_stack = port.ipv4_addr().is_some(); - let has_ipv6_stack = port.ipv6_addr().is_some(); - let external_ips_v4 = build_external_ipv4_config( - source_nat.as_ref(), - ephemeral_ip.as_ref(), - floating_ips, - has_ipv4_stack, - )?; - let external_ips_v6 = build_external_ipv6_config( - source_nat.as_ref(), - ephemeral_ip.as_ref(), - floating_ips, - has_ipv6_stack, - )?; - - // The above functions building the external address configuration only - // fail if we're provided external addresses from an IP version we don't - // have a VPC-private IP stack for. E.g., IPv6 external IPs for an - // IPv4-only interface. If we're provided IPv6 external IPs and we have - // both an IPv4 and IPv6 interface, then those methods succeed, but - // return an `ExternalIpCfg` where all the fields are empty. + // NOTE: The Option::map() call here is a bit confusing. // - // However, the `SetExternalIpsReq` method accepts an _option_ around - // those values. Those should be None if there are zero addresses of the - // corresponding version in the parameters. In that case, all the fields - // of the `ExternalIpCfg`s are None or empty. This function does that - // conversion for us. - fn convert_empty_ip_cfg( - cfg: ExternalIpCfg, - ) -> Option> { - if cfg.snat.is_none() - && cfg.ephemeral_ip.is_none() - && cfg.floating_ips.is_empty() - { - None - } else { - Some(cfg) - } - } - let external_ips_v4 = convert_empty_ip_cfg(external_ips_v4); - let external_ips_v6 = convert_empty_ip_cfg(external_ips_v6); - + // The `SetExternalIpsReq` type uses an `Option` around the IP + // configuration. However, `build_external_ipv{4,6}_config` accept an + // option and "push" that into the returned configuration type, i.e., + // it's fields are optional rather than returning an option. + // + // We map the option so we can get the `None` we need for the + // `SetExternalIpsReq`. But that does mean we always call + // `build_external_ipv{4,6}_config` with `Some(_)`. + let external_ips_v4 = external_ips + .ipv4_config() + .map(|v4| build_external_ipv4_config(Some(v4))); + let external_ips_v6 = external_ips + .ipv6_config() + .map(|v6| build_external_ipv6_config(Some(v6))); let inet_gw_map = if let Some(map) = inet_gw_map { Some( map.into_iter() @@ -1115,26 +1031,36 @@ impl Drop for PortTicket { #[cfg(test)] mod tests { + use super::PortCreateParams; + use super::PortManager; use crate::opte::Handle; - - use super::{PortCreateParams, PortManager}; use macaddr::MacAddr6; - use omicron_common::api::{ - external::{MacAddr, Vni}, - internal::shared::{ - InternetGatewayRouterTarget, NetworkInterface, - NetworkInterfaceKind, PrivateIpConfig, PrivateIpv4Config, - PrivateIpv6Config, ResolvedVpcRoute, ResolvedVpcRouteSet, - RouterTarget, RouterVersion, SourceNatConfig, - }, - }; + use omicron_common::api::external::{MacAddr, Vni}; + use omicron_common::api::internal::shared::ExternalIpConfig; + use omicron_common::api::internal::shared::ExternalIpConfigBuilder; + use omicron_common::api::internal::shared::InternetGatewayRouterTarget; + use omicron_common::api::internal::shared::NetworkInterface; + use omicron_common::api::internal::shared::NetworkInterfaceKind; + use omicron_common::api::internal::shared::PrivateIpConfig; + use omicron_common::api::internal::shared::PrivateIpv4Config; + use omicron_common::api::internal::shared::PrivateIpv6Config; + use omicron_common::api::internal::shared::ResolvedVpcRoute; + use omicron_common::api::internal::shared::ResolvedVpcRouteSet; + use omicron_common::api::internal::shared::RouterTarget; + use omicron_common::api::internal::shared::RouterVersion; + use omicron_common::api::internal::shared::SourceNatConfigV4; + use omicron_common::api::internal::shared::SourceNatConfigV6; use omicron_test_utils::dev::test_setup_log; - use oxide_vpc::api::{DhcpCfg, IpCfg, Ipv4Cidr, Ipv6Cidr}; - use oxnet::{IpNet, Ipv4Net, Ipv6Net}; - use std::{ - collections::HashSet, - net::{IpAddr, Ipv4Addr, Ipv6Addr}, - }; + use oxide_vpc::api::DhcpCfg; + use oxide_vpc::api::IpCfg; + use oxide_vpc::api::Ipv4Cidr; + use oxide_vpc::api::Ipv6Cidr; + use oxnet::IpNet; + use oxnet::Ipv4Net; + use oxnet::Ipv6Net; + use std::collections::HashSet; + use std::net::Ipv4Addr; + use std::net::Ipv6Addr; use uuid::Uuid; // Regression for https://github.com/oxidecomputer/omicron/issues/7541. @@ -1175,8 +1101,18 @@ mod tests { let ip_config1 = PrivateIpConfig::new_ipv4(private_ipv4_addr1, private_subnet) .unwrap(); - let public_ipv4_addr0 = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 4)); - let public_ipv4_addr1 = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)); + let public_ipv4_addr0 = Ipv4Addr::new(10, 0, 0, 4); + let public_ipv4_addr1 = Ipv4Addr::new(10, 0, 0, 5); + let external_ip_config0 = Some( + ExternalIpConfigBuilder::new() + .with_source_nat( + SourceNatConfigV4::new(public_ipv4_addr0, 0, MAX_PORT) + .unwrap(), + ) + .build() + .unwrap() + .into(), + ); const MAX_PORT: u16 = (1 << 14) - 1; let (port0, _ticket0) = manager .create_port(PortCreateParams { @@ -1192,12 +1128,7 @@ mod tests { primary: true, slot: 0, }, - source_nat: Some( - SourceNatConfig::new(public_ipv4_addr0, 0, MAX_PORT) - .unwrap(), - ), - ephemeral_ip: None, - floating_ips: &[], + external_ips: &external_ip_config0, firewall_rules: &[], dhcp_config: DhcpCfg { hostname: None, @@ -1351,6 +1282,16 @@ mod tests { // this point on creation, but not the other port since we don't modify // it when we create this second port. That happens when we call // `vpc_routes_ensure` below. + let external_ip_config1 = Some( + ExternalIpConfigBuilder::new() + .with_source_nat( + SourceNatConfigV4::new(public_ipv4_addr1, 0, MAX_PORT) + .unwrap(), + ) + .build() + .unwrap() + .into(), + ); let (port1, _ticket1) = manager .create_port(PortCreateParams { nic: &NetworkInterface { @@ -1365,12 +1306,7 @@ mod tests { primary: true, slot: 0, }, - source_nat: Some( - SourceNatConfig::new(public_ipv4_addr1, 0, MAX_PORT) - .unwrap(), - ), - ephemeral_ip: None, - floating_ips: &[], + external_ips: &external_ip_config1, firewall_rules: &[], dhcp_config: DhcpCfg { hostname: None, @@ -1530,13 +1466,17 @@ mod tests { primary: true, slot: 0, }; - let source_nat = - SourceNatConfig::new(IpAddr::V4(ext_ip), 0, 16383).unwrap(); + let source_nat = SourceNatConfigV4::new(ext_ip, 0, 16383).unwrap(); + let external_ips = Some( + ExternalIpConfigBuilder::new() + .with_source_nat(source_nat) + .build() + .unwrap() + .into(), + ); let prs = PortCreateParams { nic: &nic, - source_nat: Some(source_nat), - ephemeral_ip: None, - floating_ips: &[], + external_ips: &external_ips, firewall_rules: &[], dhcp_config: DhcpCfg { hostname: None, @@ -1594,13 +1534,17 @@ mod tests { primary: true, slot: 0, }; - let source_nat = - SourceNatConfig::new(IpAddr::V6(ext_ip), 0, 16383).unwrap(); + let source_nat = SourceNatConfigV6::new(ext_ip, 0, 16383).unwrap(); + let external_ips = Some( + ExternalIpConfigBuilder::new() + .with_source_nat(source_nat) + .build() + .unwrap() + .into(), + ); let prs = PortCreateParams { nic: &nic, - source_nat: Some(source_nat), - ephemeral_ip: None, - floating_ips: &[], + external_ips: &external_ips, firewall_rules: &[], dhcp_config: DhcpCfg { hostname: None, @@ -1668,13 +1612,22 @@ mod tests { }; // Ipv4 source NAT, Ipv6 ephemeral - let source_nat = - SourceNatConfig::new(IpAddr::V4(ext_ipv4), 0, 16383).unwrap(); + let source_nat = SourceNatConfigV4::new(ext_ipv4, 0, 16383).unwrap(); + let external_ipv4 = ExternalIpConfigBuilder::new() + .with_source_nat(source_nat) + .build() + .unwrap(); + let external_ipv6 = ExternalIpConfigBuilder::new() + .with_ephemeral_ip(ext_ipv6) + .build() + .unwrap(); + let external_ips = Some(ExternalIpConfig::DualStack { + v4: external_ipv4, + v6: external_ipv6, + }); let prs = PortCreateParams { nic: &nic, - source_nat: Some(source_nat), - ephemeral_ip: Some(ext_ipv6.into()), - floating_ips: &[], + external_ips: &external_ips, firewall_rules: &[], dhcp_config: DhcpCfg { hostname: None, @@ -1753,13 +1706,17 @@ mod tests { primary: true, slot: 0, }; - let source_nat = - SourceNatConfig::new(IpAddr::V6(ext_ip), 0, 16383).unwrap(); + let source_nat = SourceNatConfigV6::new(ext_ip, 0, 16383).unwrap(); + let external_ips = Some( + ExternalIpConfigBuilder::new() + .with_source_nat(source_nat) + .build() + .unwrap() + .into(), + ); let prs = PortCreateParams { nic: &nic, - source_nat: Some(source_nat), - ephemeral_ip: None, - floating_ips: &[], + external_ips: &external_ips, firewall_rules: &[], dhcp_config: DhcpCfg { hostname: None, @@ -1794,13 +1751,17 @@ mod tests { primary: true, slot: 0, }; - let source_nat = - SourceNatConfig::new(IpAddr::V4(ext_ip), 0, 16383).unwrap(); + let source_nat = SourceNatConfigV4::new(ext_ip, 0, 16383).unwrap(); + let external_ips = Some( + ExternalIpConfigBuilder::new() + .with_source_nat(source_nat) + .build() + .unwrap() + .into(), + ); let prs = PortCreateParams { nic: &nic, - source_nat: Some(source_nat), - ephemeral_ip: None, - floating_ips: &[], + external_ips: &external_ips, firewall_rules: &[], dhcp_config: DhcpCfg { hostname: None, diff --git a/nexus-sled-agent-shared/src/inventory.rs b/nexus-sled-agent-shared/src/inventory.rs index 0a581fdcdca..c154ba4eb80 100644 --- a/nexus-sled-agent-shared/src/inventory.rs +++ b/nexus-sled-agent-shared/src/inventory.rs @@ -24,7 +24,7 @@ use omicron_common::update::OmicronZoneManifestSource; use omicron_common::{ api::{ external::{ByteCount, Generation}, - internal::shared::{NetworkInterface, SourceNatConfig}, + internal::shared::{NetworkInterface, SourceNatConfigGeneric}, }, disk::{DatasetConfig, DiskVariant, OmicronPhysicalDiskConfig}, update::ArtifactId, @@ -1176,7 +1176,7 @@ pub enum OmicronZoneType { /// The service vNIC providing outbound connectivity using OPTE. nic: NetworkInterface, /// The SNAT configuration for outbound connections. - snat_cfg: SourceNatConfig, + snat_cfg: SourceNatConfigGeneric, }, /// Type of clickhouse zone used for a single node clickhouse deployment diff --git a/nexus/db-model/src/deployment.rs b/nexus/db-model/src/deployment.rs index 02c6d3b5404..68a44f8caa9 100644 --- a/nexus/db-model/src/deployment.rs +++ b/nexus/db-model/src/deployment.rs @@ -896,7 +896,7 @@ impl BpOmicronZone { self.snat_last_port, ) { (Some(ip), Some(first_port), Some(last_port)) => { - nexus_types::inventory::SourceNatConfig::new( + nexus_types::inventory::SourceNatConfigGeneric::new( ip.ip(), *first_port, *last_port, diff --git a/nexus/db-model/src/external_ip.rs b/nexus/db-model/src/external_ip.rs index 3f9e57c34c3..4588ddfd465 100644 --- a/nexus/db-model/src/external_ip.rs +++ b/nexus/db-model/src/external_ip.rs @@ -26,7 +26,7 @@ use nexus_types::external_api::shared; use nexus_types::external_api::shared::ProbeExternalIp; use nexus_types::external_api::shared::ProbeExternalIpKind; use nexus_types::external_api::views; -use nexus_types::inventory::SourceNatConfig; +use nexus_types::inventory::SourceNatConfigGeneric; use omicron_common::api::external::Error; use omicron_common::api::external::IdentityMetadata; use omicron_common::api::internal::shared::SourceNatConfigError; @@ -170,7 +170,7 @@ impl TryFrom<&'_ ExternalIp> for OmicronZoneExternalIp { match row.kind { IpKind::SNat => Ok(Self::Snat(OmicronZoneExternalSnatIp { id: ExternalIpUuid::from_untyped_uuid(row.id), - snat_cfg: SourceNatConfig::new( + snat_cfg: SourceNatConfigGeneric::new( row.ip.ip(), row.first_port.0, row.last_port.0, @@ -231,7 +231,7 @@ pub struct FloatingIp { } impl TryFrom - for omicron_common::api::internal::shared::SourceNatConfig + for omicron_common::api::internal::shared::SourceNatConfigGeneric { type Error = SourceNatConfigError; diff --git a/nexus/db-model/src/inventory.rs b/nexus/db-model/src/inventory.rs index b2e1c3ed10d..ce6bea7e863 100644 --- a/nexus/db-model/src/inventory.rs +++ b/nexus/db-model/src/inventory.rs @@ -2667,7 +2667,7 @@ impl InvOmicronSledConfigZone { self.snat_last_port, ) { (Some(ip), Some(first_port), Some(last_port)) => { - nexus_types::inventory::SourceNatConfig::new( + nexus_types::inventory::SourceNatConfigGeneric::new( ip.ip(), *first_port, *last_port, diff --git a/nexus/db-queries/src/db/datastore/deployment/external_networking.rs b/nexus/db-queries/src/db/datastore/deployment/external_networking.rs index 39a976a2eb3..1c2a063227c 100644 --- a/nexus/db-queries/src/db/datastore/deployment/external_networking.rs +++ b/nexus/db-queries/src/db/datastore/deployment/external_networking.rs @@ -531,7 +531,7 @@ mod tests { use nexus_types::deployment::OmicronZoneExternalSnatIp; use nexus_types::deployment::blueprint_zone_type; use nexus_types::identity::Resource; - use nexus_types::inventory::SourceNatConfig; + use nexus_types::inventory::SourceNatConfigGeneric; use omicron_common::address::DNS_OPTE_IPV4_SUBNET; use omicron_common::address::IpRange; use omicron_common::address::IpRangeIter; @@ -657,7 +657,7 @@ mod tests { let ntp_id = OmicronZoneUuid::new_v4(); let ntp_external_ip = OmicronZoneExternalSnatIp { id: ExternalIpUuid::new_v4(), - snat_cfg: SourceNatConfig::new( + snat_cfg: SourceNatConfigGeneric::new( external_ips.next().expect("exhausted external_ips"), NUM_SOURCE_NAT_PORTS, 2 * NUM_SOURCE_NAT_PORTS - 1, @@ -1137,7 +1137,7 @@ mod tests { external_ip.snat_cfg.port_range_raw(); first += NUM_SOURCE_NAT_PORTS; last += NUM_SOURCE_NAT_PORTS; - external_ip.snat_cfg = SourceNatConfig::new( + external_ip.snat_cfg = SourceNatConfigGeneric::new( external_ip.snat_cfg.ip, first, last, diff --git a/nexus/db-queries/src/db/datastore/external_ip.rs b/nexus/db-queries/src/db/datastore/external_ip.rs index 9b8f93c7a72..68971597441 100644 --- a/nexus/db-queries/src/db/datastore/external_ip.rs +++ b/nexus/db-queries/src/db/datastore/external_ip.rs @@ -1159,7 +1159,7 @@ mod tests { use nexus_types::deployment::OmicronZoneExternalFloatingIp; use nexus_types::deployment::OmicronZoneExternalSnatIp; use nexus_types::external_api::shared::IpRange; - use nexus_types::inventory::SourceNatConfig; + use nexus_types::inventory::SourceNatConfigGeneric; use omicron_common::address::NUM_SOURCE_NAT_PORTS; use omicron_test_utils::dev; use omicron_uuid_kinds::ExternalIpUuid; @@ -1214,7 +1214,7 @@ mod tests { let external_ip = if allocate_snat { OmicronZoneExternalIp::Snat(OmicronZoneExternalSnatIp { id: ExternalIpUuid::new_v4(), - snat_cfg: SourceNatConfig::new( + snat_cfg: SourceNatConfigGeneric::new( ip, 0, NUM_SOURCE_NAT_PORTS - 1, diff --git a/nexus/db-queries/src/db/queries/external_ip.rs b/nexus/db-queries/src/db/queries/external_ip.rs index a9353171cc0..332ed5c3b65 100644 --- a/nexus/db-queries/src/db/queries/external_ip.rs +++ b/nexus/db-queries/src/db/queries/external_ip.rs @@ -829,7 +829,7 @@ mod tests { use nexus_types::deployment::OmicronZoneExternalSnatIp; use nexus_types::external_api::params::InstanceCreate; use nexus_types::external_api::shared::IpRange; - use nexus_types::inventory::SourceNatConfig; + use nexus_types::inventory::SourceNatConfigGeneric; use omicron_common::address::NUM_SOURCE_NAT_PORTS; use omicron_common::api::external::Error; use omicron_common::api::external::IdentityMetadataCreateParams; @@ -1388,8 +1388,12 @@ mod tests { let ip_10_0_0_3_snat_0 = OmicronZoneExternalIp::Snat(OmicronZoneExternalSnatIp { id: ip_10_0_0_3.id(), - snat_cfg: SourceNatConfig::new(ip_10_0_0_3.ip(), 0, 16383) - .unwrap(), + snat_cfg: SourceNatConfigGeneric::new( + ip_10_0_0_3.ip(), + 0, + 16383, + ) + .unwrap(), }); let err = context .db.datastore() @@ -1410,7 +1414,7 @@ mod tests { let ip_10_0_0_1_snat_32768 = OmicronZoneExternalIp::Snat(OmicronZoneExternalSnatIp { id: ExternalIpUuid::new_v4(), - snat_cfg: SourceNatConfig::new( + snat_cfg: SourceNatConfigGeneric::new( "10.0.0.1".parse().unwrap(), 32768, 49151, @@ -1462,7 +1466,7 @@ mod tests { let ip_10_0_0_1_snat_49152 = OmicronZoneExternalIp::Snat(OmicronZoneExternalSnatIp { id: ip_10_0_0_1_snat_32768.id(), - snat_cfg: SourceNatConfig::new( + snat_cfg: SourceNatConfigGeneric::new( ip_10_0_0_1_snat_32768.ip(), 49152, 65535, @@ -1786,7 +1790,7 @@ mod tests { id: ExternalIpUuid::from_untyped_uuid(uuid::uuid!( "cd7bf0bc-72f6-497d-89b9-787039da448a" )), - snat_cfg: SourceNatConfig::new( + snat_cfg: SourceNatConfigGeneric::new( "10.0.0.1".parse().unwrap(), 0, (1 << 14) - 1, @@ -2057,7 +2061,7 @@ mod tests { let id = ExternalIpUuid::new_v4(); let snat = OmicronZoneExternalIp::Snat(OmicronZoneExternalSnatIp { id, - snat_cfg: SourceNatConfig::new( + snat_cfg: SourceNatConfigGeneric::new( expected_addr, first_port, last_port, diff --git a/nexus/inventory/src/examples.rs b/nexus/inventory/src/examples.rs index be60e52249a..c3ae457d84f 100644 --- a/nexus/inventory/src/examples.rs +++ b/nexus/inventory/src/examples.rs @@ -53,6 +53,7 @@ use omicron_uuid_kinds::PhysicalDiskUuid; use omicron_uuid_kinds::SledUuid; use omicron_uuid_kinds::ZpoolUuid; use sled_agent_types::inventory::v9::OmicronZonesConfig as OmicronZonesConfigV9; +use sled_agent_types::inventory::v10::OmicronZonesConfig as OmicronZonesConfigV10; use sled_agent_types::zone_images::MupdateOverrideNonBootInfo; use sled_agent_types::zone_images::MupdateOverrideNonBootMismatch; use sled_agent_types::zone_images::MupdateOverrideNonBootResult; @@ -382,15 +383,14 @@ pub fn representative() -> Representative { let sled14_data = include_str!("../example-data/madrid-sled14.json"); let sled16_data = include_str!("../example-data/madrid-sled16.json"); let sled17_data = include_str!("../example-data/madrid-sled17.json"); - let sled14_v9: OmicronZonesConfigV9 = - serde_json::from_str(sled14_data).unwrap(); - let sled16_v9: OmicronZonesConfigV9 = - serde_json::from_str(sled16_data).unwrap(); - let sled17_v9: OmicronZonesConfigV9 = - serde_json::from_str(sled17_data).unwrap(); - let sled14 = OmicronZonesConfig::try_from(sled14_v9).unwrap(); - let sled16 = OmicronZonesConfig::try_from(sled16_v9).unwrap(); - let sled17 = OmicronZonesConfig::try_from(sled17_v9).unwrap(); + let extract_current_omicron_zones_config = |data: &str| { + let as_v9: OmicronZonesConfigV9 = serde_json::from_str(data).unwrap(); + OmicronZonesConfigV10::try_from(as_v9) + .and_then(OmicronZonesConfig::try_from) + }; + let sled14 = extract_current_omicron_zones_config(sled14_data).unwrap(); + let sled16 = extract_current_omicron_zones_config(sled16_data).unwrap(); + let sled17 = extract_current_omicron_zones_config(sled17_data).unwrap(); // Convert these to `OmicronSledConfig`s. We'll start with empty disks and // datasets for now, and add to them below for sled14. diff --git a/nexus/reconfigurator/planning/src/blueprint_editor/allocators/external_networking.rs b/nexus/reconfigurator/planning/src/blueprint_editor/allocators/external_networking.rs index c63eaad88d0..3a3c31686cf 100644 --- a/nexus/reconfigurator/planning/src/blueprint_editor/allocators/external_networking.rs +++ b/nexus/reconfigurator/planning/src/blueprint_editor/allocators/external_networking.rs @@ -11,7 +11,7 @@ use nexus_types::deployment::BlueprintZoneDisposition; use nexus_types::deployment::BlueprintZoneType; use nexus_types::deployment::ExternalIpPolicy; use nexus_types::deployment::OmicronZoneExternalIp; -use nexus_types::inventory::SourceNatConfig; +use nexus_types::inventory::SourceNatConfigGeneric; use omicron_common::address::DNS_OPTE_IPV4_SUBNET; use omicron_common::address::DNS_OPTE_IPV6_SUBNET; use omicron_common::address::NEXUS_OPTE_IPV4_SUBNET; @@ -414,7 +414,7 @@ pub struct ExternalNetworkingChoice { #[derive(Debug, Clone)] pub struct ExternalSnatNetworkingChoice { - pub snat_cfg: SourceNatConfig, + pub snat_cfg: SourceNatConfigGeneric, pub nic_ip_config: PrivateIpConfig, pub nic_mac: MacAddr, } @@ -575,7 +575,7 @@ impl ExternalIpAllocator { fn claim_next_snat_ip( &mut self, - ) -> Result { + ) -> Result { // Prefer reusing an existing SNAT IP, if we still have port ranges // available on that ip. for (ip, used_port_ranges) in self.used_snat_ips.iter_mut() { @@ -618,7 +618,7 @@ enum SnatPortRange { } impl SnatPortRange { - fn into_source_nat_config(self, ip: IpAddr) -> SourceNatConfig { + fn into_source_nat_config(self, ip: IpAddr) -> SourceNatConfigGeneric { let first = match self { SnatPortRange::One => 0, SnatPortRange::Two => NUM_SOURCE_NAT_PORTS, @@ -630,9 +630,10 @@ impl SnatPortRange { let last = first + (NUM_SOURCE_NAT_PORTS - 1); // By construction our (first, last) pair is aligned, so we can unwrap - // here. We'll use an explicit match to guard against `SourceNatConfig` - // gaining other kinds of validation we're currently not aware of. - match SourceNatConfig::new(ip, first, last) { + // here. We'll use an explicit match to guard against + // `SourceNatConfigGeneric` gaining other kinds of validation we're + // currently not aware of. + match SourceNatConfigGeneric::new(ip, first, last) { Ok(cfg) => cfg, Err(SourceNatConfigError::UnalignedPortPair { .. }) => { unreachable!("port pair guaranteed aligned: {first}, {last}"); diff --git a/nexus/reconfigurator/planning/tests/integration_tests/planner.rs b/nexus/reconfigurator/planning/tests/integration_tests/planner.rs index 8cfe3277f56..e0a620a3c8d 100644 --- a/nexus/reconfigurator/planning/tests/integration_tests/planner.rs +++ b/nexus/reconfigurator/planning/tests/integration_tests/planner.rs @@ -56,7 +56,7 @@ use omicron_common::api::external::Vni; use omicron_common::api::internal::shared::NetworkInterface; use omicron_common::api::internal::shared::NetworkInterfaceKind; use omicron_common::api::internal::shared::PrivateIpConfig; -use omicron_common::api::internal::shared::SourceNatConfig; +use omicron_common::api::internal::shared::SourceNatConfigGeneric; use omicron_common::disk::DatasetKind; use omicron_common::disk::DiskIdentity; use omicron_common::policy::BOUNDARY_NTP_REDUNDANCY; @@ -3791,7 +3791,7 @@ fn test_update_boundary_ntp() { }, external_ip: OmicronZoneExternalSnatIp { id: ExternalIpUuid::new_v4(), - snat_cfg: SourceNatConfig::new( + snat_cfg: SourceNatConfigGeneric::new( IpAddr::V6(Ipv6Addr::LOCALHOST), 0, 0x4000 - 1, diff --git a/nexus/src/app/instance.rs b/nexus/src/app/instance.rs index a402ee2c1bd..b5449953304 100644 --- a/nexus/src/app/instance.rs +++ b/nexus/src/app/instance.rs @@ -18,8 +18,11 @@ use crate::external_api::params; use cancel_safe_futures::prelude::*; use futures::future::Fuse; use futures::{FutureExt, SinkExt, StreamExt}; +use itertools::Either; +use itertools::Itertools as _; use nexus_db_lookup::LookupPath; use nexus_db_lookup::lookup; +use nexus_db_model::ExternalIp; use nexus_db_model::InstanceIntendedState as IntendedState; use nexus_db_model::InstanceUpdate; use nexus_db_model::IpAttachState; @@ -51,7 +54,11 @@ use omicron_common::api::external::NameOrId; use omicron_common::api::external::UpdateResult; use omicron_common::api::external::http_pagination::PaginatedBy; use omicron_common::api::internal::nexus; -use omicron_common::api::internal::shared::SourceNatConfig; +use omicron_common::api::internal::shared::ExternalIpConfig; +use omicron_common::api::internal::shared::ExternalIpConfigBuilder; +use omicron_common::api::internal::shared::ExternalIps; +use omicron_common::api::internal::shared::external_ip::ConcreteIp; +use omicron_common::api::internal::shared::external_ip::SourceNatConfig; use omicron_uuid_kinds::GenericUuid; use omicron_uuid_kinds::InstanceUuid; use omicron_uuid_kinds::MulticastGroupUuid; @@ -70,6 +77,7 @@ use sled_agent_client::types::InstanceMigrationTargetParams; use sled_agent_client::types::VmmPutStateBody; use std::collections::HashSet; use std::matches; +use std::net::IpAddr; use std::net::SocketAddr; use std::sync::Arc; use tokio::io::{AsyncRead, AsyncWrite}; @@ -1366,75 +1374,16 @@ impl super::Nexus { .derive_guest_network_interface_info(&opctx, &authz_instance) .await?; - // Collect the external IPs for the instance. - let (snat_ip, external_ips): (Vec<_>, Vec<_>) = self + // Collect the external IPs for the instance, and construct the external + // configuration for the sled-agent request. + let all_external_ips = self .db_datastore .instance_lookup_external_ips( &opctx, InstanceUuid::from_untyped_uuid(authz_instance.id()), ) - .await? - .into_iter() - .partition(|ip| ip.kind == IpKind::SNat); - - // Sanity checks on the number and kind of each IP address. - if external_ips.len() > MAX_EXTERNAL_IPS_PER_INSTANCE { - return Err(Error::internal_error( - format!( - "Expected the number of external IPs to be limited to \ - {}, but found {}", - MAX_EXTERNAL_IPS_PER_INSTANCE, - external_ips.len(), - ) - .as_str(), - ) - .into()); - } - - // If there are any external IPs not yet fully attached/detached,then - // there are attach/detach sagas in progress. That should complete in - // its own time, so return a 503 to indicate a possible retry. - if external_ips.iter().any(|v| v.state != IpAttachState::Attached) { - return Err(Error::unavail( - "External IP attach/detach is in progress during instance_ensure_registered" - ).into()); - } - - // Partition remaining external IPs by class: we can have at most - // one ephemeral ip. - let (ephemeral_ips, floating_ips): (Vec<_>, Vec<_>) = external_ips - .into_iter() - .partition(|ip| ip.kind == IpKind::Ephemeral); - - if ephemeral_ips.len() > MAX_EPHEMERAL_IPS_PER_INSTANCE { - return Err(Error::internal_error( - format!( - "Expected at most {} ephemeral IP for an instance, found {}", - MAX_EPHEMERAL_IPS_PER_INSTANCE, - ephemeral_ips.len() - ) - .as_str(), - ) - .into()); - } - - let ephemeral_ip = ephemeral_ips.get(0).map(|model| model.ip.ip()); - - let floating_ips = - floating_ips.into_iter().map(|model| model.ip.ip()).collect(); - if snat_ip.len() != 1 { - return Err(Error::internal_error( - "Expected exactly one SNAT IP address for an instance", - ) - .into()); - } - let source_nat = - SourceNatConfig::try_from(snat_ip.into_iter().next().unwrap()) - .map_err(|err| { - Error::internal_error(&format!( - "read invalid SNAT config from db: {err}" - )) - })?; + .await?; + let external_ips = build_external_ip_config(&all_external_ips)?; // Gather the firewall rules for the VPC this instance is in. // The NIC info we gathered above doesn't have VPC information @@ -1538,9 +1487,7 @@ impl super::Nexus { let local_config = sled_agent_client::types::InstanceSledLocalConfig { hostname, nics, - source_nat, - ephemeral_ip, - floating_ips, + external_ips, firewall_rules, multicast_groups, dhcp_config: sled_agent_client::types::DhcpConfig { @@ -2393,6 +2340,127 @@ impl super::Nexus { } } +fn build_external_ip_config( + ips: &[ExternalIp], +) -> Result, Error> { + // Partition into concrete IPv4 and IPv6 addresses, using subset of data + // needed for the concrete conversions only. + let (ipv4, ipv6): (Vec<_>, Vec<_>) = + ips.iter().partition_map(|ip| match ip.ip.ip() { + IpAddr::V4(v4) => Either::Left(ExternalIpData { + ip: v4, + first_port: ip.first_port.into(), + last_port: ip.last_port.into(), + kind: ip.kind, + attach_state: ip.state, + }), + IpAddr::V6(v6) => Either::Right(ExternalIpData { + ip: v6, + first_port: ip.first_port.into(), + last_port: ip.last_port.into(), + kind: ip.kind, + attach_state: ip.state, + }), + }); + let ipv4_config = if ipv4.is_empty() { + None + } else { + Some(build_concrete_external_ip_config(ipv4)?) + }; + let ipv6_config = if ipv6.is_empty() { + None + } else { + Some(build_concrete_external_ip_config(ipv6)?) + }; + match (ipv4_config, ipv6_config) { + (None, None) => Ok(None), + (None, Some(v6)) => Ok(Some(ExternalIpConfig::V6(v6))), + (Some(v4), None) => Ok(Some(ExternalIpConfig::V4(v4))), + (Some(v4), Some(v6)) => { + Ok(Some(ExternalIpConfig::DualStack { v4, v6 })) + } + } +} + +// Subset of an `ExternalIp` needed to build the `ExternalIpConfig` for the +// concrete address version. +struct ExternalIpData { + ip: T, + first_port: u16, + last_port: u16, + kind: IpKind, + attach_state: IpAttachState, +} + +fn build_concrete_external_ip_config( + ips: Vec>, +) -> Result, Error> +where + T: ConcreteIp, +{ + let mut builder = ExternalIpConfigBuilder::new(); + let mut seen_snat_ip = false; + let mut seen_ephemeral_ip = false; + let mut floating_ips = Vec::new(); + for ip in ips.iter() { + if ip.attach_state != IpAttachState::Attached { + return Err(Error::unavail( + "External IP attach/detach is in progress \ + during instance_ensure_registered", + )); + } + match ip.kind { + IpKind::SNat if !seen_snat_ip => { + seen_snat_ip = true; + let source_nat = + SourceNatConfig::new(ip.ip, ip.first_port, ip.last_port) + .map_err(|e| { + Error::internal_error( + format!( + "Failed to build source NAT config: {e}" + ) + .as_str(), + ) + })?; + builder = builder.with_source_nat(source_nat); + } + IpKind::SNat => { + return Err(Error::internal_error( + "Expected at most one SNAT IP address for an instance", + )); + } + IpKind::Ephemeral if !seen_ephemeral_ip => { + seen_ephemeral_ip = true; + builder = builder.with_ephemeral_ip(ip.ip); + } + IpKind::Ephemeral => { + return Err(Error::internal_error( + "Expected at most 1 Ephemeral IP for an instance", + )); + } + IpKind::Floating => floating_ips.push(ip.ip), + } + } + + // Ensure limit to the number of non-SNAT IPs. + let n_external_ips = usize::from(seen_ephemeral_ip) + floating_ips.len(); + if n_external_ips > MAX_EXTERNAL_IPS_PER_INSTANCE { + return Err(Error::internal_error( + format!( + "Expected the number of external IPs per IP version to \ + be limited to {}, but found {}", + MAX_EXTERNAL_IPS_PER_INSTANCE, n_external_ips + ) + .as_str(), + )); + } + builder.with_floating_ips(floating_ips).build().map_err(|e| { + Error::internal_error( + format!("Failed to build external IPs: {e}").as_str(), + ) + }) +} + /// Writes the VMM and migration state supplied in `new_runtime_state` to the /// database (provided that it's newer than what's already there). /// diff --git a/nexus/test-utils/src/nexus_test.rs b/nexus/test-utils/src/nexus_test.rs index fc65da7b362..cd5c2f694a2 100644 --- a/nexus/test-utils/src/nexus_test.rs +++ b/nexus/test-utils/src/nexus_test.rs @@ -10,8 +10,10 @@ use crate::starter::PopulateCrdb; use crate::starter::setup_with_config_impl; #[cfg(feature = "omicron-dev")] use anyhow::Context; +#[cfg(feature = "omicron-dev")] use anyhow::Result; use camino::Utf8Path; +#[cfg(feature = "omicron-dev")] use camino::Utf8PathBuf; use dropshot::test_util::ClientTestContext; use dropshot::test_util::LogContext; diff --git a/nexus/test-utils/src/starter.rs b/nexus/test-utils/src/starter.rs index dd3a630afae..ebde32998c6 100644 --- a/nexus/test-utils/src/starter.rs +++ b/nexus/test-utils/src/starter.rs @@ -78,7 +78,7 @@ use omicron_common::api::internal::shared::DatasetKind; use omicron_common::api::internal::shared::NetworkInterface; use omicron_common::api::internal::shared::NetworkInterfaceKind; use omicron_common::api::internal::shared::PrivateIpConfig; -use omicron_common::api::internal::shared::SourceNatConfig; +use omicron_common::api::internal::shared::SourceNatConfigGeneric; use omicron_common::api::internal::shared::SwitchLocation; use omicron_common::disk::CompressionAlgorithm; use omicron_common::zpool_name::ZpoolName; @@ -1072,8 +1072,12 @@ impl<'a, N: NexusServer> ControlPlaneStarter<'a, N> { }, external_ip: OmicronZoneExternalSnatIp { id: ExternalIpUuid::new_v4(), - snat_cfg: SourceNatConfig::new(external_ip, 0, 16383) - .unwrap(), + snat_cfg: SourceNatConfigGeneric::new( + external_ip, + 0, + 16383, + ) + .unwrap(), }, }, ), diff --git a/nexus/types/src/deployment.rs b/nexus/types/src/deployment.rs index 5f29e8ac852..12360c88966 100644 --- a/nexus/types/src/deployment.rs +++ b/nexus/types/src/deployment.rs @@ -15,7 +15,7 @@ use crate::external_api::views::SledState; use crate::internal_api::params::DnsConfigParams; use crate::inventory::Collection; -pub use crate::inventory::SourceNatConfig; +pub use crate::inventory::SourceNatConfigGeneric; pub use crate::inventory::ZpoolName; use blueprint_diff::ClickhouseClusterConfigDiffTablesForSingleBlueprint; use blueprint_display::BpDatasetsTableSchema; diff --git a/nexus/types/src/deployment/network_resources.rs b/nexus/types/src/deployment/network_resources.rs index 7dbfe208a91..fd5a35c6a80 100644 --- a/nexus/types/src/deployment/network_resources.rs +++ b/nexus/types/src/deployment/network_resources.rs @@ -9,7 +9,7 @@ use iddqd::TriHashMap; use iddqd::tri_upcast; use omicron_common::api::external::IpVersion; use omicron_common::api::external::MacAddr; -use omicron_common::api::internal::shared::SourceNatConfig; +use omicron_common::api::internal::shared::SourceNatConfigGeneric; use omicron_uuid_kinds::ExternalIpUuid; use omicron_uuid_kinds::OmicronZoneUuid; use omicron_uuid_kinds::VnicUuid; @@ -212,7 +212,7 @@ impl OmicronZoneExternalIp { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum OmicronZoneExternalIpKey { Floating(IpAddr), - Snat(SourceNatConfig), + Snat(SourceNatConfigGeneric), } /// Floating external IP allocated to an Omicron-managed zone. @@ -285,7 +285,7 @@ impl OmicronZoneExternalFloatingAddr { )] pub struct OmicronZoneExternalSnatIp { pub id: ExternalIpUuid, - pub snat_cfg: SourceNatConfig, + pub snat_cfg: SourceNatConfigGeneric, } /// Network interface allocated to an Omicron-managed zone. diff --git a/nexus/types/src/internal_api/params.rs b/nexus/types/src/internal_api/params.rs index 8f39354affb..7c4d4296677 100644 --- a/nexus/types/src/internal_api/params.rs +++ b/nexus/types/src/internal_api/params.rs @@ -18,7 +18,7 @@ use omicron_common::api::internal::nexus::Certificate; use omicron_common::api::internal::shared::AllowedSourceIps; use omicron_common::api::internal::shared::ExternalPortDiscovery; use omicron_common::api::internal::shared::RackNetworkConfig; -use omicron_common::api::internal::shared::SourceNatConfig; +use omicron_common::api::internal::shared::SourceNatConfigGeneric; use omicron_uuid_kinds::DatasetUuid; use omicron_uuid_kinds::PhysicalDiskUuid; use omicron_uuid_kinds::SledUuid; @@ -130,7 +130,7 @@ pub enum ServiceKind { Oximeter, Dendrite, Tfport, - BoundaryNtp { snat: SourceNatConfig, nic: ServiceNic }, + BoundaryNtp { snat: SourceNatConfigGeneric, nic: ServiceNic }, InternalNtp, Mgd, } diff --git a/nexus/types/src/inventory.rs b/nexus/types/src/inventory.rs index a6122a2ee55..cf03a0bec94 100644 --- a/nexus/types/src/inventory.rs +++ b/nexus/types/src/inventory.rs @@ -36,7 +36,7 @@ use nexus_sled_agent_shared::inventory::ZoneImageResolverInventory; use omicron_common::api::external::ByteCount; pub use omicron_common::api::internal::shared::NetworkInterface; pub use omicron_common::api::internal::shared::NetworkInterfaceKind; -pub use omicron_common::api::internal::shared::SourceNatConfig; +pub use omicron_common::api::internal::shared::SourceNatConfigGeneric; use omicron_common::disk::M2Slot; pub use omicron_common::zpool_name::ZpoolName; use omicron_uuid_kinds::CollectionUuid; diff --git a/openapi/nexus-lockstep.json b/openapi/nexus-lockstep.json index 0f88634a0f2..1b6ea918840 100644 --- a/openapi/nexus-lockstep.json +++ b/openapi/nexus-lockstep.json @@ -5849,7 +5849,7 @@ "$ref": "#/components/schemas/ExternalIpUuid" }, "snat_cfg": { - "$ref": "#/components/schemas/SourceNatConfig" + "$ref": "#/components/schemas/SourceNatConfigGeneric" } }, "required": [ @@ -5895,7 +5895,7 @@ "description": "The SNAT configuration for outbound connections.", "allOf": [ { - "$ref": "#/components/schemas/SourceNatConfig" + "$ref": "#/components/schemas/SourceNatConfigGeneric" } ] }, @@ -8595,7 +8595,7 @@ "type": "string", "format": "uuid" }, - "SourceNatConfig": { + "SourceNatConfigGeneric": { "description": "An IP address and port range used for source NAT, i.e., making outbound network connections from guests or services.", "type": "object", "properties": { diff --git a/openapi/sled-agent/sled-agent-11.0.0-5f3d9f.json b/openapi/sled-agent/sled-agent-11.0.0-5f3d9f.json new file mode 100644 index 00000000000..62813d54e05 --- /dev/null +++ b/openapi/sled-agent/sled-agent-11.0.0-5f3d9f.json @@ -0,0 +1,9177 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Oxide Sled Agent API", + "description": "API for interacting with individual sleds", + "contact": { + "url": "https://oxide.computer", + "email": "api@oxide.computer" + }, + "version": "11.0.0" + }, + "paths": { + "/artifacts": { + "get": { + "operationId": "artifact_list", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtifactListResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/artifacts/{sha256}": { + "put": { + "operationId": "artifact_put", + "parameters": [ + { + "in": "path", + "name": "sha256", + "required": true, + "schema": { + "type": "string", + "format": "hex string (32 bytes)" + } + }, + { + "in": "query", + "name": "generation", + "required": true, + "schema": { + "$ref": "#/components/schemas/Generation" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtifactPutResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/artifacts/{sha256}/copy-from-depot": { + "post": { + "operationId": "artifact_copy_from_depot", + "parameters": [ + { + "in": "path", + "name": "sha256", + "required": true, + "schema": { + "type": "string", + "format": "hex string (32 bytes)" + } + }, + { + "in": "query", + "name": "generation", + "required": true, + "schema": { + "$ref": "#/components/schemas/Generation" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtifactCopyFromDepotBody" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "successfully enqueued operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtifactCopyFromDepotResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/artifacts-config": { + "get": { + "operationId": "artifact_config_get", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtifactConfig" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "artifact_config_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtifactConfig" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bootstore/status": { + "get": { + "summary": "Get the internal state of the local bootstore node", + "operationId": "bootstore_status", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BootstoreStatus" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/debug/switch-zone-policy": { + "get": { + "summary": "A debugging endpoint only used by `omdb` that allows us to test", + "description": "restarting the switch zone without restarting sled-agent. See for context.", + "operationId": "debug_operator_switch_zone_policy_get", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperatorSwitchZonePolicy" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "summary": "A debugging endpoint only used by `omdb` that allows us to test", + "description": "restarting the switch zone without restarting sled-agent. See for context.\n\nSetting the switch zone policy is asynchronous and inherently racy with the standard process of starting the switch zone. If the switch zone is in the process of being started or stopped when this policy is changed, the new policy may not take effect until that transition completes.", + "operationId": "debug_operator_switch_zone_policy_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperatorSwitchZonePolicy" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/disks/{disk_id}": { + "put": { + "operationId": "disk_put", + "parameters": [ + { + "in": "path", + "name": "disk_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiskEnsureBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiskRuntimeState" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/eip-gateways": { + "put": { + "summary": "Update per-NIC IP address <-> internet gateway mappings.", + "operationId": "set_eip_gateways", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalIpGatewayMap" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/inventory": { + "get": { + "summary": "Fetch basic information about this sled", + "operationId": "inventory", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Inventory" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/local-storage/{zpool_id}/{dataset_id}": { + "post": { + "summary": "Create a local storage dataset", + "operationId": "local_storage_dataset_ensure", + "parameters": [ + { + "in": "path", + "name": "dataset_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/DatasetUuid" + } + }, + { + "in": "path", + "name": "zpool_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/ExternalZpoolUuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalStorageDatasetEnsureRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "summary": "Delete a local storage dataset", + "operationId": "local_storage_dataset_delete", + "parameters": [ + { + "in": "path", + "name": "dataset_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/DatasetUuid" + } + }, + { + "in": "path", + "name": "zpool_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/ExternalZpoolUuid" + } + } + ], + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/network-bootstore-config": { + "get": { + "summary": "This API endpoint is only reading the local sled agent's view of the", + "description": "bootstore. The boostore is a distributed data store that is eventually consistent. Reads from individual nodes may not represent the latest state.", + "operationId": "read_network_bootstore_config_cache", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EarlyNetworkConfig" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "write_network_bootstore_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EarlyNetworkConfig" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/omicron-config": { + "put": { + "operationId": "omicron_config_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OmicronSledConfig" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/probes": { + "put": { + "summary": "Update the entire set of probe zones on this sled.", + "description": "Probe zones are used to debug networking configuration. They look similar to instances, in that they have an OPTE port on a VPC subnet and external addresses, but no actual VM.", + "operationId": "probes_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProbeSet" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/sled-identifiers": { + "get": { + "summary": "Fetch sled identifiers", + "operationId": "sled_identifiers", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SledIdentifiers" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/sleds": { + "put": { + "summary": "Add a sled to a rack that was already initialized via RSS", + "operationId": "sled_add", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddSledRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support/dladm-info": { + "get": { + "operationId": "support_dladm_info", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_SledDiagnosticsQueryOutput", + "type": "array", + "items": { + "$ref": "#/components/schemas/SledDiagnosticsQueryOutput" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support/health-check": { + "get": { + "operationId": "support_health_check", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_SledDiagnosticsQueryOutput", + "type": "array", + "items": { + "$ref": "#/components/schemas/SledDiagnosticsQueryOutput" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support/ipadm-info": { + "get": { + "operationId": "support_ipadm_info", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_SledDiagnosticsQueryOutput", + "type": "array", + "items": { + "$ref": "#/components/schemas/SledDiagnosticsQueryOutput" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support/logs/download/{zone}": { + "get": { + "summary": "This endpoint returns a zip file of a zone's logs organized by service.", + "operationId": "support_logs_download", + "parameters": [ + { + "in": "path", + "name": "zone", + "description": "The zone for which one would like to collect logs for", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "max_rotated", + "description": "The max number of rotated logs to include in the final support bundle", + "required": true, + "schema": { + "type": "integer", + "format": "uint", + "minimum": 0 + } + } + ], + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + } + }, + "/support/logs/zones": { + "get": { + "summary": "This endpoint returns a list of known zones on a sled that have service", + "description": "logs that can be collected into a support bundle.", + "operationId": "support_logs", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_String", + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support/nvmeadm-info": { + "get": { + "operationId": "support_nvmeadm_info", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SledDiagnosticsQueryOutput" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support/pargs-info": { + "get": { + "operationId": "support_pargs_info", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_SledDiagnosticsQueryOutput", + "type": "array", + "items": { + "$ref": "#/components/schemas/SledDiagnosticsQueryOutput" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support/pfiles-info": { + "get": { + "operationId": "support_pfiles_info", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_SledDiagnosticsQueryOutput", + "type": "array", + "items": { + "$ref": "#/components/schemas/SledDiagnosticsQueryOutput" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support/pstack-info": { + "get": { + "operationId": "support_pstack_info", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_SledDiagnosticsQueryOutput", + "type": "array", + "items": { + "$ref": "#/components/schemas/SledDiagnosticsQueryOutput" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support/zfs-info": { + "get": { + "operationId": "support_zfs_info", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SledDiagnosticsQueryOutput" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support/zoneadm-info": { + "get": { + "operationId": "support_zoneadm_info", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SledDiagnosticsQueryOutput" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support/zpool-info": { + "get": { + "operationId": "support_zpool_info", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SledDiagnosticsQueryOutput" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support-bundles/{zpool_id}/{dataset_id}": { + "get": { + "summary": "List all support bundles within a particular dataset", + "operationId": "support_bundle_list", + "parameters": [ + { + "in": "path", + "name": "dataset_id", + "description": "The dataset on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/DatasetUuid" + } + }, + { + "in": "path", + "name": "zpool_id", + "description": "The zpool on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/ZpoolUuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_SupportBundleMetadata", + "type": "array", + "items": { + "$ref": "#/components/schemas/SupportBundleMetadata" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support-bundles/{zpool_id}/{dataset_id}/{support_bundle_id}": { + "post": { + "summary": "Starts creation of a support bundle within a particular dataset", + "description": "Callers should transfer chunks of the bundle with \"support_bundle_transfer\", and then call \"support_bundle_finalize\" once the bundle has finished transferring.\n\nIf a support bundle was previously created without being finalized successfully, this endpoint will reset the state.\n\nIf a support bundle was previously created and finalized successfully, this endpoint will return metadata indicating that it already exists.", + "operationId": "support_bundle_start_creation", + "parameters": [ + { + "in": "path", + "name": "dataset_id", + "description": "The dataset on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/DatasetUuid" + } + }, + { + "in": "path", + "name": "support_bundle_id", + "description": "The ID of the support bundle itself", + "required": true, + "schema": { + "$ref": "#/components/schemas/SupportBundleUuid" + } + }, + { + "in": "path", + "name": "zpool_id", + "description": "The zpool on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/ZpoolUuid" + } + } + ], + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SupportBundleMetadata" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "summary": "Delete a support bundle from a particular dataset", + "operationId": "support_bundle_delete", + "parameters": [ + { + "in": "path", + "name": "dataset_id", + "description": "The dataset on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/DatasetUuid" + } + }, + { + "in": "path", + "name": "support_bundle_id", + "description": "The ID of the support bundle itself", + "required": true, + "schema": { + "$ref": "#/components/schemas/SupportBundleUuid" + } + }, + { + "in": "path", + "name": "zpool_id", + "description": "The zpool on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/ZpoolUuid" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support-bundles/{zpool_id}/{dataset_id}/{support_bundle_id}/download": { + "get": { + "summary": "Fetch a support bundle from a particular dataset", + "operationId": "support_bundle_download", + "parameters": [ + { + "in": "header", + "name": "range", + "description": "A request to access a portion of the resource, such as `bytes=0-499`\n\nSee: ", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "dataset_id", + "description": "The dataset on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/DatasetUuid" + } + }, + { + "in": "path", + "name": "support_bundle_id", + "description": "The ID of the support bundle itself", + "required": true, + "schema": { + "$ref": "#/components/schemas/SupportBundleUuid" + } + }, + { + "in": "path", + "name": "zpool_id", + "description": "The zpool on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/ZpoolUuid" + } + } + ], + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + }, + "head": { + "summary": "Fetch metadata about a support bundle from a particular dataset", + "operationId": "support_bundle_head", + "parameters": [ + { + "in": "header", + "name": "range", + "description": "A request to access a portion of the resource, such as `bytes=0-499`\n\nSee: ", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "dataset_id", + "description": "The dataset on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/DatasetUuid" + } + }, + { + "in": "path", + "name": "support_bundle_id", + "description": "The ID of the support bundle itself", + "required": true, + "schema": { + "$ref": "#/components/schemas/SupportBundleUuid" + } + }, + { + "in": "path", + "name": "zpool_id", + "description": "The zpool on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/ZpoolUuid" + } + } + ], + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + } + }, + "/support-bundles/{zpool_id}/{dataset_id}/{support_bundle_id}/download/{file}": { + "get": { + "summary": "Fetch a file within a support bundle from a particular dataset", + "operationId": "support_bundle_download_file", + "parameters": [ + { + "in": "header", + "name": "range", + "description": "A request to access a portion of the resource, such as `bytes=0-499`\n\nSee: ", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "dataset_id", + "description": "The dataset on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/DatasetUuid" + } + }, + { + "in": "path", + "name": "file", + "description": "The path of the file within the support bundle to query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "support_bundle_id", + "description": "The ID of the support bundle itself", + "required": true, + "schema": { + "$ref": "#/components/schemas/SupportBundleUuid" + } + }, + { + "in": "path", + "name": "zpool_id", + "description": "The zpool on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/ZpoolUuid" + } + } + ], + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + }, + "head": { + "summary": "Fetch metadata about a file within a support bundle from a particular dataset", + "operationId": "support_bundle_head_file", + "parameters": [ + { + "in": "header", + "name": "range", + "description": "A request to access a portion of the resource, such as `bytes=0-499`\n\nSee: ", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "dataset_id", + "description": "The dataset on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/DatasetUuid" + } + }, + { + "in": "path", + "name": "file", + "description": "The path of the file within the support bundle to query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "support_bundle_id", + "description": "The ID of the support bundle itself", + "required": true, + "schema": { + "$ref": "#/components/schemas/SupportBundleUuid" + } + }, + { + "in": "path", + "name": "zpool_id", + "description": "The zpool on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/ZpoolUuid" + } + } + ], + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + } + }, + "/support-bundles/{zpool_id}/{dataset_id}/{support_bundle_id}/finalize": { + "post": { + "summary": "Finalizes the creation of a support bundle", + "description": "If the requested hash matched the bundle, the bundle is created. Otherwise, an error is returned.", + "operationId": "support_bundle_finalize", + "parameters": [ + { + "in": "path", + "name": "dataset_id", + "description": "The dataset on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/DatasetUuid" + } + }, + { + "in": "path", + "name": "support_bundle_id", + "description": "The ID of the support bundle itself", + "required": true, + "schema": { + "$ref": "#/components/schemas/SupportBundleUuid" + } + }, + { + "in": "path", + "name": "zpool_id", + "description": "The zpool on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/ZpoolUuid" + } + }, + { + "in": "query", + "name": "hash", + "required": true, + "schema": { + "type": "string", + "format": "hex string (32 bytes)" + } + } + ], + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SupportBundleMetadata" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/support-bundles/{zpool_id}/{dataset_id}/{support_bundle_id}/index": { + "get": { + "summary": "Fetch the index (list of files within a support bundle)", + "operationId": "support_bundle_index", + "parameters": [ + { + "in": "header", + "name": "range", + "description": "A request to access a portion of the resource, such as `bytes=0-499`\n\nSee: ", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "dataset_id", + "description": "The dataset on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/DatasetUuid" + } + }, + { + "in": "path", + "name": "support_bundle_id", + "description": "The ID of the support bundle itself", + "required": true, + "schema": { + "$ref": "#/components/schemas/SupportBundleUuid" + } + }, + { + "in": "path", + "name": "zpool_id", + "description": "The zpool on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/ZpoolUuid" + } + } + ], + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + }, + "head": { + "summary": "Fetch metadata about the list of files within a support bundle", + "operationId": "support_bundle_head_index", + "parameters": [ + { + "in": "header", + "name": "range", + "description": "A request to access a portion of the resource, such as `bytes=0-499`\n\nSee: ", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "dataset_id", + "description": "The dataset on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/DatasetUuid" + } + }, + { + "in": "path", + "name": "support_bundle_id", + "description": "The ID of the support bundle itself", + "required": true, + "schema": { + "$ref": "#/components/schemas/SupportBundleUuid" + } + }, + { + "in": "path", + "name": "zpool_id", + "description": "The zpool on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/ZpoolUuid" + } + } + ], + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + } + } + }, + "/support-bundles/{zpool_id}/{dataset_id}/{support_bundle_id}/transfer": { + "put": { + "summary": "Transfers a chunk of a support bundle within a particular dataset", + "operationId": "support_bundle_transfer", + "parameters": [ + { + "in": "path", + "name": "dataset_id", + "description": "The dataset on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/DatasetUuid" + } + }, + { + "in": "path", + "name": "support_bundle_id", + "description": "The ID of the support bundle itself", + "required": true, + "schema": { + "$ref": "#/components/schemas/SupportBundleUuid" + } + }, + { + "in": "path", + "name": "zpool_id", + "description": "The zpool on which this support bundle was provisioned", + "required": true, + "schema": { + "$ref": "#/components/schemas/ZpoolUuid" + } + }, + { + "in": "query", + "name": "offset", + "required": true, + "schema": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SupportBundleMetadata" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/switch-ports": { + "post": { + "operationId": "uplink_ensure", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SwitchPorts" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v2p": { + "get": { + "summary": "List v2p mappings present on sled", + "operationId": "list_v2p", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_VirtualNetworkInterfaceHost", + "type": "array", + "items": { + "$ref": "#/components/schemas/VirtualNetworkInterfaceHost" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "summary": "Create a mapping from a virtual NIC to a physical host", + "operationId": "set_v2p", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VirtualNetworkInterfaceHost" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "summary": "Delete a mapping from a virtual NIC to a physical host", + "operationId": "del_v2p", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VirtualNetworkInterfaceHost" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/vmms/{propolis_id}": { + "put": { + "operationId": "vmm_register", + "parameters": [ + { + "in": "path", + "name": "propolis_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/PropolisUuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceEnsureBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SledVmmState" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "vmm_unregister", + "parameters": [ + { + "in": "path", + "name": "propolis_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/PropolisUuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmmUnregisterResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/vmms/{propolis_id}/disks/{disk_id}/snapshot": { + "post": { + "summary": "Take a snapshot of a disk that is attached to an instance", + "operationId": "vmm_issue_disk_snapshot_request", + "parameters": [ + { + "in": "path", + "name": "disk_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "propolis_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/PropolisUuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmmIssueDiskSnapshotRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmmIssueDiskSnapshotRequestResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/vmms/{propolis_id}/external-ip": { + "put": { + "operationId": "vmm_put_external_ip", + "parameters": [ + { + "in": "path", + "name": "propolis_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/PropolisUuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceExternalIpBody" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "vmm_delete_external_ip", + "parameters": [ + { + "in": "path", + "name": "propolis_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/PropolisUuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceExternalIpBody" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/vmms/{propolis_id}/multicast-group": { + "put": { + "operationId": "vmm_join_multicast_group", + "parameters": [ + { + "in": "path", + "name": "propolis_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/PropolisUuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceMulticastBody" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "vmm_leave_multicast_group", + "parameters": [ + { + "in": "path", + "name": "propolis_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/PropolisUuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceMulticastBody" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/vmms/{propolis_id}/state": { + "get": { + "operationId": "vmm_get_state", + "parameters": [ + { + "in": "path", + "name": "propolis_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/PropolisUuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SledVmmState" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "vmm_put_state", + "parameters": [ + { + "in": "path", + "name": "propolis_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/PropolisUuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmmPutStateBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmmPutStateResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/vpc/{vpc_id}/firewall/rules": { + "put": { + "operationId": "vpc_firewall_rules_put", + "parameters": [ + { + "in": "path", + "name": "vpc_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VpcFirewallRulesEnsureBody" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/vpc-routes": { + "get": { + "summary": "Get the current versions of VPC routing rules.", + "operationId": "list_vpc_routes", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_ResolvedVpcRouteState", + "type": "array", + "items": { + "$ref": "#/components/schemas/ResolvedVpcRouteState" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "summary": "Update VPC routing rules.", + "operationId": "set_vpc_routes", + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "Array_of_ResolvedVpcRouteSet", + "type": "array", + "items": { + "$ref": "#/components/schemas/ResolvedVpcRouteSet" + } + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/zones": { + "get": { + "summary": "List the zones that are currently managed by the sled agent.", + "operationId": "zones_list", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_String", + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/zones/bundle-cleanup": { + "post": { + "summary": "Trigger a zone bundle cleanup.", + "operationId": "zone_bundle_cleanup", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_CleanupCount", + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/CleanupCount" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/zones/bundle-cleanup/context": { + "get": { + "summary": "Return context used by the zone-bundle cleanup task.", + "operationId": "zone_bundle_cleanup_context", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupContext" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "summary": "Update context used by the zone-bundle cleanup task.", + "operationId": "zone_bundle_cleanup_context_update", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupContextUpdate" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/zones/bundle-cleanup/utilization": { + "get": { + "summary": "Return utilization information about all zone bundles.", + "operationId": "zone_bundle_utilization", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_BundleUtilization", + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/BundleUtilization" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/zones/bundles": { + "get": { + "summary": "List all zone bundles that exist, even for now-deleted zones.", + "operationId": "zone_bundle_list_all", + "parameters": [ + { + "in": "query", + "name": "filter", + "description": "An optional substring used to filter zone bundles.", + "schema": { + "nullable": true, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_ZoneBundleMetadata", + "type": "array", + "items": { + "$ref": "#/components/schemas/ZoneBundleMetadata" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/zones/bundles/{zone_name}": { + "get": { + "summary": "List the zone bundles that are available for a running zone.", + "operationId": "zone_bundle_list", + "parameters": [ + { + "in": "path", + "name": "zone_name", + "description": "The name of the zone.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_ZoneBundleMetadata", + "type": "array", + "items": { + "$ref": "#/components/schemas/ZoneBundleMetadata" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/zones/bundles/{zone_name}/{bundle_id}": { + "get": { + "summary": "Fetch the binary content of a single zone bundle.", + "operationId": "zone_bundle_get", + "parameters": [ + { + "in": "path", + "name": "bundle_id", + "description": "The ID for this bundle itself.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "zone_name", + "description": "The name of the zone this bundle is derived from.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "summary": "Delete a zone bundle.", + "operationId": "zone_bundle_delete", + "parameters": [ + { + "in": "path", + "name": "bundle_id", + "description": "The ID for this bundle itself.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "zone_name", + "description": "The name of the zone this bundle is derived from.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "AddSledRequest": { + "description": "A request to Add a given sled after rack initialization has occurred", + "type": "object", + "properties": { + "sled_id": { + "$ref": "#/components/schemas/BaseboardId" + }, + "start_request": { + "$ref": "#/components/schemas/StartSledAgentRequest" + } + }, + "required": [ + "sled_id", + "start_request" + ] + }, + "ArtifactConfig": { + "type": "object", + "properties": { + "artifacts": { + "type": "array", + "items": { + "type": "string", + "format": "hex string (32 bytes)" + }, + "uniqueItems": true + }, + "generation": { + "$ref": "#/components/schemas/Generation" + } + }, + "required": [ + "artifacts", + "generation" + ] + }, + "ArtifactCopyFromDepotBody": { + "type": "object", + "properties": { + "depot_base_url": { + "type": "string" + } + }, + "required": [ + "depot_base_url" + ] + }, + "ArtifactCopyFromDepotResponse": { + "type": "object" + }, + "ArtifactListResponse": { + "type": "object", + "properties": { + "generation": { + "$ref": "#/components/schemas/Generation" + }, + "list": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "uint", + "minimum": 0 + } + } + }, + "required": [ + "generation", + "list" + ] + }, + "ArtifactPutResponse": { + "type": "object", + "properties": { + "datasets": { + "description": "The number of valid M.2 artifact datasets we found on the sled. There is typically one of these datasets for each functional M.2.", + "type": "integer", + "format": "uint", + "minimum": 0 + }, + "successful_writes": { + "description": "The number of valid writes to the M.2 artifact datasets. This should be less than or equal to the number of artifact datasets.", + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "required": [ + "datasets", + "successful_writes" + ] + }, + "Baseboard": { + "description": "Describes properties that should uniquely identify a Gimlet.", + "oneOf": [ + { + "type": "object", + "properties": { + "identifier": { + "type": "string" + }, + "model": { + "type": "string" + }, + "revision": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "gimlet" + ] + } + }, + "required": [ + "identifier", + "model", + "revision", + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "unknown" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "identifier": { + "type": "string" + }, + "model": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "pc" + ] + } + }, + "required": [ + "identifier", + "model", + "type" + ] + } + ] + }, + "BaseboardId": { + "description": "A representation of a Baseboard ID as used in the inventory subsystem This type is essentially the same as a `Baseboard` except it doesn't have a revision or HW type (Gimlet, PC, Unknown).", + "type": "object", + "properties": { + "part_number": { + "description": "Oxide Part Number", + "type": "string" + }, + "serial_number": { + "description": "Serial number (unique for a given part number)", + "type": "string" + } + }, + "required": [ + "part_number", + "serial_number" + ] + }, + "BfdMode": { + "description": "BFD connection mode.", + "type": "string", + "enum": [ + "single_hop", + "multi_hop" + ] + }, + "BfdPeerConfig": { + "type": "object", + "properties": { + "detection_threshold": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "local": { + "nullable": true, + "type": "string", + "format": "ip" + }, + "mode": { + "$ref": "#/components/schemas/BfdMode" + }, + "remote": { + "type": "string", + "format": "ip" + }, + "required_rx": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "switch": { + "$ref": "#/components/schemas/SwitchLocation" + } + }, + "required": [ + "detection_threshold", + "mode", + "remote", + "required_rx", + "switch" + ] + }, + "BgpConfig": { + "type": "object", + "properties": { + "asn": { + "description": "The autonomous system number for the BGP configuration.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "checker": { + "nullable": true, + "description": "Checker to apply to incoming messages.", + "default": null, + "type": "string" + }, + "originate": { + "description": "The set of prefixes for the BGP router to originate.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv4Net" + } + }, + "shaper": { + "nullable": true, + "description": "Shaper to apply to outgoing messages.", + "default": null, + "type": "string" + } + }, + "required": [ + "asn", + "originate" + ] + }, + "BgpPeerConfig": { + "type": "object", + "properties": { + "addr": { + "description": "Address of the peer.", + "type": "string", + "format": "ipv4" + }, + "allowed_export": { + "description": "Define export policy for a peer.", + "default": { + "type": "no_filtering" + }, + "allOf": [ + { + "$ref": "#/components/schemas/ImportExportPolicy" + } + ] + }, + "allowed_import": { + "description": "Define import policy for a peer.", + "default": { + "type": "no_filtering" + }, + "allOf": [ + { + "$ref": "#/components/schemas/ImportExportPolicy" + } + ] + }, + "asn": { + "description": "The autonomous system number of the router the peer belongs to.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "communities": { + "description": "Include the provided communities in updates sent to the peer.", + "default": [], + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "connect_retry": { + "nullable": true, + "description": "The interval in seconds between peer connection retry attempts.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "delay_open": { + "nullable": true, + "description": "How long to delay sending open messages to a peer. In seconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "enforce_first_as": { + "description": "Enforce that the first AS in paths received from this peer is the peer's AS.", + "default": false, + "type": "boolean" + }, + "hold_time": { + "nullable": true, + "description": "How long to keep a session alive without a keepalive in seconds. Defaults to 6.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "idle_hold_time": { + "nullable": true, + "description": "How long to keep a peer in idle after a state machine reset in seconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "keepalive": { + "nullable": true, + "description": "The interval to send keepalive messages at.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "local_pref": { + "nullable": true, + "description": "Apply a local preference to routes received from this peer.", + "default": null, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "md5_auth_key": { + "nullable": true, + "description": "Use the given key for TCP-MD5 authentication with the peer.", + "default": null, + "type": "string" + }, + "min_ttl": { + "nullable": true, + "description": "Require messages from a peer have a minimum IP time to live field.", + "default": null, + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "multi_exit_discriminator": { + "nullable": true, + "description": "Apply the provided multi-exit discriminator (MED) updates sent to the peer.", + "default": null, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "port": { + "description": "Switch port the peer is reachable on.", + "type": "string" + }, + "remote_asn": { + "nullable": true, + "description": "Require that a peer has a specified ASN.", + "default": null, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "description": "Associate a VLAN ID with a BGP peer session.", + "default": null, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "addr", + "asn", + "port" + ] + }, + "BlobStorageBackend": { + "description": "A storage backend for a disk whose initial contents are given explicitly by the specification.", + "type": "object", + "properties": { + "base64": { + "description": "The disk's initial contents, encoded as a base64 string.", + "type": "string" + }, + "readonly": { + "description": "Indicates whether the storage is read-only.", + "type": "boolean" + } + }, + "required": [ + "base64", + "readonly" + ], + "additionalProperties": false + }, + "Board": { + "description": "A VM's mainboard.", + "type": "object", + "properties": { + "chipset": { + "description": "The chipset to expose to guest software.", + "allOf": [ + { + "$ref": "#/components/schemas/Chipset" + } + ] + }, + "cpuid": { + "nullable": true, + "description": "The CPUID values to expose to the guest. If `None`, bhyve will derive default values from the host's CPUID values.", + "allOf": [ + { + "$ref": "#/components/schemas/Cpuid" + } + ] + }, + "cpus": { + "description": "The number of virtual logical processors attached to this VM.", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "guest_hv_interface": { + "description": "The hypervisor platform to expose to the guest. The default is a bhyve-compatible interface with no additional features.\n\nFor compatibility with older versions of Propolis, this field is only serialized if it specifies a non-default interface.", + "allOf": [ + { + "$ref": "#/components/schemas/GuestHypervisorInterface" + } + ] + }, + "memory_mb": { + "description": "The amount of guest RAM attached to this VM.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "chipset", + "cpus", + "memory_mb" + ], + "additionalProperties": false + }, + "BootImageHeader": { + "type": "object", + "properties": { + "data_size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "flags": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "image_name": { + "type": "string" + }, + "image_size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "sha256": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "minItems": 32, + "maxItems": 32 + }, + "target_size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "data_size", + "flags", + "image_name", + "image_size", + "sha256", + "target_size" + ] + }, + "BootOrderEntry": { + "description": "An entry in the boot order stored in a [`BootSettings`] component.", + "type": "object", + "properties": { + "id": { + "description": "The ID of another component in the spec that Propolis should try to boot from.\n\nCurrently, only disk device components are supported.", + "allOf": [ + { + "$ref": "#/components/schemas/SpecKey" + } + ] + } + }, + "required": [ + "id" + ] + }, + "BootPartitionContents": { + "type": "object", + "properties": { + "boot_disk": { + "x-rust-type": { + "crate": "std", + "parameters": [ + { + "$ref": "#/components/schemas/M2Slot" + }, + { + "type": "string" + } + ], + "path": "::std::result::Result", + "version": "*" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "ok": { + "$ref": "#/components/schemas/M2Slot" + } + }, + "required": [ + "ok" + ] + }, + { + "type": "object", + "properties": { + "err": { + "type": "string" + } + }, + "required": [ + "err" + ] + } + ] + }, + "slot_a": { + "x-rust-type": { + "crate": "std", + "parameters": [ + { + "$ref": "#/components/schemas/BootPartitionDetails" + }, + { + "type": "string" + } + ], + "path": "::std::result::Result", + "version": "*" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "ok": { + "$ref": "#/components/schemas/BootPartitionDetails" + } + }, + "required": [ + "ok" + ] + }, + { + "type": "object", + "properties": { + "err": { + "type": "string" + } + }, + "required": [ + "err" + ] + } + ] + }, + "slot_b": { + "x-rust-type": { + "crate": "std", + "parameters": [ + { + "$ref": "#/components/schemas/BootPartitionDetails" + }, + { + "type": "string" + } + ], + "path": "::std::result::Result", + "version": "*" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "ok": { + "$ref": "#/components/schemas/BootPartitionDetails" + } + }, + "required": [ + "ok" + ] + }, + { + "type": "object", + "properties": { + "err": { + "type": "string" + } + }, + "required": [ + "err" + ] + } + ] + } + }, + "required": [ + "boot_disk", + "slot_a", + "slot_b" + ] + }, + "BootPartitionDetails": { + "type": "object", + "properties": { + "artifact_hash": { + "type": "string", + "format": "hex string (32 bytes)" + }, + "artifact_size": { + "type": "integer", + "format": "uint", + "minimum": 0 + }, + "header": { + "$ref": "#/components/schemas/BootImageHeader" + } + }, + "required": [ + "artifact_hash", + "artifact_size", + "header" + ] + }, + "BootSettings": { + "description": "Settings supplied to the guest's firmware image that specify the order in which it should consider its options when selecting a device to try to boot from.", + "type": "object", + "properties": { + "order": { + "description": "An ordered list of components to attempt to boot from.", + "type": "array", + "items": { + "$ref": "#/components/schemas/BootOrderEntry" + } + } + }, + "required": [ + "order" + ], + "additionalProperties": false + }, + "BootstoreStatus": { + "type": "object", + "properties": { + "accepted_connections": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "established_connections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EstablishedConnection" + } + }, + "fsm_ledger_generation": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "fsm_state": { + "type": "string" + }, + "negotiating_connections": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "network_config_ledger_generation": { + "nullable": true, + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "peers": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "required": [ + "accepted_connections", + "established_connections", + "fsm_ledger_generation", + "fsm_state", + "negotiating_connections", + "peers" + ] + }, + "BundleUtilization": { + "description": "The portion of a debug dataset used for zone bundles.", + "type": "object", + "properties": { + "bytes_available": { + "description": "The total number of bytes available for zone bundles.\n\nThis is `dataset_quota` multiplied by the context's storage limit.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "bytes_used": { + "description": "Total bundle usage, in bytes.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "dataset_quota": { + "description": "The total dataset quota, in bytes.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "bytes_available", + "bytes_used", + "dataset_quota" + ] + }, + "ByteCount": { + "description": "Byte count to express memory or storage capacity.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "Chipset": { + "description": "A kind of virtual chipset.", + "oneOf": [ + { + "description": "An Intel 440FX-compatible chipset.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "i440_fx" + ] + }, + "value": { + "$ref": "#/components/schemas/I440Fx" + } + }, + "required": [ + "type", + "value" + ], + "additionalProperties": false + } + ] + }, + "CleanupContext": { + "description": "Context provided for the zone bundle cleanup task.", + "type": "object", + "properties": { + "period": { + "description": "The period on which automatic checks and cleanup is performed.", + "allOf": [ + { + "$ref": "#/components/schemas/CleanupPeriod" + } + ] + }, + "priority": { + "description": "The priority ordering for keeping old bundles.", + "allOf": [ + { + "$ref": "#/components/schemas/PriorityOrder" + } + ] + }, + "storage_limit": { + "description": "The limit on the dataset quota available for zone bundles.", + "allOf": [ + { + "$ref": "#/components/schemas/StorageLimit" + } + ] + } + }, + "required": [ + "period", + "priority", + "storage_limit" + ] + }, + "CleanupContextUpdate": { + "description": "Parameters used to update the zone bundle cleanup context.", + "type": "object", + "properties": { + "period": { + "nullable": true, + "description": "The new period on which automatic cleanups are run.", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] + }, + "priority": { + "nullable": true, + "description": "The priority ordering for preserving old zone bundles.", + "allOf": [ + { + "$ref": "#/components/schemas/PriorityOrder" + } + ] + }, + "storage_limit": { + "nullable": true, + "description": "The new limit on the underlying dataset quota allowed for bundles.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + } + }, + "CleanupCount": { + "description": "The count of bundles / bytes removed during a cleanup operation.", + "type": "object", + "properties": { + "bundles": { + "description": "The number of bundles removed.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "bytes": { + "description": "The number of bytes removed.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "bundles", + "bytes" + ] + }, + "CleanupPeriod": { + "description": "A period on which bundles are automatically cleaned up.", + "allOf": [ + { + "$ref": "#/components/schemas/Duration" + } + ] + }, + "ComponentV0": { + "oneOf": [ + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/VirtioDisk" + }, + "type": { + "type": "string", + "enum": [ + "virtio_disk" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/NvmeDisk" + }, + "type": { + "type": "string", + "enum": [ + "nvme_disk" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/VirtioNic" + }, + "type": { + "type": "string", + "enum": [ + "virtio_nic" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/SerialPort" + }, + "type": { + "type": "string", + "enum": [ + "serial_port" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/PciPciBridge" + }, + "type": { + "type": "string", + "enum": [ + "pci_pci_bridge" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/QemuPvpanic" + }, + "type": { + "type": "string", + "enum": [ + "qemu_pvpanic" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/BootSettings" + }, + "type": { + "type": "string", + "enum": [ + "boot_settings" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/SoftNpuPciPort" + }, + "type": { + "type": "string", + "enum": [ + "soft_npu_pci_port" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/SoftNpuPort" + }, + "type": { + "type": "string", + "enum": [ + "soft_npu_port" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/SoftNpuP9" + }, + "type": { + "type": "string", + "enum": [ + "soft_npu_p9" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/P9fs" + }, + "type": { + "type": "string", + "enum": [ + "p9fs" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/MigrationFailureInjector" + }, + "type": { + "type": "string", + "enum": [ + "migration_failure_injector" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/CrucibleStorageBackend" + }, + "type": { + "type": "string", + "enum": [ + "crucible_storage_backend" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/FileStorageBackend" + }, + "type": { + "type": "string", + "enum": [ + "file_storage_backend" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/BlobStorageBackend" + }, + "type": { + "type": "string", + "enum": [ + "blob_storage_backend" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/VirtioNetworkBackend" + }, + "type": { + "type": "string", + "enum": [ + "virtio_network_backend" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "component": { + "$ref": "#/components/schemas/DlpiNetworkBackend" + }, + "type": { + "type": "string", + "enum": [ + "dlpi_network_backend" + ] + } + }, + "required": [ + "component", + "type" + ], + "additionalProperties": false + } + ] + }, + "CompressionAlgorithm": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "on" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "off" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "gzip" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "level": { + "$ref": "#/components/schemas/GzipLevel" + }, + "type": { + "type": "string", + "enum": [ + "gzip_n" + ] + } + }, + "required": [ + "level", + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "lz4" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "lzjb" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "zle" + ] + } + }, + "required": [ + "type" + ] + } + ] + }, + "ConfigReconcilerInventory": { + "description": "Describes the last attempt made by the sled-agent-config-reconciler to reconcile the current sled config against the actual state of the sled.", + "type": "object", + "properties": { + "boot_partitions": { + "$ref": "#/components/schemas/BootPartitionContents" + }, + "datasets": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ConfigReconcilerInventoryResult" + } + }, + "external_disks": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ConfigReconcilerInventoryResult" + } + }, + "last_reconciled_config": { + "$ref": "#/components/schemas/OmicronSledConfig" + }, + "orphaned_datasets": { + "title": "IdOrdMap", + "x-rust-type": { + "crate": "iddqd", + "parameters": [ + { + "$ref": "#/components/schemas/OrphanedDataset" + } + ], + "path": "iddqd::IdOrdMap", + "version": "*" + }, + "type": "array", + "items": { + "$ref": "#/components/schemas/OrphanedDataset" + }, + "uniqueItems": true + }, + "remove_mupdate_override": { + "nullable": true, + "description": "The result of removing the mupdate override file on disk.\n\n`None` if `remove_mupdate_override` was not provided in the sled config.", + "allOf": [ + { + "$ref": "#/components/schemas/RemoveMupdateOverrideInventory" + } + ] + }, + "zones": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ConfigReconcilerInventoryResult" + } + } + }, + "required": [ + "boot_partitions", + "datasets", + "external_disks", + "last_reconciled_config", + "orphaned_datasets", + "zones" + ] + }, + "ConfigReconcilerInventoryResult": { + "oneOf": [ + { + "type": "object", + "properties": { + "result": { + "type": "string", + "enum": [ + "ok" + ] + } + }, + "required": [ + "result" + ] + }, + { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "result": { + "type": "string", + "enum": [ + "err" + ] + } + }, + "required": [ + "message", + "result" + ] + } + ] + }, + "ConfigReconcilerInventoryStatus": { + "description": "Status of the sled-agent-config-reconciler task.", + "oneOf": [ + { + "description": "The reconciler task has not yet run for the first time since sled-agent started.", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "not_yet_run" + ] + } + }, + "required": [ + "status" + ] + }, + { + "description": "The reconciler task is actively running.", + "type": "object", + "properties": { + "config": { + "$ref": "#/components/schemas/OmicronSledConfig" + }, + "running_for": { + "$ref": "#/components/schemas/Duration" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "running" + ] + } + }, + "required": [ + "config", + "running_for", + "started_at", + "status" + ] + }, + { + "description": "The reconciler task is currently idle, but previously did complete a reconciliation attempt.\n\nThis variant does not include the `OmicronSledConfig` used in the last attempt, because that's always available via [`ConfigReconcilerInventory::last_reconciled_config`].", + "type": "object", + "properties": { + "completed_at": { + "type": "string", + "format": "date-time" + }, + "ran_for": { + "$ref": "#/components/schemas/Duration" + }, + "status": { + "type": "string", + "enum": [ + "idle" + ] + } + }, + "required": [ + "completed_at", + "ran_for", + "status" + ] + } + ] + }, + "Cpuid": { + "description": "A set of CPUID values to expose to a guest.", + "type": "object", + "properties": { + "entries": { + "description": "A list of CPUID leaves/subleaves and their associated values.\n\nPropolis servers require that each entry's `leaf` be unique and that it falls in either the \"standard\" (0 to 0xFFFF) or \"extended\" (0x8000_0000 to 0x8000_FFFF) function ranges, since these are the only valid input ranges currently defined by Intel and AMD. See the Intel 64 and IA-32 Architectures Software Developer's Manual (June 2024) Table 3-17 and the AMD64 Architecture Programmer's Manual (March 2024) Volume 3's documentation of the CPUID instruction.", + "type": "array", + "items": { + "$ref": "#/components/schemas/CpuidEntry" + } + }, + "vendor": { + "description": "The CPU vendor to emulate.\n\nCPUID leaves in the extended range (0x8000_0000 to 0x8000_FFFF) have vendor-defined semantics. Propolis uses this value to determine these semantics when deciding whether it needs to specialize the supplied template values for these leaves.", + "allOf": [ + { + "$ref": "#/components/schemas/CpuidVendor" + } + ] + } + }, + "required": [ + "entries", + "vendor" + ], + "additionalProperties": false + }, + "CpuidEntry": { + "description": "A full description of a CPUID leaf/subleaf and the values it produces.", + "type": "object", + "properties": { + "eax": { + "description": "The value to return in eax.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "ebx": { + "description": "The value to return in ebx.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "ecx": { + "description": "The value to return in ecx.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "edx": { + "description": "The value to return in edx.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "leaf": { + "description": "The leaf (function) number for this entry.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "subleaf": { + "nullable": true, + "description": "The subleaf (index) number for this entry, if it uses subleaves.", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "eax", + "ebx", + "ecx", + "edx", + "leaf" + ], + "additionalProperties": false + }, + "CpuidVendor": { + "description": "A CPU vendor to use when interpreting the meanings of CPUID leaves in the extended ID range (0x80000000 to 0x8000FFFF).", + "type": "string", + "enum": [ + "amd", + "intel" + ] + }, + "CrucibleStorageBackend": { + "description": "A Crucible storage backend.", + "type": "object", + "properties": { + "readonly": { + "description": "Indicates whether the storage is read-only.", + "type": "boolean" + }, + "request_json": { + "description": "A serialized `[crucible_client_types::VolumeConstructionRequest]`. This is stored in serialized form so that breaking changes to the definition of a `VolumeConstructionRequest` do not inadvertently break instance spec deserialization.\n\nWhen using a spec to initialize a new instance, the spec author must ensure this request is well-formed and can be deserialized by the version of `crucible_client_types` used by the target Propolis.", + "type": "string" + } + }, + "required": [ + "readonly", + "request_json" + ], + "additionalProperties": false + }, + "DatasetConfig": { + "description": "Configuration information necessary to request a single dataset.\n\nThese datasets are tracked directly by Nexus.", + "type": "object", + "properties": { + "compression": { + "description": "The compression mode to be used by the dataset", + "allOf": [ + { + "$ref": "#/components/schemas/CompressionAlgorithm" + } + ] + }, + "id": { + "description": "The UUID of the dataset being requested", + "allOf": [ + { + "$ref": "#/components/schemas/DatasetUuid" + } + ] + }, + "name": { + "description": "The dataset's name", + "allOf": [ + { + "$ref": "#/components/schemas/DatasetName" + } + ] + }, + "quota": { + "nullable": true, + "description": "The upper bound on the amount of storage used by this dataset", + "allOf": [ + { + "$ref": "#/components/schemas/ByteCount" + } + ] + }, + "reservation": { + "nullable": true, + "description": "The lower bound on the amount of storage usable by this dataset", + "allOf": [ + { + "$ref": "#/components/schemas/ByteCount" + } + ] + } + }, + "required": [ + "compression", + "id", + "name" + ] + }, + "DatasetKind": { + "description": "The kind of dataset. See the `DatasetKind` enum in omicron-common for possible values.", + "type": "string" + }, + "DatasetName": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/components/schemas/DatasetKind" + }, + "pool_name": { + "$ref": "#/components/schemas/ZpoolName" + } + }, + "required": [ + "kind", + "pool_name" + ] + }, + "DatasetUuid": { + "x-rust-type": { + "crate": "omicron-uuid-kinds", + "path": "omicron_uuid_kinds::DatasetUuid", + "version": "*" + }, + "type": "string", + "format": "uuid" + }, + "DelegatedZvol": { + "description": "Delegate a ZFS volume to a zone", + "oneOf": [ + { + "description": "Delegate a slice of the local storage dataset present on this pool into the zone.", + "type": "object", + "properties": { + "dataset_id": { + "$ref": "#/components/schemas/DatasetUuid" + }, + "type": { + "type": "string", + "enum": [ + "local_storage" + ] + }, + "zpool_id": { + "$ref": "#/components/schemas/ExternalZpoolUuid" + } + }, + "required": [ + "dataset_id", + "type", + "zpool_id" + ] + } + ] + }, + "DhcpConfig": { + "description": "DHCP configuration for a port\n\nNot present here: Hostname (DHCPv4 option 12; used in DHCPv6 option 39); we use `InstanceRuntimeState::hostname` for this value.", + "type": "object", + "properties": { + "dns_servers": { + "description": "DNS servers to send to the instance\n\n(DHCPv4 option 6; DHCPv6 option 23)", + "type": "array", + "items": { + "type": "string", + "format": "ip" + } + }, + "host_domain": { + "nullable": true, + "description": "DNS zone this instance's hostname belongs to (e.g. the `project.example` part of `instance1.project.example`)\n\n(DHCPv4 option 15; used in DHCPv6 option 39)", + "type": "string" + }, + "search_domains": { + "description": "DNS search domains\n\n(DHCPv4 option 119; DHCPv6 option 24)", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "dns_servers", + "search_domains" + ] + }, + "DiskEnsureBody": { + "description": "Sent from to a sled agent to establish the runtime state of a Disk", + "type": "object", + "properties": { + "initial_runtime": { + "description": "Last runtime state of the Disk known to Nexus (used if the agent has never seen this Disk before).", + "allOf": [ + { + "$ref": "#/components/schemas/DiskRuntimeState" + } + ] + }, + "target": { + "description": "requested runtime state of the Disk", + "allOf": [ + { + "$ref": "#/components/schemas/DiskStateRequested" + } + ] + } + }, + "required": [ + "initial_runtime", + "target" + ] + }, + "DiskIdentity": { + "description": "Uniquely identifies a disk.", + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "serial": { + "type": "string" + }, + "vendor": { + "type": "string" + } + }, + "required": [ + "model", + "serial", + "vendor" + ] + }, + "DiskRuntimeState": { + "description": "Runtime state of the Disk, which includes its attach state and some minimal metadata", + "type": "object", + "properties": { + "disk_state": { + "description": "runtime state of the Disk", + "allOf": [ + { + "$ref": "#/components/schemas/DiskState" + } + ] + }, + "gen": { + "description": "generation number for this state", + "allOf": [ + { + "$ref": "#/components/schemas/Generation" + } + ] + }, + "time_updated": { + "description": "timestamp for this information", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "disk_state", + "gen", + "time_updated" + ] + }, + "DiskState": { + "description": "State of a Disk", + "oneOf": [ + { + "description": "Disk is being initialized", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "creating" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "Disk is ready but detached from any Instance", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "detached" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "Disk is ready to receive blocks from an external source", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "import_ready" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "Disk is importing blocks from a URL", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "importing_from_url" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "Disk is importing blocks from bulk writes", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "importing_from_bulk_writes" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "Disk is being finalized to state Detached", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "finalizing" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "Disk is undergoing maintenance", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "maintenance" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "Disk is being attached to the given Instance", + "type": "object", + "properties": { + "instance": { + "type": "string", + "format": "uuid" + }, + "state": { + "type": "string", + "enum": [ + "attaching" + ] + } + }, + "required": [ + "instance", + "state" + ] + }, + { + "description": "Disk is attached to the given Instance", + "type": "object", + "properties": { + "instance": { + "type": "string", + "format": "uuid" + }, + "state": { + "type": "string", + "enum": [ + "attached" + ] + } + }, + "required": [ + "instance", + "state" + ] + }, + { + "description": "Disk is being detached from the given Instance", + "type": "object", + "properties": { + "instance": { + "type": "string", + "format": "uuid" + }, + "state": { + "type": "string", + "enum": [ + "detaching" + ] + } + }, + "required": [ + "instance", + "state" + ] + }, + { + "description": "Disk has been destroyed", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "destroyed" + ] + } + }, + "required": [ + "state" + ] + }, + { + "description": "Disk is unavailable", + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "faulted" + ] + } + }, + "required": [ + "state" + ] + } + ] + }, + "DiskStateRequested": { + "description": "Used to request a Disk state change", + "oneOf": [ + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "detached" + ] + } + }, + "required": [ + "state" + ] + }, + { + "type": "object", + "properties": { + "instance": { + "type": "string", + "format": "uuid" + }, + "state": { + "type": "string", + "enum": [ + "attached" + ] + } + }, + "required": [ + "instance", + "state" + ] + }, + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "destroyed" + ] + } + }, + "required": [ + "state" + ] + }, + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "faulted" + ] + } + }, + "required": [ + "state" + ] + } + ] + }, + "DiskVariant": { + "type": "string", + "enum": [ + "U2", + "M2" + ] + }, + "DlpiNetworkBackend": { + "description": "A network backend associated with a DLPI VNIC on the host.", + "type": "object", + "properties": { + "vnic_name": { + "description": "The name of the VNIC to use as a backend.", + "type": "string" + } + }, + "required": [ + "vnic_name" + ], + "additionalProperties": false + }, + "Duration": { + "type": "object", + "properties": { + "nanos": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "secs": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "nanos", + "secs" + ] + }, + "EarlyNetworkConfig": { + "description": "Network configuration required to bring up the control plane\n\nThe fields in this structure are those from [`crate::rack_init::RackInitializeRequest`] necessary for use beyond RSS. This is just for the initial rack configuration and cold boot purposes. Updates come from Nexus.", + "type": "object", + "properties": { + "body": { + "$ref": "#/components/schemas/EarlyNetworkConfigBody" + }, + "generation": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "schema_version": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "body", + "generation", + "schema_version" + ] + }, + "EarlyNetworkConfigBody": { + "description": "This is the actual configuration of EarlyNetworking.\n\nWe nest it below the \"header\" of `generation` and `schema_version` so that we can perform partial deserialization of `EarlyNetworkConfig` to only read the header and defer deserialization of the body once we know the schema version. This is possible via the use of [`serde_json::value::RawValue`] in future (post-v1) deserialization paths.", + "type": "object", + "properties": { + "ntp_servers": { + "description": "The external NTP server addresses.", + "type": "array", + "items": { + "type": "string" + } + }, + "rack_network_config": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/RackNetworkConfigV2" + } + ] + } + }, + "required": [ + "ntp_servers" + ] + }, + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + }, + "EstablishedConnection": { + "type": "object", + "properties": { + "addr": { + "type": "string" + }, + "baseboard": { + "$ref": "#/components/schemas/Baseboard" + } + }, + "required": [ + "addr", + "baseboard" + ] + }, + "ExternalIp": { + "description": "An external IP address used by a probe.", + "type": "object", + "properties": { + "first_port": { + "description": "The first port used by the address.", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "ip": { + "description": "The external IP address.", + "type": "string", + "format": "ip" + }, + "kind": { + "description": "The kind of address this is.", + "allOf": [ + { + "$ref": "#/components/schemas/IpKind" + } + ] + }, + "last_port": { + "description": "The last port used by the address.", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "first_port", + "ip", + "kind", + "last_port" + ] + }, + "ExternalIpConfig": { + "description": "A single- or dual-stack external IP configuration.", + "oneOf": [ + { + "description": "Single-stack IPv4 external IP configuration.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v4" + ] + }, + "value": { + "$ref": "#/components/schemas/ExternalIpv4Config" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "description": "Single-stack IPv6 external IP configuration.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v6" + ] + }, + "value": { + "$ref": "#/components/schemas/ExternalIpv6Config" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "description": "Both IPv4 and IPv6 external IP configuration.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "dual_stack" + ] + }, + "value": { + "type": "object", + "properties": { + "v4": { + "$ref": "#/components/schemas/ExternalIpv4Config" + }, + "v6": { + "$ref": "#/components/schemas/ExternalIpv6Config" + } + }, + "required": [ + "v4", + "v6" + ] + } + }, + "required": [ + "type", + "value" + ] + } + ] + }, + "ExternalIpGatewayMap": { + "description": "Per-NIC mappings from external IP addresses to the Internet Gateways which can choose them as a source.", + "type": "object", + "properties": { + "mappings": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "uniqueItems": true + } + } + } + }, + "required": [ + "mappings" + ] + }, + "ExternalIpv4Config": { + "description": "External IP address configuration.\n\nThis encapsulates all the external addresses of a single IP version, including source NAT, Ephemeral, and Floating IPs. Note that not all of these need to be specified, but this type can only be constructed if _at least one_ of them is.", + "type": "object", + "properties": { + "ephemeral_ip": { + "nullable": true, + "description": "An Ephemeral address for in- and outbound connectivity.", + "type": "string", + "format": "ipv4" + }, + "floating_ips": { + "description": "Additional Floating IPs for in- and outbound connectivity.", + "type": "array", + "items": { + "type": "string", + "format": "ipv4" + } + }, + "source_nat": { + "nullable": true, + "description": "Source NAT configuration, for outbound-only connectivity.", + "allOf": [ + { + "$ref": "#/components/schemas/SourceNatConfigV4" + } + ] + } + }, + "required": [ + "floating_ips" + ] + }, + "ExternalIpv6Config": { + "description": "External IP address configuration.\n\nThis encapsulates all the external addresses of a single IP version, including source NAT, Ephemeral, and Floating IPs. Note that not all of these need to be specified, but this type can only be constructed if _at least one_ of them is.", + "type": "object", + "properties": { + "ephemeral_ip": { + "nullable": true, + "description": "An Ephemeral address for in- and outbound connectivity.", + "type": "string", + "format": "ipv6" + }, + "floating_ips": { + "description": "Additional Floating IPs for in- and outbound connectivity.", + "type": "array", + "items": { + "type": "string", + "format": "ipv6" + } + }, + "source_nat": { + "nullable": true, + "description": "Source NAT configuration, for outbound-only connectivity.", + "allOf": [ + { + "$ref": "#/components/schemas/SourceNatConfigV6" + } + ] + } + }, + "required": [ + "floating_ips" + ] + }, + "ExternalZpoolUuid": { + "x-rust-type": { + "crate": "omicron-uuid-kinds", + "path": "omicron_uuid_kinds::ExternalZpoolUuid", + "version": "*" + }, + "type": "string", + "format": "uuid" + }, + "FileStorageBackend": { + "description": "A storage backend backed by a file in the host system's file system.", + "type": "object", + "properties": { + "block_size": { + "description": "Block size of the backend", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "path": { + "description": "A path to a file that backs a disk.", + "type": "string" + }, + "readonly": { + "description": "Indicates whether the storage is read-only.", + "type": "boolean" + }, + "workers": { + "nullable": true, + "description": "Optional worker threads for the file backend, exposed for testing only.", + "type": "integer", + "format": "uint", + "minimum": 1 + } + }, + "required": [ + "block_size", + "path", + "readonly" + ], + "additionalProperties": false + }, + "Generation": { + "description": "Generation numbers stored in the database, used for optimistic concurrency control", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "GuestHypervisorInterface": { + "description": "A hypervisor interface to expose to the guest.", + "oneOf": [ + { + "description": "Expose a bhyve-like interface (\"bhyve bhyve \" as the hypervisor ID in leaf 0x4000_0000 and no additional leaves or features).", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bhyve" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "description": "Expose a Hyper-V-compatible hypervisor interface with the supplied features enabled.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "hyper_v" + ] + }, + "value": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HyperVFeatureFlag" + }, + "uniqueItems": true + } + }, + "required": [ + "features" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "value" + ], + "additionalProperties": false + } + ] + }, + "GzipLevel": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "HostIdentifier": { + "description": "A `HostIdentifier` represents either an IP host or network (v4 or v6), or an entire VPC (identified by its VNI). It is used in firewall rule host filters.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "ip" + ] + }, + "value": { + "$ref": "#/components/schemas/IpNet" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "vpc" + ] + }, + "value": { + "$ref": "#/components/schemas/Vni" + } + }, + "required": [ + "type", + "value" + ] + } + ] + }, + "HostPhase2DesiredContents": { + "description": "Describes the desired contents of a host phase 2 slot (i.e., the boot partition on one of the internal M.2 drives).", + "oneOf": [ + { + "description": "Do not change the current contents.\n\nWe use this value when we've detected a sled has been mupdated (and we don't want to overwrite phase 2 images until we understand how to recover from that mupdate) and as the default value when reading an [`OmicronSledConfig`] that was ledgered before this concept existed.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "current_contents" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Set the phase 2 slot to the given artifact.\n\nThe artifact will come from an unpacked and distributed TUF repo.", + "type": "object", + "properties": { + "hash": { + "type": "string", + "format": "hex string (32 bytes)" + }, + "type": { + "type": "string", + "enum": [ + "artifact" + ] + } + }, + "required": [ + "hash", + "type" + ] + } + ] + }, + "HostPhase2DesiredSlots": { + "description": "Describes the desired contents for both host phase 2 slots.", + "type": "object", + "properties": { + "slot_a": { + "$ref": "#/components/schemas/HostPhase2DesiredContents" + }, + "slot_b": { + "$ref": "#/components/schemas/HostPhase2DesiredContents" + } + }, + "required": [ + "slot_a", + "slot_b" + ] + }, + "HostPortConfig": { + "type": "object", + "properties": { + "addrs": { + "description": "IP Address and prefix (e.g., `192.168.0.1/16`) to apply to switchport (must be in infra_ip pool). May also include an optional VLAN ID.", + "type": "array", + "items": { + "$ref": "#/components/schemas/UplinkAddressConfig" + } + }, + "lldp": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/LldpPortConfig" + } + ] + }, + "port": { + "description": "Switchport to use for external connectivity", + "type": "string" + }, + "tx_eq": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/TxEqConfig" + } + ] + } + }, + "required": [ + "addrs", + "port" + ] + }, + "Hostname": { + "title": "An RFC-1035-compliant hostname", + "description": "A hostname identifies a host on a network, and is usually a dot-delimited sequence of labels, where each label contains only letters, digits, or the hyphen. See RFCs 1035 and 952 for more details.", + "type": "string", + "pattern": "^([a-zA-Z0-9]+[a-zA-Z0-9\\-]*(? for background.", + "oneOf": [ + { + "description": "Start the switch zone if a switch is present.\n\nThis is the default policy.", + "type": "object", + "properties": { + "policy": { + "type": "string", + "enum": [ + "start_if_switch_present" + ] + } + }, + "required": [ + "policy" + ] + }, + { + "description": "Even if a switch zone is present, stop the switch zone.", + "type": "object", + "properties": { + "policy": { + "type": "string", + "enum": [ + "stop_despite_switch_presence" + ] + } + }, + "required": [ + "policy" + ] + } + ] + }, + "OrphanedDataset": { + "type": "object", + "properties": { + "available": { + "$ref": "#/components/schemas/ByteCount" + }, + "id": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/DatasetUuid" + } + ] + }, + "mounted": { + "type": "boolean" + }, + "name": { + "$ref": "#/components/schemas/DatasetName" + }, + "reason": { + "type": "string" + }, + "used": { + "$ref": "#/components/schemas/ByteCount" + } + }, + "required": [ + "available", + "mounted", + "name", + "reason", + "used" + ] + }, + "P9fs": { + "description": "Describes a filesystem to expose through a P9 device.\n\nThis is only supported by Propolis servers compiled with the `falcon` feature.", + "type": "object", + "properties": { + "chunk_size": { + "description": "The chunk size to use in the 9P protocol. Vanilla Helios images should use 8192. Falcon Helios base images and Linux can use up to 65536.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "pci_path": { + "description": "The PCI path at which to attach the guest to this P9 filesystem.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + }, + "source": { + "description": "The host source path to mount into the guest.", + "type": "string" + }, + "target": { + "description": "The 9P target filesystem tag.", + "type": "string" + } + }, + "required": [ + "chunk_size", + "pci_path", + "source", + "target" + ], + "additionalProperties": false + }, + "PciPath": { + "description": "A PCI bus/device/function tuple.", + "type": "object", + "properties": { + "bus": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "device": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "function": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "bus", + "device", + "function" + ] + }, + "PciPciBridge": { + "description": "A PCI-PCI bridge.", + "type": "object", + "properties": { + "downstream_bus": { + "description": "The logical bus number of this bridge's downstream bus. Other devices may use this bus number in their PCI paths to indicate they should be attached to this bridge's bus.", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "pci_path": { + "description": "The PCI path at which to attach this bridge.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + } + }, + "required": [ + "downstream_bus", + "pci_path" + ], + "additionalProperties": false + }, + "PhysicalDiskUuid": { + "x-rust-type": { + "crate": "omicron-uuid-kinds", + "path": "omicron_uuid_kinds::PhysicalDiskUuid", + "version": "*" + }, + "type": "string", + "format": "uuid" + }, + "PortConfigV2": { + "type": "object", + "properties": { + "addresses": { + "description": "This port's addresses and optional vlan IDs", + "type": "array", + "items": { + "$ref": "#/components/schemas/UplinkAddressConfig" + } + }, + "autoneg": { + "description": "Whether or not to set autonegotiation", + "default": false, + "type": "boolean" + }, + "bgp_peers": { + "description": "BGP peers on this port", + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpPeerConfig" + } + }, + "lldp": { + "nullable": true, + "description": "LLDP configuration for this port", + "allOf": [ + { + "$ref": "#/components/schemas/LldpPortConfig" + } + ] + }, + "port": { + "description": "Nmae of the port this config applies to.", + "type": "string" + }, + "routes": { + "description": "The set of routes associated with this port.", + "type": "array", + "items": { + "$ref": "#/components/schemas/RouteConfig" + } + }, + "switch": { + "description": "Switch the port belongs to.", + "allOf": [ + { + "$ref": "#/components/schemas/SwitchLocation" + } + ] + }, + "tx_eq": { + "nullable": true, + "description": "TX-EQ configuration for this port", + "allOf": [ + { + "$ref": "#/components/schemas/TxEqConfig" + } + ] + }, + "uplink_port_fec": { + "nullable": true, + "description": "Port forward error correction type.", + "allOf": [ + { + "$ref": "#/components/schemas/PortFec" + } + ] + }, + "uplink_port_speed": { + "description": "Port speed.", + "allOf": [ + { + "$ref": "#/components/schemas/PortSpeed" + } + ] + } + }, + "required": [ + "addresses", + "bgp_peers", + "port", + "routes", + "switch", + "uplink_port_speed" + ] + }, + "PortFec": { + "description": "Switchport FEC options", + "type": "string", + "enum": [ + "firecode", + "none", + "rs" + ] + }, + "PortSpeed": { + "description": "Switchport Speed options", + "type": "string", + "enum": [ + "speed0_g", + "speed1_g", + "speed10_g", + "speed25_g", + "speed40_g", + "speed50_g", + "speed100_g", + "speed200_g", + "speed400_g" + ] + }, + "PriorityDimension": { + "description": "A dimension along with bundles can be sorted, to determine priority.", + "oneOf": [ + { + "description": "Sorting by time, with older bundles with lower priority.", + "type": "string", + "enum": [ + "time" + ] + }, + { + "description": "Sorting by the cause for creating the bundle.", + "type": "string", + "enum": [ + "cause" + ] + } + ] + }, + "PriorityOrder": { + "description": "The priority order for bundles during cleanup.\n\nBundles are sorted along the dimensions in [`PriorityDimension`], with each dimension appearing exactly once. During cleanup, lesser-priority bundles are pruned first, to maintain the dataset quota. Note that bundles are sorted by each dimension in the order in which they appear, with each dimension having higher priority than the next.", + "type": "array", + "items": { + "$ref": "#/components/schemas/PriorityDimension" + }, + "minItems": 2, + "maxItems": 2 + }, + "PrivateIpConfig": { + "description": "VPC-private IP address configuration for a network interface.", + "oneOf": [ + { + "description": "The interface has only an IPv4 configuration.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v4" + ] + }, + "value": { + "$ref": "#/components/schemas/PrivateIpv4Config" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "description": "The interface has only an IPv6 configuration.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v6" + ] + }, + "value": { + "$ref": "#/components/schemas/PrivateIpv6Config" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "description": "The interface is dual-stack.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "dual_stack" + ] + }, + "value": { + "type": "object", + "properties": { + "v4": { + "description": "The interface's IPv4 configuration.", + "allOf": [ + { + "$ref": "#/components/schemas/PrivateIpv4Config" + } + ] + }, + "v6": { + "description": "The interface's IPv6 configuration.", + "allOf": [ + { + "$ref": "#/components/schemas/PrivateIpv6Config" + } + ] + } + }, + "required": [ + "v4", + "v6" + ] + } + }, + "required": [ + "type", + "value" + ] + } + ] + }, + "PrivateIpv4Config": { + "description": "VPC-private IPv4 configuration for a network interface.", + "type": "object", + "properties": { + "ip": { + "description": "VPC-private IP address.", + "type": "string", + "format": "ipv4" + }, + "subnet": { + "description": "The IP subnet.", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4Net" + } + ] + }, + "transit_ips": { + "description": "Additional networks on which the interface can send / receive traffic.", + "default": [], + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv4Net" + } + } + }, + "required": [ + "ip", + "subnet" + ] + }, + "PrivateIpv6Config": { + "description": "VPC-private IPv6 configuration for a network interface.", + "type": "object", + "properties": { + "ip": { + "description": "VPC-private IP address.", + "type": "string", + "format": "ipv6" + }, + "subnet": { + "description": "The IP subnet.", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6Net" + } + ] + }, + "transit_ips": { + "description": "Additional networks on which the interface can send / receive traffic.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Ipv6Net" + } + } + }, + "required": [ + "ip", + "subnet", + "transit_ips" + ] + }, + "ProbeCreate": { + "description": "Parameters used to create a probe.", + "type": "object", + "properties": { + "external_ips": { + "description": "The external IP addresses assigned to the probe.", + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalIp" + } + }, + "id": { + "description": "The ID for the probe.", + "allOf": [ + { + "$ref": "#/components/schemas/ProbeUuid" + } + ] + }, + "interface": { + "description": "The probe's networking interface.", + "allOf": [ + { + "$ref": "#/components/schemas/NetworkInterface" + } + ] + } + }, + "required": [ + "external_ips", + "id", + "interface" + ] + }, + "ProbeSet": { + "description": "A set of probes that the target sled should run.", + "type": "object", + "properties": { + "probes": { + "title": "IdHashMap", + "description": "The exact set of probes to run.", + "x-rust-type": { + "crate": "iddqd", + "parameters": [ + { + "$ref": "#/components/schemas/ProbeCreate" + } + ], + "path": "iddqd::IdHashMap", + "version": "*" + }, + "type": "array", + "items": { + "$ref": "#/components/schemas/ProbeCreate" + }, + "uniqueItems": true + } + }, + "required": [ + "probes" + ] + }, + "ProbeUuid": { + "x-rust-type": { + "crate": "omicron-uuid-kinds", + "path": "omicron_uuid_kinds::ProbeUuid", + "version": "*" + }, + "type": "string", + "format": "uuid" + }, + "QemuPvpanic": { + "type": "object", + "properties": { + "enable_isa": { + "description": "Enable the QEMU PVPANIC ISA bus device (I/O port 0x505).", + "type": "boolean" + } + }, + "required": [ + "enable_isa" + ], + "additionalProperties": false + }, + "RackNetworkConfigV2": { + "description": "Initial network configuration", + "type": "object", + "properties": { + "bfd": { + "description": "BFD configuration for connecting the rack to external networks", + "default": [], + "type": "array", + "items": { + "$ref": "#/components/schemas/BfdPeerConfig" + } + }, + "bgp": { + "description": "BGP configurations for connecting the rack to external networks", + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpConfig" + } + }, + "infra_ip_first": { + "description": "First ip address to be used for configuring network infrastructure", + "type": "string", + "format": "ipv4" + }, + "infra_ip_last": { + "description": "Last ip address to be used for configuring network infrastructure", + "type": "string", + "format": "ipv4" + }, + "ports": { + "description": "Uplinks for connecting the rack to external networks", + "type": "array", + "items": { + "$ref": "#/components/schemas/PortConfigV2" + } + }, + "rack_subnet": { + "$ref": "#/components/schemas/Ipv6Net" + } + }, + "required": [ + "bgp", + "infra_ip_first", + "infra_ip_last", + "ports", + "rack_subnet" + ] + }, + "RemoveMupdateOverrideBootSuccessInventory": { + "description": "Status of removing the mupdate override on the boot disk.", + "oneOf": [ + { + "description": "The mupdate override was successfully removed.", + "type": "string", + "enum": [ + "removed" + ] + }, + { + "description": "No mupdate override was found.\n\nThis is considered a success for idempotency reasons.", + "type": "string", + "enum": [ + "no_override" + ] + } + ] + }, + "RemoveMupdateOverrideInventory": { + "description": "Status of removing the mupdate override in the inventory.", + "type": "object", + "properties": { + "boot_disk_result": { + "description": "The result of removing the mupdate override on the boot disk.", + "x-rust-type": { + "crate": "std", + "parameters": [ + { + "$ref": "#/components/schemas/RemoveMupdateOverrideBootSuccessInventory" + }, + { + "type": "string" + } + ], + "path": "::std::result::Result", + "version": "*" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "ok": { + "$ref": "#/components/schemas/RemoveMupdateOverrideBootSuccessInventory" + } + }, + "required": [ + "ok" + ] + }, + { + "type": "object", + "properties": { + "err": { + "type": "string" + } + }, + "required": [ + "err" + ] + } + ] + }, + "non_boot_message": { + "description": "What happened on non-boot disks.\n\nWe aren't modeling this out in more detail, because we plan to not try and keep ledgered data in sync across both disks in the future.", + "type": "string" + } + }, + "required": [ + "boot_disk_result", + "non_boot_message" + ] + }, + "ResolvedVpcFirewallRule": { + "description": "VPC firewall rule after object name resolution has been performed by Nexus", + "type": "object", + "properties": { + "action": { + "$ref": "#/components/schemas/VpcFirewallRuleAction" + }, + "direction": { + "$ref": "#/components/schemas/VpcFirewallRuleDirection" + }, + "filter_hosts": { + "nullable": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/HostIdentifier" + }, + "uniqueItems": true + }, + "filter_ports": { + "nullable": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/L4PortRange" + } + }, + "filter_protocols": { + "nullable": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/VpcFirewallRuleProtocol" + } + }, + "priority": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "status": { + "$ref": "#/components/schemas/VpcFirewallRuleStatus" + }, + "targets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NetworkInterface" + } + } + }, + "required": [ + "action", + "direction", + "priority", + "status", + "targets" + ] + }, + "ResolvedVpcRoute": { + "description": "A VPC route resolved into a concrete target.", + "type": "object", + "properties": { + "dest": { + "$ref": "#/components/schemas/IpNet" + }, + "target": { + "$ref": "#/components/schemas/RouterTarget" + } + }, + "required": [ + "dest", + "target" + ] + }, + "ResolvedVpcRouteSet": { + "description": "An updated set of routes for a given VPC and/or subnet.", + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/RouterId" + }, + "routes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResolvedVpcRoute" + }, + "uniqueItems": true + }, + "version": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/RouterVersion" + } + ] + } + }, + "required": [ + "id", + "routes" + ] + }, + "ResolvedVpcRouteState": { + "description": "Version information for routes on a given VPC subnet.", + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/RouterId" + }, + "version": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/RouterVersion" + } + ] + } + }, + "required": [ + "id" + ] + }, + "RouteConfig": { + "type": "object", + "properties": { + "destination": { + "description": "The destination of the route.", + "allOf": [ + { + "$ref": "#/components/schemas/IpNet" + } + ] + }, + "nexthop": { + "description": "The nexthop/gateway address.", + "type": "string", + "format": "ip" + }, + "rib_priority": { + "nullable": true, + "description": "The RIB priority (i.e. Admin Distance) associated with this route.", + "default": null, + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "description": "The VLAN id associated with this route.", + "default": null, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "destination", + "nexthop" + ] + }, + "RouterId": { + "description": "Identifier for a VPC and/or subnet.", + "type": "object", + "properties": { + "kind": { + "$ref": "#/components/schemas/RouterKind" + }, + "vni": { + "$ref": "#/components/schemas/Vni" + } + }, + "required": [ + "kind", + "vni" + ] + }, + "RouterKind": { + "description": "The scope of a set of VPC router rules.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "system" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "subnet": { + "$ref": "#/components/schemas/IpNet" + }, + "type": { + "type": "string", + "enum": [ + "custom" + ] + } + }, + "required": [ + "subnet", + "type" + ] + } + ] + }, + "RouterTarget": { + "description": "The target for a given router entry.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "drop" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "internet_gateway" + ] + }, + "value": { + "$ref": "#/components/schemas/InternetGatewayRouterTarget" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "ip" + ] + }, + "value": { + "type": "string", + "format": "ip" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "vpc_subnet" + ] + }, + "value": { + "$ref": "#/components/schemas/IpNet" + } + }, + "required": [ + "type", + "value" + ] + } + ] + }, + "RouterVersion": { + "description": "Information on the current parent router (and version) of a route set according to the control plane.", + "type": "object", + "properties": { + "router_id": { + "type": "string", + "format": "uuid" + }, + "version": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "router_id", + "version" + ] + }, + "SerialPort": { + "description": "A serial port device.", + "type": "object", + "properties": { + "num": { + "description": "The serial port number for this port.", + "allOf": [ + { + "$ref": "#/components/schemas/SerialPortNumber" + } + ] + } + }, + "required": [ + "num" + ], + "additionalProperties": false + }, + "SerialPortNumber": { + "description": "A serial port identifier, which determines what I/O ports a guest can use to access a port.", + "type": "string", + "enum": [ + "com1", + "com2", + "com3", + "com4" + ] + }, + "SledCpuFamily": { + "description": "Identifies the kind of CPU present on a sled, determined by reading CPUID.\n\nThis is intended to broadly support the control plane answering the question \"can I run this instance on that sled?\" given an instance with either no or some CPU platform requirement. It is not enough information for more precise placement questions - for example, is a CPU a high-frequency part or many-core part? We don't include Genoa here, but in that CPU family there are high frequency parts, many-core parts, and large-cache parts. To support those questions (or satisfactorily answer #8730) we would need to collect additional information and send it along.", + "oneOf": [ + { + "description": "The CPU vendor or its family number don't correspond to any of the known family variants.", + "type": "string", + "enum": [ + "unknown" + ] + }, + { + "description": "AMD Milan processors (or very close). Could be an actual Milan in a Gimlet, a close-to-Milan client Zen 3 part, or Zen 4 (for which Milan is the greatest common denominator).", + "type": "string", + "enum": [ + "amd_milan" + ] + }, + { + "description": "AMD Turin processors (or very close). Could be an actual Turin in a Cosmo, or a close-to-Turin client Zen 5 part.", + "type": "string", + "enum": [ + "amd_turin" + ] + }, + { + "description": "AMD Turin Dense processors. There are no \"Turin Dense-like\" CPUs unlike other cases, so this means a bona fide Zen 5c Turin Dense part.", + "type": "string", + "enum": [ + "amd_turin_dense" + ] + } + ] + }, + "SledDiagnosticsQueryOutput": { + "oneOf": [ + { + "type": "object", + "properties": { + "success": { + "type": "object", + "properties": { + "command": { + "description": "The command and its arguments.", + "type": "string" + }, + "exit_code": { + "nullable": true, + "description": "The exit code if one was present when the command exited.", + "type": "integer", + "format": "int32" + }, + "exit_status": { + "description": "The exit status of the command. This will be the exit code (if any) and exit reason such as from a signal.", + "type": "string" + }, + "stdio": { + "description": "Any stdout/stderr produced by the command.", + "type": "string" + } + }, + "required": [ + "command", + "exit_status", + "stdio" + ] + } + }, + "required": [ + "success" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "failure": { + "type": "object", + "properties": { + "error": { + "description": "The reason the command failed to execute.", + "type": "string" + } + }, + "required": [ + "error" + ] + } + }, + "required": [ + "failure" + ], + "additionalProperties": false + } + ] + }, + "SledIdentifiers": { + "description": "Identifiers for a single sled.\n\nThis is intended primarily to be used in timeseries, to identify sled from which metric data originates.", + "type": "object", + "properties": { + "model": { + "description": "Model name of the sled", + "type": "string" + }, + "rack_id": { + "description": "Control plane ID of the rack this sled is a member of", + "type": "string", + "format": "uuid" + }, + "revision": { + "description": "Revision number of the sled", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "serial": { + "description": "Serial number of the sled", + "type": "string" + }, + "sled_id": { + "description": "Control plane ID for the sled itself", + "type": "string", + "format": "uuid" + } + }, + "required": [ + "model", + "rack_id", + "revision", + "serial", + "sled_id" + ] + }, + "SledRole": { + "description": "Describes the role of the sled within the rack.\n\nNote that this may change if the sled is physically moved within the rack.", + "oneOf": [ + { + "description": "The sled is a general compute sled.", + "type": "string", + "enum": [ + "gimlet" + ] + }, + { + "description": "The sled is attached to the network switch, and has additional responsibilities.", + "type": "string", + "enum": [ + "scrimlet" + ] + } + ] + }, + "SledUuid": { + "x-rust-type": { + "crate": "omicron-uuid-kinds", + "path": "omicron_uuid_kinds::SledUuid", + "version": "*" + }, + "type": "string", + "format": "uuid" + }, + "SledVmmState": { + "description": "A wrapper type containing a sled's total knowledge of the state of a VMM.", + "type": "object", + "properties": { + "migration_in": { + "nullable": true, + "description": "The current state of any inbound migration to this VMM.", + "allOf": [ + { + "$ref": "#/components/schemas/MigrationRuntimeState" + } + ] + }, + "migration_out": { + "nullable": true, + "description": "The state of any outbound migration from this VMM.", + "allOf": [ + { + "$ref": "#/components/schemas/MigrationRuntimeState" + } + ] + }, + "vmm_state": { + "description": "The most recent state of the sled's VMM process.", + "allOf": [ + { + "$ref": "#/components/schemas/VmmRuntimeState" + } + ] + } + }, + "required": [ + "vmm_state" + ] + }, + "SoftNpuP9": { + "description": "Describes a PCI device that shares host files with the guest using the P9 protocol.\n\nThis is only supported by Propolis servers compiled with the `falcon` feature.", + "type": "object", + "properties": { + "pci_path": { + "description": "The PCI path at which to attach the guest to this port.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + } + }, + "required": [ + "pci_path" + ], + "additionalProperties": false + }, + "SoftNpuPciPort": { + "description": "Describes a SoftNPU PCI device.\n\nThis is only supported by Propolis servers compiled with the `falcon` feature.", + "type": "object", + "properties": { + "pci_path": { + "description": "The PCI path at which to attach the guest to this port.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + } + }, + "required": [ + "pci_path" + ], + "additionalProperties": false + }, + "SoftNpuPort": { + "description": "Describes a port in a SoftNPU emulated ASIC.\n\nThis is only supported by Propolis servers compiled with the `falcon` feature.", + "type": "object", + "properties": { + "backend_id": { + "description": "The name of the port's associated DLPI backend.", + "allOf": [ + { + "$ref": "#/components/schemas/SpecKey" + } + ] + }, + "link_name": { + "description": "The data link name for this port.", + "type": "string" + } + }, + "required": [ + "backend_id", + "link_name" + ], + "additionalProperties": false + }, + "SourceNatConfigGeneric": { + "description": "An IP address and port range used for source NAT, i.e., making outbound network connections from guests or services.", + "type": "object", + "properties": { + "first_port": { + "description": "The first port used for source NAT, inclusive.", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "ip": { + "description": "The external address provided to the instance or service.", + "type": "string", + "format": "ip" + }, + "last_port": { + "description": "The last port used for source NAT, also inclusive.", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "first_port", + "ip", + "last_port" + ] + }, + "SourceNatConfigV4": { + "description": "An IP address and port range used for source NAT, i.e., making outbound network connections from guests or services.", + "type": "object", + "properties": { + "first_port": { + "description": "The first port used for source NAT, inclusive.", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "ip": { + "description": "The external address provided to the instance or service.", + "type": "string", + "format": "ipv4" + }, + "last_port": { + "description": "The last port used for source NAT, also inclusive.", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "first_port", + "ip", + "last_port" + ] + }, + "SourceNatConfigV6": { + "description": "An IP address and port range used for source NAT, i.e., making outbound network connections from guests or services.", + "type": "object", + "properties": { + "first_port": { + "description": "The first port used for source NAT, inclusive.", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "ip": { + "description": "The external address provided to the instance or service.", + "type": "string", + "format": "ipv6" + }, + "last_port": { + "description": "The last port used for source NAT, also inclusive.", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "first_port", + "ip", + "last_port" + ] + }, + "SpecKey": { + "description": "A key identifying a component in an instance spec.", + "oneOf": [ + { + "title": "uuid", + "allOf": [ + { + "type": "string", + "format": "uuid" + } + ] + }, + { + "title": "name", + "allOf": [ + { + "type": "string" + } + ] + } + ] + }, + "StartSledAgentRequest": { + "description": "Configuration information for launching a Sled Agent.", + "type": "object", + "properties": { + "body": { + "$ref": "#/components/schemas/StartSledAgentRequestBody" + }, + "generation": { + "description": "The current generation number of data as stored in CRDB.\n\nThe initial generation is set during RSS time and then only mutated by Nexus. For now, we don't actually anticipate mutating this data, but we leave open the possiblity.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "schema_version": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "body", + "generation", + "schema_version" + ] + }, + "StartSledAgentRequestBody": { + "description": "This is the actual app level data of `StartSledAgentRequest`\n\nWe nest it below the \"header\" of `generation` and `schema_version` so that we can perform partial deserialization of `EarlyNetworkConfig` to only read the header and defer deserialization of the body once we know the schema version. This is possible via the use of [`serde_json::value::RawValue`] in future (post-v1) deserialization paths.", + "type": "object", + "properties": { + "id": { + "description": "Uuid of the Sled Agent to be created.", + "allOf": [ + { + "$ref": "#/components/schemas/SledUuid" + } + ] + }, + "is_lrtq_learner": { + "description": "Is this node an LRTQ learner node?\n\nWe only put the node into learner mode if `use_trust_quorum` is also true.", + "type": "boolean" + }, + "rack_id": { + "description": "Uuid of the rack to which this sled agent belongs.", + "type": "string", + "format": "uuid" + }, + "subnet": { + "description": "Portion of the IP space to be managed by the Sled Agent.", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6Subnet" + } + ] + }, + "use_trust_quorum": { + "description": "Use trust quorum for key generation", + "type": "boolean" + } + }, + "required": [ + "id", + "is_lrtq_learner", + "rack_id", + "subnet", + "use_trust_quorum" + ] + }, + "StorageLimit": { + "description": "The limit on space allowed for zone bundles, as a percentage of the overall dataset's quota.", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "SupportBundleMetadata": { + "description": "Metadata about a support bundle", + "type": "object", + "properties": { + "state": { + "$ref": "#/components/schemas/SupportBundleState" + }, + "support_bundle_id": { + "$ref": "#/components/schemas/SupportBundleUuid" + } + }, + "required": [ + "state", + "support_bundle_id" + ] + }, + "SupportBundleState": { + "type": "string", + "enum": [ + "complete", + "incomplete" + ] + }, + "SupportBundleUuid": { + "x-rust-type": { + "crate": "omicron-uuid-kinds", + "path": "omicron_uuid_kinds::SupportBundleUuid", + "version": "*" + }, + "type": "string", + "format": "uuid" + }, + "SwitchLocation": { + "description": "Identifies switch physical location", + "oneOf": [ + { + "description": "Switch in upper slot", + "type": "string", + "enum": [ + "switch0" + ] + }, + { + "description": "Switch in lower slot", + "type": "string", + "enum": [ + "switch1" + ] + } + ] + }, + "SwitchPorts": { + "description": "A set of switch uplinks.", + "type": "object", + "properties": { + "uplinks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HostPortConfig" + } + } + }, + "required": [ + "uplinks" + ] + }, + "TxEqConfig": { + "description": "Per-port tx-eq overrides. This can be used to fine-tune the transceiver equalization settings to improve signal integrity.", + "type": "object", + "properties": { + "main": { + "nullable": true, + "description": "Main tap", + "type": "integer", + "format": "int32" + }, + "post1": { + "nullable": true, + "description": "Post-cursor tap1", + "type": "integer", + "format": "int32" + }, + "post2": { + "nullable": true, + "description": "Post-cursor tap2", + "type": "integer", + "format": "int32" + }, + "pre1": { + "nullable": true, + "description": "Pre-cursor tap1", + "type": "integer", + "format": "int32" + }, + "pre2": { + "nullable": true, + "description": "Pre-cursor tap2", + "type": "integer", + "format": "int32" + } + } + }, + "UplinkAddressConfig": { + "type": "object", + "properties": { + "address": { + "$ref": "#/components/schemas/IpNet" + }, + "vlan_id": { + "nullable": true, + "description": "The VLAN id (if any) associated with this address.", + "default": null, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "address" + ] + }, + "VirtioDisk": { + "description": "A disk that presents a virtio-block interface to the guest.", + "type": "object", + "properties": { + "backend_id": { + "description": "The name of the disk's backend component.", + "allOf": [ + { + "$ref": "#/components/schemas/SpecKey" + } + ] + }, + "pci_path": { + "description": "The PCI bus/device/function at which this disk should be attached.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + } + }, + "required": [ + "backend_id", + "pci_path" + ], + "additionalProperties": false + }, + "VirtioNetworkBackend": { + "description": "A network backend associated with a virtio-net (viona) VNIC on the host.", + "type": "object", + "properties": { + "vnic_name": { + "description": "The name of the viona VNIC to use as a backend.", + "type": "string" + } + }, + "required": [ + "vnic_name" + ], + "additionalProperties": false + }, + "VirtioNic": { + "description": "A network card that presents a virtio-net interface to the guest.", + "type": "object", + "properties": { + "backend_id": { + "description": "The name of the device's backend.", + "allOf": [ + { + "$ref": "#/components/schemas/SpecKey" + } + ] + }, + "interface_id": { + "description": "A caller-defined correlation identifier for this interface. If Propolis is configured to collect network interface kstats in its Oximeter metrics, the metric series for this interface will be associated with this identifier.", + "type": "string", + "format": "uuid" + }, + "pci_path": { + "description": "The PCI path at which to attach this device.", + "allOf": [ + { + "$ref": "#/components/schemas/PciPath" + } + ] + } + }, + "required": [ + "backend_id", + "interface_id", + "pci_path" + ], + "additionalProperties": false + }, + "VirtualNetworkInterfaceHost": { + "description": "A mapping from a virtual NIC to a physical host", + "type": "object", + "properties": { + "physical_host_ip": { + "type": "string", + "format": "ipv6" + }, + "virtual_ip": { + "type": "string", + "format": "ip" + }, + "virtual_mac": { + "$ref": "#/components/schemas/MacAddr" + }, + "vni": { + "$ref": "#/components/schemas/Vni" + } + }, + "required": [ + "physical_host_ip", + "virtual_ip", + "virtual_mac", + "vni" + ] + }, + "VmmIssueDiskSnapshotRequestBody": { + "type": "object", + "properties": { + "snapshot_id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "snapshot_id" + ] + }, + "VmmIssueDiskSnapshotRequestResponse": { + "type": "object", + "properties": { + "snapshot_id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "snapshot_id" + ] + }, + "VmmPutStateBody": { + "description": "The body of a request to move a previously-ensured instance into a specific runtime state.", + "type": "object", + "properties": { + "state": { + "description": "The state into which the instance should be driven.", + "allOf": [ + { + "$ref": "#/components/schemas/VmmStateRequested" + } + ] + } + }, + "required": [ + "state" + ] + }, + "VmmPutStateResponse": { + "description": "The response sent from a request to move an instance into a specific runtime state.", + "type": "object", + "properties": { + "updated_runtime": { + "nullable": true, + "description": "The current runtime state of the instance after handling the request to change its state. If the instance's state did not change, this field is `None`.", + "allOf": [ + { + "$ref": "#/components/schemas/SledVmmState" + } + ] + } + } + }, + "VmmRuntimeState": { + "description": "The dynamic runtime properties of an individual VMM process.", + "type": "object", + "properties": { + "gen": { + "description": "The generation number for this VMM's state.", + "allOf": [ + { + "$ref": "#/components/schemas/Generation" + } + ] + }, + "state": { + "description": "The last state reported by this VMM.", + "allOf": [ + { + "$ref": "#/components/schemas/VmmState" + } + ] + }, + "time_updated": { + "description": "Timestamp for the VMM's state.", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "gen", + "state", + "time_updated" + ] + }, + "VmmSpec": { + "description": "Specifies the virtual hardware configuration of a new Propolis VMM in the form of a Propolis instance specification.\n\nSled-agent expects that when an instance spec is provided alongside an `InstanceSledLocalConfig` to initialize a new instance, the NIC IDs in that config's network interface list will match the IDs of the virtio network backends in the instance spec.", + "allOf": [ + { + "$ref": "#/components/schemas/InstanceSpecV0" + } + ] + }, + "VmmState": { + "description": "One of the states that a VMM can be in.", + "oneOf": [ + { + "description": "The VMM is initializing and has not started running guest CPUs yet.", + "type": "string", + "enum": [ + "starting" + ] + }, + { + "description": "The VMM has finished initializing and may be running guest CPUs.", + "type": "string", + "enum": [ + "running" + ] + }, + { + "description": "The VMM is shutting down.", + "type": "string", + "enum": [ + "stopping" + ] + }, + { + "description": "The VMM's guest has stopped, and the guest will not run again, but the VMM process may not have released all of its resources yet.", + "type": "string", + "enum": [ + "stopped" + ] + }, + { + "description": "The VMM is being restarted or its guest OS is rebooting.", + "type": "string", + "enum": [ + "rebooting" + ] + }, + { + "description": "The VMM is part of a live migration.", + "type": "string", + "enum": [ + "migrating" + ] + }, + { + "description": "The VMM process reported an internal failure.", + "type": "string", + "enum": [ + "failed" + ] + }, + { + "description": "The VMM process has been destroyed and its resources have been released.", + "type": "string", + "enum": [ + "destroyed" + ] + } + ] + }, + "VmmStateRequested": { + "description": "Requestable running state of an Instance.\n\nA subset of [`omicron_common::api::external::InstanceState`].", + "oneOf": [ + { + "description": "Run this instance by migrating in from a previous running incarnation of the instance.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "migration_target" + ] + }, + "value": { + "$ref": "#/components/schemas/InstanceMigrationTargetParams" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "description": "Start the instance if it is not already running.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "running" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Stop the instance.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "stopped" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Immediately reset the instance, as though it had stopped and immediately began to run again.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reboot" + ] + } + }, + "required": [ + "type" + ] + } + ] + }, + "VmmUnregisterResponse": { + "description": "The response sent from a request to unregister an instance.", + "type": "object", + "properties": { + "updated_runtime": { + "nullable": true, + "description": "The current state of the instance after handling the request to unregister it. If the instance's state did not change, this field is `None`.", + "allOf": [ + { + "$ref": "#/components/schemas/SledVmmState" + } + ] + } + } + }, + "Vni": { + "description": "A Geneve Virtual Network Identifier", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "VpcFirewallIcmpFilter": { + "type": "object", + "properties": { + "code": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/IcmpParamRange" + } + ] + }, + "icmp_type": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "icmp_type" + ] + }, + "VpcFirewallRuleAction": { + "type": "string", + "enum": [ + "allow", + "deny" + ] + }, + "VpcFirewallRuleDirection": { + "type": "string", + "enum": [ + "inbound", + "outbound" + ] + }, + "VpcFirewallRuleProtocol": { + "description": "The protocols that may be specified in a firewall rule's filter", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "tcp" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "udp" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "icmp" + ] + }, + "value": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/VpcFirewallIcmpFilter" + } + ] + } + }, + "required": [ + "type", + "value" + ] + } + ] + }, + "VpcFirewallRuleStatus": { + "type": "string", + "enum": [ + "disabled", + "enabled" + ] + }, + "VpcFirewallRulesEnsureBody": { + "description": "Update firewall rules for a VPC", + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResolvedVpcFirewallRule" + } + }, + "vni": { + "$ref": "#/components/schemas/Vni" + } + }, + "required": [ + "rules", + "vni" + ] + }, + "ZoneArtifactInventory": { + "description": "Inventory representation of a single zone artifact on a boot disk.\n\nPart of [`ZoneManifestBootInventory`].", + "type": "object", + "properties": { + "expected_hash": { + "description": "The expected digest of the file's contents.", + "type": "string", + "format": "hex string (32 bytes)" + }, + "expected_size": { + "description": "The expected size of the file, in bytes.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "file_name": { + "description": "The name of the zone file on disk, for example `nexus.tar.gz`. Zone files are always \".tar.gz\".", + "type": "string" + }, + "path": { + "description": "The full path to the zone file.", + "type": "string", + "format": "Utf8PathBuf" + }, + "status": { + "description": "The status of the artifact.\n\nThis is `Ok(())` if the artifact is present and matches the expected size and digest, or an error message if it is missing or does not match.", + "x-rust-type": { + "crate": "std", + "parameters": [ + { + "type": "null" + }, + { + "type": "string" + } + ], + "path": "::std::result::Result", + "version": "*" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "ok": { + "type": "string", + "enum": [ + null + ] + } + }, + "required": [ + "ok" + ] + }, + { + "type": "object", + "properties": { + "err": { + "type": "string" + } + }, + "required": [ + "err" + ] + } + ] + } + }, + "required": [ + "expected_hash", + "expected_size", + "file_name", + "path", + "status" + ] + }, + "ZoneBundleCause": { + "description": "The reason or cause for a zone bundle, i.e., why it was created.", + "oneOf": [ + { + "description": "Some other, unspecified reason.", + "type": "string", + "enum": [ + "other" + ] + }, + { + "description": "A zone bundle taken when a sled agent finds a zone that it does not expect to be running.", + "type": "string", + "enum": [ + "unexpected_zone" + ] + }, + { + "description": "An instance zone was terminated.", + "type": "string", + "enum": [ + "terminated_instance" + ] + } + ] + }, + "ZoneBundleId": { + "description": "An identifier for a zone bundle.", + "type": "object", + "properties": { + "bundle_id": { + "description": "The ID for this bundle itself.", + "type": "string", + "format": "uuid" + }, + "zone_name": { + "description": "The name of the zone this bundle is derived from.", + "type": "string" + } + }, + "required": [ + "bundle_id", + "zone_name" + ] + }, + "ZoneBundleMetadata": { + "description": "Metadata about a zone bundle.", + "type": "object", + "properties": { + "cause": { + "description": "The reason or cause a bundle was created.", + "allOf": [ + { + "$ref": "#/components/schemas/ZoneBundleCause" + } + ] + }, + "id": { + "description": "Identifier for this zone bundle", + "allOf": [ + { + "$ref": "#/components/schemas/ZoneBundleId" + } + ] + }, + "time_created": { + "description": "The time at which this zone bundle was created.", + "type": "string", + "format": "date-time" + }, + "version": { + "description": "A version number for this zone bundle.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "cause", + "id", + "time_created", + "version" + ] + }, + "ZoneImageResolverInventory": { + "description": "Inventory representation of zone image resolver status and health.", + "type": "object", + "properties": { + "mupdate_override": { + "description": "The mupdate override status.", + "allOf": [ + { + "$ref": "#/components/schemas/MupdateOverrideInventory" + } + ] + }, + "zone_manifest": { + "description": "The zone manifest status.", + "allOf": [ + { + "$ref": "#/components/schemas/ZoneManifestInventory" + } + ] + } + }, + "required": [ + "mupdate_override", + "zone_manifest" + ] + }, + "ZoneManifestBootInventory": { + "description": "Inventory representation of zone artifacts on the boot disk.\n\nPart of [`ZoneManifestInventory`].", + "type": "object", + "properties": { + "artifacts": { + "title": "IdOrdMap", + "description": "The artifacts on disk.", + "x-rust-type": { + "crate": "iddqd", + "parameters": [ + { + "$ref": "#/components/schemas/ZoneArtifactInventory" + } + ], + "path": "iddqd::IdOrdMap", + "version": "*" + }, + "type": "array", + "items": { + "$ref": "#/components/schemas/ZoneArtifactInventory" + }, + "uniqueItems": true + }, + "source": { + "description": "The manifest source.\n\nIn production this is [`OmicronZoneManifestSource::Installinator`], but in some development and testing flows Sled Agent synthesizes zone manifests. In those cases, the source is [`OmicronZoneManifestSource::SledAgent`].", + "allOf": [ + { + "$ref": "#/components/schemas/OmicronZoneManifestSource" + } + ] + } + }, + "required": [ + "artifacts", + "source" + ] + }, + "ZoneManifestInventory": { + "description": "Inventory representation of a zone manifest.\n\nPart of [`ZoneImageResolverInventory`].\n\nA zone manifest is a listing of all the zones present in a system's install dataset. This struct contains information about the install dataset gathered from a system.", + "type": "object", + "properties": { + "boot_disk_path": { + "description": "The full path to the zone manifest file on the boot disk.", + "type": "string", + "format": "Utf8PathBuf" + }, + "boot_inventory": { + "description": "The manifest read from the boot disk, and whether the manifest is valid.", + "x-rust-type": { + "crate": "std", + "parameters": [ + { + "$ref": "#/components/schemas/ZoneManifestBootInventory" + }, + { + "type": "string" + } + ], + "path": "::std::result::Result", + "version": "*" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "ok": { + "$ref": "#/components/schemas/ZoneManifestBootInventory" + } + }, + "required": [ + "ok" + ] + }, + { + "type": "object", + "properties": { + "err": { + "type": "string" + } + }, + "required": [ + "err" + ] + } + ] + }, + "non_boot_status": { + "title": "IdOrdMap", + "description": "Information about the install dataset on non-boot disks.", + "x-rust-type": { + "crate": "iddqd", + "parameters": [ + { + "$ref": "#/components/schemas/ZoneManifestNonBootInventory" + } + ], + "path": "iddqd::IdOrdMap", + "version": "*" + }, + "type": "array", + "items": { + "$ref": "#/components/schemas/ZoneManifestNonBootInventory" + }, + "uniqueItems": true + } + }, + "required": [ + "boot_disk_path", + "boot_inventory", + "non_boot_status" + ] + }, + "ZoneManifestNonBootInventory": { + "description": "Inventory representation of a zone manifest on a non-boot disk.\n\nUnlike [`ZoneManifestBootInventory`] which is structured since Reconfigurator makes decisions based on it, information about non-boot disks is purely advisory. For simplicity, we store information in an unstructured format.", + "type": "object", + "properties": { + "is_valid": { + "description": "Whether the status is valid.", + "type": "boolean" + }, + "message": { + "description": "A message describing the status.\n\nIf `is_valid` is true, then the message describes the list of artifacts found and their hashes.\n\nIf `is_valid` is false, then this message describes the reason for the invalid status. This could include errors reading the zone manifest, or zone file mismatches.", + "type": "string" + }, + "path": { + "description": "The full path to the zone manifest JSON on the non-boot disk.", + "type": "string", + "format": "Utf8PathBuf" + }, + "zpool_id": { + "description": "The ID of the non-boot zpool.", + "allOf": [ + { + "$ref": "#/components/schemas/InternalZpoolUuid" + } + ] + } + }, + "required": [ + "is_valid", + "message", + "path", + "zpool_id" + ] + }, + "ZpoolName": { + "title": "The name of a Zpool", + "description": "Zpool names are of the format ox{i,p}_. They are either Internal or External, and should be unique", + "type": "string", + "pattern": "^ox[ip]_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "ZpoolUuid": { + "x-rust-type": { + "crate": "omicron-uuid-kinds", + "path": "omicron_uuid_kinds::ZpoolUuid", + "version": "*" + }, + "type": "string", + "format": "uuid" + }, + "PropolisUuid": { + "x-rust-type": { + "crate": "omicron-uuid-kinds", + "path": "omicron_uuid_kinds::PropolisUuid", + "version": "*" + }, + "type": "string", + "format": "uuid" + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/openapi/sled-agent/sled-agent-latest.json b/openapi/sled-agent/sled-agent-latest.json index 652e9998ad5..79671bf5429 120000 --- a/openapi/sled-agent/sled-agent-latest.json +++ b/openapi/sled-agent/sled-agent-latest.json @@ -1 +1 @@ -sled-agent-10.0.0-898597.json \ No newline at end of file +sled-agent-11.0.0-5f3d9f.json \ No newline at end of file diff --git a/sled-agent/api/src/lib.rs b/sled-agent/api/src/lib.rs index 6dcf7e1c76d..caacc8b89ab 100644 --- a/sled-agent/api/src/lib.rs +++ b/sled-agent/api/src/lib.rs @@ -35,6 +35,7 @@ use omicron_uuid_kinds::{ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sled_agent_types::inventory::v9; +use sled_agent_types::inventory::v10; use sled_agent_types::probes; use sled_agent_types::{ bootstore::BootstoreStatus, @@ -74,6 +75,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (11, ADD_DUAL_STACK_EXTERNAL_IP_CONFIG), (10, ADD_DUAL_STACK_SHARED_NETWORK_INTERFACES), (9, DELEGATE_ZVOL_TO_PROPOLIS), (8, REMOVE_SLED_ROLE), @@ -338,13 +340,28 @@ pub trait SledAgentApi { #[endpoint { method = PUT, path = "/omicron-config", - versions = VERSION_ADD_DUAL_STACK_SHARED_NETWORK_INTERFACES.. + versions = VERSION_ADD_DUAL_STACK_EXTERNAL_IP_CONFIG.. }] async fn omicron_config_put( rqctx: RequestContext, body: TypedBody, ) -> Result; + #[endpoint { + operation_id = "omicron_config_put", + method = PUT, + path = "/omicron-config", + versions = + VERSION_ADD_DUAL_STACK_SHARED_NETWORK_INTERFACES..VERSION_ADD_DUAL_STACK_EXTERNAL_IP_CONFIG, + }] + async fn v10_omicron_config_put( + rqctx: RequestContext, + body: TypedBody, + ) -> Result { + let body = body.try_map(OmicronSledConfig::try_from)?; + Self::omicron_config_put(rqctx, body).await + } + #[endpoint { method = PUT, path = "/omicron-config", @@ -355,8 +372,8 @@ pub trait SledAgentApi { rqctx: RequestContext, body: TypedBody, ) -> Result { - let body = body.try_map(OmicronSledConfig::try_from)?; - Self::omicron_config_put(rqctx, body).await + let body = body.try_map(v10::OmicronSledConfig::try_from)?; + Self::v10_omicron_config_put(rqctx, body).await } #[endpoint { @@ -385,7 +402,7 @@ pub trait SledAgentApi { operation_id = "vmm_register", method = PUT, path = "/vmms/{propolis_id}", - versions = VERSION_ADD_DUAL_STACK_SHARED_NETWORK_INTERFACES.. + versions = VERSION_ADD_DUAL_STACK_EXTERNAL_IP_CONFIG.. }] async fn vmm_register( rqctx: RequestContext, @@ -398,12 +415,12 @@ pub trait SledAgentApi { path = "/vmms/{propolis_id}", operation_id = "vmm_register", versions = - VERSION_DELEGATE_ZVOL_TO_PROPOLIS..VERSION_ADD_DUAL_STACK_SHARED_NETWORK_INTERFACES + VERSION_ADD_DUAL_STACK_SHARED_NETWORK_INTERFACES..VERSION_ADD_DUAL_STACK_EXTERNAL_IP_CONFIG }] - async fn v9_vmm_register( + async fn v10_vmm_register( rqctx: RequestContext, path_params: Path, - body: TypedBody, + body: TypedBody, ) -> Result, HttpError> { let body = body.try_map( sled_agent_types::instance::InstanceEnsureBody::try_from, @@ -411,6 +428,22 @@ pub trait SledAgentApi { Self::vmm_register(rqctx, path_params, body).await } + #[endpoint { + method = PUT, + path = "/vmms/{propolis_id}", + operation_id = "vmm_register", + versions = + VERSION_DELEGATE_ZVOL_TO_PROPOLIS..VERSION_ADD_DUAL_STACK_SHARED_NETWORK_INTERFACES + }] + async fn v9_vmm_register( + rqctx: RequestContext, + path_params: Path, + body: TypedBody, + ) -> Result, HttpError> { + let body = body.try_map(v10::InstanceEnsureBody::try_from)?; + Self::v10_vmm_register(rqctx, path_params, body).await + } + #[endpoint { operation_id = "vmm_register", method = PUT, @@ -681,12 +714,27 @@ pub trait SledAgentApi { #[endpoint { method = GET, path = "/inventory", - versions = VERSION_ADD_DUAL_STACK_SHARED_NETWORK_INTERFACES.., + versions = VERSION_ADD_DUAL_STACK_EXTERNAL_IP_CONFIG.., }] async fn inventory( rqctx: RequestContext, ) -> Result, HttpError>; + /// Fetch basic information about this sled + #[endpoint { + operation_id = "inventory", + method = GET, + path = "/inventory", + versions = + VERSION_ADD_DUAL_STACK_SHARED_NETWORK_INTERFACES..VERSION_ADD_DUAL_STACK_EXTERNAL_IP_CONFIG, + }] + async fn v10_inventory( + rqctx: RequestContext, + ) -> Result, HttpError> { + let HttpResponseOk(inventory) = Self::inventory(rqctx).await?; + inventory.try_into().map_err(HttpError::from).map(HttpResponseOk) + } + /// Fetch basic information about this sled #[endpoint { operation_id = "inventory", @@ -698,7 +746,7 @@ pub trait SledAgentApi { async fn v9_inventory( rqctx: RequestContext, ) -> Result, HttpError> { - let HttpResponseOk(inventory) = Self::inventory(rqctx).await?; + let HttpResponseOk(inventory) = Self::v10_inventory(rqctx).await?; inventory.try_into().map_err(HttpError::from).map(HttpResponseOk) } diff --git a/sled-agent/api/src/v3.rs b/sled-agent/api/src/v3.rs index 3a17b4d3fa0..7bcd8404d90 100644 --- a/sled-agent/api/src/v3.rs +++ b/sled-agent/api/src/v3.rs @@ -16,7 +16,7 @@ use nexus_sled_agent_shared::inventory::{ use omicron_common::address::NEXUS_LOCKSTEP_PORT; use omicron_common::api::external::ByteCount; use omicron_common::api::external::Generation; -use omicron_common::api::internal::shared::SourceNatConfig; +use omicron_common::api::internal::shared::external_ip::v1::SourceNatConfig; use omicron_common::api::internal::shared::network_interface::v1::NetworkInterface; use omicron_common::disk::DatasetConfig; use omicron_common::disk::OmicronPhysicalDiskConfig; diff --git a/sled-agent/api/src/v6.rs b/sled-agent/api/src/v6.rs index 1a6fad50d17..8835bfc1870 100644 --- a/sled-agent/api/src/v6.rs +++ b/sled-agent/api/src/v6.rs @@ -12,7 +12,7 @@ use omicron_common::api::external::Hostname; use omicron_common::api::internal::nexus::HostIdentifier; use omicron_common::api::internal::nexus::VmmRuntimeState; use omicron_common::api::internal::shared::DhcpConfig; -use omicron_common::api::internal::shared::SourceNatConfig; +use omicron_common::api::internal::shared::external_ip::v1::SourceNatConfig; use omicron_common::api::internal::shared::network_interface::v1::NetworkInterface; use omicron_uuid_kinds::InstanceUuid; use schemars::JsonSchema; diff --git a/sled-agent/api/src/v8.rs b/sled-agent/api/src/v8.rs index 0d53f1157d3..cc18138cd78 100644 --- a/sled-agent/api/src/v8.rs +++ b/sled-agent/api/src/v8.rs @@ -9,7 +9,7 @@ use omicron_common::api::external::Hostname; use omicron_common::api::internal::nexus::HostIdentifier; use omicron_common::api::internal::nexus::VmmRuntimeState; use omicron_common::api::internal::shared::DhcpConfig; -use omicron_common::api::internal::shared::SourceNatConfig; +use omicron_common::api::internal::shared::external_ip::v1::SourceNatConfig; use omicron_common::api::internal::shared::network_interface::v1::NetworkInterface; use omicron_uuid_kinds::InstanceUuid; use schemars::JsonSchema; diff --git a/sled-agent/config-reconciler/src/ledger/legacy_configs.rs b/sled-agent/config-reconciler/src/ledger/legacy_configs.rs index 221adcbd701..9fc919196c6 100644 --- a/sled-agent/config-reconciler/src/ledger/legacy_configs.rs +++ b/sled-agent/config-reconciler/src/ledger/legacy_configs.rs @@ -16,6 +16,7 @@ use serde::Deserialize; use serde::Serialize; use sled_agent_types::inventory::v9::OmicronSledConfig as OmicronSledConfigV9; use sled_agent_types::inventory::v9::OmicronZoneConfig as OmicronZoneConfigV9; +use sled_agent_types::inventory::v10::OmicronSledConfig as OmicronSledConfigV10; use slog::Logger; use slog::error; use slog::warn; @@ -45,9 +46,14 @@ pub(super) async fn try_convert_v9_sled_config( ) -> Option { let old = Ledger::::new(log, datasets.clone()).await?; - let new_config = old.into_inner().0.try_into().unwrap_or_else(|e| { + let new_config = OmicronSledConfigV10::try_from(old.into_inner().0).unwrap_or_else(|e| { panic!( - "Failed to convert OmicronSledConfigV9 to the current version: {e}" + "Failed to convert OmicronSledConfigV9 to the OmicronSledConfigV10: {e}" + ) + }); + let new_config = new_config.try_into().unwrap_or_else(|e| { + panic!( + "Failed to convert OmicronSledConfigV10 to the curent version: {e}" ) }); write_converted_ledger( @@ -138,9 +144,13 @@ pub(super) async fn convert_legacy_ledgers( // instead. This conversion _is_ fallible. Unfortunately, if it fails, // there's nothing we can do. That conversion is determinstic, so doing it // again won't change the result. + let sled_config = OmicronSledConfigV10::try_from(sled_config) + .unwrap_or_else(|e| panic!( + "Failed to convert OmicronSledConfigV9 to OmicronSledConfigV10: {e}" + )); let sled_config = OmicronSledConfig::try_from(sled_config) .unwrap_or_else(|e| panic!( - "Failed to convert OmicronSledConfigV9 to the current version: {e}" + "Failed to convert OmicronSledConfigV10 to the current version: {e}" )); // Write the newly-merged config to disk. @@ -395,8 +405,10 @@ pub(super) mod tests { tokio::fs::read_to_string(dst_file).await.unwrap().as_str(), ) .expect("successfully converted config"); - let new = OmicronSledConfig::try_from(new_as_v9) + let new_as_v10 = OmicronSledConfigV10::try_from(new_as_v9) .expect("successfully converted v9 config"); + let new = OmicronSledConfig::try_from(new_as_v10) + .expect("successfully converted v10 config"); assert_eq!(new, converted); logctx.cleanup_successful(); } diff --git a/sled-agent/src/instance.rs b/sled-agent/src/instance.rs index 2b2418e0f09..4bcfdc375ca 100644 --- a/sled-agent/src/instance.rs +++ b/sled-agent/src/instance.rs @@ -24,8 +24,8 @@ use illumos_utils::zone::PROPOLIS_ZONE_PREFIX; use illumos_utils::zpool::ZpoolOrRamdisk; use omicron_common::api::internal::nexus::{SledVmmState, VmmRuntimeState}; use omicron_common::api::internal::shared::{ - DelegatedZvol, NetworkInterface, ResolvedVpcFirewallRule, SledIdentifiers, - SourceNatConfig, + DelegatedZvol, ExternalIpConfig, NetworkInterface, ResolvedVpcFirewallRule, + SledIdentifiers, }; use omicron_common::backoff; use omicron_common::backoff::BackoffError; @@ -534,9 +534,8 @@ struct InstanceRunner { // Guest NIC and OPTE port information requested_nics: Vec, - source_nat: SourceNatConfig, - ephemeral_ip: Option, - floating_ips: Vec, + external_ips: Option, + // Multicast groups to which this instance belongs. multicast_groups: Vec, firewall_rules: Vec, @@ -720,11 +719,11 @@ impl InstanceRunner { .map_err(|_| Error::FailedSendClientClosed) }, AddExternalIp { ip, tx } => { - tx.send(self.add_external_ip(&ip).await.map_err(|e| e.into())) + tx.send(self.add_external_ip(&ip).map_err(|e| e.into())) .map_err(|_| Error::FailedSendClientClosed) }, DeleteExternalIp { ip, tx } => { - tx.send(self.delete_external_ip(&ip).await.map_err(|e| e.into())) + tx.send(self.delete_external_ip(&ip).map_err(|e| e.into())) .map_err(|_| Error::FailedSendClientClosed) }, RefreshExternalIps { tx } => { @@ -1346,60 +1345,112 @@ impl InstanceRunner { running_state.running_zone.release_opte_ports(); } - async fn add_external_ip_inner( + fn add_external_ip_inner( &mut self, - ip: &InstanceExternalIpBody, + request: &InstanceExternalIpBody, ) -> Result<(), Error> { + let Some(primary_nic) = self.primary_nic() else { + return Err(Error::Opte(illumos_utils::opte::Error::NoPrimaryNic)); + }; + let nic_id = primary_nic.id; + let nic_kind = primary_nic.kind; + + // TODO-completeness: + // + // If we have no external IP configuration for the requested address + // version, then we fail the overall request. We _could_ support this, + // dynamically creating the external IP configuration for the specified + // address on-demand. But it's not clear that's what we want right now, + // and so we'll defer it. Instead, this means instances need to be + // created with the IP stacks they need. + // + // We should revisit this when actually implementing the public API for + // external dual-stack addressing, see + // https://github.com/oxidecomputer/omicron/issues/9248. + let Some(external_ips) = &mut self.external_ips else { + return Err(Error::Opte( + illumos_utils::opte::Error::InvalidPortIpConfig, + )); + }; + // v4 + v6 handling is delegated to `external_ips_ensure`. // If OPTE is unhappy, we undo at `Instance` level. - match ip { - // For idempotency of add/delete, we want to return - // success on 'already done'. - InstanceExternalIpBody::Ephemeral(ip) - if Some(ip) == self.ephemeral_ip.as_ref() => - { - return Ok(()); - } - InstanceExternalIpBody::Floating(ip) - if self.floating_ips.contains(ip) => - { - return Ok(()); + // In each match arm below, we check a few things + // + // - We have an IP configuration of the same IP version as the requested + // address. + // - And either: + // - The new address and the existing one are equal, for + // idempotency. This returns early. + // - Or we have no existing address of this type, in which case we + // assign it. + match request { + InstanceExternalIpBody::Ephemeral(IpAddr::V4(ipv4)) => { + let Some(cfg) = external_ips.ipv4_config_mut() else { + return Err(Error::Opte( + illumos_utils::opte::Error::InvalidPortIpConfig, + )); + }; + match cfg.ephemeral_ip_mut() { + Some(eip) if eip == ipv4 => return Ok(()), + Some(eip) => return Err(Error::Opte( + illumos_utils::opte::Error::ImplicitEphemeralIpDetach( + (*ipv4).into(), + (*eip).into(), + ), + )), + empty @ None => { + let _ = empty.insert(*ipv4); + } + } } - // New Ephemeral IP while current exists -- error without - // explicit delete. - InstanceExternalIpBody::Ephemeral(ip) - if self.ephemeral_ip.is_some() => - { - return Err(Error::Opte( - illumos_utils::opte::Error::ImplicitEphemeralIpDetach( - *ip, - self.ephemeral_ip.unwrap(), - ), - )); + InstanceExternalIpBody::Ephemeral(IpAddr::V6(ipv6)) => { + let Some(cfg) = external_ips.ipv6_config_mut() else { + return Err(Error::Opte( + illumos_utils::opte::Error::InvalidPortIpConfig, + )); + }; + match cfg.ephemeral_ip_mut() { + Some(eip) if eip == ipv6 => return Ok(()), + Some(eip) => return Err(Error::Opte( + illumos_utils::opte::Error::ImplicitEphemeralIpDetach( + (*ipv6).into(), + (*eip).into(), + ), + )), + empty @ None => { + let _ = empty.insert(*ipv6); + } + } } - // Not found, proceed with OPTE update. - InstanceExternalIpBody::Ephemeral(ip) => { - self.ephemeral_ip = Some(*ip); + InstanceExternalIpBody::Floating(IpAddr::V4(ipv4)) => { + let Some(cfg) = external_ips.ipv4_config_mut() else { + return Err(Error::Opte( + illumos_utils::opte::Error::InvalidPortIpConfig, + )); + }; + if cfg.floating_ips().contains(ipv4) { + return Ok(()); + } + cfg.floating_ips_mut().push(*ipv4); } - InstanceExternalIpBody::Floating(ip) => { - self.floating_ips.push(*ip); + InstanceExternalIpBody::Floating(IpAddr::V6(ipv6)) => { + let Some(cfg) = external_ips.ipv6_config_mut() else { + return Err(Error::Opte( + illumos_utils::opte::Error::InvalidPortIpConfig, + )); + }; + if cfg.floating_ips().contains(ipv6) { + return Ok(()); + } + cfg.floating_ips_mut().push(*ipv6); } } - let Some(primary_nic) = self.primary_nic() else { - return Err(Error::Opte(illumos_utils::opte::Error::NoPrimaryNic)); - }; - - self.port_manager.external_ips_ensure( - primary_nic.id, - primary_nic.kind, - Some(self.source_nat), - self.ephemeral_ip, - &self.floating_ips, - )?; - - Ok(()) + self.port_manager + .external_ips_ensure(nic_id, nic_kind, external_ips) + .map_err(Error::Opte) } fn refresh_external_ips_inner(&mut self) -> Result<(), Error> { @@ -1407,60 +1458,104 @@ impl InstanceRunner { return Err(Error::Opte(illumos_utils::opte::Error::NoPrimaryNic)); }; - self.port_manager.external_ips_ensure( - primary_nic.id, - primary_nic.kind, - Some(self.source_nat), - self.ephemeral_ip, - &self.floating_ips, - )?; + let Some(external_ips) = &self.external_ips else { + return Ok(()); + }; - Ok(()) + self.port_manager + .external_ips_ensure( + primary_nic.id, + primary_nic.kind, + &external_ips, + ) + .map_err(Error::Opte) } - async fn delete_external_ip_inner( + fn delete_external_ip_inner( &mut self, - ip: &InstanceExternalIpBody, + request: &InstanceExternalIpBody, ) -> Result<(), Error> { + let Some(primary_nic) = self.primary_nic() else { + return Err(Error::Opte(illumos_utils::opte::Error::NoPrimaryNic)); + }; + let nic_id = primary_nic.id; + let nic_kind = primary_nic.kind; + + // See note at the top of `add_external_ip_inner()`. + let Some(external_ips) = &mut self.external_ips else { + return Err(Error::Opte( + illumos_utils::opte::Error::InvalidPortIpConfig, + )); + }; + // v4 + v6 handling is delegated to `external_ips_ensure`. // If OPTE is unhappy, we undo at `Instance` level. - match ip { - // For idempotency of add/delete, we want to return - // success on 'already done'. - // IP Mismatch and 'deleted in past' can't really be - // disambiguated here. - InstanceExternalIpBody::Ephemeral(ip) - if self.ephemeral_ip != Some(*ip) => - { - return Ok(()); + // We want to idempotently delete addresses, but it's not really + // possible to tell the difference between: + // + // - Having no IP at all + // - Having deleted the requested IP but afterwards reassigned a new + // one. + // + // Also note that we don't fail if we're asked to delete an external + // address that we don't have an IP stack for (i.e., delete an IPv4 when + // we're IPv6-only). This is to preserve the original behavior, which + // would not fail in this case either. + match request { + InstanceExternalIpBody::Ephemeral(IpAddr::V4(ipv4)) => { + let Some(cfg) = external_ips.ipv4_config_mut() else { + return Ok(()); + }; + // Take out of the option only if it contains the requested + // address. If it doesn't, either it's None or has another + // address, both of which mean we've "succeeded". + if cfg.ephemeral_ip_mut().take_if(|eip| eip == ipv4).is_none() { + return Ok(()); + } + } + InstanceExternalIpBody::Ephemeral(IpAddr::V6(ipv6)) => { + let Some(cfg) = external_ips.ipv6_config_mut() else { + return Ok(()); + }; + if cfg.ephemeral_ip_mut().take_if(|eip| eip == ipv6).is_none() { + return Ok(()); + } } - InstanceExternalIpBody::Ephemeral(_) => { - self.ephemeral_ip = None; + InstanceExternalIpBody::Floating(IpAddr::V4(ipv4)) => { + let Some(cfg) = external_ips.ipv4_config_mut() else { + return Ok(()); + }; + let fips = cfg.floating_ips_mut(); + let floating_index = fips.iter().position(|v| v == ipv4); + if let Some(pos) = floating_index { + // Swap remove is valid here, OPTE is not sensitive + // to Floating Ip ordering. + fips.swap_remove(pos); + } else { + return Ok(()); + } } - InstanceExternalIpBody::Floating(ip) => { - let floating_index = - self.floating_ips.iter().position(|v| v == ip); + InstanceExternalIpBody::Floating(IpAddr::V6(ipv6)) => { + let Some(cfg) = external_ips.ipv6_config_mut() else { + return Ok(()); + }; + let fips = cfg.floating_ips_mut(); + let floating_index = fips.iter().position(|v| v == ipv6); if let Some(pos) = floating_index { // Swap remove is valid here, OPTE is not sensitive // to Floating Ip ordering. - self.floating_ips.swap_remove(pos); + fips.swap_remove(pos); } else { return Ok(()); } } } - let Some(primary_nic) = self.primary_nic() else { - return Err(Error::Opte(illumos_utils::opte::Error::NoPrimaryNic)); - }; - self.port_manager.external_ips_ensure( - primary_nic.id, - primary_nic.kind, - Some(self.source_nat), - self.ephemeral_ip, - &self.floating_ips, + nic_id, + nic_kind, + &external_ips, )?; Ok(()) @@ -1680,9 +1775,7 @@ impl Instance { vnic_allocator, port_manager, requested_nics: local_config.nics, - source_nat: local_config.source_nat, - ephemeral_ip: local_config.ephemeral_ip, - floating_ips: local_config.floating_ips, + external_ips: local_config.external_ips, multicast_groups: local_config.multicast_groups, firewall_rules: local_config.firewall_rules, dhcp_config, @@ -2047,20 +2140,9 @@ impl InstanceRunner { let mut opte_ports = Vec::with_capacity(self.requested_nics.len()); let mut opte_port_names = Vec::with_capacity(self.requested_nics.len()); for nic in self.requested_nics.iter() { - let (snat, ephemeral_ip, floating_ips) = if nic.primary { - ( - Some(self.source_nat), - self.ephemeral_ip, - &self.floating_ips[..], - ) - } else { - (None, None, &[][..]) - }; let port = self.port_manager.create_port(PortCreateParams { nic, - source_nat: snat, - ephemeral_ip, - floating_ips, + external_ips: &self.external_ips, firewall_rules: &self.firewall_rules, dhcp_config: self.dhcp_config.clone(), })?; @@ -2306,7 +2388,7 @@ impl InstanceRunner { } } - async fn add_external_ip( + fn add_external_ip( &mut self, ip: &InstanceExternalIpBody, ) -> Result<(), Error> { @@ -2315,35 +2397,25 @@ impl InstanceRunner { // Be cautious and reset state if either fails. // Note we don't need to re-ensure port manager/OPTE state // since that's the last call we make internally. - let old_eph = self.ephemeral_ip; - let out = self.add_external_ip_inner(ip).await; - + let old_config = self.external_ips.clone(); + let out = self.add_external_ip_inner(ip); if out.is_err() { - self.ephemeral_ip = old_eph; - if let InstanceExternalIpBody::Floating(ip) = ip { - self.floating_ips.retain(|v| v != ip); - } + self.external_ips = old_config; } out } - async fn delete_external_ip( + fn delete_external_ip( &mut self, ip: &InstanceExternalIpBody, ) -> Result<(), Error> { // Similar logic to `add_external_ip`, except here we // need to readd the floating IP if it was removed. // OPTE doesn't care about the order of floating IPs. - let old_eph = self.ephemeral_ip; - let out = self.delete_external_ip_inner(ip).await; - + let old_config = self.external_ips.clone(); + let out = self.delete_external_ip_inner(ip); if out.is_err() { - self.ephemeral_ip = old_eph; - if let InstanceExternalIpBody::Floating(ip) = ip { - if !self.floating_ips.contains(ip) { - self.floating_ips.push(*ip); - } - } + self.external_ips = old_config; } out } @@ -2502,7 +2574,9 @@ mod tests { use omicron_common::FileKv; use omicron_common::api::external::{Generation, Hostname}; use omicron_common::api::internal::nexus::VmmState; - use omicron_common::api::internal::shared::{DhcpConfig, SledIdentifiers}; + use omicron_common::api::internal::shared::{ + DhcpConfig, ExternalIpConfigBuilder, SledIdentifiers, SourceNatConfigV6, + }; use omicron_common::disk::DiskIdentity; use omicron_uuid_kinds::InternalZpoolUuid; use propolis_client::types::{ @@ -2707,17 +2781,20 @@ mod tests { components: Default::default(), }); + let external_ips = Some( + ExternalIpConfigBuilder::new() + .with_source_nat( + SourceNatConfigV6::new(Ipv6Addr::UNSPECIFIED, 0, 16383) + .unwrap(), + ) + .build() + .expect("Should be a valid External IP config") + .into(), + ); let local_config = InstanceSledLocalConfig { hostname: Hostname::from_str("bert").unwrap(), nics: vec![], - source_nat: SourceNatConfig::new( - IpAddr::V6(Ipv6Addr::UNSPECIFIED), - 0, - 16383, - ) - .unwrap(), - ephemeral_ip: None, - floating_ips: vec![], + external_ips, multicast_groups: vec![], firewall_rules: vec![], dhcp_config: DhcpConfig { @@ -3324,9 +3401,7 @@ mod tests { vnic_allocator, port_manager, requested_nics: local_config.nics, - source_nat: local_config.source_nat, - ephemeral_ip: local_config.ephemeral_ip, - floating_ips: local_config.floating_ips, + external_ips: local_config.external_ips, multicast_groups: local_config.multicast_groups, firewall_rules: local_config.firewall_rules, dhcp_config, diff --git a/sled-agent/src/probe_manager.rs b/sled-agent/src/probe_manager.rs index c329e86b3f7..7d238714fb9 100644 --- a/sled-agent/src/probe_manager.rs +++ b/sled-agent/src/probe_manager.rs @@ -6,6 +6,7 @@ //! running a full VM. use crate::metrics::MetricsRequestQueue; +use anyhow::Context as _; use anyhow::{Result, anyhow}; use dropshot::HttpError; use iddqd::IdHashItem; @@ -20,6 +21,7 @@ use omicron_common::api::external::{ VpcFirewallRuleAction, VpcFirewallRuleDirection, VpcFirewallRulePriority, VpcFirewallRuleStatus, }; +use omicron_common::api::internal::shared::ExternalIpConfigBuilder; use omicron_common::api::internal::shared::{ NetworkInterface, ResolvedVpcFirewallRule, }; @@ -36,6 +38,7 @@ use sled_agent_zone_images::ramdisk_file_source; use slog::{Logger, error, warn}; use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; +use std::net::IpAddr; use std::sync::Arc; use tokio::sync::watch; use tokio::task::JoinHandle; @@ -334,16 +337,32 @@ impl ProbeManagerInner { .as_ref() .ok_or(anyhow!("no interface specified for probe"))?; + // NOTE: The Nexus probe API only supports constructing an Ephemeral + // address, so ensure that's the case here and us it as such. let eip = probe .external_ips .get(0) .ok_or(anyhow!("expected an external ip"))?; + anyhow::ensure!( + matches!(eip.kind, sled_agent_types::probes::IpKind::Ephemeral), + "Probes are expected to have an Ephemeral IP address", + ); + let external_ips = match eip.ip { + IpAddr::V4(ipv4) => ExternalIpConfigBuilder::new() + .with_ephemeral_ip(ipv4) + .build() + .context("building ExternalIpConfig")? + .into(), + IpAddr::V6(ipv6) => ExternalIpConfigBuilder::new() + .with_ephemeral_ip(ipv6) + .build() + .context("building ExternalIpConfig")? + .into(), + }; let port = self.port_manager.create_port(PortCreateParams { nic, - source_nat: None, - ephemeral_ip: Some(eip.ip), - floating_ips: &[], + external_ips: &Some(external_ips), firewall_rules: &[ResolvedVpcFirewallRule { status: VpcFirewallRuleStatus::Enabled, direction: VpcFirewallRuleDirection::Inbound, diff --git a/sled-agent/src/rack_setup/plan/service.rs b/sled-agent/src/rack_setup/plan/service.rs index bd3e0a2d09e..09b65c379b2 100644 --- a/sled-agent/src/rack_setup/plan/service.rs +++ b/sled-agent/src/rack_setup/plan/service.rs @@ -38,7 +38,7 @@ use omicron_common::address::{ use omicron_common::api::external::{Generation, MacAddr, Vni}; use omicron_common::api::internal::shared::{ NetworkInterface, NetworkInterfaceKind, PrivateIpConfig, - PrivateIpConfigError, SourceNatConfig, SourceNatConfigError, + PrivateIpConfigError, SourceNatConfigError, SourceNatConfigGeneric, }; use omicron_common::backoff::{ BackoffError, retry_notify_ext, retry_policy_internal_service_aggressive, @@ -225,7 +225,7 @@ pub fn from_ipaddr_to_external_floating_ip( } pub fn from_source_nat_config_to_external_snat_ip( - snat_cfg: SourceNatConfig, + snat_cfg: SourceNatConfigGeneric, ) -> OmicronZoneExternalSnatIp { // This is pretty weird: IP IDs don't exist yet, so it's fine for us // to make them up (Nexus will record them as a part of the @@ -1242,7 +1242,7 @@ impl ServicePortBuilder { fn next_snat( &mut self, svc_id: OmicronZoneUuid, - ) -> Result<(NetworkInterface, SourceNatConfig), PlanError> { + ) -> Result<(NetworkInterface, SourceNatConfigGeneric), PlanError> { use omicron_common::address::{ NTP_OPTE_IPV4_SUBNET, NTP_OPTE_IPV6_SUBNET, }; @@ -1261,7 +1261,7 @@ impl ServicePortBuilder { } let snat_cfg = - match SourceNatConfig::new(snat_ip, first_port, last_port) { + match SourceNatConfigGeneric::new(snat_ip, first_port, last_port) { Ok(cfg) => cfg, // We know our port pair is aligned, making this unreachable. Err(err @ SourceNatConfigError::UnalignedPortPair { .. }) => { diff --git a/sled-agent/src/services.rs b/sled-agent/src/services.rs index ee76886fb6b..2277aab5acf 100644 --- a/sled-agent/src/services.rs +++ b/sled-agent/src/services.rs @@ -63,7 +63,6 @@ use nexus_config::{ConfigDropshotWithTls, DeploymentConfig}; use nexus_sled_agent_shared::inventory::{ OmicronZoneConfig, OmicronZoneType, ZoneKind, }; -use omicron_common::address::AZ_PREFIX; use omicron_common::address::DENDRITE_PORT; use omicron_common::address::LLDP_PORT; use omicron_common::address::MGS_PORT; @@ -73,6 +72,7 @@ use omicron_common::address::SLED_PREFIX; use omicron_common::address::TFPORTD_PORT; use omicron_common::address::WICKETD_NEXUS_PROXY_PORT; use omicron_common::address::WICKETD_PORT; +use omicron_common::address::{AZ_PREFIX, MAX_PORT}; use omicron_common::address::{BOOTSTRAP_ARTIFACT_PORT, COCKROACH_ADMIN_PORT}; use omicron_common::address::{ CLICKHOUSE_ADMIN_PORT, CLICKHOUSE_TCP_PORT, @@ -80,8 +80,10 @@ use omicron_common::address::{ }; use omicron_common::address::{Ipv6Subnet, NEXUS_TECHPORT_EXTERNAL_PORT}; use omicron_common::api::external::Generation; +use omicron_common::api::internal::shared::external_ip::ConcreteIp; use omicron_common::api::internal::shared::{ - HostPortConfig, PrivateIpConfig, RackNetworkConfig, SledIdentifiers, + ExternalIpConfig, ExternalIpConfigBuilder, ExternalIps, HostPortConfig, + PrivateIpConfig, RackNetworkConfig, SledIdentifiers, }; use omicron_common::backoff::{ BackoffError, retry_notify, retry_policy_internal_service_aggressive, @@ -1078,14 +1080,22 @@ impl ServiceManager { }) .collect(); - let external_ip; - let (zone_kind, nic, snat, floating_ips) = match &zone_args - .omicron_type() - { + let (zone_kind, nic, external_ips) = match &zone_args.omicron_type() { Some( zone_type @ OmicronZoneType::Nexus { external_ip, nic, .. }, ) => { - (zone_type.kind(), nic, None, std::slice::from_ref(external_ip)) + let eip = match external_ip { + IpAddr::V4(ipv4) => ExternalIpConfigBuilder::new() + .with_floating_ips(vec![*ipv4]) + .build() + .map(Into::into), + IpAddr::V6(ipv6) => ExternalIpConfigBuilder::new() + .with_floating_ips(vec![*ipv6]) + .build() + .map(Into::into), + } + .expect("guaranteed to have exactly one floating IP"); + (zone_type.kind(), nic, eip) } Some( zone_type @ OmicronZoneType::ExternalDns { @@ -1094,19 +1104,41 @@ impl ServiceManager { .. }, ) => { - external_ip = dns_address.ip(); - ( - zone_type.kind(), - nic, - None, - std::slice::from_ref(&external_ip), - ) + let eip = match dns_address.ip() { + IpAddr::V4(ipv4) => ExternalIpConfigBuilder::new() + .with_floating_ips(vec![ipv4]) + .build() + .map(Into::into), + IpAddr::V6(ipv6) => ExternalIpConfigBuilder::new() + .with_floating_ips(vec![ipv6]) + .build() + .map(Into::into), + } + .expect("guaranteed to have exactly one floating IP"); + (zone_type.kind(), nic, eip) } Some( zone_type @ OmicronZoneType::BoundaryNtp { nic, snat_cfg, .. }, - ) => (zone_type.kind(), nic, Some(*snat_cfg), &[][..]), + ) => { + let eip = if let Some(snat) = snat_cfg.try_as_ipv4() { + ExternalIpConfigBuilder::new() + .with_source_nat(snat) + .build() + .expect("guaranteed to have exactly one SNAT") + .into() + } else if let Some(snat) = snat_cfg.try_as_ipv6() { + ExternalIpConfigBuilder::new() + .with_source_nat(snat) + .build() + .expect("guaranteed to have exactly one SNAT") + .into() + } else { + unreachable!("Generic SNAT IP must be IPv4 or IPv6"); + }; + (zone_type.kind(), nic, eip) + } _ => unreachable!("unexpected zone type"), }; @@ -1115,12 +1147,16 @@ impl ServiceManager { // Nexus will plumb them down later but services' default OPTE // config allows outbound access which is enough for // Boundary NTP which needs to come up before Nexus. + // + // This is kind of silly, but we wrap the external IP configuration in + // an option and immediately unwrap it below. The PortCreateParams is + // used for instances, which technically can have no external IP + // configuration at all, hence it being optional there. + let external_ips = Some(external_ips); let port = port_manager .create_port(PortCreateParams { nic, - source_nat: snat, - ephemeral_ip: None, - floating_ips, + external_ips: &external_ips, firewall_rules: &[], dhcp_config: DhcpCfg::default(), }) @@ -1128,17 +1164,10 @@ impl ServiceManager { service: zone_kind, err: Box::new(err), })?; - - // We also need to update the switch with the NAT mappings - // XXX: need to revisit iff. any services get more than one - // address. - let (target_ip, first_port, last_port) = match snat { - Some(s) => { - let (first_port, last_port) = s.port_range_raw(); - (s.ip, first_port, last_port) - } - None => (floating_ips[0], 0, u16::MAX), + let Some(external_ips) = external_ips else { + unreachable!("wrapped into Option::Some(_) above"); }; + let nat_data = extract_nat_data_for_external_ip_config(&external_ips); for dpd_client in &dpd_clients { // TODO-correctness(#2933): If we fail part-way we need to @@ -1149,18 +1178,23 @@ impl ServiceManager { "zone_type" => zone_kind.report_str(), ); - dpd_ensure_nat_entry( - dpd_client, - &self.inner.log, - target_ip, - dpd_client::types::MacAddr { a: port.0.mac().into_array() }, - first_port, - last_port, - port.0.vni().as_u32(), - underlay_address, - ) - .await - .map_err(BackoffError::transient) + for data in nat_data.iter() { + dpd_ensure_nat_entry( + dpd_client, + &self.inner.log, + data.ip, + dpd_client::types::MacAddr { + a: port.0.mac().into_array(), + }, + data.first_port, + data.last_port, + port.0.vni().as_u32(), + underlay_address, + ) + .await + .map_err(BackoffError::::transient)?; + } + Ok::<(), BackoffError>(()) }; let log_failure = |error, _| { warn!( @@ -4142,6 +4176,59 @@ impl ServiceManager { } } +struct NatData { + ip: IpAddr, + first_port: u16, + last_port: u16, +} + +// Construct a list of IP address and port-ranges needed to update +// Dendrite wtih the NAT mappings. This handles dual-stack and mulitple +// addresses. +fn extract_nat_data_for_external_ip_config( + external_ips: &ExternalIpConfig, +) -> Vec { + let mut nat_data = Vec::new(); + if let Some(cfg) = external_ips.ipv4_config() { + nat_data + .append(&mut extract_nat_data_for_concrete_external_ip_config(cfg)); + } + if let Some(cfg) = external_ips.ipv6_config() { + nat_data + .append(&mut extract_nat_data_for_concrete_external_ip_config(cfg)); + } + nat_data +} + +fn extract_nat_data_for_concrete_external_ip_config( + cfg: &ExternalIps, +) -> Vec { + let mut nat_data = Vec::new(); + if let Some(snat) = cfg.source_nat() { + let (first_port, last_port) = snat.port_range_raw(); + nat_data.push(NatData { + ip: snat.ip.into_ipaddr(), + first_port, + last_port, + }); + } + if let Some(ip) = cfg.ephemeral_ip() { + nat_data.push(NatData { + ip: ip.into_ipaddr(), + first_port: 0, + last_port: MAX_PORT, + }); + } + for ip in cfg.floating_ips() { + nat_data.push(NatData { + ip: ip.into_ipaddr(), + first_port: 0, + last_port: MAX_PORT, + }); + } + nat_data +} + fn internal_dns_addrobj_name(gz_address_index: u32) -> String { format!("internaldns{gz_address_index}") } diff --git a/sled-agent/types/src/instance.rs b/sled-agent/types/src/instance.rs index d58873545f3..23f2bb7785f 100644 --- a/sled-agent/types/src/instance.rs +++ b/sled-agent/types/src/instance.rs @@ -4,28 +4,26 @@ //! Common instance-related types. -use std::{ - fmt, - net::{IpAddr, SocketAddr}, -}; - -use omicron_common::api::{ - external::Hostname, - internal::{ - nexus::{SledVmmState, VmmRuntimeState}, - shared::{ - DelegatedZvol, DhcpConfig, NetworkInterface, - ResolvedVpcFirewallRule, SourceNatConfig, - }, - }, -}; +use omicron_common::api::external::Hostname; +use omicron_common::api::internal::nexus::SledVmmState; +use omicron_common::api::internal::nexus::VmmRuntimeState; +use omicron_common::api::internal::shared::DelegatedZvol; +use omicron_common::api::internal::shared::DhcpConfig; +use omicron_common::api::internal::shared::ExternalIpConfig; +use omicron_common::api::internal::shared::NetworkInterface; +use omicron_common::api::internal::shared::ResolvedVpcFirewallRule; use omicron_uuid_kinds::InstanceUuid; -use propolis_client::instance_spec::{ - ComponentV0, CrucibleStorageBackend, FileStorageBackend, SpecKey, - VirtioNetworkBackend, -}; +use propolis_client::instance_spec::ComponentV0; +use propolis_client::instance_spec::CrucibleStorageBackend; +use propolis_client::instance_spec::FileStorageBackend; +use propolis_client::instance_spec::SpecKey; +use propolis_client::instance_spec::VirtioNetworkBackend; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use serde::Serialize; +use std::fmt; +use std::net::IpAddr; +use std::net::SocketAddr; use uuid::Uuid; /// The body of a request to ensure that a instance and VMM are known to a sled @@ -64,11 +62,7 @@ pub struct InstanceEnsureBody { pub struct InstanceSledLocalConfig { pub hostname: Hostname, pub nics: Vec, - pub source_nat: SourceNatConfig, - /// Zero or more external IP addresses (either floating or ephemeral), - /// provided to an instance to allow inbound connectivity. - pub ephemeral_ip: Option, - pub floating_ips: Vec, + pub external_ips: Option, pub multicast_groups: Vec, pub firewall_rules: Vec, pub dhcp_config: DhcpConfig, diff --git a/sled-agent/types/src/inventory/mod.rs b/sled-agent/types/src/inventory/mod.rs index e8198a8c8a4..ffb67ef1e7b 100644 --- a/sled-agent/types/src/inventory/mod.rs +++ b/sled-agent/types/src/inventory/mod.rs @@ -9,4 +9,5 @@ //! directly in the `sled-agent-api` crate. The current versions of the types //! should be in `nexus-sled-agent-shared`. +pub mod v10; pub mod v9; diff --git a/sled-agent/types/src/inventory/v10.rs b/sled-agent/types/src/inventory/v10.rs new file mode 100644 index 00000000000..f48d84a0687 --- /dev/null +++ b/sled-agent/types/src/inventory/v10.rs @@ -0,0 +1,834 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Version 10 of the sled-agent API types. + +use crate::instance::InstanceMetadata; +use crate::instance::InstanceMulticastMembership; +use crate::instance::VmmSpec; +use chrono::DateTime; +use chrono::Utc; +use iddqd::IdOrdItem; +use iddqd::IdOrdMap; +use iddqd::id_upcast; +use nexus_sled_agent_shared::inventory; +use nexus_sled_agent_shared::inventory::BootPartitionContents; +use nexus_sled_agent_shared::inventory::ConfigReconcilerInventoryResult; +use nexus_sled_agent_shared::inventory::HostPhase2DesiredSlots; +use nexus_sled_agent_shared::inventory::InventoryDataset; +use nexus_sled_agent_shared::inventory::InventoryDisk; +use nexus_sled_agent_shared::inventory::InventoryZpool; +use nexus_sled_agent_shared::inventory::OmicronZoneDataset; +use nexus_sled_agent_shared::inventory::OmicronZoneImageSource; +use nexus_sled_agent_shared::inventory::OrphanedDataset; +use nexus_sled_agent_shared::inventory::RemoveMupdateOverrideInventory; +use nexus_sled_agent_shared::inventory::SledRole; +use nexus_sled_agent_shared::inventory::ZoneImageResolverInventory; +use omicron_common::api::external; +use omicron_common::api::external::ByteCount; +use omicron_common::api::external::Generation; +use omicron_common::api::external::Hostname; +use omicron_common::api::internal::nexus::VmmRuntimeState; +use omicron_common::api::internal::shared::DelegatedZvol; +use omicron_common::api::internal::shared::DhcpConfig; +use omicron_common::api::internal::shared::ExternalIpConfig; +use omicron_common::api::internal::shared::NetworkInterface; +use omicron_common::api::internal::shared::ResolvedVpcFirewallRule; +use omicron_common::api::internal::shared::SourceNatConfigGeneric; +use omicron_common::api::internal::shared::external_ip::v1::SourceNatConfig; +use omicron_common::disk::DatasetConfig; +use omicron_common::disk::OmicronPhysicalDiskConfig; +use omicron_common::zpool_name::ZpoolName; +use omicron_uuid_kinds::DatasetUuid; +use omicron_uuid_kinds::InstanceUuid; +use omicron_uuid_kinds::MupdateOverrideUuid; +use omicron_uuid_kinds::OmicronZoneUuid; +use omicron_uuid_kinds::PhysicalDiskUuid; +use omicron_uuid_kinds::SledUuid; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use sled_hardware_types::Baseboard; +use sled_hardware_types::SledCpuFamily; +use std::collections::BTreeMap; +use std::net::IpAddr; +use std::net::Ipv6Addr; +use std::net::SocketAddr; +use std::net::SocketAddrV6; +use std::time::Duration; +use uuid::Uuid; + +/// Identity and basic status information about this sled agent +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct Inventory { + pub sled_id: SledUuid, + pub sled_agent_address: SocketAddrV6, + pub sled_role: SledRole, + pub baseboard: Baseboard, + pub usable_hardware_threads: u32, + pub usable_physical_ram: ByteCount, + pub cpu_family: SledCpuFamily, + pub reservoir_size: ByteCount, + pub disks: Vec, + pub zpools: Vec, + pub datasets: Vec, + pub ledgered_sled_config: Option, + pub reconciler_status: ConfigReconcilerInventoryStatus, + pub last_reconciliation: Option, + pub zone_image_resolver: ZoneImageResolverInventory, +} + +impl TryFrom for Inventory { + type Error = external::Error; + + fn try_from(value: inventory::Inventory) -> Result { + let inventory::Inventory { + sled_id, + sled_agent_address, + sled_role, + baseboard, + usable_hardware_threads, + usable_physical_ram, + cpu_family, + reservoir_size, + disks, + zpools, + datasets, + ledgered_sled_config, + reconciler_status, + last_reconciliation, + zone_image_resolver, + } = value; + let ledgered_sled_config = + ledgered_sled_config.map(TryInto::try_into).transpose()?; + let reconciler_status = reconciler_status.try_into()?; + let last_reconciliation = + last_reconciliation.map(TryInto::try_into).transpose()?; + Ok(Self { + sled_id, + sled_agent_address, + sled_role, + baseboard, + usable_hardware_threads, + usable_physical_ram, + cpu_family, + reservoir_size, + disks, + zpools, + datasets, + ledgered_sled_config, + reconciler_status, + last_reconciliation, + zone_image_resolver, + }) + } +} + +/// Describes the set of Reconfigurator-managed configuration elements of a sled +#[derive(Clone, Debug, Deserialize, Eq, Serialize, JsonSchema, PartialEq)] +pub struct OmicronSledConfig { + pub generation: Generation, + #[serde( + with = "iddqd::id_ord_map::IdOrdMapAsMap::" + )] + pub disks: IdOrdMap, + #[serde(with = "iddqd::id_ord_map::IdOrdMapAsMap::")] + pub datasets: IdOrdMap, + #[serde(with = "iddqd::id_ord_map::IdOrdMapAsMap::")] + pub zones: IdOrdMap, + pub remove_mupdate_override: Option, + #[serde(default = "HostPhase2DesiredSlots::current_contents")] + pub host_phase_2: HostPhase2DesiredSlots, +} + +impl TryFrom for inventory::OmicronSledConfig { + type Error = external::Error; + + fn try_from(value: OmicronSledConfig) -> Result { + let zones = value + .zones + .into_iter() + .map(TryInto::try_into) + .collect::>()?; + Ok(Self { + generation: value.generation, + disks: value.disks, + datasets: value.datasets, + zones, + remove_mupdate_override: value.remove_mupdate_override, + host_phase_2: value.host_phase_2, + }) + } +} + +impl TryFrom for OmicronSledConfig { + type Error = external::Error; + + fn try_from( + value: inventory::OmicronSledConfig, + ) -> Result { + let zones = value + .zones + .into_iter() + .map(TryInto::try_into) + .collect::>()?; + Ok(Self { + generation: value.generation, + disks: value.disks, + datasets: value.datasets, + zones, + remove_mupdate_override: value.remove_mupdate_override, + host_phase_2: value.host_phase_2, + }) + } +} + +/// Describes one Omicron-managed zone running on a sled +#[derive( + Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq, Hash, +)] +pub struct OmicronZoneConfig { + pub id: OmicronZoneUuid, + + /// The pool on which we'll place this zone's root filesystem. + /// + /// Note that the root filesystem is transient -- the sled agent is + /// permitted to destroy this dataset each time the zone is initialized. + pub filesystem_pool: Option, + pub zone_type: OmicronZoneType, + // Use `InstallDataset` if this field is not present in a deserialized + // blueprint or ledger. + #[serde(default = "OmicronZoneImageSource::deserialize_default")] + pub image_source: OmicronZoneImageSource, +} + +impl IdOrdItem for OmicronZoneConfig { + type Key<'a> = OmicronZoneUuid; + + fn key(&self) -> Self::Key<'_> { + self.id + } + + id_upcast!(); +} + +impl TryFrom for inventory::OmicronZoneConfig { + type Error = external::Error; + + fn try_from(value: OmicronZoneConfig) -> Result { + Ok(Self { + id: value.id, + filesystem_pool: value.filesystem_pool, + zone_type: value.zone_type.try_into()?, + image_source: value.image_source, + }) + } +} + +impl TryFrom for OmicronZoneConfig { + type Error = external::Error; + + fn try_from( + value: inventory::OmicronZoneConfig, + ) -> Result { + Ok(Self { + id: value.id, + filesystem_pool: value.filesystem_pool, + zone_type: value.zone_type.try_into()?, + image_source: value.image_source, + }) + } +} + +/// Describes the set of Omicron-managed zones running on a sled +#[derive(Deserialize, Serialize, JsonSchema)] +pub struct OmicronZonesConfig { + /// generation number of this configuration + /// + /// This generation number is owned by the control plane (i.e., rss or + /// nexus, depending on whether rss-to-nexus handoff has happened). it + /// should not be bumped within sled agent. + /// + /// Sled Agent rejects attempts to set the configuration to a generation + /// older than the one it's currently running. + pub generation: Generation, + + /// list of running zones + pub zones: Vec, +} + +impl TryFrom for inventory::OmicronZonesConfig { + type Error = external::Error; + + fn try_from(value: OmicronZonesConfig) -> Result { + value + .zones + .into_iter() + .map(TryInto::try_into) + .collect::>() + .map(|zones| inventory::OmicronZonesConfig { + generation: value.generation, + zones, + }) + } +} + +impl TryFrom for OmicronZonesConfig { + type Error = external::Error; + + fn try_from( + value: inventory::OmicronZonesConfig, + ) -> Result { + value + .zones + .into_iter() + .map(TryInto::try_into) + .collect::>() + .map(|zones| OmicronZonesConfig { + generation: value.generation, + zones, + }) + } +} + +/// Describes what kind of zone this is (i.e., what component is running in it) +/// as well as any type-specific configuration +#[derive( + Clone, Debug, Deserialize, Eq, Serialize, JsonSchema, PartialEq, Hash, +)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum OmicronZoneType { + BoundaryNtp { + address: SocketAddrV6, + ntp_servers: Vec, + dns_servers: Vec, + domain: Option, + /// The service vNIC providing outbound connectivity using OPTE. + nic: NetworkInterface, + /// The SNAT configuration for outbound connections. + snat_cfg: SourceNatConfig, + }, + + /// Type of clickhouse zone used for a single node clickhouse deployment + Clickhouse { + address: SocketAddrV6, + dataset: OmicronZoneDataset, + }, + + /// A zone used to run a Clickhouse Keeper node + /// + /// Keepers are only used in replicated clickhouse setups + ClickhouseKeeper { + address: SocketAddrV6, + dataset: OmicronZoneDataset, + }, + + /// A zone used to run a Clickhouse Server in a replicated deployment + ClickhouseServer { + address: SocketAddrV6, + dataset: OmicronZoneDataset, + }, + + CockroachDb { + address: SocketAddrV6, + dataset: OmicronZoneDataset, + }, + + Crucible { + address: SocketAddrV6, + dataset: OmicronZoneDataset, + }, + CruciblePantry { + address: SocketAddrV6, + }, + ExternalDns { + dataset: OmicronZoneDataset, + /// The address at which the external DNS server API is reachable. + http_address: SocketAddrV6, + /// The address at which the external DNS server is reachable. + dns_address: SocketAddr, + /// The service vNIC providing external connectivity using OPTE. + nic: NetworkInterface, + }, + InternalDns { + dataset: OmicronZoneDataset, + http_address: SocketAddrV6, + dns_address: SocketAddrV6, + /// The addresses in the global zone which should be created + /// + /// For the DNS service, which exists outside the sleds's typical subnet + /// - adding an address in the GZ is necessary to allow inter-zone + /// traffic routing. + gz_address: Ipv6Addr, + + /// The address is also identified with an auxiliary bit of information + /// to ensure that the created global zone address can have a unique + /// name. + gz_address_index: u32, + }, + InternalNtp { + address: SocketAddrV6, + }, + Nexus { + /// The address at which the internal nexus server is reachable. + internal_address: SocketAddrV6, + /// The port at which the internal lockstep server is reachable. This + /// shares the same IP address with `internal_address`. + #[serde(default = "default_nexus_lockstep_port")] + lockstep_port: u16, + /// The address at which the external nexus server is reachable. + external_ip: IpAddr, + /// The service vNIC providing external connectivity using OPTE. + nic: NetworkInterface, + /// Whether Nexus's external endpoint should use TLS + external_tls: bool, + /// External DNS servers Nexus can use to resolve external hosts. + external_dns_servers: Vec, + }, + Oximeter { + address: SocketAddrV6, + }, +} + +const fn default_nexus_lockstep_port() -> u16 { + omicron_common::address::NEXUS_LOCKSTEP_PORT +} + +impl TryFrom for inventory::OmicronZoneType { + type Error = external::Error; + + fn try_from(value: OmicronZoneType) -> Result { + match value { + OmicronZoneType::BoundaryNtp { + address, + ntp_servers, + dns_servers, + domain, + nic, + snat_cfg, + } => { + let (first_port, last_port) = snat_cfg.port_range_raw(); + let snat_cfg = SourceNatConfigGeneric::new( + snat_cfg.ip, + first_port, + last_port, + ) + .map_err(|e| external::Error::invalid_request(e.to_string()))?; + Ok(Self::BoundaryNtp { + address, + ntp_servers, + dns_servers, + domain, + nic, + snat_cfg, + }) + } + OmicronZoneType::Clickhouse { address, dataset } => { + Ok(Self::Clickhouse { address, dataset }) + } + OmicronZoneType::ClickhouseKeeper { address, dataset } => { + Ok(Self::ClickhouseKeeper { address, dataset }) + } + OmicronZoneType::ClickhouseServer { address, dataset } => { + Ok(Self::ClickhouseServer { address, dataset }) + } + OmicronZoneType::CockroachDb { address, dataset } => { + Ok(Self::CockroachDb { address, dataset }) + } + OmicronZoneType::Crucible { address, dataset } => { + Ok(Self::Crucible { address, dataset }) + } + OmicronZoneType::CruciblePantry { address } => { + Ok(Self::CruciblePantry { address }) + } + OmicronZoneType::ExternalDns { + dataset, + http_address, + dns_address, + nic, + } => Ok(Self::ExternalDns { + dataset, + http_address, + dns_address, + nic, + }), + OmicronZoneType::InternalDns { + dataset, + http_address, + dns_address, + gz_address, + gz_address_index, + } => Ok(Self::InternalDns { + dataset, + http_address, + dns_address, + gz_address, + gz_address_index, + }), + OmicronZoneType::InternalNtp { address } => { + Ok(Self::InternalNtp { address }) + } + OmicronZoneType::Nexus { + internal_address, + lockstep_port, + external_ip, + nic, + external_tls, + external_dns_servers, + } => Ok(Self::Nexus { + internal_address, + lockstep_port, + external_ip, + nic, + external_tls, + external_dns_servers, + }), + OmicronZoneType::Oximeter { address } => { + Ok(Self::Oximeter { address }) + } + } + } +} + +impl TryFrom for OmicronZoneType { + type Error = external::Error; + + fn try_from( + value: inventory::OmicronZoneType, + ) -> Result { + match value { + inventory::OmicronZoneType::BoundaryNtp { + address, + ntp_servers, + dns_servers, + domain, + nic, + snat_cfg, + } => { + let snat_cfg = + SourceNatConfig::try_from(snat_cfg).map_err(|e| { + external::Error::invalid_request(e.to_string()) + })?; + Ok(Self::BoundaryNtp { + address, + ntp_servers, + dns_servers, + domain, + nic, + snat_cfg, + }) + } + inventory::OmicronZoneType::Clickhouse { address, dataset } => { + Ok(Self::Clickhouse { address, dataset }) + } + inventory::OmicronZoneType::ClickhouseKeeper { + address, + dataset, + } => Ok(Self::ClickhouseKeeper { address, dataset }), + inventory::OmicronZoneType::ClickhouseServer { + address, + dataset, + } => Ok(Self::ClickhouseServer { address, dataset }), + inventory::OmicronZoneType::CockroachDb { address, dataset } => { + Ok(Self::CockroachDb { address, dataset }) + } + inventory::OmicronZoneType::Crucible { address, dataset } => { + Ok(Self::Crucible { address, dataset }) + } + inventory::OmicronZoneType::CruciblePantry { address } => { + Ok(Self::CruciblePantry { address }) + } + inventory::OmicronZoneType::ExternalDns { + dataset, + http_address, + dns_address, + nic, + } => Ok(Self::ExternalDns { + dataset, + http_address, + dns_address, + nic, + }), + inventory::OmicronZoneType::InternalDns { + dataset, + http_address, + dns_address, + gz_address, + gz_address_index, + } => Ok(Self::InternalDns { + dataset, + http_address, + dns_address, + gz_address, + gz_address_index, + }), + inventory::OmicronZoneType::InternalNtp { address } => { + Ok(Self::InternalNtp { address }) + } + inventory::OmicronZoneType::Nexus { + internal_address, + lockstep_port, + external_ip, + nic, + external_tls, + external_dns_servers, + } => Ok(Self::Nexus { + internal_address, + lockstep_port, + external_ip, + nic, + external_tls, + external_dns_servers, + }), + inventory::OmicronZoneType::Oximeter { address } => { + Ok(Self::Oximeter { address }) + } + } + } +} + +/// Describes the last attempt made by the sled-agent-config-reconciler to +/// reconcile the current sled config against the actual state of the sled. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct ConfigReconcilerInventory { + pub last_reconciled_config: OmicronSledConfig, + pub external_disks: + BTreeMap, + pub datasets: BTreeMap, + pub orphaned_datasets: IdOrdMap, + pub zones: BTreeMap, + pub boot_partitions: BootPartitionContents, + /// The result of removing the mupdate override file on disk. + /// + /// `None` if `remove_mupdate_override` was not provided in the sled config. + pub remove_mupdate_override: Option, +} + +impl TryFrom + for inventory::ConfigReconcilerInventory +{ + type Error = external::Error; + + fn try_from(value: ConfigReconcilerInventory) -> Result { + Ok(Self { + last_reconciled_config: value.last_reconciled_config.try_into()?, + external_disks: value.external_disks, + datasets: value.datasets, + orphaned_datasets: value.orphaned_datasets, + zones: value.zones, + boot_partitions: value.boot_partitions, + remove_mupdate_override: value.remove_mupdate_override, + }) + } +} + +impl TryFrom + for ConfigReconcilerInventory +{ + type Error = external::Error; + + fn try_from( + value: inventory::ConfigReconcilerInventory, + ) -> Result { + Ok(Self { + last_reconciled_config: value.last_reconciled_config.try_into()?, + external_disks: value.external_disks, + datasets: value.datasets, + orphaned_datasets: value.orphaned_datasets, + zones: value.zones, + boot_partitions: value.boot_partitions, + remove_mupdate_override: value.remove_mupdate_override, + }) + } +} + +/// Status of the sled-agent-config-reconciler task. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ConfigReconcilerInventoryStatus { + /// The reconciler task has not yet run for the first time since sled-agent + /// started. + NotYetRun, + /// The reconciler task is actively running. + Running { + config: Box, + started_at: DateTime, + running_for: Duration, + }, + /// The reconciler task is currently idle, but previously did complete a + /// reconciliation attempt. + /// + /// This variant does not include the `OmicronSledConfig` used in the last + /// attempt, because that's always available via + /// [`ConfigReconcilerInventory::last_reconciled_config`]. + Idle { completed_at: DateTime, ran_for: Duration }, +} + +impl TryFrom + for inventory::ConfigReconcilerInventoryStatus +{ + type Error = external::Error; + + fn try_from( + value: ConfigReconcilerInventoryStatus, + ) -> Result { + match value { + ConfigReconcilerInventoryStatus::NotYetRun => Ok(Self::NotYetRun), + ConfigReconcilerInventoryStatus::Running { + config, + started_at, + running_for, + } => Ok(Self::Running { + config: Box::new((*config).try_into()?), + started_at, + running_for, + }), + ConfigReconcilerInventoryStatus::Idle { completed_at, ran_for } => { + Ok(Self::Idle { completed_at, ran_for }) + } + } + } +} + +impl TryFrom + for ConfigReconcilerInventoryStatus +{ + type Error = external::Error; + + fn try_from( + value: inventory::ConfigReconcilerInventoryStatus, + ) -> Result { + match value { + inventory::ConfigReconcilerInventoryStatus::NotYetRun => { + Ok(Self::NotYetRun) + } + inventory::ConfigReconcilerInventoryStatus::Running { + config, + started_at, + running_for, + } => Ok(Self::Running { + config: Box::new((*config).try_into()?), + started_at, + running_for, + }), + inventory::ConfigReconcilerInventoryStatus::Idle { + completed_at, + ran_for, + } => Ok(Self::Idle { completed_at, ran_for }), + } + } +} + +/// The body of a request to ensure that a instance and VMM are known to a sled +/// agent. +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct InstanceEnsureBody { + /// The virtual hardware configuration this virtual machine should have when + /// it is started. + pub vmm_spec: VmmSpec, + + /// Information about the sled-local configuration that needs to be + /// established to make the VM's virtual hardware fully functional. + pub local_config: InstanceSledLocalConfig, + + /// The initial VMM runtime state for the VMM being registered. + pub vmm_runtime: VmmRuntimeState, + + /// The ID of the instance for which this VMM is being created. + pub instance_id: InstanceUuid, + + /// The ID of the migration in to this VMM, if this VMM is being + /// ensured is part of a migration in. If this is `None`, the VMM is not + /// being created due to a migration. + pub migration_id: Option, + + /// The address at which this VMM should serve a Propolis server API. + pub propolis_addr: SocketAddr, + + /// Metadata used to track instance statistics. + pub metadata: InstanceMetadata, +} + +impl TryFrom for crate::instance::InstanceEnsureBody { + type Error = external::Error; + + fn try_from(value: InstanceEnsureBody) -> Result { + let InstanceEnsureBody { + vmm_spec, + local_config, + vmm_runtime, + instance_id, + migration_id, + propolis_addr, + metadata, + } = value; + let local_config = local_config.try_into()?; + Ok(Self { + vmm_spec, + local_config, + vmm_runtime, + instance_id, + migration_id, + propolis_addr, + metadata, + }) + } +} + +/// Describes sled-local configuration that a sled-agent must establish to make +/// the instance's virtual hardware fully functional. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct InstanceSledLocalConfig { + pub hostname: Hostname, + pub nics: Vec, + pub source_nat: SourceNatConfig, + /// Zero or more external IP addresses (either floating or ephemeral), + /// provided to an instance to allow inbound connectivity. + pub ephemeral_ip: Option, + pub floating_ips: Vec, + pub multicast_groups: Vec, + pub firewall_rules: Vec, + pub dhcp_config: DhcpConfig, + pub delegated_zvols: Vec, +} + +impl TryFrom + for crate::instance::InstanceSledLocalConfig +{ + type Error = external::Error; + + fn try_from(value: InstanceSledLocalConfig) -> Result { + let InstanceSledLocalConfig { + hostname, + nics, + source_nat, + ephemeral_ip, + floating_ips, + multicast_groups, + firewall_rules, + dhcp_config, + delegated_zvols, + } = value; + let external_ips = ExternalIpConfig::try_from_generic( + Some(source_nat), + ephemeral_ip, + floating_ips, + ) + .map_err(|e| external::Error::invalid_request(e.to_string()))?; + // NOTE: Previous versions always had the source NAT information + // specified, it wasn't optional. The newer version added support for + // optional SNAT addresses. + let external_ips = Some(external_ips); + Ok(Self { + hostname, + nics, + external_ips, + multicast_groups, + firewall_rules, + dhcp_config, + delegated_zvols, + }) + } +} diff --git a/sled-agent/types/src/inventory/v9.rs b/sled-agent/types/src/inventory/v9.rs index 45ee29bebfc..6c5520f227c 100644 --- a/sled-agent/types/src/inventory/v9.rs +++ b/sled-agent/types/src/inventory/v9.rs @@ -7,12 +7,12 @@ use crate::instance::InstanceMetadata; use crate::instance::InstanceMulticastMembership; use crate::instance::VmmSpec; +use crate::inventory::v10; use chrono::DateTime; use chrono::Utc; use iddqd::IdOrdItem; use iddqd::IdOrdMap; use iddqd::id_upcast; -use nexus_sled_agent_shared::inventory; use nexus_sled_agent_shared::inventory::BootPartitionContents; use nexus_sled_agent_shared::inventory::ConfigReconcilerInventoryResult; use nexus_sled_agent_shared::inventory::HostPhase2DesiredSlots; @@ -33,7 +33,7 @@ use omicron_common::api::internal::nexus::HostIdentifier; use omicron_common::api::internal::nexus::VmmRuntimeState; use omicron_common::api::internal::shared::DelegatedZvol; use omicron_common::api::internal::shared::DhcpConfig; -use omicron_common::api::internal::shared::SourceNatConfig; +use omicron_common::api::internal::shared::external_ip::v1::SourceNatConfig; use omicron_common::api::internal::shared::network_interface::v1::NetworkInterface; use omicron_common::disk::DatasetConfig; use omicron_common::disk::OmicronPhysicalDiskConfig; @@ -78,10 +78,10 @@ pub struct Inventory { pub zone_image_resolver: ZoneImageResolverInventory, } -impl TryFrom for Inventory { +impl TryFrom for Inventory { type Error = external::Error; - fn try_from(value: inventory::Inventory) -> Result { + fn try_from(value: v10::Inventory) -> Result { let ledgered_sled_config = value.ledgered_sled_config.map(TryInto::try_into).transpose()?; let reconciler_status = value.reconciler_status.try_into()?; @@ -124,7 +124,7 @@ pub struct OmicronSledConfig { pub host_phase_2: HostPhase2DesiredSlots, } -impl TryFrom for inventory::OmicronSledConfig { +impl TryFrom for v10::OmicronSledConfig { type Error = external::Error; fn try_from(value: OmicronSledConfig) -> Result { @@ -144,11 +144,9 @@ impl TryFrom for inventory::OmicronSledConfig { } } -impl TryFrom for OmicronSledConfig { +impl TryFrom for OmicronSledConfig { type Error = external::Error; - fn try_from( - value: inventory::OmicronSledConfig, - ) -> Result { + fn try_from(value: v10::OmicronSledConfig) -> Result { let zones = value .zones .into_iter() @@ -192,7 +190,7 @@ impl IdOrdItem for OmicronZoneConfig { id_upcast!(); } -impl TryFrom for inventory::OmicronZoneConfig { +impl TryFrom for v10::OmicronZoneConfig { type Error = external::Error; fn try_from(value: OmicronZoneConfig) -> Result { @@ -205,12 +203,10 @@ impl TryFrom for inventory::OmicronZoneConfig { } } -impl TryFrom for OmicronZoneConfig { +impl TryFrom for OmicronZoneConfig { type Error = external::Error; - fn try_from( - value: inventory::OmicronZoneConfig, - ) -> Result { + fn try_from(value: v10::OmicronZoneConfig) -> Result { Ok(Self { id: value.id, filesystem_pool: value.filesystem_pool, @@ -223,21 +219,21 @@ impl TryFrom for OmicronZoneConfig { /// Describes the set of Omicron-managed zones running on a sled #[derive(Deserialize, Serialize, JsonSchema)] pub struct OmicronZonesConfig { - /// generation number of this configuration + /// Generation number of this configuration /// - /// This generation number is owned by the control plane (i.e., RSS or - /// Nexus, depending on whether RSS-to-Nexus handoff has happened). It - /// should not be bumped within Sled Agent. + /// This generation number is owned by the control plane (i.e., rss or + /// nexus, depending on whether rss-to-nexus handoff has happened). it + /// should not be bumped within sled agent. /// - /// Sled Agent rejects attempts to set the configuration to a generation + /// Sled agent rejects attempts to set the configuration to a generation /// older than the one it's currently running. pub generation: Generation, - /// list of running zones + /// List of running zones pub zones: Vec, } -impl TryFrom for inventory::OmicronZonesConfig { +impl TryFrom for v10::OmicronZonesConfig { type Error = external::Error; fn try_from(value: OmicronZonesConfig) -> Result { @@ -246,7 +242,7 @@ impl TryFrom for inventory::OmicronZonesConfig { .into_iter() .map(TryInto::try_into) .collect::>() - .map(|zones| inventory::OmicronZonesConfig { + .map(|zones| v10::OmicronZonesConfig { generation: value.generation, zones, }) @@ -354,7 +350,7 @@ const fn default_nexus_lockstep_port() -> u16 { omicron_common::address::NEXUS_LOCKSTEP_PORT } -impl TryFrom for inventory::OmicronZoneType { +impl TryFrom for v10::OmicronZoneType { type Error = external::Error; fn try_from(value: OmicronZoneType) -> Result { @@ -441,14 +437,12 @@ impl TryFrom for inventory::OmicronZoneType { } } -impl TryFrom for OmicronZoneType { +impl TryFrom for OmicronZoneType { type Error = external::Error; - fn try_from( - value: inventory::OmicronZoneType, - ) -> Result { + fn try_from(value: v10::OmicronZoneType) -> Result { match value { - inventory::OmicronZoneType::BoundaryNtp { + v10::OmicronZoneType::BoundaryNtp { address, ntp_servers, dns_servers, @@ -463,27 +457,25 @@ impl TryFrom for OmicronZoneType { nic: nic.try_into()?, snat_cfg, }), - inventory::OmicronZoneType::Clickhouse { address, dataset } => { + v10::OmicronZoneType::Clickhouse { address, dataset } => { Ok(Self::Clickhouse { address, dataset }) } - inventory::OmicronZoneType::ClickhouseKeeper { - address, - dataset, - } => Ok(Self::ClickhouseKeeper { address, dataset }), - inventory::OmicronZoneType::ClickhouseServer { - address, - dataset, - } => Ok(Self::ClickhouseServer { address, dataset }), - inventory::OmicronZoneType::CockroachDb { address, dataset } => { + v10::OmicronZoneType::ClickhouseKeeper { address, dataset } => { + Ok(Self::ClickhouseKeeper { address, dataset }) + } + v10::OmicronZoneType::ClickhouseServer { address, dataset } => { + Ok(Self::ClickhouseServer { address, dataset }) + } + v10::OmicronZoneType::CockroachDb { address, dataset } => { Ok(Self::CockroachDb { address, dataset }) } - inventory::OmicronZoneType::Crucible { address, dataset } => { + v10::OmicronZoneType::Crucible { address, dataset } => { Ok(Self::Crucible { address, dataset }) } - inventory::OmicronZoneType::CruciblePantry { address } => { + v10::OmicronZoneType::CruciblePantry { address } => { Ok(Self::CruciblePantry { address }) } - inventory::OmicronZoneType::ExternalDns { + v10::OmicronZoneType::ExternalDns { dataset, http_address, dns_address, @@ -494,7 +486,7 @@ impl TryFrom for OmicronZoneType { dns_address, nic: nic.try_into()?, }), - inventory::OmicronZoneType::InternalDns { + v10::OmicronZoneType::InternalDns { dataset, http_address, dns_address, @@ -507,10 +499,10 @@ impl TryFrom for OmicronZoneType { gz_address, gz_address_index, }), - inventory::OmicronZoneType::InternalNtp { address } => { + v10::OmicronZoneType::InternalNtp { address } => { Ok(Self::InternalNtp { address }) } - inventory::OmicronZoneType::Nexus { + v10::OmicronZoneType::Nexus { internal_address, lockstep_port, external_ip, @@ -525,7 +517,7 @@ impl TryFrom for OmicronZoneType { external_tls, external_dns_servers, }), - inventory::OmicronZoneType::Oximeter { address } => { + v10::OmicronZoneType::Oximeter { address } => { Ok(Self::Oximeter { address }) } } @@ -550,13 +542,11 @@ pub struct ConfigReconcilerInventory { pub remove_mupdate_override: Option, } -impl TryFrom - for ConfigReconcilerInventory -{ +impl TryFrom for ConfigReconcilerInventory { type Error = external::Error; fn try_from( - value: inventory::ConfigReconcilerInventory, + value: v10::ConfigReconcilerInventory, ) -> Result { Ok(Self { last_reconciled_config: value.last_reconciled_config.try_into()?, @@ -592,19 +582,19 @@ pub enum ConfigReconcilerInventoryStatus { Idle { completed_at: DateTime, ran_for: Duration }, } -impl TryFrom +impl TryFrom for ConfigReconcilerInventoryStatus { type Error = external::Error; fn try_from( - value: inventory::ConfigReconcilerInventoryStatus, + value: v10::ConfigReconcilerInventoryStatus, ) -> Result { match value { - inventory::ConfigReconcilerInventoryStatus::NotYetRun => { + v10::ConfigReconcilerInventoryStatus::NotYetRun => { Ok(Self::NotYetRun) } - inventory::ConfigReconcilerInventoryStatus::Running { + v10::ConfigReconcilerInventoryStatus::Running { config, started_at, running_for, @@ -613,7 +603,7 @@ impl TryFrom started_at, running_for, }), - inventory::ConfigReconcilerInventoryStatus::Idle { + v10::ConfigReconcilerInventoryStatus::Idle { completed_at, ran_for, } => Ok(Self::Idle { completed_at, ran_for }), @@ -651,7 +641,7 @@ pub struct InstanceEnsureBody { pub metadata: InstanceMetadata, } -impl TryFrom for crate::instance::InstanceEnsureBody { +impl TryFrom for v10::InstanceEnsureBody { type Error = external::Error; fn try_from(value: InstanceEnsureBody) -> Result { @@ -685,9 +675,7 @@ pub struct InstanceSledLocalConfig { pub delegated_zvols: Vec, } -impl TryFrom - for crate::instance::InstanceSledLocalConfig -{ +impl TryFrom for v10::InstanceSledLocalConfig { type Error = external::Error; fn try_from(value: InstanceSledLocalConfig) -> Result {