From 2088693046c7a666a50a5e6a8b0a14e99f7d01a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karen=20C=C3=A1rcamo?= Date: Fri, 23 Feb 2024 19:39:35 +1300 Subject: [PATCH] [sled-agent] Self assembling external DNS zone (#5059) ### Overview In addition to implementing the external DNS self assembling zone, this PR contains a new SMF service called `opte-interface-setup`. Closes: https://github.com/oxidecomputer/omicron/issues/2881 Related: https://github.com/oxidecomputer/omicron/issues/1898 ### Implementation This service makes use of the zone-network CLI tool to avoid having too many CLIs doing things. The CLI is now shipped independently so it can be called by two different services. The [`zone-networking opte-interface-set-up`]( https://github.com/oxidecomputer/omicron/pull/5059/files#diff-5fb7b70dc87176e02517181b0887ce250b6a4e4079e495990551deeca741dc8bR181-R202) command sets up what the `ensure_address_for_port()` method used to set up. ### Justification The reasoning behind this new service is to avoid setting up too many things via the method_script.sh file, and to avoid code duplication. The Nexus zone will also be using this service to set up the OPTE interface. --- illumos-utils/src/ipadm.rs | 25 +++ illumos-utils/src/opte/port.rs | 8 +- illumos-utils/src/route.rs | 67 +++++++- illumos-utils/src/running_zone.rs | 7 +- package-manifest.toml | 56 +++++-- sled-agent/src/services.rs | 140 ++++++++++++----- smf/external-dns/manifest.xml | 12 +- smf/opte-interface-setup/manifest.xml | 46 ++++++ smf/zone-network-setup/manifest.xml | 2 +- zone-network-setup/src/bin/zone-networking.rs | 145 ++++++++++++++---- 10 files changed, 417 insertions(+), 91 deletions(-) create mode 100644 smf/opte-interface-setup/manifest.xml diff --git a/illumos-utils/src/ipadm.rs b/illumos-utils/src/ipadm.rs index f4d884d452..1c9e1e234e 100644 --- a/illumos-utils/src/ipadm.rs +++ b/illumos-utils/src/ipadm.rs @@ -107,4 +107,29 @@ impl Ipadm { }; Ok(()) } + + // Create gateway on the IP interface if it doesn't already exist + pub fn create_opte_gateway( + opte_iface: &String, + ) -> Result<(), ExecutionError> { + let addrobj = format!("{}/public", opte_iface); + let mut cmd = std::process::Command::new(PFEXEC); + let cmd = cmd.args(&[IPADM, "show-addr", &addrobj]); + match execute(cmd) { + Err(_) => { + let mut cmd = std::process::Command::new(PFEXEC); + let cmd = cmd.args(&[ + IPADM, + "create-addr", + "-t", + "-T", + "dhcp", + &addrobj, + ]); + execute(cmd)?; + } + Ok(_) => (), + }; + Ok(()) + } } diff --git a/illumos-utils/src/opte/port.rs b/illumos-utils/src/opte/port.rs index d06cdfe1ec..6fbb89c450 100644 --- a/illumos-utils/src/opte/port.rs +++ b/illumos-utils/src/opte/port.rs @@ -15,7 +15,7 @@ struct PortInner { // Name of the port as identified by OPTE name: String, // IP address within the VPC Subnet - _ip: IpAddr, + ip: IpAddr, // VPC-private MAC address mac: MacAddr6, // Emulated PCI slot for the guest NIC, passed to Propolis @@ -95,7 +95,7 @@ impl Port { Self { inner: Arc::new(PortInner { name, - _ip: ip, + ip, mac, slot, vni, @@ -105,6 +105,10 @@ impl Port { } } + pub fn ip(&self) -> &IpAddr { + &self.inner.ip + } + pub fn name(&self) -> &str { &self.inner.name } diff --git a/illumos-utils/src/route.rs b/illumos-utils/src/route.rs index 2b6af9a9fd..ceff2b3d9e 100644 --- a/illumos-utils/src/route.rs +++ b/illumos-utils/src/route.rs @@ -7,27 +7,76 @@ use crate::zone::ROUTE; use crate::{execute, inner, output_to_exec_error, ExecutionError, PFEXEC}; use libc::ESRCH; -use std::net::Ipv6Addr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; /// Wraps commands for interacting with routing tables. pub struct Route {} +pub enum Gateway { + Ipv4(Ipv4Addr), + Ipv6(Ipv6Addr), +} + #[cfg_attr(any(test, feature = "testing"), mockall::automock)] impl Route { pub fn ensure_default_route_with_gateway( - gateway: &Ipv6Addr, + gateway: Gateway, ) -> Result<(), ExecutionError> { + let inet; + let gw; + match gateway { + Gateway::Ipv4(addr) => { + inet = "-inet"; + gw = addr.to_string(); + } + Gateway::Ipv6(addr) => { + inet = "-inet6"; + gw = addr.to_string(); + } + } // Add the desired route if it doesn't already exist let destination = "default"; let mut cmd = std::process::Command::new(PFEXEC); + let cmd = cmd.args(&[ROUTE, "-n", "get", inet, destination, inet, &gw]); + + let out = + cmd.output().map_err(|err| ExecutionError::ExecutionStart { + command: inner::to_string(cmd), + err, + })?; + match out.status.code() { + Some(0) => (), + // If the entry is not found in the table, + // the exit status of the command will be 3 (ESRCH). + // When that is the case, we'll add the route. + Some(ESRCH) => { + let mut cmd = std::process::Command::new(PFEXEC); + let cmd = + cmd.args(&[ROUTE, "add", inet, destination, inet, &gw]); + execute(cmd)?; + } + Some(_) | None => return Err(output_to_exec_error(cmd, &out)), + }; + Ok(()) + } + + pub fn ensure_opte_route( + gateway: &Ipv4Addr, + iface: &String, + opte_ip: &IpAddr, + ) -> Result<(), ExecutionError> { + // Add the desired route if it doesn't already exist + let mut cmd = std::process::Command::new(PFEXEC); let cmd = cmd.args(&[ ROUTE, "-n", "get", - "-inet6", - destination, - "-inet6", + "-host", &gateway.to_string(), + &opte_ip.to_string(), + "-interface", + "-ifp", + &iface.to_string(), ]); let out = @@ -45,10 +94,12 @@ impl Route { let cmd = cmd.args(&[ ROUTE, "add", - "-inet6", - destination, - "-inet6", + "-host", &gateway.to_string(), + &opte_ip.to_string(), + "-interface", + "-ifp", + &iface.to_string(), ]); execute(cmd)?; } diff --git a/illumos-utils/src/running_zone.rs b/illumos-utils/src/running_zone.rs index 4b4107f529..1c1df01980 100644 --- a/illumos-utils/src/running_zone.rs +++ b/illumos-utils/src/running_zone.rs @@ -888,7 +888,7 @@ impl RunningZone { /// Return references to the OPTE ports for this zone. pub fn opte_ports(&self) -> impl Iterator { - self.inner.opte_ports.iter().map(|(port, _)| port) + self.inner.opte_ports() } /// Remove the OPTE ports on this zone from the port manager. @@ -1130,6 +1130,11 @@ impl InstalledZone { path.push("root/var/svc/profile/site.xml"); path } + + /// Returns references to the OPTE ports for this zone. + pub fn opte_ports(&self) -> impl Iterator { + self.opte_ports.iter().map(|(port, _)| port) + } } #[derive(Clone)] diff --git a/package-manifest.toml b/package-manifest.toml index 1a749c5b61..c474a52736 100644 --- a/package-manifest.toml +++ b/package-manifest.toml @@ -120,7 +120,7 @@ setup_hint = """ service_name = "oximeter" only_for_targets.image = "standard" source.type = "composite" -source.packages = [ "oximeter-collector.tar.gz", "zone-network-setup.tar.gz" ] +source.packages = [ "oximeter-collector.tar.gz", "zone-network-setup.tar.gz", "zone-network-install.tar.gz" ] output.type = "zone" [package.oximeter-collector] @@ -140,7 +140,12 @@ output.intermediate_only = true service_name = "clickhouse" only_for_targets.image = "standard" source.type = "composite" -source.packages = [ "clickhouse_svc.tar.gz", "internal-dns-cli.tar.gz", "zone-network-setup.tar.gz" ] +source.packages = [ + "clickhouse_svc.tar.gz", + "internal-dns-cli.tar.gz", + "zone-network-setup.tar.gz", + "zone-network-install.tar.gz" +] output.type = "zone" [package.clickhouse_svc] @@ -161,7 +166,12 @@ setup_hint = "Run `./tools/ci_download_clickhouse` to download the necessary bin service_name = "clickhouse_keeper" only_for_targets.image = "standard" source.type = "composite" -source.packages = [ "clickhouse_keeper_svc.tar.gz", "internal-dns-cli.tar.gz", "zone-network-setup.tar.gz" ] +source.packages = [ + "clickhouse_keeper_svc.tar.gz", + "internal-dns-cli.tar.gz", + "zone-network-setup.tar.gz", + "zone-network-install.tar.gz" +] output.type = "zone" [package.clickhouse_keeper_svc] @@ -182,7 +192,12 @@ setup_hint = "Run `./tools/ci_download_clickhouse` to download the necessary bin service_name = "cockroachdb" only_for_targets.image = "standard" source.type = "composite" -source.packages = [ "cockroachdb-service.tar.gz", "internal-dns-cli.tar.gz", "zone-network-setup.tar.gz" ] +source.packages = [ + "cockroachdb-service.tar.gz", + "internal-dns-cli.tar.gz", + "zone-network-setup.tar.gz", + "zone-network-install.tar.gz" +] output.type = "zone" [package.cockroachdb-service] @@ -220,7 +235,13 @@ output.type = "zone" service_name = "external_dns" only_for_targets.image = "standard" source.type = "composite" -source.packages = [ "dns-server.tar.gz", "external-dns-customizations.tar.gz" ] +source.packages = [ + "dns-server.tar.gz", + "external-dns-customizations.tar.gz", + "zone-network-setup.tar.gz", + "zone-network-install.tar.gz", + "opte-interface-setup.tar.gz" +] output.type = "zone" [package.dns-server] @@ -393,7 +414,7 @@ output.intermediate_only = true service_name = "crucible" only_for_targets.image = "standard" source.type = "composite" -source.packages = [ "crucible.tar.gz", "zone-network-setup.tar.gz" ] +source.packages = [ "crucible.tar.gz", "zone-network-setup.tar.gz", "zone-network-install.tar.gz" ] output.type = "zone" @@ -401,7 +422,7 @@ output.type = "zone" service_name = "crucible_pantry" only_for_targets.image = "standard" source.type = "composite" -source.packages = [ "crucible-pantry.tar.gz", "zone-network-setup.tar.gz" ] +source.packages = [ "crucible-pantry.tar.gz", "zone-network-setup.tar.gz", "zone-network-install.tar.gz" ] output.type = "zone" # Packages not built within Omicron, but which must be imported. @@ -643,14 +664,31 @@ source.packages = [ ] output.type = "zone" -[package.zone-network-setup] +[package.zone-network-install] service_name = "zone-network-setup" only_for_targets.image = "standard" source.type = "local" +source.paths = [ + { from = "smf/zone-network-setup/manifest.xml", to = "/var/svc/manifest/site/zone-network-setup/manifest.xml" }, +] +output.type = "zone" +output.intermediate_only = true + +[package.zone-network-setup] +service_name = "zone-network-cli" +only_for_targets.image = "standard" +source.type = "local" source.rust.binary_names = ["zone-networking"] source.rust.release = true +output.type = "zone" +output.intermediate_only = true + +[package.opte-interface-setup] +service_name = "opte-interface-setup" +only_for_targets.image = "standard" +source.type = "local" source.paths = [ - { from = "smf/zone-network-setup/manifest.xml", to = "/var/svc/manifest/site/zone-network-setup/manifest.xml" }, + { from = "smf/opte-interface-setup/manifest.xml", to = "/var/svc/manifest/site/opte-interface-setup/manifest.xml" }, ] output.type = "zone" output.intermediate_only = true diff --git a/sled-agent/src/services.rs b/sled-agent/src/services.rs index bcd648cd2d..f3ddfdbf89 100644 --- a/sled-agent/src/services.rs +++ b/sled-agent/src/services.rs @@ -53,7 +53,8 @@ use illumos_utils::dladm::{ use illumos_utils::link::{Link, VnicAllocator}; use illumos_utils::opte::{DhcpCfg, Port, PortManager, PortTicket}; use illumos_utils::running_zone::{ - InstalledZone, RunCommandError, RunningZone, ZoneBuilderFactory, + EnsureAddressError, InstalledZone, RunCommandError, RunningZone, + ZoneBuilderFactory, }; use illumos_utils::zfs::ZONE_ZFS_RAMDISK_DATASET_MOUNTPOINT; use illumos_utils::zone::AddressRequest; @@ -68,6 +69,8 @@ use omicron_common::address::COCKROACH_PORT; use omicron_common::address::CRUCIBLE_PANTRY_PORT; use omicron_common::address::CRUCIBLE_PORT; use omicron_common::address::DENDRITE_PORT; +use omicron_common::address::DNS_HTTP_PORT; +use omicron_common::address::DNS_PORT; use omicron_common::address::MGS_PORT; use omicron_common::address::RACK_PREFIX; use omicron_common::address::SLED_PREFIX; @@ -1444,6 +1447,32 @@ impl ServiceManager { .add_instance(ServiceInstanceBuilder::new("default"))) } + fn opte_interface_set_up_install( + zone: &InstalledZone, + ) -> Result { + let port_idx = 0; + let port = zone.opte_ports().nth(port_idx).ok_or_else(|| { + Error::ZoneEnsureAddress(EnsureAddressError::MissingOptePort { + zone: String::from(zone.name()), + port_idx, + }) + })?; + + let opte_interface = port.vnic_name(); + let opte_gateway = port.gateway().ip().to_string(); + let opte_ip = port.ip().to_string(); + + let mut config_builder = PropertyGroupBuilder::new("config"); + config_builder = config_builder + .add_property("interface", "astring", opte_interface) + .add_property("gateway", "astring", &opte_gateway) + .add_property("ip", "astring", &opte_ip); + + Ok(ServiceBuilder::new("oxide/opte-interface-setup") + .add_property_group(config_builder) + .add_instance(ServiceInstanceBuilder::new("default"))) + } + async fn initialize_zone( &self, request: ZoneArgs<'_>, @@ -1841,6 +1870,73 @@ impl ServiceManager { })?; return Ok(RunningZone::boot(installed_zone).await?); } + ZoneArgs::Omicron(OmicronZoneConfigLocal { + zone: + OmicronZoneConfig { + zone_type: OmicronZoneType::ExternalDns { .. }, + underlay_address, + .. + }, + .. + }) => { + let Some(info) = self.inner.sled_info.get() else { + return Err(Error::SledAgentNotReady); + }; + + let static_addr = underlay_address.to_string(); + + let nw_setup_service = Self::zone_network_setup_install( + info, + &installed_zone, + &static_addr.clone(), + )?; + + // Like Nexus, we need to be reachable externally via + // `dns_address` but we don't listen on that address + // directly but instead on a VPC private IP. OPTE will + // en/decapsulate as appropriate. + let opte_interface_setup = + Self::opte_interface_set_up_install(&installed_zone)?; + + let port_idx = 0; + let port = installed_zone + .opte_ports() + .nth(port_idx) + .ok_or_else(|| { + Error::ZoneEnsureAddress( + EnsureAddressError::MissingOptePort { + zone: String::from(installed_zone.name()), + port_idx, + }, + ) + })?; + let opte_ip = port.ip(); + + let http_addr = format!("[{}]:{}", static_addr, DNS_HTTP_PORT); + let dns_addr = format!("{}:{}", opte_ip, DNS_PORT); + + let external_dns_config = PropertyGroupBuilder::new("config") + .add_property("http_address", "astring", &http_addr) + .add_property("dns_address", "astring", &dns_addr); + let external_dns_service = + ServiceBuilder::new("oxide/external_dns").add_instance( + ServiceInstanceBuilder::new("default") + .add_property_group(external_dns_config), + ); + + let profile = ProfileBuilder::new("omicron") + .add_service(nw_setup_service) + .add_service(opte_interface_setup) + .add_service(disabled_ssh_service) + .add_service(external_dns_service); + profile + .add_to_zone(&self.inner.log, &installed_zone) + .await + .map_err(|err| { + Error::io("Failed to setup External DNS profile", err) + })?; + return Ok(RunningZone::boot(installed_zone).await?); + } _ => {} } @@ -2081,47 +2177,6 @@ impl ServiceManager { .await .map_err(|err| Error::io_path(&config_path, err))?; } - - OmicronZoneType::ExternalDns { - http_address, - dns_address, - .. - } => { - info!( - self.inner.log, - "Setting up external-dns service" - ); - - // Like Nexus, we need to be reachable externally via - // `dns_address` but we don't listen on that address - // directly but instead on a VPC private IP. OPTE will - // en/decapsulate as appropriate. - let port_ip = running_zone - .ensure_address_for_port("public", 0) - .await? - .ip(); - let dns_address = - SocketAddr::new(port_ip, dns_address.port()); - - smfh.setprop( - "config/http_address", - format!( - "[{}]:{}", - http_address.ip(), - http_address.port(), - ), - )?; - smfh.setprop( - "config/dns_address", - dns_address.to_string(), - )?; - - // Refresh the manifest with the new properties we set, - // so they become "effective" properties when the - // service is enabled. - smfh.refresh()?; - } - OmicronZoneType::InternalDns { http_address, dns_address, @@ -2262,6 +2317,7 @@ impl ServiceManager { | OmicronZoneType::CockroachDb { .. } | OmicronZoneType::Crucible { .. } | OmicronZoneType::CruciblePantry { .. } + | OmicronZoneType::ExternalDns { .. } | OmicronZoneType::Oximeter { .. } => { panic!( "{} is a service which exists as part of a \ diff --git a/smf/external-dns/manifest.xml b/smf/external-dns/manifest.xml index 05c3c02b4e..1b6ee2cba0 100644 --- a/smf/external-dns/manifest.xml +++ b/smf/external-dns/manifest.xml @@ -4,13 +4,23 @@ - + + + + + + + + + diff --git a/smf/opte-interface-setup/manifest.xml b/smf/opte-interface-setup/manifest.xml new file mode 100644 index 0000000000..5b886c8a71 --- /dev/null +++ b/smf/opte-interface-setup/manifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/smf/zone-network-setup/manifest.xml b/smf/zone-network-setup/manifest.xml index 0776329749..a20ff949a4 100644 --- a/smf/zone-network-setup/manifest.xml +++ b/smf/zone-network-setup/manifest.xml @@ -18,7 +18,7 @@ diff --git a/zone-network-setup/src/bin/zone-networking.rs b/zone-network-setup/src/bin/zone-networking.rs index f3d18832c5..e36afd6b03 100644 --- a/zone-network-setup/src/bin/zone-networking.rs +++ b/zone-network-setup/src/bin/zone-networking.rs @@ -5,17 +5,31 @@ //! CLI to set up zone networking use anyhow::anyhow; -use clap::{arg, command}; +use clap::{arg, command, ArgMatches, Command}; use illumos_utils::ipadm::Ipadm; -use illumos_utils::route::Route; +use illumos_utils::route::{Gateway, Route}; use omicron_common::cmd::fatal; use omicron_common::cmd::CmdError; -use slog::info; +use slog::{info, Logger}; use std::fs; -use std::net::Ipv6Addr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; pub const HOSTS_FILE: &str = "/etc/inet/hosts"; +fn parse_ip(s: &str) -> anyhow::Result { + if s == "unknown" { + return Err(anyhow!("ERROR: Missing input value")); + }; + s.parse().map_err(|_| anyhow!("ERROR: Invalid IP address")) +} + +fn parse_ipv4(s: &str) -> anyhow::Result { + if s == "unknown" { + return Err(anyhow!("ERROR: Missing input value")); + }; + s.parse().map_err(|_| anyhow!("ERROR: Invalid IPv4 address")) +} + fn parse_ipv6(s: &str) -> anyhow::Result { if s == "unknown" { return Err(anyhow!("ERROR: Missing input value")); @@ -30,6 +44,13 @@ fn parse_datalink(s: &str) -> anyhow::Result { s.parse().map_err(|_| anyhow!("ERROR: Invalid data link")) } +fn parse_opte_iface(s: &str) -> anyhow::Result { + if s == "unknown" { + return Err(anyhow!("ERROR: Missing OPTE interface")); + }; + s.parse().map_err(|_| anyhow!("ERROR: Invalid OPTE interface")) +} + #[tokio::main] async fn main() { if let Err(message) = do_run().await { @@ -47,34 +68,81 @@ async fn do_run() -> Result<(), CmdError> { .map_err(|err| CmdError::Failure(anyhow!(err)))?; let matches = command!() - .arg( - arg!( - -d --datalink "datalink" - ) - .required(true) - .value_parser(parse_datalink), - ) - .arg( - arg!( - -g --gateway "gateway" - ) - .required(true) - .value_parser(parse_ipv6), + .subcommand( + Command::new("set-up") + .about( + "Sets up common networking configuration across all zones", + ) + .arg( + arg!( + -d --datalink "datalink" + ) + .required(true) + .value_parser(parse_datalink), + ) + .arg( + arg!( + -g --gateway "gateway" + ) + .required(true) + .value_parser(parse_ipv6), + ) + .arg( + arg!( + -s --static_addr "static_addr" + ) + .required(true) + .value_parser(parse_ipv6), + ), ) - .arg( - arg!( - -s --static_addr "static_addr" - ) - .required(true) - .value_parser(parse_ipv6), + .subcommand( + Command::new("opte-interface-set-up") + .about("Sets up OPTE interface") + .arg( + arg!( + -i --opte_interface "opte_interface" + ) + .required(true) + .value_parser(parse_opte_iface), + ) + .arg( + arg!( + -g --opte_gateway "opte_gateway" + ) + .required(true) + .value_parser(parse_ipv4), + ) + .arg( + arg!( + -p --opte_ip "opte_ip" + ) + .required(true) + .value_parser(parse_ip), + ), ) .get_matches(); - let zonename = - zone::current().await.expect("Could not determine local zone name"); + if let Some(matches) = matches.subcommand_matches("set-up") { + set_up(matches, log.clone()).await?; + } + + if let Some(matches) = matches.subcommand_matches("opte-interface-set-up") { + opte_interface_set_up(matches, log.clone()).await?; + } + + Ok(()) +} + +async fn set_up(matches: &ArgMatches, log: Logger) -> Result<(), CmdError> { let datalink: &String = matches.get_one("datalink").unwrap(); let static_addr: &Ipv6Addr = matches.get_one("static_addr").unwrap(); - let gateway: &Ipv6Addr = matches.get_one("gateway").unwrap(); + let gateway: Ipv6Addr = *matches.get_one("gateway").unwrap(); + let zonename = zone::current().await.map_err(|err| { + CmdError::Failure(anyhow!( + "Could not determine local zone name: {}", + err + )) + })?; // TODO: remove when https://github.com/oxidecomputer/stlouis/issues/435 is // addressed @@ -91,7 +159,7 @@ async fn do_run() -> Result<(), CmdError> { .map_err(|err| CmdError::Failure(anyhow!(err)))?; info!(&log, "Ensuring there is a default route"; "gateway" => ?gateway); - Route::ensure_default_route_with_gateway(gateway) + Route::ensure_default_route_with_gateway(Gateway::Ipv6(gateway)) .map_err(|err| CmdError::Failure(anyhow!(err)))?; info!(&log, "Populating hosts file for zone"; "zonename" => ?zonename); @@ -109,3 +177,26 @@ async fn do_run() -> Result<(), CmdError> { Ok(()) } + +async fn opte_interface_set_up( + matches: &ArgMatches, + log: Logger, +) -> Result<(), CmdError> { + let interface: &String = matches.get_one("opte_interface").unwrap(); + let gateway: Ipv4Addr = *matches.get_one("opte_gateway").unwrap(); + let opte_ip: &IpAddr = matches.get_one("opte_ip").unwrap(); + + info!(&log, "Creating gateway on the OPTE IP interface if it doesn't already exist"; "OPTE interface" => ?interface); + Ipadm::create_opte_gateway(interface) + .map_err(|err| CmdError::Failure(anyhow!(err)))?; + + info!(&log, "Ensuring there is a gateway route"; "OPTE gateway" => ?gateway, "OPTE interface" => ?interface, "OPTE IP" => ?opte_ip); + Route::ensure_opte_route(&gateway, interface, &opte_ip) + .map_err(|err| CmdError::Failure(anyhow!(err)))?; + + info!(&log, "Ensuring there is a default route"; "gateway" => ?gateway); + Route::ensure_default_route_with_gateway(Gateway::Ipv4(gateway)) + .map_err(|err| CmdError::Failure(anyhow!(err)))?; + + Ok(()) +}