Compare commits
12 Commits
b5c081f596
...
feat/insta
| Author | SHA1 | Date | |
|---|---|---|---|
| c2fa4f1869 | |||
| ee278ac817 | |||
| 09a06f136e | |||
| 5f147fa672 | |||
| c80ede706b | |||
| 9ba939bde1 | |||
| 44bf21718c | |||
| b2825ec1ef | |||
| 609d7acb5d | |||
| 5ab58f0253 | |||
| 5af13800b7 | |||
| 8126b233d8 |
@@ -31,6 +31,7 @@ pub struct BrocadeOptions {
|
||||
pub struct TimeoutConfig {
|
||||
pub shell_ready: Duration,
|
||||
pub command_execution: Duration,
|
||||
pub command_output: Duration,
|
||||
pub cleanup: Duration,
|
||||
pub message_wait: Duration,
|
||||
}
|
||||
@@ -40,6 +41,7 @@ impl Default for TimeoutConfig {
|
||||
Self {
|
||||
shell_ready: Duration::from_secs(10),
|
||||
command_execution: Duration::from_secs(60), // Commands like `deploy` (for a LAG) can take a while
|
||||
command_output: Duration::from_secs(5), // Delay to start logging "waiting for command output"
|
||||
cleanup: Duration::from_secs(10),
|
||||
message_wait: Duration::from_millis(500),
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::str::FromStr;
|
||||
use async_trait::async_trait;
|
||||
use harmony_types::switch::{PortDeclaration, PortLocation};
|
||||
use log::{debug, info};
|
||||
use regex::Regex;
|
||||
|
||||
use crate::{
|
||||
BrocadeClient, BrocadeInfo, Error, ExecutionMode, InterSwitchLink, InterfaceInfo,
|
||||
@@ -103,13 +104,37 @@ impl NetworkOperatingSystemClient {
|
||||
};
|
||||
|
||||
Some(Ok(InterfaceInfo {
|
||||
name: format!("{} {}", interface_type, port_location),
|
||||
name: format!("{interface_type} {port_location}"),
|
||||
port_location,
|
||||
interface_type,
|
||||
operating_mode,
|
||||
status,
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_configure_interfaces_error(&self, err: Error) -> Error {
|
||||
debug!("[Brocade] {err}");
|
||||
|
||||
if let Error::CommandError(message) = &err {
|
||||
if message.contains("switchport")
|
||||
&& message.contains("Cannot configure aggregator member")
|
||||
{
|
||||
let re = Regex::new(r"\(conf-if-([a-zA-Z]+)-([\d/]+)\)#").unwrap();
|
||||
|
||||
if let Some(caps) = re.captures(message) {
|
||||
let interface_type = &caps[1];
|
||||
let port_location = &caps[2];
|
||||
let interface = format!("{interface_type} {port_location}");
|
||||
|
||||
return Error::CommandError(format!(
|
||||
"Cannot configure interface '{interface}', it is a member of a port-channel (LAG)"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -197,11 +222,10 @@ impl BrocadeClient for NetworkOperatingSystemClient {
|
||||
commands.push("exit".into());
|
||||
}
|
||||
|
||||
commands.push("write memory".into());
|
||||
|
||||
self.shell
|
||||
.run_commands(commands, ExecutionMode::Regular)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|err| self.map_configure_interfaces_error(err))?;
|
||||
|
||||
info!("[Brocade] Interfaces configured.");
|
||||
|
||||
@@ -213,7 +237,7 @@ impl BrocadeClient for NetworkOperatingSystemClient {
|
||||
|
||||
let output = self
|
||||
.shell
|
||||
.run_command("show port-channel", ExecutionMode::Regular)
|
||||
.run_command("show port-channel summary", ExecutionMode::Regular)
|
||||
.await?;
|
||||
|
||||
let used_ids: Vec<u8> = output
|
||||
@@ -248,7 +272,12 @@ impl BrocadeClient for NetworkOperatingSystemClient {
|
||||
ports: &[PortLocation],
|
||||
) -> Result<(), Error> {
|
||||
info!(
|
||||
"[Brocade] Configuring port-channel '{channel_name} {channel_id}' with ports: {ports:?}"
|
||||
"[Brocade] Configuring port-channel '{channel_id} {channel_name}' with ports: {}",
|
||||
ports
|
||||
.iter()
|
||||
.map(|p| format!("{p}"))
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
);
|
||||
|
||||
let interfaces = self.get_interfaces().await?;
|
||||
@@ -276,8 +305,6 @@ impl BrocadeClient for NetworkOperatingSystemClient {
|
||||
commands.push("exit".into());
|
||||
}
|
||||
|
||||
commands.push("write memory".into());
|
||||
|
||||
self.shell
|
||||
.run_commands(commands, ExecutionMode::Regular)
|
||||
.await?;
|
||||
@@ -294,7 +321,6 @@ impl BrocadeClient for NetworkOperatingSystemClient {
|
||||
"configure terminal".into(),
|
||||
format!("no interface port-channel {}", channel_name),
|
||||
"exit".into(),
|
||||
"write memory".into(),
|
||||
];
|
||||
|
||||
self.shell
|
||||
|
||||
@@ -211,7 +211,7 @@ impl BrocadeSession {
|
||||
let mut output = Vec::new();
|
||||
let start = Instant::now();
|
||||
let read_timeout = Duration::from_millis(500);
|
||||
let log_interval = Duration::from_secs(3);
|
||||
let log_interval = Duration::from_secs(5);
|
||||
let mut last_log = Instant::now();
|
||||
|
||||
loop {
|
||||
@@ -221,7 +221,9 @@ impl BrocadeSession {
|
||||
));
|
||||
}
|
||||
|
||||
if start.elapsed() > Duration::from_secs(5) && last_log.elapsed() > log_interval {
|
||||
if start.elapsed() > self.options.timeouts.command_output
|
||||
&& last_log.elapsed() > log_interval
|
||||
{
|
||||
info!("[Brocade] Waiting for command output...");
|
||||
last_log = Instant::now();
|
||||
}
|
||||
@@ -276,7 +278,7 @@ impl BrocadeSession {
|
||||
let output_lower = output.to_lowercase();
|
||||
if ERROR_PATTERNS.iter().any(|&p| output_lower.contains(p)) {
|
||||
return Err(Error::CommandError(format!(
|
||||
"Command '{command}' failed: {}",
|
||||
"Command error: {}",
|
||||
output.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ async fn main() {
|
||||
let gateway_ipv4 = Ipv4Addr::new(192, 168, 33, 1);
|
||||
let gateway_ip = IpAddr::V4(gateway_ipv4);
|
||||
let topology = harmony::topology::HAClusterTopology {
|
||||
kubeconfig: None,
|
||||
domain_name: "ncd0.harmony.mcd".to_string(), // TODO this must be set manually correctly
|
||||
// when setting up the opnsense firewall
|
||||
router: Arc::new(UnmanagedRouter::new(
|
||||
@@ -105,6 +106,7 @@ async fn main() {
|
||||
name: "wk2".to_string(),
|
||||
},
|
||||
],
|
||||
node_exporter: opnsense.clone(),
|
||||
switch_client: switch_client.clone(),
|
||||
};
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ pub async fn get_topology() -> HAClusterTopology {
|
||||
let gateway_ipv4 = ipv4!("192.168.1.1");
|
||||
let gateway_ip = IpAddr::V4(gateway_ipv4);
|
||||
harmony::topology::HAClusterTopology {
|
||||
kubeconfig: None,
|
||||
domain_name: "demo.harmony.mcd".to_string(),
|
||||
router: Arc::new(UnmanagedRouter::new(
|
||||
gateway_ip,
|
||||
@@ -79,6 +80,7 @@ pub async fn get_topology() -> HAClusterTopology {
|
||||
name: "bootstrap".to_string(),
|
||||
},
|
||||
workers: vec![],
|
||||
node_exporter: opnsense.clone(),
|
||||
switch_client: switch_client.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ pub async fn get_topology() -> HAClusterTopology {
|
||||
let gateway_ipv4 = ipv4!("192.168.1.1");
|
||||
let gateway_ip = IpAddr::V4(gateway_ipv4);
|
||||
harmony::topology::HAClusterTopology {
|
||||
kubeconfig: None,
|
||||
domain_name: "demo.harmony.mcd".to_string(),
|
||||
router: Arc::new(UnmanagedRouter::new(
|
||||
gateway_ip,
|
||||
@@ -74,6 +75,7 @@ pub async fn get_topology() -> HAClusterTopology {
|
||||
name: "cp0".to_string(),
|
||||
},
|
||||
workers: vec![],
|
||||
node_exporter: opnsense.clone(),
|
||||
switch_client: switch_client.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ async fn main() {
|
||||
let gateway_ipv4 = Ipv4Addr::new(10, 100, 8, 1);
|
||||
let gateway_ip = IpAddr::V4(gateway_ipv4);
|
||||
let topology = harmony::topology::HAClusterTopology {
|
||||
kubeconfig: None,
|
||||
domain_name: "demo.harmony.mcd".to_string(),
|
||||
router: Arc::new(UnmanagedRouter::new(
|
||||
gateway_ip,
|
||||
@@ -77,6 +78,7 @@ async fn main() {
|
||||
name: "cp0".to_string(),
|
||||
},
|
||||
workers: vec![],
|
||||
node_exporter: opnsense.clone(),
|
||||
switch_client: switch_client.clone(),
|
||||
};
|
||||
|
||||
|
||||
21
examples/opnsense_node_exporter/Cargo.toml
Normal file
21
examples/opnsense_node_exporter/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "example-opnsense-node-exporter"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
harmony = { path = "../../harmony" }
|
||||
harmony_cli = { path = "../../harmony_cli" }
|
||||
harmony_types = { path = "../../harmony_types" }
|
||||
harmony_secret = { path = "../../harmony_secret" }
|
||||
harmony_secret_derive = { path = "../../harmony_secret_derive" }
|
||||
cidr = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
harmony_macros = { path = "../../harmony_macros" }
|
||||
log = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
url = { workspace = true }
|
||||
serde.workspace = true
|
||||
async-trait.workspace = true
|
||||
79
examples/opnsense_node_exporter/src/main.rs
Normal file
79
examples/opnsense_node_exporter/src/main.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use cidr::Ipv4Cidr;
|
||||
use harmony::{
|
||||
executors::ExecutorError,
|
||||
hardware::{HostCategory, Location, PhysicalHost, SwitchGroup},
|
||||
infra::opnsense::OPNSenseManagementInterface,
|
||||
inventory::Inventory,
|
||||
modules::opnsense::node_exporter::NodeExporterScore,
|
||||
topology::{
|
||||
HAClusterTopology, LogicalHost, PreparationError, PreparationOutcome, Topology,
|
||||
UnmanagedRouter, node_exporter::NodeExporter,
|
||||
},
|
||||
};
|
||||
use harmony_macros::{ip, ipv4, mac_address};
|
||||
|
||||
struct OpnSenseTopology {
|
||||
node_exporter: Arc<dyn NodeExporter>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Topology for OpnSenseTopology {
|
||||
async fn ensure_ready(&self) -> Result<PreparationOutcome, PreparationError> {
|
||||
Ok(PreparationOutcome::Success {
|
||||
details: "Success".to_string(),
|
||||
})
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
"OpnsenseTopology"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NodeExporter for OpnSenseTopology {
|
||||
async fn ensure_initialized(&self) -> Result<(), ExecutorError> {
|
||||
self.node_exporter.ensure_initialized().await
|
||||
}
|
||||
|
||||
async fn commit_config(&self) -> Result<(), ExecutorError> {
|
||||
self.node_exporter.commit_config().await
|
||||
}
|
||||
|
||||
async fn reload_restart(&self) -> Result<(), ExecutorError> {
|
||||
self.node_exporter.reload_restart().await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let firewall = harmony::topology::LogicalHost {
|
||||
ip: ip!("192.168.1.1"),
|
||||
name: String::from("fw0"),
|
||||
};
|
||||
|
||||
let opnsense = Arc::new(
|
||||
harmony::infra::opnsense::OPNSenseFirewall::new(firewall, None, "root", "opnsense").await,
|
||||
);
|
||||
|
||||
let topology = OpnSenseTopology {
|
||||
node_exporter: opnsense.clone(),
|
||||
};
|
||||
|
||||
let inventory = Inventory::empty();
|
||||
|
||||
let node_exporter_score = NodeExporterScore {};
|
||||
|
||||
harmony_cli::run(
|
||||
inventory,
|
||||
topology,
|
||||
vec![Box::new(node_exporter_score)],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
@@ -4,19 +4,19 @@ use harmony_types::{
|
||||
net::{MacAddress, Url},
|
||||
switch::PortLocation,
|
||||
};
|
||||
use k8s_openapi::api::core::v1::Namespace;
|
||||
use kube::api::ObjectMeta;
|
||||
use log::debug;
|
||||
use log::info;
|
||||
|
||||
use crate::data::FileContent;
|
||||
use crate::executors::ExecutorError;
|
||||
use crate::hardware::PhysicalHost;
|
||||
use crate::modules::okd::crd::{
|
||||
InstallPlanApproval, OperatorGroup, OperatorGroupSpec, Subscription, SubscriptionSpec,
|
||||
nmstate::{self, NMState, NodeNetworkConfigurationPolicy, NodeNetworkConfigurationPolicySpec},
|
||||
};
|
||||
use crate::topology::PxeOptions;
|
||||
use crate::{data::FileContent, modules::okd::crd::nmstate::NMState};
|
||||
use crate::{
|
||||
executors::ExecutorError, modules::okd::crd::nmstate::NodeNetworkConfigurationPolicySpec,
|
||||
};
|
||||
use crate::{
|
||||
modules::okd::crd::nmstate::{self, NodeNetworkConfigurationPolicy},
|
||||
topology::node_exporter::NodeExporter,
|
||||
};
|
||||
|
||||
use super::{
|
||||
DHCPStaticEntry, DhcpServer, DnsRecord, DnsRecordType, DnsServer, Firewall, HostNetworkConfig,
|
||||
@@ -38,10 +38,12 @@ pub struct HAClusterTopology {
|
||||
pub tftp_server: Arc<dyn TftpServer>,
|
||||
pub http_server: Arc<dyn HttpServer>,
|
||||
pub dns_server: Arc<dyn DnsServer>,
|
||||
pub node_exporter: Arc<dyn NodeExporter>,
|
||||
pub switch_client: Arc<dyn SwitchClient>,
|
||||
pub bootstrap_host: LogicalHost,
|
||||
pub control_plane: Vec<LogicalHost>,
|
||||
pub workers: Vec<LogicalHost>,
|
||||
pub kubeconfig: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -60,9 +62,17 @@ impl Topology for HAClusterTopology {
|
||||
#[async_trait]
|
||||
impl K8sclient for HAClusterTopology {
|
||||
async fn k8s_client(&self) -> Result<Arc<K8sClient>, String> {
|
||||
Ok(Arc::new(
|
||||
K8sClient::try_default().await.map_err(|e| e.to_string())?,
|
||||
))
|
||||
match &self.kubeconfig {
|
||||
None => Ok(Arc::new(
|
||||
K8sClient::try_default().await.map_err(|e| e.to_string())?,
|
||||
)),
|
||||
Some(kubeconfig) => {
|
||||
let Some(client) = K8sClient::from_kubeconfig(&kubeconfig).await else {
|
||||
return Err("Failed to create k8s client".to_string());
|
||||
};
|
||||
Ok(Arc::new(client))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,60 +98,48 @@ impl HAClusterTopology {
|
||||
}
|
||||
|
||||
async fn ensure_nmstate_operator_installed(&self) -> Result<(), String> {
|
||||
// FIXME: Find a way to check nmstate is already available (get pod -n openshift-nmstate)
|
||||
debug!("Installing NMState operator...");
|
||||
let k8s_client = self.k8s_client().await?;
|
||||
|
||||
let nmstate_namespace = Namespace {
|
||||
metadata: ObjectMeta {
|
||||
name: Some("openshift-nmstate".to_string()),
|
||||
finalizers: Some(vec!["kubernetes".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
debug!("Creating NMState namespace: {nmstate_namespace:#?}");
|
||||
k8s_client
|
||||
.apply(&nmstate_namespace, None)
|
||||
debug!("Installing NMState controller...");
|
||||
k8s_client.apply_url(url::Url::parse("https://github.com/nmstate/kubernetes-nmstate/releases/download/v0.84.0/nmstate.io_nmstates.yaml
|
||||
").unwrap(), Some("nmstate"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let nmstate_operator_group = OperatorGroup {
|
||||
metadata: ObjectMeta {
|
||||
name: Some("openshift-nmstate".to_string()),
|
||||
namespace: Some("openshift-nmstate".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
spec: OperatorGroupSpec {
|
||||
target_namespaces: vec!["openshift-nmstate".to_string()],
|
||||
},
|
||||
};
|
||||
debug!("Creating NMState operator group: {nmstate_operator_group:#?}");
|
||||
k8s_client
|
||||
.apply(&nmstate_operator_group, None)
|
||||
debug!("Creating NMState namespace...");
|
||||
k8s_client.apply_url(url::Url::parse("https://github.com/nmstate/kubernetes-nmstate/releases/download/v0.84.0/namespace.yaml
|
||||
").unwrap(), Some("nmstate"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let nmstate_subscription = Subscription {
|
||||
metadata: ObjectMeta {
|
||||
name: Some("kubernetes-nmstate-operator".to_string()),
|
||||
namespace: Some("openshift-nmstate".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
spec: SubscriptionSpec {
|
||||
channel: Some("stable".to_string()),
|
||||
install_plan_approval: Some(InstallPlanApproval::Automatic),
|
||||
name: "kubernetes-nmstate-operator".to_string(),
|
||||
source: "redhat-operators".to_string(),
|
||||
source_namespace: "openshift-marketplace".to_string(),
|
||||
},
|
||||
};
|
||||
debug!("Subscribing to NMState Operator: {nmstate_subscription:#?}");
|
||||
k8s_client
|
||||
.apply(&nmstate_subscription, None)
|
||||
debug!("Creating NMState service account...");
|
||||
k8s_client.apply_url(url::Url::parse("https://github.com/nmstate/kubernetes-nmstate/releases/download/v0.84.0/service_account.yaml
|
||||
").unwrap(), Some("nmstate"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
debug!("Creating NMState role...");
|
||||
k8s_client.apply_url(url::Url::parse("https://github.com/nmstate/kubernetes-nmstate/releases/download/v0.84.0/role.yaml
|
||||
").unwrap(), Some("nmstate"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
debug!("Creating NMState role binding...");
|
||||
k8s_client.apply_url(url::Url::parse("https://github.com/nmstate/kubernetes-nmstate/releases/download/v0.84.0/role_binding.yaml
|
||||
").unwrap(), Some("nmstate"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
debug!("Creating NMState operator...");
|
||||
k8s_client.apply_url(url::Url::parse("https://github.com/nmstate/kubernetes-nmstate/releases/download/v0.84.0/operator.yaml
|
||||
").unwrap(), Some("nmstate"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
k8s_client
|
||||
.wait_until_deployment_ready("nmstate-operator", Some("nmstate"), None)
|
||||
.await?;
|
||||
|
||||
let nmstate = NMState {
|
||||
metadata: ObjectMeta {
|
||||
name: Some("nmstate".to_string()),
|
||||
@@ -162,11 +160,7 @@ impl HAClusterTopology {
|
||||
42 // FIXME: Find a better way to declare the bond id
|
||||
}
|
||||
|
||||
async fn configure_bond(
|
||||
&self,
|
||||
host: &PhysicalHost,
|
||||
config: &HostNetworkConfig,
|
||||
) -> Result<(), SwitchError> {
|
||||
async fn configure_bond(&self, config: &HostNetworkConfig) -> Result<(), SwitchError> {
|
||||
self.ensure_nmstate_operator_installed()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -175,29 +169,33 @@ impl HAClusterTopology {
|
||||
))
|
||||
})?;
|
||||
|
||||
let bond_config = self.create_bond_configuration(host, config);
|
||||
debug!("Configuring bond for host {host:?}: {bond_config:#?}");
|
||||
let bond_config = self.create_bond_configuration(config);
|
||||
debug!(
|
||||
"Applying NMState bond config for host {}: {bond_config:#?}",
|
||||
config.host_id
|
||||
);
|
||||
self.k8s_client()
|
||||
.await
|
||||
.unwrap()
|
||||
.apply(&bond_config, None)
|
||||
.await
|
||||
.unwrap();
|
||||
.map_err(|e| SwitchError::new(format!("Failed to configure bond: {e}")))?;
|
||||
|
||||
todo!()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_bond_configuration(
|
||||
&self,
|
||||
host: &PhysicalHost,
|
||||
config: &HostNetworkConfig,
|
||||
) -> NodeNetworkConfigurationPolicy {
|
||||
let host_name = host.id.clone();
|
||||
|
||||
let host_name = &config.host_id;
|
||||
let bond_id = self.get_next_bond_id();
|
||||
let bond_name = format!("bond{bond_id}");
|
||||
|
||||
info!("Configuring bond '{bond_name}' for host '{host_name}'...");
|
||||
|
||||
let mut bond_mtu: Option<u32> = None;
|
||||
let mut bond_mac_address: Option<String> = None;
|
||||
let mut copy_mac_from: Option<String> = None;
|
||||
let mut bond_ports = Vec::new();
|
||||
let mut interfaces: Vec<nmstate::InterfaceSpec> = Vec::new();
|
||||
|
||||
@@ -223,14 +221,14 @@ impl HAClusterTopology {
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
bond_ports.push(interface_name);
|
||||
bond_ports.push(interface_name.clone());
|
||||
|
||||
// Use the first port's details for the bond mtu and mac address
|
||||
if bond_mtu.is_none() {
|
||||
bond_mtu = Some(switch_port.interface.mtu);
|
||||
}
|
||||
if bond_mac_address.is_none() {
|
||||
bond_mac_address = Some(switch_port.interface.mac_address.to_string());
|
||||
if copy_mac_from.is_none() {
|
||||
copy_mac_from = Some(interface_name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,8 +237,7 @@ impl HAClusterTopology {
|
||||
description: Some(format!("Network bond for host {host_name}")),
|
||||
r#type: "bond".to_string(),
|
||||
state: "up".to_string(),
|
||||
mtu: bond_mtu,
|
||||
mac_address: bond_mac_address,
|
||||
copy_mac_from,
|
||||
ipv4: Some(nmstate::IpStackSpec {
|
||||
dhcp: Some(true),
|
||||
enabled: Some(true),
|
||||
@@ -275,16 +272,12 @@ impl HAClusterTopology {
|
||||
}
|
||||
}
|
||||
|
||||
async fn configure_port_channel(
|
||||
&self,
|
||||
host: &PhysicalHost,
|
||||
config: &HostNetworkConfig,
|
||||
) -> Result<(), SwitchError> {
|
||||
async fn configure_port_channel(&self, config: &HostNetworkConfig) -> Result<(), SwitchError> {
|
||||
debug!("Configuring port channel: {config:#?}");
|
||||
let switch_ports = config.switch_ports.iter().map(|s| s.port.clone()).collect();
|
||||
|
||||
self.switch_client
|
||||
.configure_port_channel(&format!("Harmony_{}", host.id), switch_ports)
|
||||
.configure_port_channel(&format!("Harmony_{}", config.host_id), switch_ports)
|
||||
.await
|
||||
.map_err(|e| SwitchError::new(format!("Failed to configure switch: {e}")))?;
|
||||
|
||||
@@ -299,6 +292,7 @@ impl HAClusterTopology {
|
||||
};
|
||||
|
||||
Self {
|
||||
kubeconfig: None,
|
||||
domain_name: "DummyTopology".to_string(),
|
||||
router: dummy_infra.clone(),
|
||||
load_balancer: dummy_infra.clone(),
|
||||
@@ -307,6 +301,7 @@ impl HAClusterTopology {
|
||||
tftp_server: dummy_infra.clone(),
|
||||
http_server: dummy_infra.clone(),
|
||||
dns_server: dummy_infra.clone(),
|
||||
node_exporter: dummy_infra.clone(),
|
||||
switch_client: dummy_infra.clone(),
|
||||
bootstrap_host: dummy_host,
|
||||
control_plane: vec![],
|
||||
@@ -480,13 +475,26 @@ impl Switch for HAClusterTopology {
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
async fn configure_host_network(
|
||||
&self,
|
||||
host: &PhysicalHost,
|
||||
config: HostNetworkConfig,
|
||||
) -> Result<(), SwitchError> {
|
||||
self.configure_bond(host, &config).await?;
|
||||
self.configure_port_channel(host, &config).await
|
||||
async fn configure_host_network(&self, config: &HostNetworkConfig) -> Result<(), SwitchError> {
|
||||
self.configure_bond(config).await?;
|
||||
self.configure_port_channel(config).await
|
||||
}
|
||||
|
||||
//TODO add snmp here
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NodeExporter for HAClusterTopology {
|
||||
async fn ensure_initialized(&self) -> Result<(), ExecutorError> {
|
||||
self.node_exporter.ensure_initialized().await
|
||||
}
|
||||
|
||||
async fn commit_config(&self) -> Result<(), ExecutorError> {
|
||||
self.node_exporter.commit_config().await
|
||||
}
|
||||
|
||||
async fn reload_restart(&self) -> Result<(), ExecutorError> {
|
||||
self.node_exporter.reload_restart().await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,6 +685,21 @@ impl DnsServer for DummyInfra {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NodeExporter for DummyInfra {
|
||||
async fn ensure_initialized(&self) -> Result<(), ExecutorError> {
|
||||
unimplemented!("{}", UNIMPLEMENTED_DUMMY_INFRA)
|
||||
}
|
||||
|
||||
async fn commit_config(&self) -> Result<(), ExecutorError> {
|
||||
unimplemented!("{}", UNIMPLEMENTED_DUMMY_INFRA)
|
||||
}
|
||||
|
||||
async fn reload_restart(&self) -> Result<(), ExecutorError> {
|
||||
unimplemented!("{}", UNIMPLEMENTED_DUMMY_INFRA)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SwitchClient for DummyInfra {
|
||||
async fn setup(&self) -> Result<(), SwitchError> {
|
||||
|
||||
@@ -14,6 +14,7 @@ use kube::{
|
||||
api::{Api, AttachParams, DeleteParams, ListParams, Patch, PatchParams, ResourceExt},
|
||||
config::{KubeConfigOptions, Kubeconfig},
|
||||
core::ErrorResponse,
|
||||
discovery::{ApiCapabilities, Scope},
|
||||
error::DiscoveryError,
|
||||
runtime::reflector::Lookup,
|
||||
};
|
||||
@@ -22,11 +23,12 @@ use kube::{
|
||||
api::{ApiResource, GroupVersionKind},
|
||||
runtime::wait::await_condition,
|
||||
};
|
||||
use log::{debug, error, info, trace};
|
||||
use log::{debug, error, info, trace, warn};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use serde_json::json;
|
||||
use similar::TextDiff;
|
||||
use tokio::{io::AsyncReadExt, time::sleep};
|
||||
use url::Url;
|
||||
|
||||
#[derive(new, Clone)]
|
||||
pub struct K8sClient {
|
||||
@@ -88,7 +90,8 @@ impl K8sClient {
|
||||
} else {
|
||||
Api::default_namespaced_with(self.client.clone(), &gvk)
|
||||
};
|
||||
Ok(resource.get(name).await?)
|
||||
|
||||
resource.get(name).await
|
||||
}
|
||||
|
||||
pub async fn get_deployment(
|
||||
@@ -103,8 +106,9 @@ impl K8sClient {
|
||||
debug!("getting default namespace deployment");
|
||||
Api::default_namespaced(self.client.clone())
|
||||
};
|
||||
|
||||
debug!("getting deployment {} in ns {}", name, namespace.unwrap());
|
||||
Ok(deps.get_opt(name).await?)
|
||||
deps.get_opt(name).await
|
||||
}
|
||||
|
||||
pub async fn get_pod(&self, name: &str, namespace: Option<&str>) -> Result<Option<Pod>, Error> {
|
||||
@@ -113,7 +117,8 @@ impl K8sClient {
|
||||
} else {
|
||||
Api::default_namespaced(self.client.clone())
|
||||
};
|
||||
Ok(pods.get_opt(name).await?)
|
||||
|
||||
pods.get_opt(name).await
|
||||
}
|
||||
|
||||
pub async fn scale_deployment(
|
||||
@@ -156,9 +161,9 @@ impl K8sClient {
|
||||
|
||||
pub async fn wait_until_deployment_ready(
|
||||
&self,
|
||||
name: String,
|
||||
name: &str,
|
||||
namespace: Option<&str>,
|
||||
timeout: Option<u64>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<(), String> {
|
||||
let api: Api<Deployment>;
|
||||
|
||||
@@ -168,9 +173,9 @@ impl K8sClient {
|
||||
api = Api::default_namespaced(self.client.clone());
|
||||
}
|
||||
|
||||
let establish = await_condition(api, name.as_str(), conditions::is_deployment_completed());
|
||||
let t = timeout.unwrap_or(300);
|
||||
let res = tokio::time::timeout(std::time::Duration::from_secs(t), establish).await;
|
||||
let establish = await_condition(api, name, conditions::is_deployment_completed());
|
||||
let timeout = timeout.unwrap_or(Duration::from_secs(120));
|
||||
let res = tokio::time::timeout(timeout, establish).await;
|
||||
|
||||
if res.is_ok() {
|
||||
Ok(())
|
||||
@@ -260,7 +265,7 @@ impl K8sClient {
|
||||
|
||||
if let Some(s) = status.status {
|
||||
let mut stdout_buf = String::new();
|
||||
if let Some(mut stdout) = process.stdout().take() {
|
||||
if let Some(mut stdout) = process.stdout() {
|
||||
stdout
|
||||
.read_to_string(&mut stdout_buf)
|
||||
.await
|
||||
@@ -366,14 +371,14 @@ impl K8sClient {
|
||||
Ok(current) => {
|
||||
trace!("Received current value {current:#?}");
|
||||
// The resource exists, so we calculate and display a diff.
|
||||
println!("\nPerforming dry-run for resource: '{}'", name);
|
||||
println!("\nPerforming dry-run for resource: '{name}'");
|
||||
let mut current_yaml = serde_yaml::to_value(¤t).unwrap_or_else(|_| {
|
||||
panic!("Could not serialize current value : {current:#?}")
|
||||
});
|
||||
if current_yaml.is_mapping() && current_yaml.get("status").is_some() {
|
||||
let map = current_yaml.as_mapping_mut().unwrap();
|
||||
let removed = map.remove_entry("status");
|
||||
trace!("Removed status {:?}", removed);
|
||||
trace!("Removed status {removed:?}");
|
||||
} else {
|
||||
trace!(
|
||||
"Did not find status entry for current object {}/{}",
|
||||
@@ -402,14 +407,14 @@ impl K8sClient {
|
||||
similar::ChangeTag::Insert => "+",
|
||||
similar::ChangeTag::Equal => " ",
|
||||
};
|
||||
print!("{}{}", sign, change);
|
||||
print!("{sign}{change}");
|
||||
}
|
||||
// In a dry run, we return the new resource state that would have been applied.
|
||||
Ok(resource.clone())
|
||||
}
|
||||
Err(Error::Api(ErrorResponse { code: 404, .. })) => {
|
||||
// The resource does not exist, so the "diff" is the entire new resource.
|
||||
println!("\nPerforming dry-run for new resource: '{}'", name);
|
||||
println!("\nPerforming dry-run for new resource: '{name}'");
|
||||
println!(
|
||||
"Resource does not exist. It would be created with the following content:"
|
||||
);
|
||||
@@ -418,14 +423,14 @@ impl K8sClient {
|
||||
|
||||
// Print each line of the new resource with a '+' prefix.
|
||||
for line in new_yaml.lines() {
|
||||
println!("+{}", line);
|
||||
println!("+{line}");
|
||||
}
|
||||
// In a dry run, we return the new resource state that would have been created.
|
||||
Ok(resource.clone())
|
||||
}
|
||||
Err(e) => {
|
||||
// Another API error occurred.
|
||||
error!("Failed to get resource '{}': {}", name, e);
|
||||
error!("Failed to get resource '{name}': {e}");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
@@ -440,7 +445,7 @@ impl K8sClient {
|
||||
where
|
||||
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + serde::Serialize,
|
||||
<K as Resource>::Scope: ApplyStrategy<K>,
|
||||
<K as kube::Resource>::DynamicType: Default,
|
||||
<K as Resource>::DynamicType: Default,
|
||||
{
|
||||
let mut result = Vec::new();
|
||||
for r in resource.iter() {
|
||||
@@ -505,10 +510,7 @@ impl K8sClient {
|
||||
|
||||
// 6. Apply the object to the cluster using Server-Side Apply.
|
||||
// This will create the resource if it doesn't exist, or update it if it does.
|
||||
println!(
|
||||
"Applying Argo Application '{}' in namespace '{}'...",
|
||||
name, namespace
|
||||
);
|
||||
println!("Applying '{name}' in namespace '{namespace}'...",);
|
||||
let patch_params = PatchParams::apply("harmony"); // Use a unique field manager name
|
||||
let result = api.patch(name, &patch_params, &Patch::Apply(&obj)).await?;
|
||||
|
||||
@@ -517,6 +519,51 @@ impl K8sClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a resource from a URL
|
||||
///
|
||||
/// It is the equivalent of `kubectl apply -f <url>`
|
||||
pub async fn apply_url(&self, url: Url, ns: Option<&str>) -> Result<(), Error> {
|
||||
let patch_params = PatchParams::apply("harmony");
|
||||
let discovery = kube::Discovery::new(self.client.clone()).run().await?;
|
||||
|
||||
let yaml = reqwest::get(url)
|
||||
.await
|
||||
.expect("Could not get URL")
|
||||
.text()
|
||||
.await
|
||||
.expect("Could not get content from URL");
|
||||
|
||||
for doc in multidoc_deserialize(&yaml).expect("failed to parse YAML from file") {
|
||||
let obj: DynamicObject =
|
||||
serde_yaml::from_value(doc).expect("cannot apply without valid YAML");
|
||||
let namespace = obj.metadata.namespace.as_deref().or(ns);
|
||||
let type_meta = obj
|
||||
.types
|
||||
.as_ref()
|
||||
.expect("cannot apply object without valid TypeMeta");
|
||||
let gvk = GroupVersionKind::try_from(type_meta)
|
||||
.expect("cannot apply object without valid GroupVersionKind");
|
||||
let name = obj.name_any();
|
||||
|
||||
if let Some((ar, caps)) = discovery.resolve_gvk(&gvk) {
|
||||
let api = get_dynamic_api(ar, caps, self.client.clone(), namespace, false);
|
||||
trace!(
|
||||
"Applying {}: \n{}",
|
||||
gvk.kind,
|
||||
serde_yaml::to_string(&obj).expect("Failed to serialize YAML")
|
||||
);
|
||||
let data: serde_json::Value =
|
||||
serde_json::to_value(&obj).expect("Failed to serialize JSON");
|
||||
let _r = api.patch(&name, &patch_params, &Patch::Apply(data)).await?;
|
||||
debug!("applied {} {}", gvk.kind, name);
|
||||
} else {
|
||||
warn!("Cannot apply document for unknown {gvk:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn from_kubeconfig(path: &str) -> Option<K8sClient> {
|
||||
let k = match Kubeconfig::read_from(path) {
|
||||
Ok(k) => k,
|
||||
@@ -536,6 +583,31 @@ impl K8sClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_dynamic_api(
|
||||
resource: ApiResource,
|
||||
capabilities: ApiCapabilities,
|
||||
client: Client,
|
||||
ns: Option<&str>,
|
||||
all: bool,
|
||||
) -> Api<DynamicObject> {
|
||||
if capabilities.scope == Scope::Cluster || all {
|
||||
Api::all_with(client, &resource)
|
||||
} else if let Some(namespace) = ns {
|
||||
Api::namespaced_with(client, namespace, &resource)
|
||||
} else {
|
||||
Api::default_namespaced_with(client, &resource)
|
||||
}
|
||||
}
|
||||
|
||||
fn multidoc_deserialize(data: &str) -> Result<Vec<serde_yaml::Value>, serde_yaml::Error> {
|
||||
use serde::Deserialize;
|
||||
let mut docs = vec![];
|
||||
for de in serde_yaml::Deserializer::from_str(data) {
|
||||
docs.push(serde_yaml::Value::deserialize(de)?);
|
||||
}
|
||||
Ok(docs)
|
||||
}
|
||||
|
||||
pub trait ApplyStrategy<K: Resource> {
|
||||
fn get_api(client: &Client, ns: Option<&str>) -> Api<K>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{collections::BTreeMap, process::Command, sync::Arc};
|
||||
use std::{collections::BTreeMap, process::Command, sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
@@ -155,9 +155,9 @@ impl Grafana for K8sAnywhereTopology {
|
||||
//TODO change this to a ensure ready or something better than just a timeout
|
||||
client
|
||||
.wait_until_deployment_ready(
|
||||
"grafana-grafana-deployment".to_string(),
|
||||
"grafana-grafana-deployment",
|
||||
Some("grafana"),
|
||||
Some(30),
|
||||
Some(Duration::from_secs(30)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod ha_cluster;
|
||||
pub mod ingress;
|
||||
pub mod node_exporter;
|
||||
use harmony_types::net::IpAddress;
|
||||
mod host_binding;
|
||||
mod http;
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::{
|
||||
use async_trait::async_trait;
|
||||
use derive_new::new;
|
||||
use harmony_types::{
|
||||
id::Id,
|
||||
net::{IpAddress, MacAddress},
|
||||
switch::PortLocation,
|
||||
};
|
||||
@@ -191,15 +192,12 @@ pub trait Switch: Send + Sync {
|
||||
mac_address: &MacAddress,
|
||||
) -> Result<Option<PortLocation>, SwitchError>;
|
||||
|
||||
async fn configure_host_network(
|
||||
&self,
|
||||
host: &PhysicalHost,
|
||||
config: HostNetworkConfig,
|
||||
) -> Result<(), SwitchError>;
|
||||
async fn configure_host_network(&self, config: &HostNetworkConfig) -> Result<(), SwitchError>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HostNetworkConfig {
|
||||
pub host_id: Id,
|
||||
pub switch_ports: Vec<SwitchPort>,
|
||||
}
|
||||
|
||||
|
||||
17
harmony/src/domain/topology/node_exporter.rs
Normal file
17
harmony/src/domain/topology/node_exporter.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::executors::ExecutorError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait NodeExporter: Send + Sync {
|
||||
async fn ensure_initialized(&self) -> Result<(), ExecutorError>;
|
||||
async fn commit_config(&self) -> Result<(), ExecutorError>;
|
||||
async fn reload_restart(&self) -> Result<(), ExecutorError>;
|
||||
}
|
||||
|
||||
//TODO complete this impl
|
||||
impl std::fmt::Debug for dyn NodeExporter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_fmt(format_args!("NodeExporter ",))
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ pub struct InventoryRepositoryFactory;
|
||||
impl InventoryRepositoryFactory {
|
||||
pub async fn build() -> Result<Box<dyn InventoryRepository>, RepoError> {
|
||||
Ok(Box::new(
|
||||
SqliteInventoryRepository::new(&(*DATABASE_URL)).await?,
|
||||
SqliteInventoryRepository::new(&DATABASE_URL).await?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ mod firewall;
|
||||
mod http;
|
||||
mod load_balancer;
|
||||
mod management;
|
||||
pub mod node_exporter;
|
||||
mod tftp;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
47
harmony/src/infra/opnsense/node_exporter.rs
Normal file
47
harmony/src/infra/opnsense/node_exporter.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use async_trait::async_trait;
|
||||
use log::debug;
|
||||
|
||||
use crate::{
|
||||
executors::ExecutorError, infra::opnsense::OPNSenseFirewall,
|
||||
topology::node_exporter::NodeExporter,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
impl NodeExporter for OPNSenseFirewall {
|
||||
async fn ensure_initialized(&self) -> Result<(), ExecutorError> {
|
||||
let mut config = self.opnsense_config.write().await;
|
||||
let node_exporter = config.node_exporter();
|
||||
if let Some(config) = node_exporter.get_full_config() {
|
||||
debug!(
|
||||
"Node exporter available in opnsense config, assuming it is already installed. {config:?}"
|
||||
);
|
||||
} else {
|
||||
config
|
||||
.install_package("os-node_exporter")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ExecutorError::UnexpectedError(format!("Executor failed when trying to install os-node_exporter package with error {e:?}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
config
|
||||
.node_exporter()
|
||||
.enable(true)
|
||||
.map_err(|e| ExecutorError::UnexpectedError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
async fn commit_config(&self) -> Result<(), ExecutorError> {
|
||||
OPNSenseFirewall::commit_config(self).await
|
||||
}
|
||||
|
||||
async fn reload_restart(&self) -> Result<(), ExecutorError> {
|
||||
self.opnsense_config
|
||||
.write()
|
||||
.await
|
||||
.node_exporter()
|
||||
.reload_restart()
|
||||
.await
|
||||
.map_err(|e| ExecutorError::UnexpectedError(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -38,13 +38,15 @@ impl<
|
||||
+ 'static
|
||||
+ Send
|
||||
+ Clone,
|
||||
T: Topology,
|
||||
T: Topology + K8sclient,
|
||||
> Score<T> for K8sResourceScore<K>
|
||||
where
|
||||
<K as kube::Resource>::DynamicType: Default,
|
||||
{
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||
todo!()
|
||||
Box::new(K8sResourceInterpret {
|
||||
score: self.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
|
||||
@@ -14,7 +14,8 @@ use crate::{
|
||||
},
|
||||
score::Score,
|
||||
topology::{
|
||||
oberservability::monitoring::{AlertReceiver, AlertingInterpret, ScrapeTarget}, K8sclient, Topology
|
||||
K8sclient, Topology,
|
||||
oberservability::monitoring::{AlertReceiver, AlertingInterpret, ScrapeTarget},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ use crate::{
|
||||
prometheus::prometheus::PrometheusMonitoring,
|
||||
},
|
||||
topology::{
|
||||
installable::Installable, k8s::K8sClient, oberservability::monitoring::{AlertReceiver, AlertSender, ScrapeTarget}, K8sclient, Topology
|
||||
K8sclient, Topology,
|
||||
installable::Installable,
|
||||
k8s::K8sClient,
|
||||
oberservability::monitoring::{AlertReceiver, AlertSender, ScrapeTarget},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -100,11 +100,7 @@ impl<T: Topology + HelmCommand + K8sclient + MultiTargetTopology> Interpret<T> f
|
||||
|
||||
info!("deploying ntfy...");
|
||||
client
|
||||
.wait_until_deployment_ready(
|
||||
"ntfy".to_string(),
|
||||
Some(self.score.namespace.as_str()),
|
||||
None,
|
||||
)
|
||||
.wait_until_deployment_ready("ntfy", Some(self.score.namespace.as_str()), None)
|
||||
.await?;
|
||||
info!("ntfy deployed");
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@ use crate::{
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::{HostRole, Inventory},
|
||||
modules::{
|
||||
dhcp::DhcpHostBindingScore,
|
||||
http::IPxeMacBootFileScore,
|
||||
inventory::DiscoverHostForRoleScore,
|
||||
okd::{host_network::HostNetworkConfigurationScore, templates::BootstrapIpxeTpl},
|
||||
dhcp::DhcpHostBindingScore, http::IPxeMacBootFileScore,
|
||||
inventory::DiscoverHostForRoleScore, okd::templates::BootstrapIpxeTpl,
|
||||
},
|
||||
score::Score,
|
||||
topology::{HAClusterTopology, HostBinding},
|
||||
@@ -205,28 +203,6 @@ impl OKDSetup03ControlPlaneInterpret {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Placeholder for automating network bonding configuration.
|
||||
async fn persist_network_bond(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
hosts: &Vec<PhysicalHost>,
|
||||
) -> Result<(), InterpretError> {
|
||||
info!("[ControlPlane] Ensuring persistent bonding");
|
||||
let score = HostNetworkConfigurationScore {
|
||||
hosts: hosts.clone(),
|
||||
};
|
||||
score.interpret(inventory, topology).await?;
|
||||
|
||||
inquire::Confirm::new(
|
||||
"Network configuration for control plane nodes is not automated yet. Configure it manually if needed.",
|
||||
)
|
||||
.prompt()
|
||||
.map_err(|e| InterpretError::new(format!("User prompt failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -265,10 +241,6 @@ impl Interpret<HAClusterTopology> for OKDSetup03ControlPlaneInterpret {
|
||||
// 4. Reboot the nodes to start the OS installation.
|
||||
self.reboot_targets(&nodes).await?;
|
||||
|
||||
// 5. Placeholder for post-boot network configuration (e.g., bonding).
|
||||
self.persist_network_bond(inventory, topology, &nodes)
|
||||
.await?;
|
||||
|
||||
// TODO: Implement a step to wait for the control plane nodes to join the cluster
|
||||
// and for the cluster operators to become available. This would be similar to
|
||||
// the `wait-for bootstrap-complete` command.
|
||||
|
||||
130
harmony/src/modules/okd/bootstrap_persist_network_bond.rs
Normal file
130
harmony/src/modules/okd/bootstrap_persist_network_bond.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use crate::{
|
||||
data::Version,
|
||||
hardware::PhysicalHost,
|
||||
infra::inventory::InventoryRepositoryFactory,
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::{HostRole, Inventory},
|
||||
modules::okd::host_network::HostNetworkConfigurationScore,
|
||||
score::Score,
|
||||
topology::HAClusterTopology,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use derive_new::new;
|
||||
use harmony_types::id::Id;
|
||||
use log::info;
|
||||
use serde::Serialize;
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Persist Network Bond
|
||||
// - Persist bonding via NMState
|
||||
// - Persist port channels on the Switch
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, new)]
|
||||
pub struct OKDSetupPersistNetworkBondScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDSetupPersistNetworkBondScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDSetupPersistNetworkBondInterpet::new())
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDSetupPersistNetworkBondScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OKDSetupPersistNetworkBondInterpet {
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDSetupPersistNetworkBondInterpet {
|
||||
pub fn new() -> Self {
|
||||
let version = Version::from("1.0.0").unwrap();
|
||||
Self {
|
||||
version,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures that three physical hosts are discovered and available for the ControlPlane role.
|
||||
/// It will trigger discovery if not enough hosts are found.
|
||||
async fn get_nodes(
|
||||
&self,
|
||||
_inventory: &Inventory,
|
||||
_topology: &HAClusterTopology,
|
||||
) -> Result<Vec<PhysicalHost>, InterpretError> {
|
||||
const REQUIRED_HOSTS: usize = 3;
|
||||
let repo = InventoryRepositoryFactory::build().await?;
|
||||
let control_plane_hosts = repo.get_host_for_role(&HostRole::ControlPlane).await?;
|
||||
|
||||
if control_plane_hosts.len() < REQUIRED_HOSTS {
|
||||
Err(InterpretError::new(format!(
|
||||
"OKD Requires at least {} control plane hosts, but only found {}. Cannot proceed.",
|
||||
REQUIRED_HOSTS,
|
||||
control_plane_hosts.len()
|
||||
)))
|
||||
} else {
|
||||
// Take exactly the number of required hosts to ensure consistency.
|
||||
Ok(control_plane_hosts
|
||||
.into_iter()
|
||||
.take(REQUIRED_HOSTS)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_network_bond(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
hosts: &Vec<PhysicalHost>,
|
||||
) -> Result<(), InterpretError> {
|
||||
info!("Ensuring persistent bonding");
|
||||
|
||||
let score = HostNetworkConfigurationScore {
|
||||
hosts: hosts.clone(),
|
||||
};
|
||||
score.interpret(inventory, topology).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDSetupPersistNetworkBondInterpet {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDSetupPersistNetworkBondInterpet")
|
||||
}
|
||||
|
||||
fn get_version(&self) -> Version {
|
||||
self.version.clone()
|
||||
}
|
||||
|
||||
fn get_status(&self) -> InterpretStatus {
|
||||
self.status.clone()
|
||||
}
|
||||
|
||||
fn get_children(&self) -> Vec<Id> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<Outcome, InterpretError> {
|
||||
let nodes = self.get_nodes(inventory, topology).await?;
|
||||
|
||||
let res = self.persist_network_bond(inventory, topology, &nodes).await;
|
||||
|
||||
match res {
|
||||
Ok(_) => Ok(Outcome::success(
|
||||
"Network bond successfully persisted".into(),
|
||||
)),
|
||||
Err(_) => Err(InterpretError::new(
|
||||
"Failed to persist network bond".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1 @@
|
||||
use kube::CustomResource;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod nmstate;
|
||||
|
||||
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
|
||||
#[kube(
|
||||
group = "operators.coreos.com",
|
||||
version = "v1",
|
||||
kind = "OperatorGroup",
|
||||
namespaced
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OperatorGroupSpec {
|
||||
pub target_namespaces: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
|
||||
#[kube(
|
||||
group = "operators.coreos.com",
|
||||
version = "v1alpha1",
|
||||
kind = "Subscription",
|
||||
namespaced
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionSpec {
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
pub source_namespace: String,
|
||||
pub channel: Option<String>,
|
||||
pub install_plan_approval: Option<InstallPlanApproval>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
|
||||
pub enum InstallPlanApproval {
|
||||
#[serde(rename = "Automatic")]
|
||||
Automatic,
|
||||
#[serde(rename = "Manual")]
|
||||
Manual,
|
||||
}
|
||||
|
||||
@@ -6,9 +6,16 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
|
||||
#[kube(group = "nmstate.io", version = "v1", kind = "NMState", namespaced)]
|
||||
#[kube(
|
||||
group = "nmstate.io",
|
||||
version = "v1",
|
||||
kind = "NMState",
|
||||
plural = "nmstates",
|
||||
namespaced = false
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NMStateSpec {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub probe_configuration: Option<ProbeConfig>,
|
||||
}
|
||||
|
||||
@@ -44,6 +51,7 @@ pub struct ProbeDns {
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NodeNetworkConfigurationPolicySpec {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_selector: Option<BTreeMap<String, String>>,
|
||||
pub desired_state: DesiredStateSpec,
|
||||
}
|
||||
@@ -58,37 +66,64 @@ pub struct DesiredStateSpec {
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct InterfaceSpec {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub r#type: String,
|
||||
pub state: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mac_address: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub copy_mac_from: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mtu: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub controller: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ipv4: Option<IpStackSpec>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ipv6: Option<IpStackSpec>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ethernet: Option<EthernetSpec>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link_aggregation: Option<BondSpec>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vlan: Option<VlanSpec>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vxlan: Option<VxlanSpec>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mac_vtap: Option<MacVtapSpec>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mac_vlan: Option<MacVlanSpec>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub infiniband: Option<InfinibandSpec>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub linux_bridge: Option<LinuxBridgeSpec>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ovs_bridge: Option<OvsBridgeSpec>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ethtool: Option<EthtoolSpec>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct IpStackSpec {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dhcp: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub autoconf: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub address: Option<Vec<IpAddressSpec>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_dns: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_gateway: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_routes: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dhcp_client_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dhcp_duid: Option<String>,
|
||||
}
|
||||
|
||||
@@ -102,8 +137,11 @@ pub struct IpAddressSpec {
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct EthernetSpec {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub speed: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duplex: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_negotiation: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -112,6 +150,7 @@ pub struct EthernetSpec {
|
||||
pub struct BondSpec {
|
||||
pub mode: String,
|
||||
pub ports: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<BTreeMap<String, Value>>,
|
||||
}
|
||||
|
||||
@@ -120,6 +159,7 @@ pub struct BondSpec {
|
||||
pub struct VlanSpec {
|
||||
pub base_iface: String,
|
||||
pub id: u16,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub protocol: Option<String>,
|
||||
}
|
||||
|
||||
@@ -129,8 +169,11 @@ pub struct VxlanSpec {
|
||||
pub base_iface: String,
|
||||
pub id: u32,
|
||||
pub remote: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub local: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub learning: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub destination_port: Option<u16>,
|
||||
}
|
||||
|
||||
@@ -139,6 +182,7 @@ pub struct VxlanSpec {
|
||||
pub struct MacVtapSpec {
|
||||
pub base_iface: String,
|
||||
pub mode: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub promiscuous: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -147,6 +191,7 @@ pub struct MacVtapSpec {
|
||||
pub struct MacVlanSpec {
|
||||
pub base_iface: String,
|
||||
pub mode: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub promiscuous: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -161,25 +206,35 @@ pub struct InfinibandSpec {
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct LinuxBridgeSpec {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<LinuxBridgeOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ports: Option<Vec<LinuxBridgePort>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct LinuxBridgeOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mac_ageing_time: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub multicast_snooping: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stp: Option<StpOptions>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct StpOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub forward_delay: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hello_time: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_age: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<u16>,
|
||||
}
|
||||
|
||||
@@ -187,15 +242,20 @@ pub struct StpOptions {
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct LinuxBridgePort {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vlan: Option<LinuxBridgePortVlan>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct LinuxBridgePortVlan {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trunk_tags: Option<Vec<VlanTag>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_native: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -203,6 +263,7 @@ pub struct LinuxBridgePortVlan {
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct VlanTag {
|
||||
pub id: u16,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id_range: Option<VlanIdRange>,
|
||||
}
|
||||
|
||||
@@ -216,15 +277,20 @@ pub struct VlanIdRange {
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct OvsBridgeSpec {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<OvsBridgeOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ports: Option<Vec<OvsPortSpec>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct OvsBridgeOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stp: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rstp: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mcast_snooping_enable: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -232,8 +298,11 @@ pub struct OvsBridgeOptions {
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct OvsPortSpec {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link_aggregation: Option<BondSpec>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vlan: Option<LinuxBridgePortVlan>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub r#type: Option<String>,
|
||||
}
|
||||
|
||||
@@ -246,6 +315,8 @@ pub struct EthtoolSpec {
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct EthtoolFecSpec {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
@@ -39,30 +39,70 @@ impl HostNetworkConfigurationInterpret {
|
||||
&self,
|
||||
topology: &T,
|
||||
host: &PhysicalHost,
|
||||
) -> Result<(), InterpretError> {
|
||||
let switch_ports = self.collect_switch_ports_for_host(topology, host).await?;
|
||||
if !switch_ports.is_empty() {
|
||||
topology
|
||||
.configure_host_network(host, HostNetworkConfig { switch_ports })
|
||||
.await
|
||||
.map_err(|e| InterpretError::new(format!("Failed to configure host: {e}")))?;
|
||||
current_host: &usize,
|
||||
total_hosts: &usize,
|
||||
) -> Result<HostNetworkConfig, InterpretError> {
|
||||
if host.network.is_empty() {
|
||||
info!("[Host {current_host}/{total_hosts}] No interfaces to configure, skipping");
|
||||
return Ok(HostNetworkConfig {
|
||||
host_id: host.id.clone(),
|
||||
switch_ports: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
let switch_ports = self
|
||||
.collect_switch_ports_for_host(topology, host, current_host, total_hosts)
|
||||
.await?;
|
||||
|
||||
let config = HostNetworkConfig {
|
||||
host_id: host.id.clone(),
|
||||
switch_ports,
|
||||
};
|
||||
|
||||
if !config.switch_ports.is_empty() {
|
||||
info!(
|
||||
"[Host {current_host}/{total_hosts}] Found {} ports for {} interfaces",
|
||||
config.switch_ports.len(),
|
||||
host.network.len()
|
||||
);
|
||||
|
||||
info!("[Host {current_host}/{total_hosts}] Configuring host network...");
|
||||
topology
|
||||
.configure_host_network(&config)
|
||||
.await
|
||||
.map_err(|e| InterpretError::new(format!("Failed to configure host: {e}")))?;
|
||||
} else {
|
||||
info!(
|
||||
"[Host {current_host}/{total_hosts}] No ports found for {} interfaces, skipping",
|
||||
host.network.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn collect_switch_ports_for_host<T: Topology + Switch>(
|
||||
&self,
|
||||
topology: &T,
|
||||
host: &PhysicalHost,
|
||||
current_host: &usize,
|
||||
total_hosts: &usize,
|
||||
) -> Result<Vec<SwitchPort>, InterpretError> {
|
||||
let mut switch_ports = vec![];
|
||||
|
||||
if host.network.is_empty() {
|
||||
return Ok(switch_ports);
|
||||
}
|
||||
|
||||
info!("[Host {current_host}/{total_hosts}] Collecting ports on switch...");
|
||||
for network_interface in &host.network {
|
||||
let mac_address = network_interface.mac_address;
|
||||
|
||||
match topology.get_port_for_mac_address(&mac_address).await {
|
||||
Ok(Some(port)) => {
|
||||
info!(
|
||||
"[Host {current_host}/{total_hosts}] Found port '{port}' for '{mac_address}'"
|
||||
);
|
||||
switch_ports.push(SwitchPort {
|
||||
interface: NetworkInterface {
|
||||
name: network_interface.name.clone(),
|
||||
@@ -73,7 +113,7 @@ impl HostNetworkConfigurationInterpret {
|
||||
port,
|
||||
});
|
||||
}
|
||||
Ok(None) => debug!("No port found for host '{}', skipping", host.id),
|
||||
Ok(None) => debug!("No port found for '{mac_address}', skipping"),
|
||||
Err(e) => {
|
||||
return Err(InterpretError::new(format!(
|
||||
"Failed to get port for host '{}': {}",
|
||||
@@ -85,6 +125,47 @@ impl HostNetworkConfigurationInterpret {
|
||||
|
||||
Ok(switch_ports)
|
||||
}
|
||||
|
||||
fn format_host_configuration(&self, configs: Vec<HostNetworkConfig>) -> Vec<String> {
|
||||
let mut report = vec![
|
||||
"Network Configuration Report".to_string(),
|
||||
"------------------------------------------------------------------".to_string(),
|
||||
];
|
||||
|
||||
for config in configs {
|
||||
let host = self
|
||||
.score
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.id == config.host_id)
|
||||
.unwrap();
|
||||
|
||||
println!("[Host] {host}");
|
||||
|
||||
if config.switch_ports.is_empty() {
|
||||
report.push(format!(
|
||||
"⏭️ Host {}: SKIPPED (No matching switch ports found)",
|
||||
config.host_id
|
||||
));
|
||||
} else {
|
||||
let mappings: Vec<String> = config
|
||||
.switch_ports
|
||||
.iter()
|
||||
.map(|p| format!("[{} -> {}]", p.interface.name, p.port))
|
||||
.collect();
|
||||
|
||||
report.push(format!(
|
||||
"✅ Host {}: Bonded {} port(s) {}",
|
||||
config.host_id,
|
||||
config.switch_ports.len(),
|
||||
mappings.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
report
|
||||
.push("------------------------------------------------------------------".to_string());
|
||||
report
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -114,27 +195,38 @@ impl<T: Topology + Switch> Interpret<T> for HostNetworkConfigurationInterpret {
|
||||
return Ok(Outcome::noop("No hosts to configure".into()));
|
||||
}
|
||||
|
||||
info!(
|
||||
"Started network configuration for {} host(s)...",
|
||||
self.score.hosts.len()
|
||||
);
|
||||
let host_count = self.score.hosts.len();
|
||||
info!("Started network configuration for {host_count} host(s)...",);
|
||||
|
||||
info!("Setting up switch with sane defaults...");
|
||||
topology
|
||||
.setup_switch()
|
||||
.await
|
||||
.map_err(|e| InterpretError::new(format!("Switch setup failed: {e}")))?;
|
||||
info!("Switch ready");
|
||||
|
||||
let mut current_host = 1;
|
||||
let mut host_configurations = vec![];
|
||||
|
||||
let mut configured_host_count = 0;
|
||||
for host in &self.score.hosts {
|
||||
self.configure_network_for_host(topology, host).await?;
|
||||
configured_host_count += 1;
|
||||
}
|
||||
let host_configuration = self
|
||||
.configure_network_for_host(topology, host, ¤t_host, &host_count)
|
||||
.await?;
|
||||
|
||||
if configured_host_count > 0 {
|
||||
Ok(Outcome::success(format!(
|
||||
"Configured {configured_host_count}/{} host(s)",
|
||||
self.score.hosts.len()
|
||||
)))
|
||||
host_configurations.push(host_configuration);
|
||||
current_host += 1;
|
||||
}
|
||||
if current_host > 1 {
|
||||
let details = self.format_host_configuration(host_configurations);
|
||||
|
||||
Ok(Outcome::success_with_details(
|
||||
format!(
|
||||
"Configured {}/{} host(s)",
|
||||
current_host - 1,
|
||||
self.score.hosts.len()
|
||||
),
|
||||
details,
|
||||
))
|
||||
} else {
|
||||
Ok(Outcome::noop("No hosts configured".into()))
|
||||
}
|
||||
@@ -209,6 +301,7 @@ mod tests {
|
||||
assert_that!(*configured_host_networks).contains_exactly(vec![(
|
||||
HOST_ID.clone(),
|
||||
HostNetworkConfig {
|
||||
host_id: HOST_ID.clone(),
|
||||
switch_ports: vec![SwitchPort {
|
||||
interface: EXISTING_INTERFACE.clone(),
|
||||
port: PORT.clone(),
|
||||
@@ -234,6 +327,7 @@ mod tests {
|
||||
assert_that!(*configured_host_networks).contains_exactly(vec![(
|
||||
HOST_ID.clone(),
|
||||
HostNetworkConfig {
|
||||
host_id: HOST_ID.clone(),
|
||||
switch_ports: vec![
|
||||
SwitchPort {
|
||||
interface: EXISTING_INTERFACE.clone(),
|
||||
@@ -263,6 +357,7 @@ mod tests {
|
||||
(
|
||||
HOST_ID.clone(),
|
||||
HostNetworkConfig {
|
||||
host_id: HOST_ID.clone(),
|
||||
switch_ports: vec![SwitchPort {
|
||||
interface: EXISTING_INTERFACE.clone(),
|
||||
port: PORT.clone(),
|
||||
@@ -272,6 +367,7 @@ mod tests {
|
||||
(
|
||||
ANOTHER_HOST_ID.clone(),
|
||||
HostNetworkConfig {
|
||||
host_id: ANOTHER_HOST_ID.clone(),
|
||||
switch_ports: vec![SwitchPort {
|
||||
interface: ANOTHER_EXISTING_INTERFACE.clone(),
|
||||
port: ANOTHER_PORT.clone(),
|
||||
@@ -382,11 +478,10 @@ mod tests {
|
||||
|
||||
async fn configure_host_network(
|
||||
&self,
|
||||
host: &PhysicalHost,
|
||||
config: HostNetworkConfig,
|
||||
config: &HostNetworkConfig,
|
||||
) -> Result<(), SwitchError> {
|
||||
let mut configured_host_networks = self.configured_host_networks.lock().unwrap();
|
||||
configured_host_networks.push((host.id.clone(), config.clone()));
|
||||
configured_host_networks.push((config.host_id.clone(), config.clone()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
use crate::{
|
||||
modules::okd::{
|
||||
OKDSetup01InventoryScore, OKDSetup02BootstrapScore, OKDSetup03ControlPlaneScore,
|
||||
OKDSetup04WorkersScore, OKDSetup05SanityCheckScore,
|
||||
OKDSetup04WorkersScore, OKDSetup05SanityCheckScore, OKDSetupPersistNetworkBondScore,
|
||||
bootstrap_06_installation_report::OKDSetup06InstallationReportScore,
|
||||
},
|
||||
score::Score,
|
||||
@@ -65,6 +65,7 @@ impl OKDInstallationPipeline {
|
||||
Box::new(OKDSetup01InventoryScore::new()),
|
||||
Box::new(OKDSetup02BootstrapScore::new()),
|
||||
Box::new(OKDSetup03ControlPlaneScore::new()),
|
||||
Box::new(OKDSetupPersistNetworkBondScore::new()),
|
||||
Box::new(OKDSetup04WorkersScore::new()),
|
||||
Box::new(OKDSetup05SanityCheckScore::new()),
|
||||
Box::new(OKDSetup06InstallationReportScore::new()),
|
||||
|
||||
@@ -6,6 +6,7 @@ mod bootstrap_05_sanity_check;
|
||||
mod bootstrap_06_installation_report;
|
||||
pub mod bootstrap_dhcp;
|
||||
pub mod bootstrap_load_balancer;
|
||||
mod bootstrap_persist_network_bond;
|
||||
pub mod dhcp;
|
||||
pub mod dns;
|
||||
pub mod installation;
|
||||
@@ -19,5 +20,6 @@ pub use bootstrap_03_control_plane::*;
|
||||
pub use bootstrap_04_workers::*;
|
||||
pub use bootstrap_05_sanity_check::*;
|
||||
pub use bootstrap_06_installation_report::*;
|
||||
pub use bootstrap_persist_network_bond::*;
|
||||
pub mod crd;
|
||||
pub mod host_network;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod node_exporter;
|
||||
mod shell;
|
||||
mod upgrade;
|
||||
pub use shell::*;
|
||||
|
||||
70
harmony/src/modules/opnsense/node_exporter.rs
Normal file
70
harmony/src/modules/opnsense/node_exporter.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use async_trait::async_trait;
|
||||
use harmony_types::id::Id;
|
||||
use log::info;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
data::Version,
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::Inventory,
|
||||
score::Score,
|
||||
topology::{Topology, node_exporter::NodeExporter},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NodeExporterScore {}
|
||||
|
||||
impl<T: Topology + NodeExporter> Score<T> for NodeExporterScore {
|
||||
fn name(&self) -> String {
|
||||
"NodeExporterScore".to_string()
|
||||
}
|
||||
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||
Box::new(NodeExporterInterpret {})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NodeExporterInterpret {}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Topology + NodeExporter> Interpret<T> for NodeExporterInterpret {
|
||||
async fn execute(
|
||||
&self,
|
||||
_inventory: &Inventory,
|
||||
node_exporter: &T,
|
||||
) -> Result<Outcome, InterpretError> {
|
||||
info!(
|
||||
"Making sure node exporter is initiailized: {:?}",
|
||||
node_exporter.ensure_initialized().await?
|
||||
);
|
||||
|
||||
info!("Applying Node Exporter configuration");
|
||||
|
||||
node_exporter.commit_config().await?;
|
||||
|
||||
info!("Reloading and restarting Node Exporter");
|
||||
|
||||
node_exporter.reload_restart().await?;
|
||||
|
||||
Ok(Outcome::success(format!(
|
||||
"NodeExporter successfully configured"
|
||||
)))
|
||||
}
|
||||
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("NodeExporter")
|
||||
}
|
||||
|
||||
fn get_version(&self) -> Version {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_status(&self) -> InterpretStatus {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_children(&self) -> Vec<Id> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ pub fn init() {
|
||||
HarmonyEvent::HarmonyFinished => {
|
||||
if !details.is_empty() {
|
||||
println!(
|
||||
"\n{} All done! Here's what's next for you:",
|
||||
"\n{} All done! Here's a few info for you:",
|
||||
theme::EMOJI_SUMMARY
|
||||
);
|
||||
for detail in details.iter() {
|
||||
|
||||
@@ -17,7 +17,7 @@ pub struct OPNsense {
|
||||
pub interfaces: NamedList<Interface>,
|
||||
pub dhcpd: NamedList<DhcpInterface>,
|
||||
pub snmpd: Snmpd,
|
||||
pub syslog: Syslog,
|
||||
pub syslog: Option<Syslog>,
|
||||
pub nat: Nat,
|
||||
pub filter: Filters,
|
||||
pub load_balancer: Option<LoadBalancer>,
|
||||
@@ -190,7 +190,7 @@ pub struct System {
|
||||
pub webgui: WebGui,
|
||||
pub usevirtualterminal: u8,
|
||||
pub disablenatreflection: Option<String>,
|
||||
pub disableconsolemenu: u8,
|
||||
pub disableconsolemenu: Option<u8>,
|
||||
pub disablevlanhwfilter: u8,
|
||||
pub disablechecksumoffloading: u8,
|
||||
pub disablesegmentationoffloading: u8,
|
||||
@@ -216,7 +216,7 @@ pub struct System {
|
||||
pub maximumfrags: Option<MaybeString>,
|
||||
pub aliasesresolveinterval: Option<MaybeString>,
|
||||
pub maximumtableentries: Option<MaybeString>,
|
||||
pub language: String,
|
||||
pub language: Option<String>,
|
||||
pub dnsserver: Option<MaybeString>,
|
||||
pub dns1gw: Option<String>,
|
||||
pub dns2gw: Option<String>,
|
||||
@@ -233,16 +233,16 @@ pub struct System {
|
||||
#[derive(Default, PartialEq, Debug, YaSerialize, YaDeserialize)]
|
||||
pub struct Ssh {
|
||||
pub group: String,
|
||||
pub noauto: u8,
|
||||
pub interfaces: MaybeString,
|
||||
pub kex: MaybeString,
|
||||
pub ciphers: MaybeString,
|
||||
pub macs: MaybeString,
|
||||
pub keys: MaybeString,
|
||||
pub enabled: String,
|
||||
pub passwordauth: u8,
|
||||
pub keysig: MaybeString,
|
||||
pub permitrootlogin: u8,
|
||||
pub noauto: Option<u8>,
|
||||
pub interfaces: Option<MaybeString>,
|
||||
pub kex: Option<MaybeString>,
|
||||
pub ciphers: Option<MaybeString>,
|
||||
pub macs: Option<MaybeString>,
|
||||
pub keys: Option<MaybeString>,
|
||||
pub enabled: Option<String>,
|
||||
pub passwordauth: Option<u8>,
|
||||
pub keysig: Option<MaybeString>,
|
||||
pub permitrootlogin: Option<u8>,
|
||||
pub rekeylimit: Option<MaybeString>,
|
||||
}
|
||||
|
||||
@@ -306,11 +306,11 @@ pub struct WebGui {
|
||||
pub protocol: String,
|
||||
#[yaserde(rename = "ssl-certref")]
|
||||
pub ssl_certref: String,
|
||||
pub port: MaybeString,
|
||||
pub port: Option<MaybeString>,
|
||||
#[yaserde(rename = "ssl-ciphers")]
|
||||
pub ssl_ciphers: MaybeString,
|
||||
pub interfaces: MaybeString,
|
||||
pub compression: MaybeString,
|
||||
pub ssl_ciphers: Option<MaybeString>,
|
||||
pub interfaces: Option<MaybeString>,
|
||||
pub compression: Option<MaybeString>,
|
||||
pub nohttpreferercheck: Option<u8>,
|
||||
}
|
||||
|
||||
@@ -433,7 +433,7 @@ pub struct OPNsenseXmlSection {
|
||||
#[yaserde(rename = "Interfaces")]
|
||||
pub interfaces: Option<ConfigInterfaces>,
|
||||
#[yaserde(rename = "NodeExporter")]
|
||||
pub node_exporter: Option<RawXml>,
|
||||
pub node_exporter: Option<NodeExporter>,
|
||||
#[yaserde(rename = "Kea")]
|
||||
pub kea: Option<RawXml>,
|
||||
pub monit: Option<Monit>,
|
||||
@@ -1595,3 +1595,21 @@ pub struct Ifgroups {
|
||||
#[yaserde(attribute = true)]
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Default, PartialEq, Debug, YaSerialize, YaDeserialize)]
|
||||
pub struct NodeExporter {
|
||||
pub enabled: u8,
|
||||
pub listenaddress: Option<MaybeString>,
|
||||
pub listenport: u16,
|
||||
pub cpu: u8,
|
||||
pub exec: u8,
|
||||
pub filesystem: u8,
|
||||
pub loadavg: u8,
|
||||
pub meminfo: u8,
|
||||
pub netdev: u8,
|
||||
pub time: u8,
|
||||
pub devstat: u8,
|
||||
pub interrupts: u8,
|
||||
pub ntp: u8,
|
||||
pub zfs: u8,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ use crate::{
|
||||
error::Error,
|
||||
modules::{
|
||||
caddy::CaddyConfig, dhcp_legacy::DhcpConfigLegacyISC, dns::UnboundDnsConfig,
|
||||
dnsmasq::DhcpConfigDnsMasq, load_balancer::LoadBalancerConfig, tftp::TftpConfig,
|
||||
dnsmasq::DhcpConfigDnsMasq, load_balancer::LoadBalancerConfig,
|
||||
node_exporter::NodeExporterConfig, tftp::TftpConfig,
|
||||
},
|
||||
};
|
||||
use log::{debug, info, trace, warn};
|
||||
@@ -13,6 +14,7 @@ use opnsense_config_xml::OPNsense;
|
||||
use russh::client;
|
||||
use serde::Serialize;
|
||||
use sha2::Digest;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
use super::{ConfigManager, OPNsenseShell};
|
||||
|
||||
@@ -71,6 +73,10 @@ impl Config {
|
||||
LoadBalancerConfig::new(&mut self.opnsense, self.shell.clone())
|
||||
}
|
||||
|
||||
pub fn node_exporter(&mut self) -> NodeExporterConfig<'_> {
|
||||
NodeExporterConfig::new(&mut self.opnsense, self.shell.clone())
|
||||
}
|
||||
|
||||
pub async fn upload_files(&self, source: &str, destination: &str) -> Result<String, Error> {
|
||||
self.shell.upload_folder(source, destination).await
|
||||
}
|
||||
@@ -150,7 +156,8 @@ impl Config {
|
||||
|
||||
async fn reload_config(&mut self) -> Result<(), Error> {
|
||||
info!("Reloading opnsense live config");
|
||||
let (opnsense, sha2) = Self::get_opnsense_instance(self.repository.clone()).await?;
|
||||
let (opnsense, _sha2) = Self::get_opnsense_instance(self.repository.clone()).await?;
|
||||
self.opnsense = opnsense;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -4,4 +4,5 @@ pub mod dhcp_legacy;
|
||||
pub mod dns;
|
||||
pub mod dnsmasq;
|
||||
pub mod load_balancer;
|
||||
pub mod node_exporter;
|
||||
pub mod tftp;
|
||||
|
||||
54
opnsense-config/src/modules/node_exporter.rs
Normal file
54
opnsense-config/src/modules/node_exporter.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use opnsense_config_xml::{NodeExporter, OPNsense};
|
||||
|
||||
use crate::{config::OPNsenseShell, Error};
|
||||
|
||||
pub struct NodeExporterConfig<'a> {
|
||||
opnsense: &'a mut OPNsense,
|
||||
opnsense_shell: Arc<dyn OPNsenseShell>,
|
||||
}
|
||||
|
||||
impl<'a> NodeExporterConfig<'a> {
|
||||
pub fn new(opnsense: &'a mut OPNsense, opnsense_shell: Arc<dyn OPNsenseShell>) -> Self {
|
||||
Self {
|
||||
opnsense,
|
||||
opnsense_shell,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_full_config(&self) -> &Option<NodeExporter> {
|
||||
&self.opnsense.opnsense.node_exporter
|
||||
}
|
||||
|
||||
fn with_node_exporter<F, R>(&mut self, f: F) -> Result<R, &'static str>
|
||||
where
|
||||
F: FnOnce(&mut NodeExporter) -> R,
|
||||
{
|
||||
match &mut self.opnsense.opnsense.node_exporter.as_mut() {
|
||||
Some(node_exporter) => Ok(f(node_exporter)),
|
||||
None => Err("node exporter is not yet installed"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enable(&mut self, enabled: bool) -> Result<(), &'static str> {
|
||||
self.with_node_exporter(|node_exporter| node_exporter.enabled = enabled as u8)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn reload_restart(&self) -> Result<(), Error> {
|
||||
self.opnsense_shell
|
||||
.exec("configctl node_exporter stop")
|
||||
.await?;
|
||||
self.opnsense_shell
|
||||
.exec("configctl template reload OPNsense/NodeExporter")
|
||||
.await?;
|
||||
self.opnsense_shell
|
||||
.exec("configctl node_exporter configtest")
|
||||
.await?;
|
||||
self.opnsense_shell
|
||||
.exec("configctl node_exporter start")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user