From 0fee0f5faf67d069a1f96a4e677620070ba01631 Mon Sep 17 00:00:00 2001 From: Ryan Goodfellow Date: Wed, 29 Nov 2023 16:16:14 -0800 Subject: [PATCH] add communication probes --- .github/buildomat/jobs/package.sh | 1 + .github/buildomat/jobs/tuf-repo.sh | 0 Cargo.lock | 11 +- Cargo.toml | 4 + clients/nexus-client/src/lib.rs | 2 + clients/sled-agent-client/src/lib.rs | 36 +- common/src/api/external/mod.rs | 10 + common/src/api/internal/shared.rs | 2 + dev-tools/omdb/tests/successes.out | 33 +- illumos-utils/src/running_zone.rs | 4 + installinator/src/dispatch.rs | 4 +- nexus/Cargo.toml | 2 +- nexus/db-model/src/external_ip.rs | 48 +- nexus/db-model/src/lib.rs | 2 + nexus/db-model/src/network_interface.rs | 54 +++ nexus/db-model/src/probe.rs | 48 ++ nexus/db-model/src/schema.rs | 16 +- nexus/db-model/src/service_kind.rs | 4 + nexus/db-model/src/unsigned.rs | 2 + nexus/db-queries/Cargo.toml | 1 + .../src/db/datastore/external_ip.rs | 69 +++ nexus/db-queries/src/db/datastore/mod.rs | 2 + .../src/db/datastore/network_interface.rs | 83 +++- nexus/db-queries/src/db/datastore/probe.rs | 343 ++++++++++++++ nexus/db-queries/src/db/datastore/vpc.rs | 24 + .../db-queries/src/db/queries/external_ip.rs | 6 + .../src/db/queries/network_interface.rs | 9 +- nexus/src/app/background/init.rs | 5 +- nexus/src/app/instance.rs | 4 +- nexus/src/app/instance_network.rs | 101 ++++- nexus/src/app/mod.rs | 1 + nexus/src/app/probe.rs | 79 ++++ nexus/src/app/rack.rs | 5 +- nexus/src/app/sagas/project_create.rs | 1 - nexus/src/app/switch_port.rs | 4 +- nexus/src/app/vpc.rs | 11 +- nexus/src/external_api/http_entrypoints.rs | 104 ++++- nexus/src/external_api/tag-config.json | 6 + nexus/src/internal_api/http_entrypoints.rs | 35 +- nexus/tests/integration_tests/instances.rs | 31 +- nexus/tests/integration_tests/mod.rs | 1 + nexus/tests/integration_tests/probe.rs | 93 ++++ nexus/tests/output/nexus_tags.txt | 7 + .../output/uncovered-authz-endpoints.txt | 4 + nexus/types/src/external_api/params.rs | 19 + nexus/types/src/internal_api/params.rs | 2 + openapi/nexus-internal.json | 309 +++++++++++++ openapi/nexus.json | 426 ++++++++++++++++++ openapi/sled-agent.json | 20 + package-manifest.toml | 17 + schema/all-zone-requests.json | 20 + schema/all-zones-requests.json | 20 + schema/crdb/22.0.0/up1.sql | 9 + schema/crdb/22.0.0/up2.sql | 4 + schema/crdb/22.0.0/up3.sql | 1 + schema/crdb/22.0.0/up4.sql | 1 + schema/crdb/dbinit.sql | 21 +- schema/rss-service-plan-v2.json | 20 + sled-agent/src/lib.rs | 1 + sled-agent/src/params.rs | 6 +- sled-agent/src/probe_manager.rs | 342 ++++++++++++++ sled-agent/src/sled_agent.rs | 16 + tools/ci_download_maghemite_mgd | 3 + tools/ci_download_maghemite_openapi | 4 +- tools/ci_download_thundermuffin | 153 +++++++ tools/install_builder_prerequisites.sh | 3 + tools/install_runner_prerequisites.sh | 2 - tools/thundermuffin_checksums | 1 + tools/thundermuffin_version | 1 + 69 files changed, 2591 insertions(+), 142 deletions(-) mode change 100644 => 100755 .github/buildomat/jobs/tuf-repo.sh create mode 100644 nexus/db-model/src/probe.rs create mode 100644 nexus/db-queries/src/db/datastore/probe.rs create mode 100644 nexus/src/app/probe.rs create mode 100644 nexus/tests/integration_tests/probe.rs create mode 100644 schema/crdb/22.0.0/up1.sql create mode 100644 schema/crdb/22.0.0/up2.sql create mode 100644 schema/crdb/22.0.0/up3.sql create mode 100644 schema/crdb/22.0.0/up4.sql create mode 100644 sled-agent/src/probe_manager.rs create mode 100755 tools/ci_download_thundermuffin create mode 100644 tools/thundermuffin_checksums create mode 100644 tools/thundermuffin_version diff --git a/.github/buildomat/jobs/package.sh b/.github/buildomat/jobs/package.sh index 350ab372336..9340241efe5 100755 --- a/.github/buildomat/jobs/package.sh +++ b/.github/buildomat/jobs/package.sh @@ -124,6 +124,7 @@ zones=( out/omicron-gateway-softnpu.tar.gz out/omicron-gateway-asic.tar.gz out/overlay.tar.gz + out/probe.tar.gz ) cp "${zones[@]}" /work/zones/ diff --git a/.github/buildomat/jobs/tuf-repo.sh b/.github/buildomat/jobs/tuf-repo.sh old mode 100644 new mode 100755 diff --git a/Cargo.lock b/Cargo.lock index 98275e144fc..be04909ce1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4192,6 +4192,7 @@ dependencies = [ "regex", "rustls", "samael", + "schemars", "serde", "serde_json", "serde_urlencoded", @@ -6201,9 +6202,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.69" +version = "1.0.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" +checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" dependencies = [ "unicode-ident", ] @@ -10289,8 +10290,7 @@ dependencies = [ [[package]] name = "zone" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62a428a79ea2224ce8ab05d6d8a21bdd7b4b68a8dbc1230511677a56e72ef22" +source = "git+https://github.com/oxidecomputer/zone?branch=state-derive-eq-hash#f1920d5636c69ea8179f8ec659702dcdef43268c" dependencies = [ "itertools 0.10.5", "thiserror", @@ -10301,8 +10301,7 @@ dependencies = [ [[package]] name = "zone_cfg_derive" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5c4f01d3785e222d5aca11c9813e9c46b69abfe258756c99c9b628683626cc8" +source = "git+https://github.com/oxidecomputer/zone?branch=state-derive-eq-hash#f1920d5636c69ea8179f8ec659702dcdef43268c" dependencies = [ "heck 0.4.1", "proc-macro-error", diff --git a/Cargo.toml b/Cargo.toml index d4f81b0310a..fa52ee45220 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -594,3 +594,7 @@ branch = "oxide/omicron" # to it. [patch.crates-io.omicron-workspace-hack] path = "workspace-hack" + +[patch.crates-io.zone] +git = 'https://github.com/oxidecomputer/zone' +branch = 'state-derive-eq-hash' diff --git a/clients/nexus-client/src/lib.rs b/clients/nexus-client/src/lib.rs index 3ecba7e7100..e14dc6e25c7 100644 --- a/clients/nexus-client/src/lib.rs +++ b/clients/nexus-client/src/lib.rs @@ -28,6 +28,8 @@ progenitor::generate_api!( MacAddr = omicron_common::api::external::MacAddr, Name = omicron_common::api::external::Name, NewPasswordHash = omicron_passwords::NewPasswordHash, + NetworkInterface = omicron_common::api::internal::shared::NetworkInterface, + NetworkInterfaceKind = omicron_common::api::internal::shared::NetworkInterfaceKind, } ); diff --git a/clients/sled-agent-client/src/lib.rs b/clients/sled-agent-client/src/lib.rs index 0bbd27cf3ee..4a88f2ce831 100644 --- a/clients/sled-agent-client/src/lib.rs +++ b/clients/sled-agent-client/src/lib.rs @@ -31,6 +31,8 @@ progenitor::generate_api!( IpNetwork = ipnetwork::IpNetwork, PortFec = omicron_common::api::internal::shared::PortFec, PortSpeed = omicron_common::api::internal::shared::PortSpeed, + NetworkInterface = omicron_common::api::internal::shared::NetworkInterface, + NetworkInterfaceKind = omicron_common::api::internal::shared::NetworkInterfaceKind, } ); @@ -455,40 +457,6 @@ impl From } } -impl From - for types::NetworkInterfaceKind -{ - fn from( - s: omicron_common::api::internal::shared::NetworkInterfaceKind, - ) -> Self { - use omicron_common::api::internal::shared::NetworkInterfaceKind::*; - match s { - Instance { id } => Self::Instance(id), - Service { id } => Self::Service(id), - } - } -} - -impl From - for types::NetworkInterface -{ - fn from( - s: omicron_common::api::internal::shared::NetworkInterface, - ) -> Self { - Self { - id: s.id, - kind: s.kind.into(), - name: (&s.name).into(), - ip: s.ip, - mac: s.mac.into(), - subnet: s.subnet.into(), - vni: s.vni.into(), - primary: s.primary, - slot: s.slot, - } - } -} - impl From for types::SourceNatConfig { diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 446152137a8..5d869bd8b22 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -768,6 +768,7 @@ pub enum ResourceType { Vmm, Ipv4NatEntry, FloatingIp, + ProbeNetworkInterface, } // IDENTITY METADATA @@ -2621,6 +2622,15 @@ pub struct BgpImportedRouteIpv4 { pub switch: SwitchLocation, } +#[derive( + Clone, Debug, Deserialize, JsonSchema, Serialize, PartialEq, ObjectIdentity, +)] +pub struct Probe { + #[serde(flatten)] + pub identity: IdentityMetadata, + pub sled: Uuid, +} + #[cfg(test)] mod test { use serde::Deserialize; diff --git a/common/src/api/internal/shared.rs b/common/src/api/internal/shared.rs index c8d8b1c7861..bf825fd2e7b 100644 --- a/common/src/api/internal/shared.rs +++ b/common/src/api/internal/shared.rs @@ -36,6 +36,8 @@ pub enum NetworkInterfaceKind { Instance { id: Uuid }, /// A vNIC associated with an internal service Service { id: Uuid }, + /// A vNIC associated with a probe + Probe { id: Uuid }, } /// Information required to construct a virtual network interface diff --git a/dev-tools/omdb/tests/successes.out b/dev-tools/omdb/tests/successes.out index 65520ab59c1..b97d16ee2b0 100644 --- a/dev-tools/omdb/tests/successes.out +++ b/dev-tools/omdb/tests/successes.out @@ -59,43 +59,30 @@ note: using database URL postgresql://root@[::1]:REDACTED_PORT/omicron?sslmode=d note: database schema version matches expected () ============================================= EXECUTING COMMAND: omdb ["db", "services", "list-instances"] -termination: Exited(0) +termination: Exited(1) --------------------------------------------- stdout: -SERVICE INSTANCE_ID ADDR SLED_SERIAL -CruciblePantry REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT sim-b6d65341 -Dendrite REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT sim-b6d65341 -Dendrite REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT sim-b6d65341 -ExternalDns REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT sim-b6d65341 -InternalDns REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT sim-b6d65341 -Nexus REDACTED_UUID_REDACTED_UUID_REDACTED [::ffff:127.0.0.1]:REDACTED_PORT sim-b6d65341 -Mgd REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT sim-b6d65341 -Mgd REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT sim-b6d65341 --------------------------------------------- stderr: note: using database URL postgresql://root@[::1]:REDACTED_PORT/omicron?sslmode=disable note: database schema version matches expected () +Error: listing instances of kind Probe + +Caused by: + Internal Error: unexpected database error: error in argument for $1: invalid input value for enum service_kind: "probe" ============================================= EXECUTING COMMAND: omdb ["db", "services", "list-by-sled"] -termination: Exited(0) +termination: Exited(1) --------------------------------------------- stdout: -sled: sim-b6d65341 (id REDACTED_UUID_REDACTED_UUID_REDACTED) - - SERVICE INSTANCE_ID ADDR - CruciblePantry REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT - Dendrite REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT - Dendrite REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT - ExternalDns REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT - InternalDns REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT - Nexus REDACTED_UUID_REDACTED_UUID_REDACTED [::ffff:127.0.0.1]:REDACTED_PORT - Mgd REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT - Mgd REDACTED_UUID_REDACTED_UUID_REDACTED [::1]:REDACTED_PORT - --------------------------------------------- stderr: note: using database URL postgresql://root@[::1]:REDACTED_PORT/omicron?sslmode=disable note: database schema version matches expected () +Error: listing instances of kind Probe + +Caused by: + Internal Error: unexpected database error: error in argument for $1: invalid input value for enum service_kind: "probe" ============================================= EXECUTING COMMAND: omdb ["db", "sleds"] termination: Exited(0) diff --git a/illumos-utils/src/running_zone.rs b/illumos-utils/src/running_zone.rs index ea80a6d34b6..e739e4c20d2 100644 --- a/illumos-utils/src/running_zone.rs +++ b/illumos-utils/src/running_zone.rs @@ -915,6 +915,10 @@ impl RunningZone { &self.inner.links } + pub fn links_mut(&mut self) -> &mut Vec { + &mut self.inner.links + } + /// Return the running processes associated with all the SMF services this /// zone is intended to run. pub fn service_processes( diff --git a/installinator/src/dispatch.rs b/installinator/src/dispatch.rs index 9bec14664c8..1fcf351a9b7 100644 --- a/installinator/src/dispatch.rs +++ b/installinator/src/dispatch.rs @@ -151,13 +151,13 @@ struct InstallOpts { #[clap(long)] install_on_gimlet: bool, - //TODO(ry) this probably needs to get plumbed somewhere instead of relying + //TODO this probably needs to get plumbed somewhere instead of relying //on a default. /// The first gimlet data link to use. #[clap(long, default_value = "cxgbe0")] data_link0: String, - //TODO(ry) this probably needs to get plumbed somewhere instead of relying + //TODO this probably needs to get plumbed somewhere instead of relying //on a default. /// The second gimlet data link to use. #[clap(long, default_value = "cxgbe1")] diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index 25833ec104e..6f994c6cf28 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -32,6 +32,7 @@ http.workspace = true hyper.workspace = true internal-dns.workspace = true ipnetwork.workspace = true +itertools.workspace = true macaddr.workspace = true mime_guess.workspace = true # Not under "dev-dependencies"; these also need to be implemented for @@ -93,7 +94,6 @@ diesel.workspace = true dns-server.workspace = true expectorate.workspace = true hyper-rustls.workspace = true -itertools.workspace = true gateway-messages.workspace = true gateway-test-utils.workspace = true hubtools.workspace = true diff --git a/nexus/db-model/src/external_ip.rs b/nexus/db-model/src/external_ip.rs index 1a755f0396b..31415d574b2 100644 --- a/nexus/db-model/src/external_ip.rs +++ b/nexus/db-model/src/external_ip.rs @@ -21,6 +21,7 @@ use nexus_types::external_api::views; use omicron_common::address::NUM_SOURCE_NAT_PORTS; use omicron_common::api::external::Error; use omicron_common::api::external::IdentityMetadata; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use std::convert::TryFrom; @@ -32,7 +33,7 @@ impl_enum_type!( #[diesel(postgres_type(name = "ip_kind"))] pub struct IpKindEnum; - #[derive(Clone, Copy, Debug, AsExpression, FromSqlRow, PartialEq)] + #[derive(Clone, Copy, Debug, AsExpression, FromSqlRow, PartialEq, Serialize, Deserialize, JsonSchema)] #[diesel(sql_type = IpKindEnum)] pub enum IpKind; @@ -51,7 +52,16 @@ impl_enum_type!( /// addresses and port ranges, while source NAT IPs are not discoverable in the /// API at all, and only provide outbound connectivity to instances, not /// inbound. -#[derive(Debug, Clone, Selectable, Queryable, Insertable)] +#[derive( + Debug, + Clone, + Selectable, + Queryable, + Insertable, + Serialize, + Deserialize, + JsonSchema, +)] #[diesel(table_name = external_ip)] pub struct ExternalIp { pub id: Uuid, @@ -120,6 +130,7 @@ pub struct IncompleteExternalIp { time_created: DateTime, kind: IpKind, is_service: bool, + is_probe: bool, parent_id: Option, pool_id: Uuid, project_id: Option, @@ -142,6 +153,7 @@ impl IncompleteExternalIp { time_created: Utc::now(), kind: IpKind::SNat, is_service: false, + is_probe: false, parent_id: Some(instance_id), pool_id, project_id: None, @@ -158,6 +170,28 @@ impl IncompleteExternalIp { time_created: Utc::now(), kind: IpKind::Ephemeral, is_service: false, + is_probe: false, + parent_id: Some(instance_id), + pool_id, + project_id: None, + explicit_ip: None, + explicit_port_range: None, + } + } + + pub fn for_ephemeral_probe( + id: Uuid, + instance_id: Uuid, + pool_id: Uuid, + ) -> Self { + Self { + id, + name: None, + description: None, + time_created: Utc::now(), + kind: IpKind::Ephemeral, + is_service: false, + is_probe: true, parent_id: Some(instance_id), pool_id, project_id: None, @@ -180,6 +214,7 @@ impl IncompleteExternalIp { time_created: Utc::now(), kind: IpKind::Floating, is_service: false, + is_probe: false, parent_id: None, pool_id, project_id: Some(project_id), @@ -203,6 +238,7 @@ impl IncompleteExternalIp { time_created: Utc::now(), kind: IpKind::Floating, is_service: false, + is_probe: false, parent_id: None, pool_id, project_id: Some(project_id), @@ -226,6 +262,7 @@ impl IncompleteExternalIp { time_created: Utc::now(), kind: IpKind::Floating, is_service: true, + is_probe: false, parent_id: Some(service_id), pool_id, project_id: None, @@ -255,6 +292,7 @@ impl IncompleteExternalIp { time_created: Utc::now(), kind: IpKind::SNat, is_service: true, + is_probe: false, parent_id: Some(service_id), pool_id, project_id: None, @@ -277,6 +315,7 @@ impl IncompleteExternalIp { time_created: Utc::now(), kind: IpKind::Floating, is_service: true, + is_probe: false, parent_id: Some(service_id), pool_id, project_id: None, @@ -293,6 +332,7 @@ impl IncompleteExternalIp { time_created: Utc::now(), kind: IpKind::SNat, is_service: true, + is_probe: false, parent_id: Some(service_id), pool_id, project_id: None, @@ -325,6 +365,10 @@ impl IncompleteExternalIp { &self.is_service } + pub fn is_probe(&self) -> &bool { + &self.is_probe + } + pub fn parent_id(&self) -> &Option { &self.parent_id } diff --git a/nexus/db-model/src/lib.rs b/nexus/db-model/src/lib.rs index 2c3433b2d31..0072a9677a6 100644 --- a/nexus/db-model/src/lib.rs +++ b/nexus/db-model/src/lib.rs @@ -44,6 +44,7 @@ mod network_interface; mod oximeter_info; mod physical_disk; mod physical_disk_kind; +mod probe; mod producer_endpoint; mod project; mod semver_version; @@ -139,6 +140,7 @@ pub use network_interface::*; pub use oximeter_info::*; pub use physical_disk::*; pub use physical_disk_kind::*; +pub use probe::*; pub use producer_endpoint::*; pub use project::*; pub use quota::*; diff --git a/nexus/db-model/src/network_interface.rs b/nexus/db-model/src/network_interface.rs index ada21485165..2f8793767a5 100644 --- a/nexus/db-model/src/network_interface.rs +++ b/nexus/db-model/src/network_interface.rs @@ -28,6 +28,7 @@ impl_enum_type! { Instance => b"instance" Service => b"service" + Probe => b"probe" } /// Generic Network Interface DB model. @@ -56,6 +57,40 @@ pub struct NetworkInterface { pub primary: bool, } +impl Into + for NetworkInterface +{ + fn into(self) -> omicron_common::api::internal::shared::NetworkInterface { + omicron_common::api::internal::shared::NetworkInterface { + id: self.id(), + kind: match self.kind { + NetworkInterfaceKind::Instance => + omicron_common::api::internal::shared::NetworkInterfaceKind::Instance { + id: self.parent_id + }, + NetworkInterfaceKind::Service => + omicron_common::api::internal::shared::NetworkInterfaceKind::Service { + id: self.parent_id + }, + NetworkInterfaceKind::Probe => + omicron_common::api::internal::shared::NetworkInterfaceKind::Probe { + id: self.parent_id + }, + }, + name: self.name().clone(), + ip: self.ip.ip(), + mac: self.mac.into(), + subnet: ipnetwork::IpNetwork::new( + self.ip.network(), + 24, //TODO + ).unwrap().into(), + vni: omicron_common::api::external::Vni::try_from(0).unwrap(), //TODO + primary: self.primary, + slot: self.slot.try_into().unwrap(), + } + } +} + /// Instance Network Interface DB model. /// /// The underlying "table" (`instance_network_interface`) is actually a view @@ -287,6 +322,25 @@ impl IncompleteNetworkInterface { mac, ) } + + pub fn new_probe( + interface_id: Uuid, + probe_id: Uuid, + subnet: VpcSubnet, + identity: external::IdentityMetadataCreateParams, + ip: Option, + mac: Option, + ) -> Result { + Self::new( + interface_id, + NetworkInterfaceKind::Probe, + probe_id, + subnet, + identity, + ip, + mac, + ) + } } /// Describes a set of updates for the [`NetworkInterface`] model. diff --git a/nexus/db-model/src/probe.rs b/nexus/db-model/src/probe.rs new file mode 100644 index 00000000000..d904729101d --- /dev/null +++ b/nexus/db-model/src/probe.rs @@ -0,0 +1,48 @@ +use crate::schema::probe; +use db_macros::Resource; +use nexus_types::external_api::params; +use nexus_types::identity::Resource; +use omicron_common::api::external; +use omicron_common::api::external::IdentityMetadataCreateParams; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +#[derive( + Queryable, + Insertable, + Selectable, + Clone, + Debug, + Resource, + Serialize, + Deserialize, +)] +#[diesel(table_name = probe)] +pub struct Probe { + #[diesel(embed)] + pub identity: ProbeIdentity, + + pub sled: Uuid, +} + +impl From<¶ms::ProbeCreate> for Probe { + fn from(p: ¶ms::ProbeCreate) -> Self { + Self { + identity: ProbeIdentity::new( + Uuid::new_v4(), + IdentityMetadataCreateParams { + name: p.identity.name.clone(), + description: p.identity.description.clone(), + }, + ), + sled: p.sled, + } + } +} + +impl Into for Probe { + fn into(self) -> external::Probe { + external::Probe { identity: self.identity().clone(), sled: self.sled } + } +} diff --git a/nexus/db-model/src/schema.rs b/nexus/db-model/src/schema.rs index 7f4bf514873..1f38c2a8cd0 100644 --- a/nexus/db-model/src/schema.rs +++ b/nexus/db-model/src/schema.rs @@ -13,7 +13,7 @@ use omicron_common::api::external::SemverVersion; /// /// This should be updated whenever the schema is changed. For more details, /// refer to: schema/crdb/README.adoc -pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(21, 0, 0); +pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(22, 0, 0); table! { disk (id) { @@ -567,6 +567,8 @@ table! { last_port -> Int4, project_id -> Nullable, + + is_probe -> Bool, } } @@ -1338,6 +1340,18 @@ table! { } } +table! { + probe (id) { + id -> Uuid, + name -> Text, + description -> Text, + time_created -> Timestamptz, + time_modified -> Timestamptz, + time_deleted -> Nullable, + sled -> Uuid, + } +} + table! { db_metadata (singleton) { singleton -> Bool, diff --git a/nexus/db-model/src/service_kind.rs b/nexus/db-model/src/service_kind.rs index 4210c3ee205..ac9a86db09b 100644 --- a/nexus/db-model/src/service_kind.rs +++ b/nexus/db-model/src/service_kind.rs @@ -31,6 +31,7 @@ impl_enum_type!( Tfport => b"tfport" Ntp => b"ntp" Mgd => b"mgd" + Probe => b"probe" ); impl TryFrom for ServiceUsingCertificate { @@ -90,6 +91,9 @@ impl From for ServiceKind { ServiceKind::Ntp } internal_api::params::ServiceKind::Mgd => ServiceKind::Mgd, + internal_api::params::ServiceKind::Probe { .. } => { + ServiceKind::Probe + } } } } diff --git a/nexus/db-model/src/unsigned.rs b/nexus/db-model/src/unsigned.rs index b4e9db2308a..920cad1cff9 100644 --- a/nexus/db-model/src/unsigned.rs +++ b/nexus/db-model/src/unsigned.rs @@ -7,6 +7,7 @@ use diesel::deserialize::{self, FromSql}; use diesel::pg::Pg; use diesel::serialize::{self, ToSql}; use diesel::sql_types; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::convert::TryFrom; @@ -76,6 +77,7 @@ where FromSqlRow, Serialize, Deserialize, + JsonSchema, )] #[diesel(sql_type = sql_types::Int4)] #[repr(transparent)] diff --git a/nexus/db-queries/Cargo.toml b/nexus/db-queries/Cargo.toml index d5320be7336..4c0140b3607 100644 --- a/nexus/db-queries/Cargo.toml +++ b/nexus/db-queries/Cargo.toml @@ -35,6 +35,7 @@ pq-sys = "*" rand.workspace = true ref-cast.workspace = true samael.workspace = true +schemars.workspace = true serde.workspace = true serde_json.workspace = true serde_urlencoded.workspace = true diff --git a/nexus/db-queries/src/db/datastore/external_ip.rs b/nexus/db-queries/src/db/datastore/external_ip.rs index 2adeebd8198..c741799c7d7 100644 --- a/nexus/db-queries/src/db/datastore/external_ip.rs +++ b/nexus/db-queries/src/db/datastore/external_ip.rs @@ -59,6 +59,32 @@ impl DataStore { self.allocate_external_ip(opctx, data).await } + /// Create an Ephemeral IP address for a probe. + pub async fn allocate_probe_ephemeral_ip( + &self, + opctx: &OpContext, + ip_id: Uuid, + probe_id: Uuid, + pool_name: Option, + ) -> CreateResult { + let pool = match pool_name { + Some(name) => { + let (.., pool) = LookupPath::new(opctx, &self) + .ip_pool_name(&name) + .fetch_for(authz::Action::CreateChild) + .await?; + pool + } + // If no name given, use the default pool + None => self.ip_pools_fetch_default(&opctx).await?, + }; + + let pool_id = pool.identity.id; + let data = + IncompleteExternalIp::for_ephemeral_probe(ip_id, probe_id, pool_id); + self.allocate_external_ip(opctx, data).await + } + /// Create an Ephemeral IP address for an instance. pub async fn allocate_instance_ephemeral_ip( &self, @@ -356,6 +382,7 @@ impl DataStore { diesel::update(dsl::external_ip) .filter(dsl::time_deleted.is_null()) .filter(dsl::is_service.eq(false)) + .filter(dsl::is_probe.eq(false)) .filter(dsl::parent_id.eq(instance_id)) .filter(dsl::kind.ne(IpKind::Floating)) .set(dsl::time_deleted.eq(now)) @@ -364,6 +391,30 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } + /// Delete all external IP addresses associated with the provided probe + /// ID. + /// + /// This method returns the number of records deleted, rather than the usual + /// `DeleteResult`. That's mostly useful for tests, but could be important + /// if callers have some invariants they'd like to check. + pub async fn deallocate_external_ip_by_probe_id( + &self, + opctx: &OpContext, + probe_id: Uuid, + ) -> Result { + use db::schema::external_ip::dsl; + let now = Utc::now(); + diesel::update(dsl::external_ip) + .filter(dsl::time_deleted.is_null()) + .filter(dsl::is_probe.eq(true)) + .filter(dsl::parent_id.eq(probe_id)) + .filter(dsl::kind.ne(IpKind::Floating)) + .set(dsl::time_deleted.eq(now)) + .execute_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } + /// Detach an individual Floating IP address from its parent instance. /// /// As in `deallocate_external_ip_by_instance_id`, this method returns the @@ -394,6 +445,7 @@ impl DataStore { use db::schema::external_ip::dsl; dsl::external_ip .filter(dsl::is_service.eq(false)) + .filter(dsl::is_probe.eq(false)) .filter(dsl::parent_id.eq(instance_id)) .filter(dsl::time_deleted.is_null()) .select(ExternalIp::as_select()) @@ -402,6 +454,23 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } + /// Fetch all external IP addresses of any kind for the provided probe + pub async fn probe_lookup_external_ips( + &self, + opctx: &OpContext, + probe_id: Uuid, + ) -> LookupResult> { + use db::schema::external_ip::dsl; + dsl::external_ip + .filter(dsl::is_probe.eq(true)) + .filter(dsl::parent_id.eq(probe_id)) + .filter(dsl::time_deleted.is_null()) + .select(ExternalIp::as_select()) + .get_results_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } + /// Fetch all Floating IP addresses for the provided project. pub async fn floating_ips_list( &self, diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index 93486771b5f..74b7946d7a2 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -67,6 +67,7 @@ mod ipv4_nat_entry; mod network_interface; mod oximeter; mod physical_disk; +mod probe; mod project; mod quota; mod rack; @@ -101,6 +102,7 @@ pub use db_metadata::{ pub use dns::DnsVersionUpdateBuilder; pub use instance::InstanceAndActiveVmm; pub use inventory::DataStoreInventoryTest; +pub use probe::ProbeInfo; pub use rack::RackInit; pub use silo::Discoverability; pub use switch_port::SwitchPortSettingsCombinedResult; diff --git a/nexus/db-queries/src/db/datastore/network_interface.rs b/nexus/db-queries/src/db/datastore/network_interface.rs index 4d4e43c9a7c..d033ec7ad1f 100644 --- a/nexus/db-queries/src/db/datastore/network_interface.rs +++ b/nexus/db-queries/src/db/datastore/network_interface.rs @@ -34,11 +34,11 @@ use omicron_common::api::external::http_pagination::PaginatedBy; use omicron_common::api::external::DeleteResult; use omicron_common::api::external::Error; use omicron_common::api::external::ListResultVec; +use omicron_common::api::external::LookupResult; use omicron_common::api::external::LookupType; use omicron_common::api::external::ResourceType; use omicron_common::api::external::UpdateResult; use ref_cast::RefCast; -use sled_agent_client::types as sled_client_types; use uuid::Uuid; /// OPTE requires information that's currently split across the network @@ -58,8 +58,10 @@ struct NicInfo { slot: i16, } -impl From for sled_client_types::NetworkInterface { - fn from(nic: NicInfo) -> sled_client_types::NetworkInterface { +impl From for omicron_common::api::internal::shared::NetworkInterface { + fn from( + nic: NicInfo, + ) -> omicron_common::api::internal::shared::NetworkInterface { let ip_subnet = if nic.ip.is_ipv4() { external::IpNet::V4(nic.ipv4_block.0) } else { @@ -67,20 +69,23 @@ impl From for sled_client_types::NetworkInterface { }; let kind = match nic.kind { NetworkInterfaceKind::Instance => { - sled_client_types::NetworkInterfaceKind::Instance(nic.parent_id) + omicron_common::api::internal::shared::NetworkInterfaceKind::Instance{ id: nic.parent_id } } NetworkInterfaceKind::Service => { - sled_client_types::NetworkInterfaceKind::Service(nic.parent_id) + omicron_common::api::internal::shared::NetworkInterfaceKind::Service{ id: nic.parent_id } + } + NetworkInterfaceKind::Probe => { + omicron_common::api::internal::shared::NetworkInterfaceKind::Probe{ id: nic.parent_id } } }; - sled_client_types::NetworkInterface { + omicron_common::api::internal::shared::NetworkInterface { id: nic.id, kind, - name: sled_client_types::Name::from(&nic.name.0), + name: nic.name.into(), ip: nic.ip.ip(), - mac: sled_client_types::MacAddr::from(nic.mac.0), - subnet: sled_client_types::IpNet::from(ip_subnet), - vni: sled_client_types::Vni::from(nic.vni.0), + mac: nic.mac.into(), + subnet: ip_subnet, + vni: nic.vni.0, primary: nic.primary, slot: u8::try_from(nic.slot).unwrap(), } @@ -107,6 +112,14 @@ impl DataStore { self.instance_create_network_interface_raw(&opctx, interface).await } + pub async fn probe_create_network_interface( + &self, + opctx: &OpContext, + interface: IncompleteNetworkInterface, + ) -> Result { + self.create_network_interface_raw(&opctx, interface).await + } + pub(crate) async fn instance_create_network_interface_raw( &self, opctx: &OpContext, @@ -258,7 +271,8 @@ impl DataStore { &self, opctx: &OpContext, partial_query: BoxedQuery, - ) -> ListResultVec { + ) -> ListResultVec + { use db::schema::network_interface; use db::schema::vpc; use db::schema::vpc_subnet; @@ -294,7 +308,7 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; Ok(rows .into_iter() - .map(sled_client_types::NetworkInterface::from) + .map(omicron_common::api::internal::shared::NetworkInterface::from) .collect()) } @@ -304,7 +318,8 @@ impl DataStore { &self, opctx: &OpContext, authz_instance: &authz::Instance, - ) -> ListResultVec { + ) -> ListResultVec + { opctx.authorize(authz::Action::ListChildren, authz_instance).await?; use db::schema::network_interface; @@ -320,13 +335,31 @@ impl DataStore { .await } + pub async fn derive_probe_network_interface_info( + &self, + opctx: &OpContext, + probe_id: Uuid, + ) -> ListResultVec + { + use db::schema::network_interface; + self.derive_network_interface_info( + opctx, + network_interface::table + .filter(network_interface::parent_id.eq(probe_id)) + .filter(network_interface::kind.eq(NetworkInterfaceKind::Probe)) + .into_boxed(), + ) + .await + } + /// Return information about all VNICs connected to a VPC required /// for the sled agent to instantiate firewall rules via OPTE. pub async fn derive_vpc_network_interface_info( &self, opctx: &OpContext, authz_vpc: &authz::Vpc, - ) -> ListResultVec { + ) -> ListResultVec + { opctx.authorize(authz::Action::ListChildren, authz_vpc).await?; use db::schema::network_interface; @@ -345,7 +378,8 @@ impl DataStore { &self, opctx: &OpContext, authz_subnet: &authz::VpcSubnet, - ) -> ListResultVec { + ) -> ListResultVec + { opctx.authorize(authz::Action::ListChildren, authz_subnet).await?; use db::schema::network_interface; @@ -388,6 +422,25 @@ impl DataStore { .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) } + /// Get network interface associated with a given probe. + pub async fn probe_get_network_interface( + &self, + opctx: &OpContext, + probe_id: Uuid, + ) -> LookupResult { + use db::schema::network_interface::dsl; + + dsl::network_interface + .filter(dsl::time_deleted.is_null()) + .filter(dsl::parent_id.eq(probe_id)) + .select(NetworkInterface::as_select()) + .first_async::( + &*self.pool_connection_authorized(opctx).await?, + ) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } + /// Update a network interface associated with a given instance. pub async fn instance_update_network_interface( &self, diff --git a/nexus/db-queries/src/db/datastore/probe.rs b/nexus/db-queries/src/db/datastore/probe.rs new file mode 100644 index 00000000000..373422c4cb2 --- /dev/null +++ b/nexus/db-queries/src/db/datastore/probe.rs @@ -0,0 +1,343 @@ +use std::net::IpAddr; + +use crate::context::OpContext; +use crate::db; +use crate::db::error::public_error_from_diesel; +use crate::db::error::ErrorHandler; +use crate::db::lookup::LookupPath; +use crate::db::model::Name; +use crate::db::pagination::paginated; +use async_bb8_diesel::AsyncRunQueryDsl; +use chrono::Utc; +use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; +use nexus_db_model::IncompleteNetworkInterface; +use nexus_db_model::Probe; +use nexus_types::external_api::params; +use nexus_types::identity::Resource; +use omicron_common::api::external::http_pagination::PaginatedBy; +use omicron_common::api::external::CreateResult; +use omicron_common::api::external::DataPageParams; +use omicron_common::api::external::DeleteResult; +use omicron_common::api::external::IdentityMetadataCreateParams; +use omicron_common::api::external::ListResultVec; +use omicron_common::api::external::LookupResult; +use omicron_common::api::external::NameOrId; +use omicron_common::api::internal::shared::NetworkInterface; +use ref_cast::RefCast; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, JsonSchema, Serialize, Deserialize)] +pub struct ProbeInfo { + pub id: Uuid, + pub name: Name, + sled: Uuid, + pub external_ips: Vec, + pub interface: NetworkInterface, +} + +#[derive(Debug, Clone, JsonSchema, Serialize, Deserialize)] +pub struct ExternalIp { + ip: IpAddr, + first_port: u16, + last_port: u16, + kind: IpKind, +} + +impl From for ExternalIp { + fn from(value: nexus_db_model::ExternalIp) -> Self { + Self { + ip: value.ip.ip(), + first_port: value.first_port.0, + last_port: value.last_port.0, + kind: value.kind.into(), + } + } +} + +#[derive(Debug, Clone, JsonSchema, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IpKind { + Snat, + Floating, + Ephemeral, +} + +impl From for IpKind { + fn from(value: nexus_db_model::IpKind) -> Self { + match value { + nexus_db_model::IpKind::SNat => Self::Snat, + nexus_db_model::IpKind::Ephemeral => Self::Ephemeral, + nexus_db_model::IpKind::Floating => Self::Floating, + } + } +} + +impl super::DataStore { + pub async fn probe_list( + &self, + opctx: &OpContext, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + use db::schema::probe::dsl; + + let pool = self.pool_connection_authorized(opctx).await?; + + let probes = match pagparams { + PaginatedBy::Id(pagparams) => { + paginated(dsl::probe, dsl::id, &pagparams) + } + PaginatedBy::Name(pagparams) => paginated( + dsl::probe, + dsl::name, + &pagparams.map_name(|n| Name::ref_cast(n)), + ), + } + .filter(dsl::time_deleted.is_null()) + .select(Probe::as_select()) + .load_async(&*pool) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + let mut result = Vec::with_capacity(probes.len()); + + for probe in probes.into_iter() { + let external_ips = self + .probe_lookup_external_ips(opctx, probe.id()) + .await? + .into_iter() + .map(Into::into) + .collect(); + + let interface = + self.probe_get_network_interface(opctx, probe.id()).await?; + + let vni = self.resolve_vpc_to_vni(opctx, interface.vpc_id).await?; + + let mut interface: omicron_common::api::internal::shared::NetworkInterface = + interface.into(); + interface.vni = vni.0; + + result.push(ProbeInfo { + id: probe.id(), + name: probe.name().clone().into(), + sled: probe.sled, + interface, + external_ips, + }) + } + + Ok(result) + } + + pub async fn probe_list_for_sled( + &self, + sled: Uuid, + opctx: &OpContext, + pagparams: &DataPageParams<'_, Uuid>, + ) -> ListResultVec { + use db::schema::probe::dsl; + + let pool = self.pool_connection_authorized(opctx).await?; + + let probes = paginated(dsl::probe, dsl::id, pagparams) + .filter(dsl::time_deleted.is_null()) + .filter(dsl::sled.eq(sled)) + .select(Probe::as_select()) + .load_async(&*pool) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + let mut result = Vec::with_capacity(probes.len()); + + for probe in probes.into_iter() { + let external_ips = self + .probe_lookup_external_ips(opctx, probe.id()) + .await? + .into_iter() + .map(Into::into) + .collect(); + + let interface = + self.probe_get_network_interface(opctx, probe.id()).await?; + + let vni = self.resolve_vpc_to_vni(opctx, interface.vpc_id).await?; + + let mut interface: omicron_common::api::internal::shared::NetworkInterface = + interface.into(); + interface.vni = vni.0; + + result.push(ProbeInfo { + id: probe.id(), + name: probe.name().clone().into(), + sled: probe.sled, + interface, + external_ips, + }) + } + + Ok(result) + } + + pub async fn probe_get( + &self, + opctx: &OpContext, + name_or_id: &NameOrId, + ) -> LookupResult { + use db::schema::probe; + use db::schema::probe::dsl; + let pool = self.pool_connection_authorized(opctx).await?; + + let name_or_id = name_or_id.clone(); + + let probe = match name_or_id { + NameOrId::Name(name) => dsl::probe + .filter(probe::name.eq(name.to_string())) + .filter(probe::time_deleted.is_null()) + .select(Probe::as_select()) + .limit(1) + .first_async::(&*pool) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)), + NameOrId::Id(id) => dsl::probe + .filter(probe::id.eq(id)) + .select(Probe::as_select()) + .limit(1) + .first_async::(&*pool) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)), + }?; + + let external_ips = self + .probe_lookup_external_ips(opctx, probe.id()) + .await? + .into_iter() + .map(Into::into) + .collect(); + + let interface = + self.probe_get_network_interface(opctx, probe.id()).await?; + + let vni = self.resolve_vpc_to_vni(opctx, interface.vpc_id).await?; + + let mut interface: omicron_common::api::internal::shared::NetworkInterface = + interface.into(); + interface.vni = vni.0; + + Ok(ProbeInfo { + id: probe.id(), + name: probe.name().clone().into(), + sled: probe.sled, + interface, + external_ips, + }) + } + + pub async fn probe_create( + &self, + opctx: &OpContext, + new_probe: ¶ms::ProbeCreate, + ) -> CreateResult { + //TODO in transaction + use db::schema::probe::dsl; + let pool = self.pool_connection_authorized(opctx).await?; + + let probe = Probe::from(new_probe); + + let _eip = self + .allocate_probe_ephemeral_ip( + opctx, + Uuid::new_v4(), + probe.id(), + new_probe.ip_pool.clone().map(Into::into), + ) + .await?; + + let default_name = omicron_common::api::external::Name::try_from( + "default".to_string(), + ) + .unwrap(); + let internal_default_name = db::model::Name::from(default_name.clone()); + + let (.., db_subnet) = LookupPath::new(opctx, self) + .project_name(&new_probe.project.clone().into()) + .vpc_name(&internal_default_name) + .vpc_subnet_name(&internal_default_name) + .fetch() + .await?; + + let incomplete = IncompleteNetworkInterface::new_probe( + Uuid::new_v4(), + probe.id(), + db_subnet, //todo!(), //VpcSubnet + IdentityMetadataCreateParams { + name: probe.name().clone(), + description: format!( + "default primary interface for {}", + probe.name(), + ), + }, + None, //Request IP address assignment + None, //Request MAC address assignment + )?; + + let _ifx = self + .probe_create_network_interface(opctx, incomplete) + .await + .map_err(|e| { + omicron_common::api::external::Error::InternalError { + internal_message: format!( + "create network interface: {e:?}" + ), + } + })?; + + let result = diesel::insert_into(dsl::probe) + .values(probe.clone()) + .returning(Probe::as_returning()) + .get_result_async(&*pool) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + Ok(result) + } + + pub async fn probe_delete( + &self, + opctx: &OpContext, + name_or_id: &NameOrId, + ) -> DeleteResult { + use db::schema::probe; + use db::schema::probe::dsl; + let pool = self.pool_connection_authorized(opctx).await?; + + let name_or_id = name_or_id.clone(); + + //TODO in transaction + let id = match name_or_id { + NameOrId::Name(name) => dsl::probe + .filter(probe::name.eq(name.to_string())) + .filter(probe::time_deleted.is_null()) + .select(probe::id) + .limit(1) + .first_async::(&*pool) + .await + .map_err(|e| { + public_error_from_diesel(e, ErrorHandler::Server) + })?, + NameOrId::Id(id) => id, + }; + + self.deallocate_external_ip_by_probe_id(opctx, id).await?; + + diesel::update(dsl::probe) + .filter(dsl::id.eq(id)) + .set(dsl::time_deleted.eq(Utc::now())) + .execute_async(&*pool) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?; + + Ok(()) + } +} diff --git a/nexus/db-queries/src/db/datastore/vpc.rs b/nexus/db-queries/src/db/datastore/vpc.rs index 4f0245e283f..f5c714faffd 100644 --- a/nexus/db-queries/src/db/datastore/vpc.rs +++ b/nexus/db-queries/src/db/datastore/vpc.rs @@ -1166,6 +1166,30 @@ impl DataStore { ) }) } + + /// Look up a VNI by VPC. + pub async fn resolve_vpc_to_vni( + &self, + opctx: &OpContext, + vpc_id: Uuid, + ) -> LookupResult { + use db::schema::vpc::dsl; + dsl::vpc + .filter(dsl::id.eq(vpc_id)) + .filter(dsl::time_deleted.is_null()) + .select(dsl::vni) + .get_result_async(&*self.pool_connection_authorized(opctx).await?) + .await + .map_err(|e| { + public_error_from_diesel( + e, + ErrorHandler::NotFoundByLookup( + ResourceType::Vpc, + LookupType::ByCompositeId("VNI".to_string()), + ), + ) + }) + } } #[cfg(test)] diff --git a/nexus/db-queries/src/db/queries/external_ip.rs b/nexus/db-queries/src/db/queries/external_ip.rs index 2a76ea7408b..3c2f2bcbd10 100644 --- a/nexus/db-queries/src/db/queries/external_ip.rs +++ b/nexus/db-queries/src/db/queries/external_ip.rs @@ -378,6 +378,12 @@ impl NextExternalIp { out.push_bind_param::, Option>(self.ip.project_id())?; out.push_sql(" AS "); out.push_identifier(dsl::project_id::NAME)?; + out.push_sql(", "); + + // is_probe flag + out.push_bind_param::(self.ip.is_probe())?; + out.push_sql(" AS "); + out.push_identifier(dsl::is_probe::NAME)?; out.push_sql(" FROM ("); self.push_address_sequence_subquery(out.reborrow())?; diff --git a/nexus/db-queries/src/db/queries/network_interface.rs b/nexus/db-queries/src/db/queries/network_interface.rs index 6d00b4bc29c..a6ce94722a0 100644 --- a/nexus/db-queries/src/db/queries/network_interface.rs +++ b/nexus/db-queries/src/db/queries/network_interface.rs @@ -164,6 +164,9 @@ impl InsertError { InsertError::InterfaceAlreadyExists(_name, NetworkInterfaceKind::Service) => { unimplemented!("service network interface") } + InsertError::InterfaceAlreadyExists(_name, NetworkInterfaceKind::Probe) => { + unimplemented!("probe network interface") + } InsertError::NoAvailableIpAddresses => { external::Error::invalid_request( "No available IP addresses for interface", @@ -396,6 +399,9 @@ fn decode_database_error( NetworkInterfaceKind::Service => { external::ResourceType::ServiceNetworkInterface } + NetworkInterfaceKind::Probe => { + external::ResourceType::ProbeNetworkInterface + } }; InsertError::External(error::public_error_from_diesel( err, @@ -605,7 +611,7 @@ impl NextMacAddress { let min_shift = x - MacAddr::MIN_GUEST_ADDR; (base.into(), max_shift, min_shift) } - NetworkInterfaceKind::Service => { + NetworkInterfaceKind::Service | NetworkInterfaceKind::Probe => { let base = MacAddr::random_system(); let x = base.to_i64(); let max_shift = MacAddr::MAX_SYSTEM_ADDR - x; @@ -2538,6 +2544,7 @@ mod tests { NetworkInterfaceKind::Service => { (inserted.mac.is_system(), "system") } + NetworkInterfaceKind::Probe => (inserted.mac.is_system(), "probe"), }; assert!( mac_in_range, diff --git a/nexus/src/app/background/init.rs b/nexus/src/app/background/init.rs index d30d2162c4c..8d582228eab 100644 --- a/nexus/src/app/background/init.rs +++ b/nexus/src/app/background/init.rs @@ -127,7 +127,7 @@ impl BackgroundTasks { let task_inventory_collection = { let collector = inventory_collection::InventoryCollector::new( datastore.clone(), - resolver, + resolver.clone(), &nexus_id.to_string(), config.inventory.nkeep, config.inventory.disable, @@ -149,7 +149,8 @@ impl BackgroundTasks { // Background task: phantom disk detection let task_phantom_disks = { - let detector = phantom_disks::PhantomDiskDetector::new(datastore); + let detector = + phantom_disks::PhantomDiskDetector::new(datastore.clone()); let task = driver.register( String::from("phantom_disks"), diff --git a/nexus/src/app/instance.rs b/nexus/src/app/instance.rs index 40452698787..fa5f46230b8 100644 --- a/nexus/src/app/instance.rs +++ b/nexus/src/app/instance.rs @@ -39,7 +39,6 @@ use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; use omicron_common::api::external::NameOrId; use omicron_common::api::external::UpdateResult; -use omicron_common::api::external::Vni; use omicron_common::api::internal::nexus; use propolis_client::support::tungstenite::protocol::frame::coding::CloseCode; use propolis_client::support::tungstenite::protocol::CloseFrame; @@ -1089,10 +1088,9 @@ impl super::Nexus { // matter which one we use because all NICs must be in the // same VPC; see the check in project_create_instance.) let firewall_rules = if let Some(nic) = nics.first() { - let vni = Vni::try_from(nic.vni.0)?; let vpc = self .db_datastore - .resolve_vni_to_vpc(opctx, db::model::Vni(vni)) + .resolve_vni_to_vpc(opctx, db::model::Vni(nic.vni)) .await?; let (.., authz_vpc) = LookupPath::new(opctx, &self.db_datastore) .vpc_id(vpc.id()) diff --git a/nexus/src/app/instance_network.rs b/nexus/src/app/instance_network.rs index 3db749f43b9..3718957f494 100644 --- a/nexus/src/app/instance_network.rs +++ b/nexus/src/app/instance_network.rs @@ -136,9 +136,9 @@ impl super::Nexus { let nic_id = nic.id; let mapping = SetVirtualNetworkInterfaceHost { virtual_ip: nic.ip, - virtual_mac: nic.mac.clone(), + virtual_mac: nic.mac.into(), physical_host_ip, - vni: nic.vni.clone(), + vni: nic.vni.into(), }; let log = self.log.clone(); @@ -225,7 +225,7 @@ impl super::Nexus { let nic_id = nic.id; let mapping = DeleteVirtualNetworkInterfaceHost { virtual_ip: nic.ip, - vni: nic.vni.clone(), + vni: nic.vni.into(), }; let log = self.log.clone(); @@ -389,11 +389,102 @@ impl super::Nexus { Ok(()) } + //TODO mostly copy pasta from function above + pub(crate) async fn probe_ensure_dpd_config( + &self, + opctx: &OpContext, + probe_id: Uuid, + sled_ip_address: std::net::Ipv6Addr, + ip_index_filter: Option, + dpd_client: &Arc, + ) -> Result<(), Error> { + let log = &self.log; + + // All external IPs map to the primary network interface, so find that + // interface. If there is no such interface, there's no way to route + // traffic destined to those IPs, so there's nothing to configure and + // it's safe to return early. + let network_interface = match self + .db_datastore + .derive_probe_network_interface_info(&opctx, probe_id) + .await? + .into_iter() + .find(|interface| interface.primary) + { + Some(interface) => interface, + None => { + info!(log, "probe has no primary network interface"; + "probe_id" => %probe_id); + return Ok(()); + } + }; + + let mac_address = + macaddr::MacAddr6::from_str(&network_interface.mac.to_string()) + .map_err(|e| { + Error::internal_error(&format!( + "failed to convert mac address: {e}" + )) + })?; + + info!(log, "looking up probe's external IPs"; + "probe_id" => %probe_id); + + let ips = self + .db_datastore + .probe_lookup_external_ips(&opctx, probe_id) + .await?; + + if let Some(wanted_index) = ip_index_filter { + if let None = ips.get(wanted_index) { + return Err(Error::internal_error(&format!( + "failed to find external ip address at index: {}", + wanted_index + ))); + } + } + + let sled_address = + Ipv6Net(Ipv6Network::new(sled_ip_address, 128).unwrap()); + + for target_ip in ips + .iter() + .enumerate() + .filter(|(index, _)| { + if let Some(wanted_index) = ip_index_filter { + *index == wanted_index + } else { + true + } + }) + .map(|(_, ip)| ip) + { + // For each external ip, add a nat entry to the database + self.ensure_nat_entry( + target_ip, + sled_address, + &network_interface, + mac_address, + opctx, + ) + .await?; + } + + // Notify dendrite that there are changes for it to reconcile. + // In the event of a failure to notify dendrite, we'll log an error + // and rely on dendrite's RPW timer to catch it up. + if let Err(e) = dpd_client.ipv4_nat_trigger_update().await { + error!(self.log, "failed to notify dendrite of nat updates"; "error" => ?e); + }; + + Ok(()) + } + async fn ensure_nat_entry( &self, target_ip: &nexus_db_model::ExternalIp, sled_address: Ipv6Net, - network_interface: &sled_agent_client::types::NetworkInterface, + network_interface: &omicron_common::api::internal::shared::NetworkInterface, mac_address: macaddr::MacAddr6, opctx: &OpContext, ) -> Result<(), Error> { @@ -404,7 +495,7 @@ impl super::Nexus { first_port: target_ip.first_port, last_port: target_ip.last_port, sled_address: sled_address.into(), - vni: DbVni(network_interface.vni.clone().into()), + vni: DbVni(network_interface.vni), mac: nexus_db_model::MacAddr( omicron_common::api::external::MacAddr(mac_address), ), diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index 5af45985db1..54daa153a09 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -50,6 +50,7 @@ mod ip_pool; mod metrics; mod network_interface; mod oximeter; +mod probe; mod project; mod quota; mod rack; diff --git a/nexus/src/app/probe.rs b/nexus/src/app/probe.rs new file mode 100644 index 00000000000..511a6975a30 --- /dev/null +++ b/nexus/src/app/probe.rs @@ -0,0 +1,79 @@ +use nexus_db_model::Probe; +use nexus_db_queries::context::OpContext; +use nexus_db_queries::db::datastore::ProbeInfo; +use nexus_types::external_api::params; +use nexus_types::identity::Resource; +use omicron_common::api::external::Error; +use omicron_common::api::external::{ + http_pagination::PaginatedBy, CreateResult, DataPageParams, DeleteResult, + ListResultVec, LookupResult, NameOrId, +}; +use uuid::Uuid; + +impl super::Nexus { + pub(crate) async fn probe_list( + &self, + opctx: &OpContext, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + self.db_datastore.probe_list(opctx, pagparams).await + } + + pub(crate) async fn probe_list_for_sled( + &self, + opctx: &OpContext, + pagparams: &DataPageParams<'_, Uuid>, + sled: Uuid, + ) -> ListResultVec { + self.db_datastore.probe_list_for_sled(sled, opctx, pagparams).await + } + + pub(crate) async fn probe_get( + &self, + opctx: &OpContext, + name_or_id: NameOrId, + ) -> LookupResult { + self.db_datastore.probe_get(opctx, &name_or_id).await + } + + pub(crate) async fn probe_create( + &self, + opctx: &OpContext, + new_probe_params: ¶ms::ProbeCreate, + ) -> CreateResult { + let probe = + self.db_datastore.probe_create(opctx, new_probe_params).await?; + + let (.., sled) = + self.sled_lookup(opctx, &new_probe_params.sled)?.fetch().await?; + + let boundary_switches = + self.boundary_switches(&self.opctx_alloc).await?; + + for switch in &boundary_switches { + let dpd_client = self.dpd_clients.get(switch).ok_or_else(|| { + Error::internal_error(&format!( + "could not find dpd client for {switch}" + )) + })?; + self.probe_ensure_dpd_config( + opctx, + probe.id(), + sled.ip.into(), + None, + dpd_client, + ) + .await?; + } + + Ok(probe) + } + + pub(crate) async fn probe_delete( + &self, + opctx: &OpContext, + name_or_id: NameOrId, + ) -> DeleteResult { + self.db_datastore.probe_delete(opctx, &name_or_id).await + } +} diff --git a/nexus/src/app/rack.rs b/nexus/src/app/rack.rs index c0307e5b5bb..1174160243a 100644 --- a/nexus/src/app/rack.rs +++ b/nexus/src/app/rack.rs @@ -848,8 +848,9 @@ impl super::Nexus { ntp_servers: Vec::new(), //TODO rack_network_config: Some(RackNetworkConfigV1 { rack_subnet: subnet, - //TODO(ry) you are here. We need to remove these too. They are - // inconsistent with a generic set of addresses on ports. + //TODO: We need to remove these. They are inconsistent with + // a generic set of addresses on ports that may not be + // contiguous. infra_ip_first: Ipv4Addr::UNSPECIFIED, infra_ip_last: Ipv4Addr::UNSPECIFIED, ports, diff --git a/nexus/src/app/sagas/project_create.rs b/nexus/src/app/sagas/project_create.rs index 40acc822c08..b31dd821f03 100644 --- a/nexus/src/app/sagas/project_create.rs +++ b/nexus/src/app/sagas/project_create.rs @@ -245,7 +245,6 @@ mod test { .filter(dsl::collection_type.eq(nexus_db_queries::db::model::CollectionTypeProvisioned::Project.to_string())) // ignore built-in services project .filter(dsl::id.ne(*SERVICES_PROJECT_ID)) - .select(VirtualProvisioningCollection::as_select()) .get_results_async::(&conn) .await diff --git a/nexus/src/app/switch_port.rs b/nexus/src/app/switch_port.rs index b9f0f94fa0d..d94aafded63 100644 --- a/nexus/src/app/switch_port.rs +++ b/nexus/src/app/switch_port.rs @@ -40,9 +40,7 @@ impl super::Nexus { ) -> CreateResult { opctx.authorize(authz::Action::Modify, &authz::FLEET).await?; - //TODO(ry) race conditions on exists check versus update/create. - // Normally I would use a DB lock here, but not sure what - // the Omicron way of doing things here is. + //TODO race conditions on exists check versus update/create. match self .db_datastore diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index c47f499c419..d4a78968b75 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -28,10 +28,9 @@ use omicron_common::api::external::LookupResult; use omicron_common::api::external::LookupType; use omicron_common::api::external::NameOrId; use omicron_common::api::external::UpdateResult; -use omicron_common::api::external::Vni; use omicron_common::api::external::VpcFirewallRuleUpdateParams; use omicron_common::api::internal::nexus::HostIdentifier; -use sled_agent_client::types::NetworkInterface; +use omicron_common::api::internal::shared::NetworkInterface; use futures::future::join_all; use ipnetwork::IpNetwork; @@ -480,7 +479,7 @@ impl super::Nexus { let mut nics = HashSet::new(); let mut targets = Vec::with_capacity(rule.targets.len()); let mut push_target_nic = |nic: &NetworkInterface| { - if nics.insert((*nic.vni, (*nic.mac).clone())) { + if nics.insert((nic.vni, *nic.mac)) { targets.push(nic.clone()); } }; @@ -589,10 +588,8 @@ impl super::Nexus { .unwrap_or(&no_interfaces) { host_addrs.push( - HostIdentifier::Vpc(Vni::try_from( - *interface.vni, - )?) - .into(), + HostIdentifier::Vpc(interface.vni) + .into(), ) } } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 3e385587607..5ea025cf3b2 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -38,13 +38,13 @@ use dropshot::{ channel, endpoint, WebsocketChannelResult, WebsocketConnection, }; use ipnetwork::IpNetwork; -use nexus_db_queries::authz; use nexus_db_queries::db; use nexus_db_queries::db::identity::AssetIdentityMetadata; use nexus_db_queries::db::identity::Resource; use nexus_db_queries::db::lookup::ImageLookup; use nexus_db_queries::db::lookup::ImageParentLookup; use nexus_db_queries::db::model::Name; +use nexus_db_queries::{authz, db::datastore::ProbeInfo}; use nexus_db_queries::{ authz::ApiResource, db::fixed_data::silo::INTERNAL_SILO_ID, }; @@ -53,6 +53,7 @@ use omicron_common::api::external::http_pagination::data_page_params_for; use omicron_common::api::external::http_pagination::marker_for_name; use omicron_common::api::external::http_pagination::marker_for_name_or_id; use omicron_common::api::external::http_pagination::name_or_id_pagination; +use omicron_common::api::external::http_pagination::PaginatedBy; use omicron_common::api::external::http_pagination::PaginatedById; use omicron_common::api::external::http_pagination::PaginatedByName; use omicron_common::api::external::http_pagination::PaginatedByNameOrId; @@ -76,6 +77,7 @@ use omicron_common::api::external::InstanceNetworkInterface; use omicron_common::api::external::InternalContext; use omicron_common::api::external::LoopbackAddress; use omicron_common::api::external::NameOrId; +use omicron_common::api::external::Probe; use omicron_common::api::external::RouterRoute; use omicron_common::api::external::RouterRouteKind; use omicron_common::api::external::SwitchPort; @@ -347,6 +349,11 @@ pub(crate) fn external_api() -> NexusApiDescription { api.register(device_auth::device_auth_confirm)?; api.register(device_auth::device_access_token)?; + api.register(probe_list)?; + api.register(probe_view)?; + api.register(probe_create)?; + api.register(probe_delete)?; + Ok(()) } @@ -5820,6 +5827,101 @@ async fn current_user_ssh_key_delete( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +/// List instrumentation probes. +#[endpoint { + method = GET, + path = "/v1/probes", + tags = ["probes"], +}] +async fn probe_list( + rqctx: RequestContext>, + query_params: Query>, +) -> Result>, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let query = query_params.into_inner(); + let pag_params = data_page_params_for(&rqctx, &query)?; + let scan_params = ScanByNameOrId::from_query(&query)?; + let paginated_by = name_or_id_pagination(&pag_params, scan_params)?; + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let probes = nexus.probe_list(&opctx, &paginated_by).await?; + + Ok(HttpResponseOk(ScanByNameOrId::results_page( + &query, + probes, + &|_, p: &ProbeInfo| match paginated_by { + PaginatedBy::Id(_) => NameOrId::Id(p.id), + PaginatedBy::Name(_) => NameOrId::Name(p.name.clone().into()), + }, + )?)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// View an instrumentation probe. +#[endpoint { + method = GET, + path = "/v1/probes/{probe}", + tags = ["probes"], +}] +async fn probe_view( + rqctx: RequestContext>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let probe = nexus.probe_get(&opctx, path.probe).await?; + Ok(HttpResponseOk(probe)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Create an instrumentation probe. +#[endpoint { + method = POST, + path = "/v1/probes", + tags = ["probes"], +}] +async fn probe_create( + rqctx: RequestContext>, + new_probe: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let new_probe_params = &new_probe.into_inner(); + let probe = nexus.probe_create(&opctx, &new_probe_params).await?; + Ok(HttpResponseCreated(probe.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Delete an instrumentation probe. +#[endpoint { + method = DELETE, + path = "/v1/probes/{probe}", + tags = ["probes"], +}] +async fn probe_delete( + rqctx: RequestContext>, + path_params: Path, +) -> Result { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + nexus.probe_delete(&opctx, path.probe).await?; + Ok(HttpResponseDeleted()) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + #[cfg(test)] mod test { use super::external_api; diff --git a/nexus/src/external_api/tag-config.json b/nexus/src/external_api/tag-config.json index 3bc8006cee8..f56e1a9c9b6 100644 --- a/nexus/src/external_api/tag-config.json +++ b/nexus/src/external_api/tag-config.json @@ -86,6 +86,12 @@ "url": "http://docs.oxide.computer/api/vpcs" } }, + "probes": { + "description": "Probes for testing network connectivity", + "external_docs": { + "url": "http://docs.oxide.computer/api/probes" + } + }, "system/status": { "description": "Endpoints related to system health", "external_docs": { diff --git a/nexus/src/internal_api/http_entrypoints.rs b/nexus/src/internal_api/http_entrypoints.rs index 9a209118930..7632ca77032 100644 --- a/nexus/src/internal_api/http_entrypoints.rs +++ b/nexus/src/internal_api/http_entrypoints.rs @@ -25,6 +25,8 @@ use dropshot::ResultsPage; use dropshot::TypedBody; use hyper::Body; use nexus_db_model::Ipv4NatEntryView; + +use nexus_db_queries::db::datastore::ProbeInfo; use nexus_types::internal_api::params::SwitchPutRequest; use nexus_types::internal_api::params::SwitchPutResponse; use nexus_types::internal_api::views::to_list; @@ -74,6 +76,8 @@ pub(crate) fn internal_api() -> NexusApiDescription { api.register(bgtask_list)?; api.register(bgtask_view)?; + api.register(probes_get)?; + Ok(()) } @@ -568,7 +572,7 @@ struct RpwNatQueryParam { /// change or until the `limit` is reached. If there are no changes, an /// empty vec is returned. #[endpoint { - method = GET, + method = GET, path = "/nat/ipv4/changeset/{from_gen}" }] async fn ipv4_nat_changeset( @@ -591,3 +595,32 @@ async fn ipv4_nat_changeset( }; apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await } + +/// Path parameters for probes +#[derive(Deserialize, JsonSchema)] +struct ProbePathParam { + sled: Uuid, +} + +#[endpoint { + method = GET, + path = "/probes/{sled}" +}] +async fn probes_get( + rqctx: RequestContext>, + path_params: Path, + query_params: Query, +) -> Result>, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let query = query_params.into_inner(); + let path = path_params.into_inner(); + let nexus = &apictx.nexus; + let opctx = crate::context::op_context_for_internal_api(&rqctx).await; + let pagparams = data_page_params_for(&rqctx, &query)?; + Ok(HttpResponseOk( + nexus.probe_list_for_sled(&opctx, &pagparams, path.sled).await?, + )) + }; + apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await +} diff --git a/nexus/tests/integration_tests/instances.rs b/nexus/tests/integration_tests/instances.rs index 44b65fa67ba..dccc8890c9c 100644 --- a/nexus/tests/integration_tests/instances.rs +++ b/nexus/tests/integration_tests/instances.rs @@ -605,12 +605,7 @@ async fn test_instance_start_creates_networking_state( // TODO(#3107) Remove this bifurcation when Nexus programs all mappings // itself. if agent.id != sled_id { - assert_sled_v2p_mappings( - agent, - &nics[0], - guest_nics[0].vni.clone().into(), - ) - .await; + assert_sled_v2p_mappings(agent, &nics[0], guest_nics[0].vni).await; } else { assert!(agent.v2p_mappings.lock().await.is_empty()); } @@ -804,12 +799,8 @@ async fn test_instance_migrate_v2p(cptestctx: &ControlPlaneTestContext) { // all mappings explicitly (without skipping the instance's current // sled) this bifurcation should be removed. if sled_agent.id != original_sled_id { - assert_sled_v2p_mappings( - sled_agent, - &nics[0], - guest_nics[0].vni.clone().into(), - ) - .await; + assert_sled_v2p_mappings(sled_agent, &nics[0], guest_nics[0].vni) + .await; } else { assert!(sled_agent.v2p_mappings.lock().await.is_empty()); } @@ -857,12 +848,8 @@ async fn test_instance_migrate_v2p(cptestctx: &ControlPlaneTestContext) { // agent will have updated any mappings there. Remove this bifurcation // when Nexus programs all mappings explicitly. if sled_agent.id != dst_sled_id { - assert_sled_v2p_mappings( - sled_agent, - &nics[0], - guest_nics[0].vni.clone().into(), - ) - .await; + assert_sled_v2p_mappings(sled_agent, &nics[0], guest_nics[0].vni) + .await; } } } @@ -4099,12 +4086,8 @@ async fn test_instance_v2p_mappings(cptestctx: &ControlPlaneTestContext) { // TODO(#3107) Remove this bifurcation when Nexus programs all mappings // itself. if sled_agent.id != sled_id { - assert_sled_v2p_mappings( - sled_agent, - &nics[0], - guest_nics[0].vni.clone().into(), - ) - .await; + assert_sled_v2p_mappings(sled_agent, &nics[0], guest_nics[0].vni) + .await; } else { assert!(sled_agent.v2p_mappings.lock().await.is_empty()); } diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index 6cb99b9e458..c086d6a4be9 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -23,6 +23,7 @@ mod metrics; mod oximeter; mod pantry; mod password_login; +mod probe; mod projects; mod quotas; mod rack; diff --git a/nexus/tests/integration_tests/probe.rs b/nexus/tests/integration_tests/probe.rs new file mode 100644 index 00000000000..983130629c1 --- /dev/null +++ b/nexus/tests/integration_tests/probe.rs @@ -0,0 +1,93 @@ +use nexus_db_queries::db::datastore::ProbeInfo; +use nexus_test_utils::{ + http_testing::{AuthnMode, NexusRequest}, + resource_helpers::{create_project, populate_ip_pool}, + SLED_AGENT_UUID, +}; +use nexus_test_utils_macros::nexus_test; +use nexus_types::external_api::params::ProbeCreate; +use omicron_common::api::external::{IdentityMetadataCreateParams, Probe}; + +type ControlPlaneTestContext = + nexus_test_utils::ControlPlaneTestContext; + +#[nexus_test] +async fn test_probe_basic_crud(ctx: &ControlPlaneTestContext) { + let client = &ctx.external_client; + + populate_ip_pool(&client, "default", None).await; + create_project(&client, "class2").await; + + let probes = NexusRequest::iter_collection_authn::( + client, + "/v1/probes", + "", + None, + ) + .await + .expect("Failed to list probes") + .all_items; + + assert_eq!(probes.len(), 0, "Expected zero probes"); + + let params = ProbeCreate { + identity: IdentityMetadataCreateParams { + name: "class1".parse().unwrap(), + description: "subspace relay probe".to_owned(), + }, + ip_pool: None, + sled: SLED_AGENT_UUID.parse().unwrap(), + project: "class2".parse().unwrap(), + }; + + let created: Probe = + NexusRequest::objects_post(client, "/v1/probes", ¶ms) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + + let probes = NexusRequest::iter_collection_authn::( + client, + "/v1/probes", + "", + None, + ) + .await + .expect("Failed to list probes") + .all_items; + + assert_eq!(probes.len(), 1, "Expected one probe"); + assert_eq!(probes[0].id, created.identity.id); + + let fetched: ProbeInfo = + NexusRequest::object_get(client, "/v1/probes/class1") + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + + assert_eq!(fetched.id, created.identity.id); + + NexusRequest::object_delete(client, "/v1/probes/class1") + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap(); + + let probes = NexusRequest::iter_collection_authn::( + client, + "/v1/probes", + "", + None, + ) + .await + .expect("Failed to list probes after delete") + .all_items; + + assert_eq!(probes.len(), 0, "Expected zero probes"); +} diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index 10e7df72865..1b49aa83ba8 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -69,6 +69,13 @@ OPERATION ID METHOD URL PATH system_policy_update PUT /v1/system/policy system_policy_view GET /v1/system/policy +API operations found with tag "probes" +OPERATION ID METHOD URL PATH +probe_create POST /v1/probes +probe_delete DELETE /v1/probes/{probe} +probe_list GET /v1/probes +probe_view GET /v1/probes/{probe} + API operations found with tag "projects" OPERATION ID METHOD URL PATH project_create POST /v1/projects diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index d76d9c54954..2e7e297be7f 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -1,8 +1,12 @@ API endpoints with no coverage in authz tests: +probe_delete (delete "/v1/probes/{probe}") ping (get "/v1/ping") +probe_list (get "/v1/probes") +probe_view (get "/v1/probes/{probe}") device_auth_request (post "/device/auth") device_auth_confirm (post "/device/confirm") device_access_token (post "/device/token") login_saml (post "/login/{silo_name}/saml/{provider_name}") login_local (post "/v1/login/{silo_name}/local") logout (post "/v1/logout") +probe_create (post "/v1/probes") diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index df399e310c2..7fc120be576 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -63,6 +63,7 @@ path_param!(ProviderPath, provider, "SAML identity provider"); path_param!(IpPoolPath, pool, "IP pool"); path_param!(SshKeyPath, ssh_key, "SSH key"); path_param!(AddressLotPath, address_lot, "address lot"); +path_param!(ProbePath, probe, "probe"); id_path_param!(GroupPath, group_id, "group"); @@ -1922,3 +1923,21 @@ pub struct UpdateableComponentCreate { pub component_type: shared::UpdateableComponentType, pub device_id: String, } + +// Probes + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct ProbeCreate { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + pub sled: Uuid, + pub ip_pool: Option, + pub project: Name, +} + +/// List BGP configs with an optional name or id. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] +pub struct ProbeListSelector { + /// A name or id to use when selecting a probe. + pub name_or_id: Option, +} diff --git a/nexus/types/src/internal_api/params.rs b/nexus/types/src/internal_api/params.rs index bc25e8d4bda..ffcb6f8e2c7 100644 --- a/nexus/types/src/internal_api/params.rs +++ b/nexus/types/src/internal_api/params.rs @@ -183,6 +183,7 @@ pub enum ServiceKind { BoundaryNtp { snat: SourceNatConfig, nic: ServiceNic }, InternalNtp, Mgd, + Probe { nic: ServiceNic }, } impl fmt::Display for ServiceKind { @@ -202,6 +203,7 @@ impl fmt::Display for ServiceKind { CruciblePantry => "crucible_pantry", BoundaryNtp { .. } | InternalNtp => "ntp", Mgd => "mgd", + Probe { .. } => "probe", }; write!(f, "{}", s) } diff --git a/openapi/nexus-internal.json b/openapi/nexus-internal.json index a1d70d838b1..b1b981f6e20 100644 --- a/openapi/nexus-internal.json +++ b/openapi/nexus-internal.json @@ -433,6 +433,74 @@ } } }, + "/probes/{sled}": { + "get": { + "operationId": "probes_get", + "parameters": [ + { + "in": "path", + "name": "sled", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/IdSortMode" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_ProbeInfo", + "type": "array", + "items": { + "$ref": "#/components/schemas/ProbeInfo" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + } + }, "/racks/{rack_id}/initialization-complete": { "put": { "summary": "Report that the Rack Setup Service initialization is complete", @@ -2998,6 +3066,34 @@ "request_id" ] }, + "ExternalIp": { + "type": "object", + "properties": { + "first_port": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "ip": { + "type": "string", + "format": "ip" + }, + "kind": { + "$ref": "#/components/schemas/IpKind" + }, + "last_port": { + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "first_port", + "ip", + "kind", + "last_port" + ] + }, "ExternalPortDiscovery": { "oneOf": [ { @@ -3825,6 +3921,34 @@ } ] }, + "IpKind": { + "type": "string", + "enum": [ + "snat", + "floating", + "ephemeral" + ] + }, + "IpNet": { + "oneOf": [ + { + "title": "v4", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4Net" + } + ] + }, + { + "title": "v6", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6Net" + } + ] + } + ] + }, "IpNetwork": { "oneOf": [ { @@ -3912,6 +4036,13 @@ "vni" ] }, + "Ipv4Net": { + "example": "192.168.1.0/24", + "title": "An IPv4 subnet", + "description": "An IPv4 subnet, including prefix and subnet mask", + "type": "string", + "pattern": "^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])/([0-9]|1[0-9]|2[0-9]|3[0-2])$" + }, "Ipv4Network": { "type": "string", "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\/(3[0-2]|[0-2]?[0-9])$" @@ -3934,6 +4065,13 @@ "last" ] }, + "Ipv6Net": { + "example": "fd12:3456::/64", + "title": "An IPv6 subnet", + "description": "An IPv6 subnet, including prefix and subnet mask", + "type": "string", + "pattern": "^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" + }, "Ipv6Network": { "type": "string", "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\")[/](12[0-8]|1[0-1][0-9]|[0-9]?[0-9])$" @@ -4272,6 +4410,119 @@ "minLength": 1, "maxLength": 63 }, + "NetworkInterface": { + "description": "Information required to construct a virtual network interface", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "ip": { + "type": "string", + "format": "ip" + }, + "kind": { + "$ref": "#/components/schemas/NetworkInterfaceKind" + }, + "mac": { + "$ref": "#/components/schemas/MacAddr" + }, + "name": { + "$ref": "#/components/schemas/Name" + }, + "primary": { + "type": "boolean" + }, + "slot": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "subnet": { + "$ref": "#/components/schemas/IpNet" + }, + "vni": { + "$ref": "#/components/schemas/Vni" + } + }, + "required": [ + "id", + "ip", + "kind", + "mac", + "name", + "primary", + "slot", + "subnet", + "vni" + ] + }, + "NetworkInterfaceKind": { + "description": "The type of network interface", + "oneOf": [ + { + "description": "A vNIC attached to a guest instance", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "instance" + ] + } + }, + "required": [ + "id", + "type" + ] + }, + { + "description": "A vNIC associated with an internal service", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "service" + ] + } + }, + "required": [ + "id", + "type" + ] + }, + { + "description": "A vNIC associated with a probe", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "probe" + ] + } + }, + "required": [ + "id", + "type" + ] + } + ] + }, "NewPasswordHash": { "title": "A password hash in PHC string format", "description": "Password hashes must be in PHC (Password Hashing Competition) string format. Passwords must be hashed with Argon2id. Password hashes may be rejected if the parameters appear not to be secure enough.", @@ -4455,6 +4706,38 @@ "speed400_g" ] }, + "ProbeInfo": { + "type": "object", + "properties": { + "external_ips": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalIp" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "interface": { + "$ref": "#/components/schemas/NetworkInterface" + }, + "name": { + "$ref": "#/components/schemas/Name" + }, + "sled": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "external_ips", + "id", + "interface", + "name", + "sled" + ] + }, "ProducerEndpoint": { "description": "Information announced by a metric server, used so that clients can contact it and collect available metric data from it.", "type": "object", @@ -5306,6 +5589,32 @@ "required": [ "type" ] + }, + { + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "nic": { + "$ref": "#/components/schemas/ServiceNic" + } + }, + "required": [ + "nic" + ] + }, + "type": { + "type": "string", + "enum": [ + "probe" + ] + } + }, + "required": [ + "content", + "type" + ] } ] }, diff --git a/openapi/nexus.json b/openapi/nexus.json index 41314601496..a4c5279fc40 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -3055,6 +3055,175 @@ } } }, + "/v1/probes": { + "get": { + "tags": [ + "probes" + ], + "summary": "List instrumentation probes.", + "operationId": "probe_list", + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "name_or_id", + "description": "A name or id to use when selecting a probe.", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/NameOrIdSortMode" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProbeInfoResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + }, + "post": { + "tags": [ + "probes" + ], + "summary": "Create an instrumentation probe.", + "operationId": "probe_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProbeCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Probe" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/probes/{probe}": { + "get": { + "tags": [ + "probes" + ], + "summary": "View an instrumentation probe.", + "operationId": "probe_view", + "parameters": [ + { + "in": "path", + "name": "probe", + "description": "Name or ID of the probe", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProbeInfo" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "tags": [ + "probes" + ], + "summary": "Delete an instrumentation probe.", + "operationId": "probe_delete", + "parameters": [ + { + "in": "path", + "name": "probe", + "description": "Name or ID of the probe", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/projects": { "get": { "tags": [ @@ -12862,6 +13031,119 @@ } ] }, + "NetworkInterface": { + "description": "Information required to construct a virtual network interface", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "ip": { + "type": "string", + "format": "ip" + }, + "kind": { + "$ref": "#/components/schemas/NetworkInterfaceKind" + }, + "mac": { + "$ref": "#/components/schemas/MacAddr" + }, + "name": { + "$ref": "#/components/schemas/Name" + }, + "primary": { + "type": "boolean" + }, + "slot": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "subnet": { + "$ref": "#/components/schemas/IpNet" + }, + "vni": { + "$ref": "#/components/schemas/Vni" + } + }, + "required": [ + "id", + "ip", + "kind", + "mac", + "name", + "primary", + "slot", + "subnet", + "vni" + ] + }, + "NetworkInterfaceKind": { + "description": "The type of network interface", + "oneOf": [ + { + "description": "A vNIC attached to a guest instance", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "instance" + ] + } + }, + "required": [ + "id", + "type" + ] + }, + { + "description": "A vNIC associated with an internal service", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "service" + ] + } + }, + "required": [ + "id", + "type" + ] + }, + { + "description": "A vNIC associated with a probe", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "probe" + ] + } + }, + "required": [ + "id", + "type" + ] + } + ] + }, "Password": { "title": "A password used to authenticate a user", "description": "Passwords may be subject to additional constraints.", @@ -12967,6 +13249,137 @@ "ok" ] }, + "Probe": { + "description": "Identity-related metadata that's included in nearly all public API objects", + "type": "object", + "properties": { + "description": { + "description": "human-readable free-form text about a resource", + "type": "string" + }, + "id": { + "description": "unique, immutable, system-controlled identifier for each resource", + "type": "string", + "format": "uuid" + }, + "name": { + "description": "unique, mutable, user-controlled identifier for each resource", + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "sled": { + "type": "string", + "format": "uuid" + }, + "time_created": { + "description": "timestamp when this resource was created", + "type": "string", + "format": "date-time" + }, + "time_modified": { + "description": "timestamp when this resource was last modified", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "description", + "id", + "name", + "sled", + "time_created", + "time_modified" + ] + }, + "ProbeCreate": { + "description": "Create-time identity-related parameters", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "ip_pool": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "name": { + "$ref": "#/components/schemas/Name" + }, + "project": { + "$ref": "#/components/schemas/Name" + }, + "sled": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "description", + "name", + "project", + "sled" + ] + }, + "ProbeInfo": { + "type": "object", + "properties": { + "external_ips": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalIp" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "interface": { + "$ref": "#/components/schemas/NetworkInterface" + }, + "name": { + "$ref": "#/components/schemas/Name" + }, + "sled": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "external_ips", + "id", + "interface", + "name", + "sled" + ] + }, + "ProbeInfoResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/ProbeInfo" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "Project": { "description": "View of a Project", "type": "object", @@ -15183,6 +15596,12 @@ "storage" ] }, + "Vni": { + "description": "A Geneve Virtual Network Identifier", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, "Vpc": { "description": "View of a VPC", "type": "object", @@ -16076,6 +16495,13 @@ "url": "http://docs.oxide.computer/api/policy" } }, + { + "name": "probes", + "description": "Probes for testing network connectivity", + "externalDocs": { + "url": "http://docs.oxide.computer/api/probes" + } + }, { "name": "projects", "description": "Projects are a grouping of associated resources such as instances and disks within a silo for purposes of billing and access control.", diff --git a/openapi/sled-agent.json b/openapi/sled-agent.json index 6076df6dbb1..5cb61ac7f33 100644 --- a/openapi/sled-agent.json +++ b/openapi/sled-agent.json @@ -5340,6 +5340,26 @@ "id", "type" ] + }, + { + "description": "A vNIC associated with a probe", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "probe" + ] + } + }, + "required": [ + "id", + "type" + ] } ] }, diff --git a/package-manifest.toml b/package-manifest.toml index 6bd40c320d7..1c4a478634d 100644 --- a/package-manifest.toml +++ b/package-manifest.toml @@ -548,6 +548,15 @@ output.type = "zone" output.intermediate_only = true setup_hint = "Run `./tools/ci_download_transceiver_control` to download the necessary binaries" +[package.thundermuffin] +service_name = "thundermuffin" +source.type = "prebuilt" +source.repo = "thundermuffin" +source.commit = "a4a6108d7c9aac2464a0b6898e88132a8f701a13" +source.sha256 = "dc55a2accd33a347df4cbdc0026cbaccea2c004940c3fec8cadcdd633d440dfa" +output.type = "zone" +output.intermediate_only = true + # To package and install the asic variant of the switch, do: # # $ cargo run --release --bin omicron-package -- -t default target create -i standard -m gimlet -s asic @@ -613,3 +622,11 @@ source.packages = [ "sp-sim-softnpu.tar.gz" ] output.type = "zone" + +[package.probe] +service_name = "probe" +source.type = "composite" +source.packages = [ + "thundermuffin.tar.gz", +] +output.type = "zone" diff --git a/schema/all-zone-requests.json b/schema/all-zone-requests.json index 8c324a15bd4..e37fbfde590 100644 --- a/schema/all-zone-requests.json +++ b/schema/all-zone-requests.json @@ -302,6 +302,26 @@ ] } } + }, + { + "description": "A vNIC associated with a probe", + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "probe" + ] + } + } } ] }, diff --git a/schema/all-zones-requests.json b/schema/all-zones-requests.json index 7a07e2f9aeb..0ac9e760a83 100644 --- a/schema/all-zones-requests.json +++ b/schema/all-zones-requests.json @@ -186,6 +186,26 @@ ] } } + }, + { + "description": "A vNIC associated with a probe", + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "probe" + ] + } + } } ] }, diff --git a/schema/crdb/22.0.0/up1.sql b/schema/crdb/22.0.0/up1.sql new file mode 100644 index 00000000000..dda436963af --- /dev/null +++ b/schema/crdb/22.0.0/up1.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS omicron.public.probe ( + id UUID NOT NULL PRIMARY KEY, + name STRING(63) NOT NULL, + description STRING(512) NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + time_modified TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + sled UUID NOT NULL +); diff --git a/schema/crdb/22.0.0/up2.sql b/schema/crdb/22.0.0/up2.sql new file mode 100644 index 00000000000..6c070463a47 --- /dev/null +++ b/schema/crdb/22.0.0/up2.sql @@ -0,0 +1,4 @@ +CREATE UNIQUE INDEX IF NOT EXISTS lookup_probe_by_name ON omicron.public.probe ( + name +) WHERE + time_deleted IS NULL; diff --git a/schema/crdb/22.0.0/up3.sql b/schema/crdb/22.0.0/up3.sql new file mode 100644 index 00000000000..3b71ba43137 --- /dev/null +++ b/schema/crdb/22.0.0/up3.sql @@ -0,0 +1 @@ +ALTER TABLE omicron.public.external_ip ADD COLUMN IF NOT EXISTS is_probe BOOL NOT NULL DEFAULT false; diff --git a/schema/crdb/22.0.0/up4.sql b/schema/crdb/22.0.0/up4.sql new file mode 100644 index 00000000000..6c989cf8c88 --- /dev/null +++ b/schema/crdb/22.0.0/up4.sql @@ -0,0 +1 @@ +ALTER TYPE omicron.public.network_interface_kind ADD VALUE IF NOT EXISTS 'probe'; diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index cc611480483..e8412ea985b 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -3089,6 +3089,25 @@ CREATE TABLE IF NOT EXISTS omicron.public.db_metadata ( ALTER TABLE omicron.public.switch_port_settings_link_config ADD COLUMN IF NOT EXISTS autoneg BOOL NOT NULL DEFAULT false; +CREATE TABLE IF NOT EXISTS omicron.public.probe ( + id UUID NOT NULL PRIMARY KEY, + name STRING(63) NOT NULL, + description STRING(512) NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + time_modified TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + sled UUID NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS lookup_probe_by_name ON omicron.public.probe ( + name +) WHERE + time_deleted IS NULL; + +ALTER TABLE omicron.public.external_ip ADD COLUMN IF NOT EXISTS is_probe BOOL NOT NULL DEFAULT false; + +ALTER TYPE omicron.public.network_interface_kind ADD VALUE IF NOT EXISTS 'probe'; + INSERT INTO omicron.public.db_metadata ( singleton, time_created, @@ -3096,7 +3115,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - ( TRUE, NOW(), NOW(), '21.0.0', NULL) + ( TRUE, NOW(), NOW(), '22.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT; diff --git a/schema/rss-service-plan-v2.json b/schema/rss-service-plan-v2.json index 62ce358938a..0874aaace52 100644 --- a/schema/rss-service-plan-v2.json +++ b/schema/rss-service-plan-v2.json @@ -271,6 +271,26 @@ ] } } + }, + { + "description": "A vNIC associated with a probe", + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "probe" + ] + } + } } ] }, diff --git a/sled-agent/src/lib.rs b/sled-agent/src/lib.rs index 527b483ee81..61c0bcf3d24 100644 --- a/sled-agent/src/lib.rs +++ b/sled-agent/src/lib.rs @@ -30,6 +30,7 @@ mod long_running_tasks; mod metrics; mod nexus; pub mod params; +mod probe_manager; mod profile; pub mod rack_setup; pub mod server; diff --git a/sled-agent/src/params.rs b/sled-agent/src/params.rs index a7d91e2b932..b3170767360 100644 --- a/sled-agent/src/params.rs +++ b/sled-agent/src/params.rs @@ -701,7 +701,7 @@ impl From for sled_agent_client::types::OmicronZoneType { domain, ntp_servers, snat_cfg: snat_cfg.into(), - nic: nic.into(), + nic: nic, }, OmicronZoneType::Clickhouse { address, dataset } => { Other::Clickhouse { @@ -737,7 +737,7 @@ impl From for sled_agent_client::types::OmicronZoneType { dataset: dataset.into(), http_address: http_address.to_string(), dns_address: dns_address.to_string(), - nic: nic.into(), + nic: nic, }, OmicronZoneType::InternalDns { dataset, @@ -774,7 +774,7 @@ impl From for sled_agent_client::types::OmicronZoneType { external_ip, external_tls, internal_address: internal_address.to_string(), - nic: nic.into(), + nic: nic, }, OmicronZoneType::Oximeter { address } => { Other::Oximeter { address: address.to_string() } diff --git a/sled-agent/src/probe_manager.rs b/sled-agent/src/probe_manager.rs new file mode 100644 index 00000000000..d576b822638 --- /dev/null +++ b/sled-agent/src/probe_manager.rs @@ -0,0 +1,342 @@ +use crate::nexus::NexusClientWithResolver; +use anyhow::{anyhow, Result}; +use illumos_utils::dladm::Etherstub; +use illumos_utils::link::VnicAllocator; +use illumos_utils::opte::params::VpcFirewallRule; +use illumos_utils::opte::{DhcpCfg, PortManager}; +use illumos_utils::running_zone::{RunningZone, ZoneBuilderFactory}; +use illumos_utils::zone::Zones; +use nexus_client::types::{ExternalIp, ProbeInfo}; +use omicron_common::api::external::{ + VpcFirewallRuleAction, VpcFirewallRuleDirection, VpcFirewallRulePriority, + VpcFirewallRuleStatus, +}; +use omicron_common::api::internal::shared::NetworkInterface; +use rand::prelude::SliceRandom; +use rand::SeedableRng; +use sled_storage::dataset::ZONE_DATASET; +use sled_storage::manager::StorageHandle; +use slog::{error, warn, Logger}; +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio::time::sleep; +use uuid::Uuid; +use zone::Zone; + +const PROBE_ZONE_PREFIX: &str = "oxz_probe"; + +pub struct ProbeManager { + inner: Arc, +} + +pub struct ProbeManagerInner { + join_handle: Mutex>>, + nexus_client: NexusClientWithResolver, + log: Logger, + sled_id: Uuid, + vnic_allocator: VnicAllocator, + storage: StorageHandle, + port_manager: PortManager, + running_probes: Mutex>, +} + +impl ProbeManager { + pub fn new( + sled_id: Uuid, + nexus_client: NexusClientWithResolver, + etherstub: Etherstub, + storage: StorageHandle, + port_manager: PortManager, + log: Logger, + ) -> Self { + Self { + inner: Arc::new(ProbeManagerInner { + join_handle: Mutex::new(None), + vnic_allocator: VnicAllocator::new("probe", etherstub), + running_probes: Mutex::new(HashMap::new()), + nexus_client, + log, + sled_id, + storage, + port_manager, + }), + } + } + + pub async fn run(&self) { + self.inner.run().await; + } +} + +#[derive(Debug, Clone)] +struct ProbeState { + id: Uuid, + status: zone::State, + external_ips: Vec, + interface: Option, +} + +impl PartialEq for ProbeState { + fn eq(&self, other: &Self) -> bool { + self.id.eq(&other.id) + } +} + +impl Eq for ProbeState {} + +impl Hash for ProbeState { + fn hash(&self, state: &mut H) { + self.id.hash(state) + } +} + +impl From for ProbeState { + fn from(value: ProbeInfo) -> Self { + Self { + id: value.id, + status: zone::State::Running, + external_ips: value.external_ips, + interface: Some(value.interface), + } + } +} + +impl TryFrom for ProbeState { + type Error = String; + fn try_from(value: Zone) -> std::result::Result { + Ok(Self { + id: value + .name() + .strip_prefix(&format!("{PROBE_ZONE_PREFIX}_")) + .ok_or(String::from("not a probe prefix"))? + .parse() + .map_err(|e| format!("invalid uuid: {e}"))?, + status: value.state(), + external_ips: Vec::new(), + interface: None, + }) + } +} + +impl ProbeManagerInner { + async fn run(self: &Arc) { + let mut join_handle = self.join_handle.lock().await; + if join_handle.is_none() { + *join_handle = Some(self.clone().reconciler()) + } + } + + fn reconciler(self: Arc) -> JoinHandle<()> { + tokio::spawn(async move { + loop { + sleep(Duration::from_secs(1)).await; + + let target = match self.target_state().await { + Ok(state) => state, + Err(e) => { + error!(self.log, "get target probe state: {e}"); + continue; + } + }; + + let current = match self.current_state().await { + Ok(state) => state, + Err(e) => { + error!(self.log, "get current probe state: {e}"); + continue; + } + }; + + self.add(target.difference(¤t)).await; + self.remove(current.difference(&target)).await; + self.check(current.intersection(&target)).await; + } + }) + } + + async fn add<'a, I>(self: &Arc, probes: I) + where + I: Iterator, + { + for probe in probes { + info!(self.log, "adding probe {}", probe.id); + if let Err(e) = self.add_probe(probe).await { + error!(self.log, "add probe: {e}"); + } + } + } + + async fn add_probe(self: &Arc, probe: &ProbeState) -> Result<()> { + let mut rng = rand::rngs::StdRng::from_entropy(); + let root = self + .storage + .get_latest_resources() + .await + .all_u2_mountpoints(ZONE_DATASET) + .choose(&mut rng) + .ok_or_else(|| anyhow!("u2 not found"))? + .clone(); + + let nic = probe + .interface + .as_ref() + .ok_or(anyhow!("no interface specified for probe"))?; + + let eip = probe + .external_ips + .get(0) + .ok_or(anyhow!("expected an external ip"))?; + + let port = self.port_manager.create_port( + &nic, + None, + Some(eip.ip), + &[], // floating ips + &[VpcFirewallRule { + status: VpcFirewallRuleStatus::Enabled, + direction: VpcFirewallRuleDirection::Inbound, + targets: vec![nic.clone()], + filter_hosts: None, + filter_ports: None, + filter_protocols: None, + action: VpcFirewallRuleAction::Allow, + priority: VpcFirewallRulePriority(100), + }], + DhcpCfg::default(), + )?; + + let installed_zone = ZoneBuilderFactory::default() + .builder() + .with_log(self.log.clone()) + .with_underlay_vnic_allocator(&self.vnic_allocator) + .with_zone_root_path(&root) + .with_zone_image_paths(&["/opt/oxide".into()]) + .with_zone_type("probe") + .with_unique_name(probe.id) + .with_datasets(&[]) + .with_filesystems(&[]) + .with_data_links(&[]) + .with_devices(&[]) + .with_opte_ports(vec![port]) + .with_links(vec![]) + .with_limit_priv(vec![]) + .install() + .await?; + + info!(self.log, "installed probe {}", probe.id); + + //TODO(ry) SMF properties? + + let rz = RunningZone::boot(installed_zone).await?; + rz.ensure_address_for_port("overlay", 0).await?; + info!(self.log, "started probe {}", probe.id); + + self.running_probes.lock().await.insert(probe.id, rz); + + Ok(()) + } + + async fn remove<'a, I>(self: &Arc, probes: I) + where + I: Iterator, + { + for probe in probes { + info!(self.log, "removing probe {}", probe.id); + self.remove_probe(probe.id).await; + } + } + + async fn remove_probe(self: &Arc, id: Uuid) { + match self.running_probes.lock().await.remove(&id) { + Some(mut running_zone) => { + for l in running_zone.links_mut() { + if let Err(e) = l.delete() { + error!(self.log, "delete probe link {}: {e}", l.name()); + } + } + running_zone.release_opte_ports(); + if let Err(e) = running_zone.stop().await { + error!(self.log, "stop probe: {e}") + } + } + None => { + warn!(self.log, "attempt to stop non-running probe: {id}") + } + } + } + + async fn check<'a, I>(self: &Arc, probes: I) + where + I: Iterator, + { + for probe in probes { + if probe.status == zone::State::Running { + continue; + } + warn!( + self.log, + "probe {} found in unexpected state {:?}", + probe.id, + probe.status + ) + //TODO(ry) handle the hooligans here + } + } + + async fn target_state(self: &Arc) -> Result> { + Ok(self + .nexus_client + .client() + .probes_get( + &self.sled_id, + None, //limit + None, //page token + None, //sort by + ) + .await? + .into_inner() + .into_iter() + .map(Into::into) + .collect()) + } + + async fn current_state(self: &Arc) -> Result> { + Ok(Zones::get() + .await? + .into_iter() + .filter_map(|z| ProbeState::try_from(z).ok()) + .collect()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use uuid::Uuid; + + #[test] + fn probe_state_set_ops() { + let a = ProbeState { + id: Uuid::new_v4(), + status: zone::State::Configured, + external_ips: Vec::new(), + interface: None, + }; + + let mut b = a.clone(); + b.status = zone::State::Running; + + let target = HashSet::from([a]); + let current = HashSet::from([b]); + + let to_add = target.difference(¤t); + let to_remove = current.difference(&target); + + assert_eq!(to_add.count(), 0); + assert_eq!(to_remove.count(), 0); + } +} diff --git a/sled-agent/src/sled_agent.rs b/sled-agent/src/sled_agent.rs index 5f278b7f382..b3b896c5ee5 100644 --- a/sled-agent/src/sled_agent.rs +++ b/sled-agent/src/sled_agent.rs @@ -21,6 +21,7 @@ use crate::params::{ InstanceUnregisterResponse, OmicronZonesConfig, SledRole, TimeSync, VpcFirewallRule, ZoneBundleMetadata, Zpool, }; +use crate::probe_manager::ProbeManager; use crate::services::{self, ServiceManager}; use crate::storage_monitor::UnderlayAccess; use crate::updates::{ConfigUpdates, UpdateManager}; @@ -268,6 +269,9 @@ struct SledAgentInner { // Handle to the traffic manager for writing OS updates to our boot disks. boot_disk_os_writer: BootDiskOsWriter, + + // Component of Sled Agent responsible for managing instrumentation probes. + probes: ProbeManager, } impl SledAgentInner { @@ -525,6 +529,15 @@ impl SledAgent { endpoint, )); + let probes = ProbeManager::new( + request.body.id, + nexus_client.clone(), + etherstub.clone(), + storage_manager.clone(), + port_manager.clone(), + log.new(o!("component" => "ProbeManager")), + ); + let sled_agent = SledAgent { inner: Arc::new(SledAgentInner { id: request.body.id, @@ -532,6 +545,7 @@ impl SledAgent { start_request: request, storage: long_running_task_handles.storage_manager.clone(), instances, + probes, hardware: long_running_task_handles.hardware_manager.clone(), updates, port_manager, @@ -554,6 +568,8 @@ impl SledAgent { log: log.clone(), }; + sled_agent.inner.probes.run().await; + // We immediately add a notification to the request queue about our // existence. If inspection of the hardware later informs us that we're // actually running on a scrimlet, that's fine, the updated value will diff --git a/tools/ci_download_maghemite_mgd b/tools/ci_download_maghemite_mgd index eff680d7fd0..cb154e46da1 100755 --- a/tools/ci_download_maghemite_mgd +++ b/tools/ci_download_maghemite_mgd @@ -27,8 +27,11 @@ ARTIFACT_URL="https://buildomat.eng.oxide.computer/public/file" REPO='oxidecomputer/maghemite' PACKAGE_BASE_URL="$ARTIFACT_URL/$REPO/image/$COMMIT" + function main { + rm -rf $DOWNLOAD_DIR/root + # # Process command-line arguments. We generally don't expect any, but # we allow callers to specify a value to override OSTYPE, just for diff --git a/tools/ci_download_maghemite_openapi b/tools/ci_download_maghemite_openapi index db53f68d2c7..6bc36f150a9 100755 --- a/tools/ci_download_maghemite_openapi +++ b/tools/ci_download_maghemite_openapi @@ -15,10 +15,10 @@ TARGET_DIR="out" # Location where intermediate artifacts are downloaded / unpacked. DOWNLOAD_DIR="$TARGET_DIR/downloads" - - function main { + rm -rf $DOWNLOAD_DIR/root + if [[ $# != 0 ]]; then echo "unexpected arguments" >&2 exit 2 diff --git a/tools/ci_download_thundermuffin b/tools/ci_download_thundermuffin new file mode 100755 index 00000000000..53e54ddcbef --- /dev/null +++ b/tools/ci_download_thundermuffin @@ -0,0 +1,153 @@ +#!/bin/bash + +# +# ci_download_probe_packages: fetches thundermuffin binary tarball package, +# unpacks it, and creates a copy, all in the current directory +# + +set -o pipefail +set -o xtrace +set -o errexit + +SOURCE_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +ARG0="$(basename "${BASH_SOURCE[0]}")" + +source "$SOURCE_DIR/thundermuffin_checksums" +source "$SOURCE_DIR/thundermuffin_version" + +TARGET_DIR="out" +# Location where intermediate artifacts are downloaded / unpacked. +DOWNLOAD_DIR="$TARGET_DIR/downloads" +# Location where the final thundermuffin directory should end up. +DEST_DIR="./$TARGET_DIR/thundermuffin" +BIN_DIR="$DEST_DIR/root/opt/oxide/thundermuffin/bin" + +ARTIFACT_URL="https://buildomat.eng.oxide.computer/public/file" + +REPO='oxidecomputer/thundermuffin' +PACKAGE_BASE_URL="$ARTIFACT_URL/$REPO/image/$COMMIT" + +function main +{ + rm -rf $DOWNLOAD_DIR/root + + # + # Process command-line arguments. We generally don't expect any, but + # we allow callers to specify a value to override OSTYPE, just for + # testing. + # + if [[ $# != 0 ]]; then + CIDL_OS="$1" + shift + else + CIDL_OS="$OSTYPE" + fi + + if [[ $# != 0 ]]; then + echo "unexpected arguments" >&2 + exit 2 + fi + + # Configure this program + configure_os "$CIDL_OS" + + CIDL_SHA256FUNC="do_sha256sum" + TARBALL_FILENAME="thundermuffin.tar.gz" + PACKAGE_URL="$PACKAGE_BASE_URL/$TARBALL_FILENAME" + TARBALL_FILE="$DOWNLOAD_DIR/$TARBALL_FILENAME" + + # Download the file. + echo "URL: $PACKAGE_URL" + echo "Local file: $TARBALL_FILE" + + mkdir -p "$DOWNLOAD_DIR" + mkdir -p "$DEST_DIR" + + fetch_and_verify + + do_untar "$TARBALL_FILE" + + do_assemble + + $SET_BINARIES +} + +function fail +{ + echo "$ARG0: $@" >&2 + exit 1 +} + +function configure_os +{ + echo "current directory: $PWD" + echo "configuring based on OS: \"$1\"" + case "$1" in + solaris*) + SET_BINARIES="" + ;; + *) + echo "WARNING: binaries for $1 are not published by thundermuffin" + SET_BINARIES="unsupported_os" + ;; + esac +} + +function do_download_curl +{ + curl --silent --show-error --fail --location --output "$2" "$1" +} + +function do_sha256sum +{ + sha256sum < "$1" | awk '{print $1}' +} + +function do_untar +{ + tar xzf "$1" -C "$DOWNLOAD_DIR" +} + +function do_assemble +{ + rm -r "$DEST_DIR" || true + mkdir "$DEST_DIR" + cp -r "$DOWNLOAD_DIR/root" "$DEST_DIR/root" +} + +function fetch_and_verify +{ + local DO_DOWNLOAD="true" + if [[ -f "$TARBALL_FILE" ]]; then + # If the file exists with a valid checksum, we can skip downloading. + calculated_sha256="$($CIDL_SHA256FUNC "$TARBALL_FILE")" || \ + fail "failed to calculate sha256sum" + if [[ "$calculated_sha256" == "$CIDL_SHA256" ]]; then + DO_DOWNLOAD="false" + fi + fi + + if [ "$DO_DOWNLOAD" == "true" ]; then + echo "Downloading..." + do_download_curl "$PACKAGE_URL" "$TARBALL_FILE" || \ + fail "failed to download file" + + # Verify the sha256sum. + calculated_sha256="$($CIDL_SHA256FUNC "$TARBALL_FILE")" || \ + fail "failed to calculate sha256sum" + if [[ "$calculated_sha256" != "$CIDL_SHA256" ]]; then + fail "sha256sum mismatch \ + (expected $CIDL_SHA256, found $calculated_sha256)" + fi + fi + +} + +function unsupported_os +{ + mkdir -p "$BIN_DIR" + echo "echo 'unsupported os' && exit 1" >> "$BIN_DIR/dpd" + chmod +x "$BIN_DIR/dpd" +} + +main "$@" diff --git a/tools/install_builder_prerequisites.sh b/tools/install_builder_prerequisites.sh index 1ce133dff3d..ffa0c78e991 100755 --- a/tools/install_builder_prerequisites.sh +++ b/tools/install_builder_prerequisites.sh @@ -207,6 +207,9 @@ retry ./tools/ci_download_maghemite_mgd # xcvradm binary which is bundled with the switch zone. retry ./tools/ci_download_transceiver_control +# Download thundermuffin. This is required to launch network probes. +retry ./tools/ci_download_thundermuffin + # Validate the PATH: expected_in_path=( 'pg_config' diff --git a/tools/install_runner_prerequisites.sh b/tools/install_runner_prerequisites.sh index 42347f518d7..2a29e97085f 100755 --- a/tools/install_runner_prerequisites.sh +++ b/tools/install_runner_prerequisites.sh @@ -120,8 +120,6 @@ function install_packages { exit "$rc" fi - pfexec svcadm enable chrony - pkg list -v "${packages[@]}" elif [[ "${HOST_OS}" == "Linux" ]]; then packages=( diff --git a/tools/thundermuffin_checksums b/tools/thundermuffin_checksums new file mode 100644 index 00000000000..5e10539bdd5 --- /dev/null +++ b/tools/thundermuffin_checksums @@ -0,0 +1 @@ +CIDL_SHA256="dc55a2accd33a347df4cbdc0026cbaccea2c004940c3fec8cadcdd633d440dfa" diff --git a/tools/thundermuffin_version b/tools/thundermuffin_version new file mode 100644 index 00000000000..cbca739f5c8 --- /dev/null +++ b/tools/thundermuffin_version @@ -0,0 +1 @@ +COMMIT="a4a6108d7c9aac2464a0b6898e88132a8f701a13"