refact: Split OKD installation score into a simple struct that returns the list of scores. Also refactored discovery logic so it can be used by bootstrap and control plane steps
Some checks failed
Run Check Script / check (pull_request) Failing after 27s
Some checks failed
Run Check Script / check (pull_request) Failing after 27s
This commit is contained in:
parent
e7ccfe6969
commit
8bcade27a1
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1759,6 +1759,7 @@ dependencies = [
|
||||
"env_logger",
|
||||
"harmony",
|
||||
"harmony_macros",
|
||||
"harmony_secret",
|
||||
"harmony_tui",
|
||||
"harmony_types",
|
||||
"log",
|
||||
|
@ -13,6 +13,7 @@ harmony_types = { path = "../../harmony_types" }
|
||||
cidr = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
harmony_macros = { path = "../../harmony_macros" }
|
||||
harmony_secret = { path = "../../harmony_secret" }
|
||||
log = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
@ -5,10 +5,7 @@ use std::{
|
||||
|
||||
use cidr::Ipv4Cidr;
|
||||
use harmony::{
|
||||
hardware::{HostCategory, Location, PhysicalHost, SwitchGroup},
|
||||
infra::opnsense::OPNSenseManagementInterface,
|
||||
inventory::Inventory,
|
||||
modules::{
|
||||
config::secret::SshKeyPair, data::{FileContent, FilePath}, hardware::{HostCategory, Location, PhysicalHost, SwitchGroup}, infra::opnsense::OPNSenseManagementInterface, inventory::Inventory, modules::{
|
||||
http::StaticFilesHttpScore,
|
||||
okd::{
|
||||
bootstrap_dhcp::OKDBootstrapDhcpScore,
|
||||
@ -16,10 +13,10 @@ use harmony::{
|
||||
dns::OKDDnsScore, ipxe::OKDIpxeScore,
|
||||
},
|
||||
tftp::TftpScore,
|
||||
},
|
||||
topology::{LogicalHost, UnmanagedRouter},
|
||||
}, topology::{LogicalHost, UnmanagedRouter}
|
||||
};
|
||||
use harmony_macros::{ip, mac_address};
|
||||
use harmony_secret::SecretManager;
|
||||
use harmony_types::net::Url;
|
||||
|
||||
#[tokio::main]
|
||||
@ -123,6 +120,8 @@ async fn main() {
|
||||
let load_balancer_score =
|
||||
harmony::modules::okd::load_balancer::OKDLoadBalancerScore::new(&topology);
|
||||
|
||||
let ssh_key = SecretManager::get_or_prompt::<SshKeyPair>().await.unwrap();
|
||||
|
||||
let tftp_score = TftpScore::new(Url::LocalFolder("./data/watchguard/tftpboot".to_string()));
|
||||
let http_score = StaticFilesHttpScore {
|
||||
folder_to_serve: Some(Url::LocalFolder(
|
||||
@ -133,13 +132,15 @@ async fn main() {
|
||||
};
|
||||
|
||||
let kickstart_filename = "inventory.kickstart".to_string();
|
||||
let cluster_pubkey_filename = "cluster_ssh_key.pub".to_string();
|
||||
let harmony_inventory_agent = "harmony_inventory_agent".to_string();
|
||||
|
||||
let ipxe_score = OKDIpxeScore {
|
||||
kickstart_filename,
|
||||
harmony_inventory_agent,
|
||||
cluster_pubkey,
|
||||
cluster_pubkey: FileContent {
|
||||
path: FilePath::Relative("cluster_ssh_key.pub".to_string()),
|
||||
content: ssh_key.public,
|
||||
},
|
||||
};
|
||||
|
||||
harmony_tui::run(
|
||||
|
@ -2,7 +2,7 @@ mod topology;
|
||||
|
||||
use crate::topology::{get_inventory, get_topology};
|
||||
use harmony::{
|
||||
config::secret::SshKeyPair, data::{FileContent, FilePath}, modules::okd::{installation::OKDInstallationScore, ipxe::OKDIpxeScore}, score::Score, topology::HAClusterTopology
|
||||
config::secret::SshKeyPair, data::{FileContent, FilePath}, modules::okd::{installation::OKDInstallationPipeline, ipxe::OKDIpxeScore}, score::Score, topology::HAClusterTopology
|
||||
};
|
||||
use harmony_secret::SecretManager;
|
||||
|
||||
@ -22,7 +22,7 @@ async fn main() {
|
||||
content: ssh_key.public,
|
||||
},
|
||||
}),
|
||||
Box::new(OKDInstallationScore {}),
|
||||
Box::new(OKDInstallationPipeline {}),
|
||||
];
|
||||
harmony_cli::run(inventory, topology, scores, None)
|
||||
.await
|
||||
|
@ -1,7 +1,12 @@
|
||||
mod topology;
|
||||
|
||||
use crate::topology::{get_inventory, get_topology};
|
||||
use harmony::modules::okd::ipxe::OKDIpxeScore;
|
||||
use harmony::{
|
||||
config::secret::SshKeyPair,
|
||||
data::{FileContent, FilePath},
|
||||
modules::okd::ipxe::OKDIpxeScore,
|
||||
};
|
||||
use harmony_secret::SecretManager;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@ -9,13 +14,16 @@ async fn main() {
|
||||
let topology = get_topology().await;
|
||||
|
||||
let kickstart_filename = "inventory.kickstart".to_string();
|
||||
let cluster_pubkey_filename = "cluster_ssh_key.pub".to_string();
|
||||
let harmony_inventory_agent = "harmony_inventory_agent".to_string();
|
||||
let ssh_key = SecretManager::get_or_prompt::<SshKeyPair>().await.unwrap();
|
||||
|
||||
let ipxe_score = OKDIpxeScore {
|
||||
kickstart_filename,
|
||||
harmony_inventory_agent,
|
||||
cluster_pubkey,
|
||||
cluster_pubkey: FileContent {
|
||||
path: FilePath::Relative("cluster_ssh_key.pub".to_string()),
|
||||
content: ssh_key.public,
|
||||
},
|
||||
};
|
||||
|
||||
harmony_cli::run(inventory, topology, vec![Box::new(ipxe_score)], None)
|
||||
|
@ -63,7 +63,7 @@ impl Inventory {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::Type)]
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::Type, Clone)]
|
||||
pub enum HostRole {
|
||||
Bootstrap,
|
||||
ControlPlane,
|
||||
|
@ -108,6 +108,7 @@ impl InventoryRepository for SqliteInventoryRepository {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_host_for_role(&self, role: HostRole) -> Result<Vec<PhysicalHost>, RepoError> {
|
||||
struct HostIdRow {
|
||||
host_id: String,
|
||||
|
122
harmony/src/modules/inventory/discovery.rs
Normal file
122
harmony/src/modules/inventory/discovery.rs
Normal file
@ -0,0 +1,122 @@
|
||||
use async_trait::async_trait;
|
||||
use harmony_types::id::Id;
|
||||
use log::{error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
data::Version,
|
||||
hardware::PhysicalHost,
|
||||
infra::inventory::InventoryRepositoryFactory,
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::{HostRole, Inventory},
|
||||
modules::inventory::LaunchDiscoverInventoryAgentScore,
|
||||
score::Score,
|
||||
topology::Topology,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoverHostForRoleScore {
|
||||
pub role: HostRole,
|
||||
}
|
||||
|
||||
impl<T: Topology> Score<T> for DiscoverHostForRoleScore {
|
||||
fn name(&self) -> String {
|
||||
"DiscoverInventoryAgentScore".to_string()
|
||||
}
|
||||
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||
Box::new(DiscoverHostForRoleInterpret {
|
||||
score: self.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DiscoverHostForRoleInterpret {
|
||||
score: DiscoverHostForRoleScore,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Topology> Interpret<T> for DiscoverHostForRoleInterpret {
|
||||
async fn execute(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &T,
|
||||
) -> Result<Outcome, InterpretError> {
|
||||
info!(
|
||||
"Launching discovery agent, make sure that your nodes are successfully PXE booted and running inventory agent. They should answer on `http://<node_ip>:8080/inventory`"
|
||||
);
|
||||
LaunchDiscoverInventoryAgentScore {
|
||||
discovery_timeout: None,
|
||||
}
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
|
||||
let host: PhysicalHost;
|
||||
let host_repo = InventoryRepositoryFactory::build().await?;
|
||||
|
||||
loop {
|
||||
let all_hosts = host_repo.get_all_hosts().await?;
|
||||
|
||||
if all_hosts.is_empty() {
|
||||
info!("No discovered hosts found yet. Waiting for hosts to appear...");
|
||||
// Sleep to avoid spamming the user and logs while waiting for nodes.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let ans = inquire::Select::new(
|
||||
&format!("Select the node to be used for role {:?}:", self.score.role),
|
||||
all_hosts,
|
||||
)
|
||||
.with_help_message("Press Esc to refresh the list of discovered hosts")
|
||||
.prompt();
|
||||
|
||||
match ans {
|
||||
Ok(choice) => {
|
||||
info!("Selected {} as the bootstrap node.", choice.summary());
|
||||
host_repo
|
||||
.save_role_mapping(&HostRole::Bootstrap, &choice)
|
||||
.await?;
|
||||
host = choice;
|
||||
break;
|
||||
}
|
||||
Err(inquire::InquireError::OperationCanceled) => {
|
||||
info!("Refresh requested. Fetching list of discovered hosts again...");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to select node for role {:?} : {}",
|
||||
self.score.role, e
|
||||
);
|
||||
return Err(InterpretError::new(format!(
|
||||
"Could not select host : {}",
|
||||
e.to_string()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Outcome::success(format!(
|
||||
"Successfully discovered host {} for role {:?}",
|
||||
host.summary(),
|
||||
self.score.role
|
||||
)))
|
||||
}
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("DiscoverHostForRoleScore")
|
||||
}
|
||||
|
||||
fn get_version(&self) -> Version {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_status(&self) -> InterpretStatus {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_children(&self) -> Vec<Id> {
|
||||
todo!()
|
||||
}
|
||||
}
|
@ -1,3 +1,6 @@
|
||||
mod discovery;
|
||||
pub use discovery::*;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use harmony_inventory_agent::local_presence::DiscoveryEvent;
|
||||
use log::{debug, info, trace};
|
||||
|
120
harmony/src/modules/okd/bootstrap_01_prepare.rs
Normal file
120
harmony/src/modules/okd/bootstrap_01_prepare.rs
Normal file
@ -0,0 +1,120 @@
|
||||
use async_trait::async_trait;
|
||||
use derive_new::new;
|
||||
use harmony_types::id::Id;
|
||||
use log::{error, info, warn};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
data::Version,
|
||||
hardware::PhysicalHost,
|
||||
infra::inventory::InventoryRepositoryFactory,
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::{HostRole, Inventory},
|
||||
modules::inventory::{DiscoverHostForRoleScore, LaunchDiscoverInventoryAgentScore},
|
||||
score::Score,
|
||||
topology::HAClusterTopology,
|
||||
};
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Step 01: Inventory (default PXE + Kickstart in RAM + Rust agent)
|
||||
// - This score exposes/ensures the default inventory assets and waits for discoveries.
|
||||
// - No early bonding. Simple access DHCP.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, new)]
|
||||
pub struct OKDSetup01InventoryScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDSetup01InventoryScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDSetup01InventoryInterpret::new(self.clone()))
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDSetup01InventoryScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OKDSetup01InventoryInterpret {
|
||||
score: OKDSetup01InventoryScore,
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDSetup01InventoryInterpret {
|
||||
pub fn new(score: OKDSetup01InventoryScore) -> Self {
|
||||
let version = Version::from("1.0.0").unwrap();
|
||||
Self {
|
||||
version,
|
||||
score,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDSetup01InventoryInterpret {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDSetup01Inventory")
|
||||
}
|
||||
|
||||
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> {
|
||||
info!("Setting up base DNS config for OKD");
|
||||
let cluster_domain = &topology.domain_name;
|
||||
let load_balancer_ip = &topology.load_balancer.get_ip();
|
||||
inquire::Confirm::new(&format!(
|
||||
"Set hostnames manually in your opnsense dnsmasq config :
|
||||
*.apps.{cluster_domain} -> {load_balancer_ip}
|
||||
api.{cluster_domain} -> {load_balancer_ip}
|
||||
api-int.{cluster_domain} -> {load_balancer_ip}
|
||||
|
||||
When you can dig them, confirm to continue.
|
||||
"
|
||||
))
|
||||
.prompt()
|
||||
.expect("Prompt error");
|
||||
// TODO reactivate automatic dns config when migration from unbound to dnsmasq is done
|
||||
// OKDDnsScore::new(topology)
|
||||
// .interpret(inventory, topology)
|
||||
// .await?;
|
||||
|
||||
// TODO refactor this section into a function discover_hosts_for_role(...) that can be used
|
||||
// from anywhere in the project, not a member of this struct
|
||||
|
||||
let mut bootstrap_host: Option<PhysicalHost> = None;
|
||||
let repo = InventoryRepositoryFactory::build().await?;
|
||||
|
||||
while bootstrap_host.is_none() {
|
||||
let hosts = repo.get_host_for_role(HostRole::Bootstrap).await?;
|
||||
bootstrap_host = hosts.into_iter().next().to_owned();
|
||||
DiscoverHostForRoleScore {
|
||||
role: HostRole::Bootstrap,
|
||||
}
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
format!(
|
||||
"Found and assigned bootstrap node: {}",
|
||||
bootstrap_host.unwrap().summary()
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
387
harmony/src/modules/okd/bootstrap_02_bootstrap.rs
Normal file
387
harmony/src/modules/okd/bootstrap_02_bootstrap.rs
Normal file
@ -0,0 +1,387 @@
|
||||
use std::{fmt::Write, path::PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use derive_new::new;
|
||||
use harmony_secret::SecretManager;
|
||||
use harmony_types::id::Id;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{fs::File, io::AsyncWriteExt, process::Command};
|
||||
|
||||
use crate::{
|
||||
config::secret::{RedhatSecret, SshKeyPair},
|
||||
data::{FileContent, FilePath, Version},
|
||||
hardware::PhysicalHost,
|
||||
infra::inventory::InventoryRepositoryFactory,
|
||||
instrumentation::{HarmonyEvent, instrument},
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::{HostRole, Inventory},
|
||||
modules::{
|
||||
dhcp::DhcpHostBindingScore,
|
||||
http::{IPxeMacBootFileScore, StaticFilesHttpScore},
|
||||
inventory::LaunchDiscoverInventoryAgentScore,
|
||||
okd::{
|
||||
bootstrap_load_balancer::OKDBootstrapLoadBalancerScore,
|
||||
templates::{BootstrapIpxeTpl, InstallConfigYaml},
|
||||
},
|
||||
},
|
||||
score::Score,
|
||||
topology::{HAClusterTopology, HostBinding},
|
||||
};
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Step 02: Bootstrap
|
||||
// - Select bootstrap node (from discovered set).
|
||||
// - Render per-MAC iPXE pointing to OKD 4.19 SCOS live assets + bootstrap ignition.
|
||||
// - Reboot the host via SSH and wait for bootstrap-complete.
|
||||
// - No bonding at this stage unless absolutely required; prefer persistence via MC later.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, new)]
|
||||
pub struct OKDSetup02BootstrapScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDSetup02BootstrapScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDSetup02BootstrapInterpret::new())
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDSetup02BootstrapScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OKDSetup02BootstrapInterpret {
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDSetup02BootstrapInterpret {
|
||||
pub fn new() -> Self {
|
||||
let version = Version::from("1.0.0").unwrap();
|
||||
Self {
|
||||
version,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_bootstrap_node(&self) -> Result<PhysicalHost, InterpretError> {
|
||||
let repo = InventoryRepositoryFactory::build().await?;
|
||||
match repo
|
||||
.get_host_for_role(HostRole::Bootstrap)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
{
|
||||
Some(host) => Ok(host),
|
||||
None => Err(InterpretError::new(
|
||||
"No bootstrap node available".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_ignition_files(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
let okd_bin_path = PathBuf::from("./data/okd/bin");
|
||||
let okd_installation_path_str =
|
||||
format!("./data/okd/installation_files_{}", inventory.location.name);
|
||||
let okd_images_path = &PathBuf::from("./data/okd/installer_image/");
|
||||
let okd_installation_path = &PathBuf::from(okd_installation_path_str);
|
||||
|
||||
let exit_status = Command::new("mkdir")
|
||||
.arg("-p")
|
||||
.arg(okd_installation_path)
|
||||
.spawn()
|
||||
.expect("Command failed to start")
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
InterpretError::new(format!("Failed to create okd installation directory : {e}"))
|
||||
})?;
|
||||
if !exit_status.success() {
|
||||
return Err(InterpretError::new(format!(
|
||||
"Failed to create okd installation directory"
|
||||
)));
|
||||
} else {
|
||||
info!(
|
||||
"Created OKD installation directory {}",
|
||||
okd_installation_path.to_string_lossy()
|
||||
);
|
||||
}
|
||||
|
||||
let redhat_secret = SecretManager::get_or_prompt::<RedhatSecret>().await?;
|
||||
let ssh_key = SecretManager::get_or_prompt::<SshKeyPair>().await?;
|
||||
|
||||
let install_config_yaml = InstallConfigYaml {
|
||||
cluster_name: &topology.get_cluster_name(),
|
||||
cluster_domain: &topology.get_cluster_base_domain(),
|
||||
pull_secret: &redhat_secret.pull_secret,
|
||||
ssh_public_key: &ssh_key.public,
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let install_config_file_path = &okd_installation_path.join("install-config.yaml");
|
||||
|
||||
self.create_file(install_config_file_path, install_config_yaml.as_bytes())
|
||||
.await?;
|
||||
|
||||
let install_config_backup_extension = install_config_file_path
|
||||
.extension()
|
||||
.map(|e| format!("{}.bak", e.to_string_lossy()))
|
||||
.unwrap_or("bak".to_string());
|
||||
|
||||
let mut install_config_backup = install_config_file_path.clone();
|
||||
install_config_backup.set_extension(install_config_backup_extension);
|
||||
|
||||
self.create_file(&install_config_backup, install_config_yaml.as_bytes())
|
||||
.await?;
|
||||
|
||||
info!("Creating manifest files with openshift-install");
|
||||
let output = Command::new(okd_bin_path.join("openshift-install"))
|
||||
.args([
|
||||
"create",
|
||||
"manifests",
|
||||
"--dir",
|
||||
okd_installation_path.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| InterpretError::new(format!("Failed to create okd manifest : {e}")))?;
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
info!("openshift-install stdout :\n\n{}", stdout);
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
info!("openshift-install stderr :\n\n{}", stderr);
|
||||
info!("openshift-install exit status : {}", output.status);
|
||||
if !output.status.success() {
|
||||
return Err(InterpretError::new(format!(
|
||||
"Failed to create okd manifest, exit code {} : {}",
|
||||
output.status, stderr
|
||||
)));
|
||||
}
|
||||
|
||||
info!("Creating ignition files with openshift-install");
|
||||
let output = Command::new(okd_bin_path.join("openshift-install"))
|
||||
.args([
|
||||
"create",
|
||||
"ignition-configs",
|
||||
"--dir",
|
||||
okd_installation_path.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
InterpretError::new(format!("Failed to create okd ignition config : {e}"))
|
||||
})?;
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
info!("openshift-install stdout :\n\n{}", stdout);
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
info!("openshift-install stderr :\n\n{}", stderr);
|
||||
info!("openshift-install exit status : {}", output.status);
|
||||
if !output.status.success() {
|
||||
return Err(InterpretError::new(format!(
|
||||
"Failed to create okd manifest, exit code {} : {}",
|
||||
output.status, stderr
|
||||
)));
|
||||
}
|
||||
|
||||
let ignition_files_http_path = PathBuf::from("okd_ignition_files");
|
||||
let prepare_file_content = async |filename: &str| -> Result<FileContent, InterpretError> {
|
||||
let local_path = okd_installation_path.join(filename);
|
||||
let remote_path = ignition_files_http_path.join(filename);
|
||||
|
||||
info!(
|
||||
"Preparing file content for local file : {} to remote : {}",
|
||||
local_path.to_string_lossy(),
|
||||
remote_path.to_string_lossy()
|
||||
);
|
||||
|
||||
let content = tokio::fs::read_to_string(&local_path).await.map_err(|e| {
|
||||
InterpretError::new(format!(
|
||||
"Could not read file content {} : {e}",
|
||||
local_path.to_string_lossy()
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(FileContent {
|
||||
path: FilePath::Relative(remote_path.to_string_lossy().to_string()),
|
||||
content,
|
||||
})
|
||||
};
|
||||
|
||||
StaticFilesHttpScore {
|
||||
remote_path: None,
|
||||
folder_to_serve: None,
|
||||
files: vec![
|
||||
prepare_file_content("bootstrap.ign").await?,
|
||||
prepare_file_content("master.ign").await?,
|
||||
prepare_file_content("worker.ign").await?,
|
||||
prepare_file_content("metadata.json").await?,
|
||||
],
|
||||
}
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
|
||||
info!("Successfully prepared ignition files for OKD installation");
|
||||
// ignition_files_http_path // = PathBuf::from("okd_ignition_files");
|
||||
info!(
|
||||
r#"Uploading images, they can be refreshed with a command similar to this one: openshift-install coreos print-stream-json | grep -Eo '"https.*(kernel.|initramfs.|rootfs.)\w+(\.img)?"' | grep x86_64 | xargs -n 1 curl -LO"#
|
||||
);
|
||||
|
||||
inquire::Confirm::new(
|
||||
&format!("push installer image files with `scp -r {}/* root@{}:/usr/local/http/scos/` until performance issue is resolved", okd_images_path.to_string_lossy(), topology.http_server.get_ip())).prompt().expect("Prompt error");
|
||||
|
||||
// let scos_http_path = PathBuf::from("scos");
|
||||
// StaticFilesHttpScore {
|
||||
// folder_to_serve: Some(Url::LocalFolder(
|
||||
// okd_images_path.to_string_lossy().to_string(),
|
||||
// )),
|
||||
// remote_path: Some(scos_http_path.to_string_lossy().to_string()),
|
||||
// files: vec![],
|
||||
// }
|
||||
// .interpret(inventory, topology)
|
||||
// .await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn configure_host_binding(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
let binding = HostBinding {
|
||||
logical_host: topology.bootstrap_host.clone(),
|
||||
physical_host: self.get_bootstrap_node().await?,
|
||||
};
|
||||
info!("Configuring host binding for bootstrap node {binding:?}");
|
||||
|
||||
DhcpHostBindingScore {
|
||||
host_binding: vec![binding],
|
||||
domain: Some(topology.domain_name.clone()),
|
||||
}
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn render_per_mac_pxe(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
let content = BootstrapIpxeTpl {
|
||||
http_ip: &topology.http_server.get_ip().to_string(),
|
||||
scos_path: "scos", // TODO use some constant
|
||||
ignition_http_path: "okd_ignition_files", // TODO use proper variable
|
||||
installation_device: "/dev/sda",
|
||||
ignition_file_name: "bootstrap.ign",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let bootstrap_node = self.get_bootstrap_node().await?;
|
||||
let mac_address = bootstrap_node.get_mac_address();
|
||||
|
||||
info!("[Bootstrap] Rendering per-MAC PXE for bootstrap node");
|
||||
debug!("bootstrap ipxe content : {content}");
|
||||
debug!("bootstrap mac addresses : {mac_address:?}");
|
||||
|
||||
IPxeMacBootFileScore {
|
||||
mac_address,
|
||||
content,
|
||||
}
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn setup_bootstrap_load_balancer(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
let outcome = OKDBootstrapLoadBalancerScore::new(topology)
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
info!("Successfully executed OKDBootstrapLoadBalancerScore : {outcome:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reboot_target(&self) -> Result<(), InterpretError> {
|
||||
// Placeholder: ssh reboot using the inventory ephemeral key
|
||||
info!("[Bootstrap] Rebooting bootstrap node via SSH");
|
||||
// TODO reboot programatically, there are some logical checks and refactoring to do such as
|
||||
// accessing the bootstrap node config (ip address) from the inventory
|
||||
let confirmation = inquire::Confirm::new(
|
||||
"Now reboot the bootstrap node so it picks up its pxe boot file. Press enter when ready.",
|
||||
)
|
||||
.prompt()
|
||||
.expect("Unexpected prompt error");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_bootstrap_complete(&self) -> Result<(), InterpretError> {
|
||||
// Placeholder: wait-for bootstrap-complete
|
||||
info!("[Bootstrap] Waiting for bootstrap-complete …");
|
||||
todo!("[Bootstrap] Waiting for bootstrap-complete …")
|
||||
}
|
||||
|
||||
async fn create_file(&self, path: &PathBuf, content: &[u8]) -> Result<(), InterpretError> {
|
||||
let mut install_config_file = File::create(path).await.map_err(|e| {
|
||||
InterpretError::new(format!(
|
||||
"Could not create file {} : {e}",
|
||||
path.to_string_lossy()
|
||||
))
|
||||
})?;
|
||||
install_config_file.write(content).await.map_err(|e| {
|
||||
InterpretError::new(format!(
|
||||
"Could not write file {} : {e}",
|
||||
path.to_string_lossy()
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDSetup02BootstrapInterpret {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDSetup02Bootstrap")
|
||||
}
|
||||
|
||||
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> {
|
||||
self.configure_host_binding(inventory, topology).await?;
|
||||
self.prepare_ignition_files(inventory, topology).await?;
|
||||
self.render_per_mac_pxe(inventory, topology).await?;
|
||||
self.setup_bootstrap_load_balancer(inventory, topology)
|
||||
.await?;
|
||||
|
||||
// TODO https://docs.okd.io/latest/installing/installing_bare_metal/upi/installing-bare-metal.html#installation-user-provisioned-validating-dns_installing-bare-metal
|
||||
// self.validate_dns_config(inventory, topology).await?;
|
||||
|
||||
self.reboot_target().await?;
|
||||
self.wait_for_bootstrap_complete().await?;
|
||||
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
"Bootstrap phase complete".into(),
|
||||
))
|
||||
}
|
||||
}
|
195
harmony/src/modules/okd/bootstrap_03_control_plane.rs
Normal file
195
harmony/src/modules/okd/bootstrap_03_control_plane.rs
Normal file
@ -0,0 +1,195 @@
|
||||
use std::{fmt::Write, path::PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use derive_new::new;
|
||||
use harmony_secret::SecretManager;
|
||||
use harmony_types::id::Id;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{fs::File, io::AsyncWriteExt, process::Command};
|
||||
|
||||
use crate::{
|
||||
config::secret::{RedhatSecret, SshKeyPair},
|
||||
data::{FileContent, FilePath, Version},
|
||||
hardware::PhysicalHost,
|
||||
infra::inventory::InventoryRepositoryFactory,
|
||||
instrumentation::{HarmonyEvent, instrument},
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::{HostRole, Inventory},
|
||||
modules::{
|
||||
dhcp::DhcpHostBindingScore,
|
||||
http::{IPxeMacBootFileScore, StaticFilesHttpScore},
|
||||
inventory::{DiscoverHostForRoleScore, LaunchDiscoverInventoryAgentScore},
|
||||
okd::{
|
||||
bootstrap_load_balancer::OKDBootstrapLoadBalancerScore,
|
||||
templates::{BootstrapIpxeTpl, InstallConfigYaml},
|
||||
},
|
||||
},
|
||||
score::Score,
|
||||
topology::{HAClusterTopology, HostBinding},
|
||||
};
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Step 03: Control Plane
|
||||
// - Render per-MAC PXE & ignition for cp0/cp1/cp2.
|
||||
// - Persist bonding via MachineConfigs (or NNCP) once SCOS is active.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, new)]
|
||||
pub struct OKDSetup03ControlPlaneScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDSetup03ControlPlaneScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDSetup03ControlPlaneInterpret::new(self.clone()))
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDSetup03ControlPlaneScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OKDSetup03ControlPlaneInterpret {
|
||||
score: OKDSetup03ControlPlaneScore,
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDSetup03ControlPlaneInterpret {
|
||||
pub fn new(score: OKDSetup03ControlPlaneScore) -> Self {
|
||||
let version = Version::from("1.0.0").unwrap();
|
||||
Self {
|
||||
version,
|
||||
score,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
|
||||
async fn configure_host_binding(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
nodes: &Vec<PhysicalHost>,
|
||||
) -> Result<(), InterpretError> {
|
||||
let binding = HostBinding {
|
||||
logical_host: topology.bootstrap_host.clone(),
|
||||
physical_host: self.get_bootstrap_node().await?,
|
||||
};
|
||||
info!("Configuring host binding for bootstrap node {binding:?}");
|
||||
|
||||
DhcpHostBindingScore {
|
||||
host_binding: vec![binding],
|
||||
domain: Some(topology.domain_name.clone()),
|
||||
}
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn configure_ipxe(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
nodes: &Vec<PhysicalHost>,
|
||||
) -> Result<(), InterpretError> {
|
||||
info!("[ControlPlane] Rendering per-MAC PXE");
|
||||
let content = BootstrapIpxeTpl {
|
||||
http_ip: &topology.http_server.get_ip().to_string(),
|
||||
scos_path: "scos", // TODO use some constant
|
||||
ignition_http_path: "okd_ignition_files", // TODO use proper variable
|
||||
installation_device: "/dev/sda",
|
||||
ignition_file_name: "bootstrap.ign",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let bootstrap_node = self.get_nodes().await?;
|
||||
let mac_address = bootstrap_node.get_mac_address();
|
||||
|
||||
info!("[Bootstrap] Rendering per-MAC PXE for bootstrap node");
|
||||
debug!("bootstrap ipxe content : {content}");
|
||||
debug!("bootstrap mac addresses : {mac_address:?}");
|
||||
|
||||
IPxeMacBootFileScore {
|
||||
mac_address,
|
||||
content,
|
||||
}
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn persist_network_bond(&self) -> Result<(), InterpretError> {
|
||||
// Generate MC or NNCP from inventory NIC data; apply via ignition or post-join.
|
||||
info!("[ControlPlane] Ensuring persistent bonding via MachineConfig/NNCP");
|
||||
inquire::Confirm::new(
|
||||
"Network configuration for control plane nodes is not automated yet, configure it manually now.",
|
||||
)
|
||||
.prompt()
|
||||
.expect("Unexpected prompt error");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_nodes(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<Vec<PhysicalHost>, InterpretError> {
|
||||
let repo = InventoryRepositoryFactory::build().await?;
|
||||
let mut control_plane_hosts = repo.get_host_for_role(HostRole::ControlPlane).await?;
|
||||
|
||||
while control_plane_hosts.len() < 3 {
|
||||
info!(
|
||||
"Discovery of 3 control plane hosts in progress, current number {}",
|
||||
control_plane_hosts.len()
|
||||
);
|
||||
DiscoverHostForRoleScore {
|
||||
role: HostRole::ControlPlane,
|
||||
}
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
control_plane_hosts = repo.get_host_for_role(HostRole::ControlPlane).await?;
|
||||
}
|
||||
|
||||
if control_plane_hosts.len() < 3 {
|
||||
Err(InterpretError::new(format!(
|
||||
"OKD Requires at least 3 hosts, got {}, cannot proceed",
|
||||
control_plane_hosts.len()
|
||||
)))
|
||||
} else {
|
||||
Ok(control_plane_hosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDSetup03ControlPlaneInterpret {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDSetup03ControlPlane")
|
||||
}
|
||||
|
||||
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?;
|
||||
// TODO add relevant methods here
|
||||
self.persist_network_bond().await?;
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
"Control plane provisioned".into(),
|
||||
))
|
||||
}
|
||||
}
|
102
harmony/src/modules/okd/bootstrap_04_workers.rs
Normal file
102
harmony/src/modules/okd/bootstrap_04_workers.rs
Normal file
@ -0,0 +1,102 @@
|
||||
use std::{fmt::Write, path::PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use derive_new::new;
|
||||
use harmony_secret::SecretManager;
|
||||
use harmony_types::id::Id;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{fs::File, io::AsyncWriteExt, process::Command};
|
||||
|
||||
use crate::{
|
||||
config::secret::{RedhatSecret, SshKeyPair},
|
||||
data::{FileContent, FilePath, Version},
|
||||
hardware::PhysicalHost,
|
||||
infra::inventory::InventoryRepositoryFactory,
|
||||
instrumentation::{HarmonyEvent, instrument},
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::{HostRole, Inventory},
|
||||
modules::{
|
||||
dhcp::DhcpHostBindingScore,
|
||||
http::{IPxeMacBootFileScore, StaticFilesHttpScore},
|
||||
inventory::LaunchDiscoverInventoryAgentScore,
|
||||
okd::{
|
||||
bootstrap_load_balancer::OKDBootstrapLoadBalancerScore,
|
||||
templates::{BootstrapIpxeTpl, InstallConfigYaml},
|
||||
},
|
||||
},
|
||||
score::Score,
|
||||
topology::{HAClusterTopology, HostBinding},
|
||||
};
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Step 04: Workers
|
||||
// - Render per-MAC PXE & ignition for workers; join nodes.
|
||||
// - Persist bonding via MC/NNCP as required (same approach as masters).
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, new)]
|
||||
pub struct OKDSetup04WorkersScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDSetup04WorkersScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDSetup04WorkersInterpret::new(self.clone()))
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDSetup04WorkersScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OKDSetup04WorkersInterpret {
|
||||
score: OKDSetup04WorkersScore,
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDSetup04WorkersInterpret {
|
||||
pub fn new(score: OKDSetup04WorkersScore) -> Self {
|
||||
let version = Version::from("1.0.0").unwrap();
|
||||
Self {
|
||||
version,
|
||||
score,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_and_reboot(&self) -> Result<(), InterpretError> {
|
||||
info!("[Workers] Rendering per-MAC PXE for workers and rebooting");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDSetup04WorkersInterpret {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDSetup04Workers")
|
||||
}
|
||||
|
||||
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> {
|
||||
self.render_and_reboot().await?;
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
"Workers provisioned".into(),
|
||||
))
|
||||
}
|
||||
}
|
101
harmony/src/modules/okd/bootstrap_05_sanity_check.rs
Normal file
101
harmony/src/modules/okd/bootstrap_05_sanity_check.rs
Normal file
@ -0,0 +1,101 @@
|
||||
use std::{fmt::Write, path::PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use derive_new::new;
|
||||
use harmony_secret::SecretManager;
|
||||
use harmony_types::id::Id;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{fs::File, io::AsyncWriteExt, process::Command};
|
||||
|
||||
use crate::{
|
||||
config::secret::{RedhatSecret, SshKeyPair},
|
||||
data::{FileContent, FilePath, Version},
|
||||
hardware::PhysicalHost,
|
||||
infra::inventory::InventoryRepositoryFactory,
|
||||
instrumentation::{HarmonyEvent, instrument},
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::{HostRole, Inventory},
|
||||
modules::{
|
||||
dhcp::DhcpHostBindingScore,
|
||||
http::{IPxeMacBootFileScore, StaticFilesHttpScore},
|
||||
inventory::LaunchDiscoverInventoryAgentScore,
|
||||
okd::{
|
||||
bootstrap_load_balancer::OKDBootstrapLoadBalancerScore,
|
||||
templates::{BootstrapIpxeTpl, InstallConfigYaml},
|
||||
},
|
||||
},
|
||||
score::Score,
|
||||
topology::{HAClusterTopology, HostBinding},
|
||||
};
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Step 05: Sanity Check
|
||||
// - Validate API reachability, ClusterOperators, ingress, and SDN status.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, new)]
|
||||
pub struct OKDSetup05SanityCheckScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDSetup05SanityCheckScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDSetup05SanityCheckInterpret::new(self.clone()))
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDSetup05SanityCheckScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OKDSetup05SanityCheckInterpret {
|
||||
score: OKDSetup05SanityCheckScore,
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDSetup05SanityCheckInterpret {
|
||||
pub fn new(score: OKDSetup05SanityCheckScore) -> Self {
|
||||
let version = Version::from("1.0.0").unwrap();
|
||||
Self {
|
||||
version,
|
||||
score,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_checks(&self) -> Result<(), InterpretError> {
|
||||
info!("[Sanity] Checking API, COs, Ingress, and SDN health …");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDSetup05SanityCheckInterpret {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDSetup05SanityCheck")
|
||||
}
|
||||
|
||||
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> {
|
||||
self.run_checks().await?;
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
"Sanity checks passed".into(),
|
||||
))
|
||||
}
|
||||
}
|
101
harmony/src/modules/okd/bootstrap_06_installation_report.rs
Normal file
101
harmony/src/modules/okd/bootstrap_06_installation_report.rs
Normal file
@ -0,0 +1,101 @@
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
use std::{fmt::Write, path::PathBuf};
|
||||
use async_trait::async_trait;
|
||||
use derive_new::new;
|
||||
use harmony_secret::SecretManager;
|
||||
use harmony_types::id::Id;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{fs::File, io::AsyncWriteExt, process::Command};
|
||||
|
||||
use crate::{
|
||||
config::secret::{RedhatSecret, SshKeyPair},
|
||||
data::{FileContent, FilePath, Version},
|
||||
hardware::PhysicalHost,
|
||||
infra::inventory::InventoryRepositoryFactory,
|
||||
instrumentation::{HarmonyEvent, instrument},
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::{HostRole, Inventory},
|
||||
modules::{
|
||||
dhcp::DhcpHostBindingScore,
|
||||
http::{IPxeMacBootFileScore, StaticFilesHttpScore},
|
||||
inventory::LaunchDiscoverInventoryAgentScore,
|
||||
okd::{
|
||||
bootstrap_load_balancer::OKDBootstrapLoadBalancerScore,
|
||||
templates::{BootstrapIpxeTpl, InstallConfigYaml},
|
||||
},
|
||||
},
|
||||
score::Score,
|
||||
topology::{HAClusterTopology, HostBinding},
|
||||
};
|
||||
|
||||
// Step 06: Installation Report
|
||||
// - Emit JSON and concise human summary of nodes, roles, versions, and health.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, new)]
|
||||
pub struct OKDSetup06InstallationReportScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDSetup06InstallationReportScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDSetup06InstallationReportInterpret::new(self.clone()))
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDSetup06InstallationReportScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OKDSetup06InstallationReportInterpret {
|
||||
score: OKDSetup06InstallationReportScore,
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDSetup06InstallationReportInterpret {
|
||||
pub fn new(score: OKDSetup06InstallationReportScore) -> Self {
|
||||
let version = Version::from("1.0.0").unwrap();
|
||||
Self {
|
||||
version,
|
||||
score,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate(&self) -> Result<(), InterpretError> {
|
||||
info!("[Report] Generating OKD installation report",);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDSetup06InstallationReportInterpret {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDSetup06InstallationReport")
|
||||
}
|
||||
|
||||
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> {
|
||||
self.generate().await?;
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
"Installation report generated".into(),
|
||||
))
|
||||
}
|
||||
}
|
@ -47,981 +47,27 @@
|
||||
//! - public_domain: External wildcard/apps domain (e.g., apps.example.com).
|
||||
//! - internal_domain: Internal cluster domain (e.g., cluster.local or harmony.mcd).
|
||||
|
||||
use std::{fmt::Write, path::PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use derive_new::new;
|
||||
use harmony_secret::SecretManager;
|
||||
use harmony_types::id::Id;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{fs::File, io::AsyncWriteExt, process::Command};
|
||||
|
||||
use crate::{
|
||||
config::secret::{RedhatSecret, SshKeyPair},
|
||||
data::{FileContent, FilePath, Version},
|
||||
hardware::PhysicalHost,
|
||||
infra::inventory::InventoryRepositoryFactory,
|
||||
instrumentation::{HarmonyEvent, instrument},
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::{HostRole, Inventory},
|
||||
modules::{
|
||||
dhcp::DhcpHostBindingScore,
|
||||
http::{IPxeMacBootFileScore, StaticFilesHttpScore},
|
||||
inventory::LaunchDiscoverInventoryAgentScore,
|
||||
okd::{
|
||||
bootstrap_load_balancer::OKDBootstrapLoadBalancerScore,
|
||||
templates::{BootstrapIpxeTpl, InstallConfigYaml},
|
||||
},
|
||||
modules::okd::{
|
||||
OKDSetup01InventoryScore, OKDSetup02BootstrapScore, OKDSetup03ControlPlaneScore,
|
||||
OKDSetup04WorkersScore, OKDSetup05SanityCheckScore,
|
||||
bootstrap_06_installation_report::OKDSetup06InstallationReportScore,
|
||||
},
|
||||
score::Score,
|
||||
topology::{HAClusterTopology, HostBinding},
|
||||
topology::HAClusterTopology,
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Public Orchestrator Score
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, new)]
|
||||
pub struct OKDInstallationScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDInstallationScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDInstallationInterpret::new(self.clone()))
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDInstallationScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Orchestrator Interpret
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OKDInstallationInterpret {
|
||||
score: OKDInstallationScore,
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDInstallationInterpret {
|
||||
pub fn new(score: OKDInstallationScore) -> Self {
|
||||
let version = Version::from("0.1.0").expect("valid version");
|
||||
Self {
|
||||
score,
|
||||
version,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_inventory_phase(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
OKDSetup01InventoryScore::new()
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_bootstrap_phase(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
OKDSetup02BootstrapScore::new()
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_control_plane_phase(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
let control_plane_score = OKDSetup03ControlPlaneScore::new();
|
||||
control_plane_score.interpret(inventory, topology).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_workers_phase(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
let workers_score = OKDSetup04WorkersScore::new();
|
||||
workers_score.interpret(inventory, topology).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_sanity_phase(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
let sanity_score = OKDSetup05SanityCheckScore::new();
|
||||
sanity_score.interpret(inventory, topology).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_report_phase(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
let report_score = OKDSetup06InstallationReportScore::new();
|
||||
report_score.interpret(inventory, topology).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDInstallationInterpret {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDInstallationInterpret")
|
||||
}
|
||||
|
||||
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> {
|
||||
instrument(HarmonyEvent::HarmonyStarted).ok();
|
||||
|
||||
info!("Starting OKD installation pipeline",);
|
||||
|
||||
self.run_inventory_phase(inventory, topology).await?;
|
||||
|
||||
self.run_bootstrap_phase(inventory, topology).await?;
|
||||
|
||||
self.run_control_plane_phase(inventory, topology).await?;
|
||||
|
||||
self.run_workers_phase(inventory, topology).await?;
|
||||
|
||||
self.run_sanity_phase(inventory, topology).await?;
|
||||
|
||||
self.run_report_phase(inventory, topology).await?;
|
||||
|
||||
instrument(HarmonyEvent::HarmonyFinished).ok();
|
||||
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
"OKD installation pipeline completed".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Step 01: Inventory (default PXE + Kickstart in RAM + Rust agent)
|
||||
// - This score exposes/ensures the default inventory assets and waits for discoveries.
|
||||
// - No early bonding. Simple access DHCP.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, new)]
|
||||
struct OKDSetup01InventoryScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDSetup01InventoryScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDSetup01InventoryInterpret::new(self.clone()))
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDSetup01InventoryScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct OKDSetup01InventoryInterpret {
|
||||
score: OKDSetup01InventoryScore,
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDSetup01InventoryInterpret {
|
||||
pub fn new(score: OKDSetup01InventoryScore) -> Self {
|
||||
let version = Version::from("1.0.0").unwrap();
|
||||
Self {
|
||||
version,
|
||||
score,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDSetup01InventoryInterpret {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDSetup01Inventory")
|
||||
}
|
||||
|
||||
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> {
|
||||
info!("Setting up base DNS config for OKD");
|
||||
let cluster_domain = &topology.domain_name;
|
||||
let load_balancer_ip = &topology.load_balancer.get_ip();
|
||||
inquire::Confirm::new(&format!(
|
||||
"Set hostnames manually in your opnsense dnsmasq config :
|
||||
*.apps.{cluster_domain} -> {load_balancer_ip}
|
||||
api.{cluster_domain} -> {load_balancer_ip}
|
||||
api-int.{cluster_domain} -> {load_balancer_ip}
|
||||
|
||||
When you can dig them, confirm to continue.
|
||||
"
|
||||
))
|
||||
.prompt()
|
||||
.expect("Prompt error");
|
||||
// TODO reactivate automatic dns config when migration from unbound to dnsmasq is done
|
||||
// OKDDnsScore::new(topology)
|
||||
// .interpret(inventory, topology)
|
||||
// .await?;
|
||||
|
||||
info!(
|
||||
"Launching discovery agent, make sure that your nodes are successfully PXE booted and running inventory agent. They should answer on `http://<node_ip>:8080/inventory`"
|
||||
);
|
||||
LaunchDiscoverInventoryAgentScore {
|
||||
discovery_timeout: None,
|
||||
}
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
|
||||
let bootstrap_host: PhysicalHost;
|
||||
let host_repo = InventoryRepositoryFactory::build().await?;
|
||||
|
||||
loop {
|
||||
let all_hosts = host_repo.get_all_hosts().await?;
|
||||
|
||||
if all_hosts.is_empty() {
|
||||
warn!("No discovered hosts found yet. Waiting for hosts to appear...");
|
||||
// Sleep to avoid spamming the user and logs while waiting for nodes.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let ans = inquire::Select::new(
|
||||
"Select the node to be used as the bootstrap node:",
|
||||
all_hosts,
|
||||
)
|
||||
.with_help_message("Press Esc to refresh the list of discovered hosts")
|
||||
.prompt();
|
||||
|
||||
match ans {
|
||||
Ok(choice) => {
|
||||
info!("Selected {} as the bootstrap node.", choice.summary());
|
||||
host_repo
|
||||
.save_role_mapping(&HostRole::Bootstrap, &choice)
|
||||
.await?;
|
||||
bootstrap_host = choice;
|
||||
break;
|
||||
}
|
||||
Err(inquire::InquireError::OperationCanceled) => {
|
||||
info!("Refresh requested. Fetching list of discovered hosts again...");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to select bootstrap node: {}", e);
|
||||
return Err(InterpretError::new(format!(
|
||||
"Could not select host : {}",
|
||||
e.to_string()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
format!(
|
||||
"Found and assigned bootstrap node: {}",
|
||||
bootstrap_host.summary()
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Step 02: Bootstrap
|
||||
// - Select bootstrap node (from discovered set).
|
||||
// - Render per-MAC iPXE pointing to OKD 4.19 SCOS live assets + bootstrap ignition.
|
||||
// - Reboot the host via SSH and wait for bootstrap-complete.
|
||||
// - No bonding at this stage unless absolutely required; prefer persistence via MC later.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, new)]
|
||||
struct OKDSetup02BootstrapScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDSetup02BootstrapScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDSetup02BootstrapInterpret::new())
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDSetup02BootstrapScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct OKDSetup02BootstrapInterpret {
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDSetup02BootstrapInterpret {
|
||||
pub fn new() -> Self {
|
||||
let version = Version::from("1.0.0").unwrap();
|
||||
Self {
|
||||
version,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_bootstrap_node(&self) -> Result<PhysicalHost, InterpretError> {
|
||||
let repo = InventoryRepositoryFactory::build().await?;
|
||||
match repo
|
||||
.get_host_for_role(HostRole::Bootstrap)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
{
|
||||
Some(host) => Ok(host),
|
||||
None => Err(InterpretError::new(
|
||||
"No bootstrap node available".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_ignition_files(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
let okd_bin_path = PathBuf::from("./data/okd/bin");
|
||||
let okd_installation_path_str =
|
||||
format!("./data/okd/installation_files_{}", inventory.location.name);
|
||||
let okd_images_path = &PathBuf::from("./data/okd/installer_image/");
|
||||
let okd_installation_path = &PathBuf::from(okd_installation_path_str);
|
||||
|
||||
let exit_status = Command::new("mkdir")
|
||||
.arg("-p")
|
||||
.arg(okd_installation_path)
|
||||
.spawn()
|
||||
.expect("Command failed to start")
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
InterpretError::new(format!("Failed to create okd installation directory : {e}"))
|
||||
})?;
|
||||
if !exit_status.success() {
|
||||
return Err(InterpretError::new(format!(
|
||||
"Failed to create okd installation directory"
|
||||
)));
|
||||
} else {
|
||||
info!(
|
||||
"Created OKD installation directory {}",
|
||||
okd_installation_path.to_string_lossy()
|
||||
);
|
||||
}
|
||||
|
||||
let redhat_secret = SecretManager::get_or_prompt::<RedhatSecret>().await?;
|
||||
let ssh_key = SecretManager::get_or_prompt::<SshKeyPair>().await?;
|
||||
|
||||
let install_config_yaml = InstallConfigYaml {
|
||||
cluster_name: &topology.get_cluster_name(),
|
||||
cluster_domain: &topology.get_cluster_base_domain(),
|
||||
pull_secret: &redhat_secret.pull_secret,
|
||||
ssh_public_key: &ssh_key.public,
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let install_config_file_path = &okd_installation_path.join("install-config.yaml");
|
||||
|
||||
self.create_file(install_config_file_path, install_config_yaml.as_bytes())
|
||||
.await?;
|
||||
|
||||
let install_config_backup_extension = install_config_file_path
|
||||
.extension()
|
||||
.map(|e| format!("{}.bak", e.to_string_lossy()))
|
||||
.unwrap_or("bak".to_string());
|
||||
|
||||
let mut install_config_backup = install_config_file_path.clone();
|
||||
install_config_backup.set_extension(install_config_backup_extension);
|
||||
|
||||
self.create_file(&install_config_backup, install_config_yaml.as_bytes())
|
||||
.await?;
|
||||
|
||||
info!("Creating manifest files with openshift-install");
|
||||
let output = Command::new(okd_bin_path.join("openshift-install"))
|
||||
.args([
|
||||
"create",
|
||||
"manifests",
|
||||
"--dir",
|
||||
okd_installation_path.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| InterpretError::new(format!("Failed to create okd manifest : {e}")))?;
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
info!("openshift-install stdout :\n\n{}", stdout);
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
info!("openshift-install stderr :\n\n{}", stderr);
|
||||
info!("openshift-install exit status : {}", output.status);
|
||||
if !output.status.success() {
|
||||
return Err(InterpretError::new(format!(
|
||||
"Failed to create okd manifest, exit code {} : {}",
|
||||
output.status, stderr
|
||||
)));
|
||||
}
|
||||
|
||||
info!("Creating ignition files with openshift-install");
|
||||
let output = Command::new(okd_bin_path.join("openshift-install"))
|
||||
.args([
|
||||
"create",
|
||||
"ignition-configs",
|
||||
"--dir",
|
||||
okd_installation_path.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
InterpretError::new(format!("Failed to create okd ignition config : {e}"))
|
||||
})?;
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
info!("openshift-install stdout :\n\n{}", stdout);
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
info!("openshift-install stderr :\n\n{}", stderr);
|
||||
info!("openshift-install exit status : {}", output.status);
|
||||
if !output.status.success() {
|
||||
return Err(InterpretError::new(format!(
|
||||
"Failed to create okd manifest, exit code {} : {}",
|
||||
output.status, stderr
|
||||
)));
|
||||
}
|
||||
|
||||
let ignition_files_http_path = PathBuf::from("okd_ignition_files");
|
||||
let prepare_file_content = async |filename: &str| -> Result<FileContent, InterpretError> {
|
||||
let local_path = okd_installation_path.join(filename);
|
||||
let remote_path = ignition_files_http_path.join(filename);
|
||||
|
||||
info!(
|
||||
"Preparing file content for local file : {} to remote : {}",
|
||||
local_path.to_string_lossy(),
|
||||
remote_path.to_string_lossy()
|
||||
);
|
||||
|
||||
let content = tokio::fs::read_to_string(&local_path).await.map_err(|e| {
|
||||
InterpretError::new(format!(
|
||||
"Could not read file content {} : {e}",
|
||||
local_path.to_string_lossy()
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(FileContent {
|
||||
path: FilePath::Relative(remote_path.to_string_lossy().to_string()),
|
||||
content,
|
||||
})
|
||||
};
|
||||
|
||||
StaticFilesHttpScore {
|
||||
remote_path: None,
|
||||
folder_to_serve: None,
|
||||
files: vec![
|
||||
prepare_file_content("bootstrap.ign").await?,
|
||||
prepare_file_content("master.ign").await?,
|
||||
prepare_file_content("worker.ign").await?,
|
||||
prepare_file_content("metadata.json").await?,
|
||||
],
|
||||
}
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
|
||||
info!("Successfully prepared ignition files for OKD installation");
|
||||
// ignition_files_http_path // = PathBuf::from("okd_ignition_files");
|
||||
info!(
|
||||
r#"Uploading images, they can be refreshed with a command similar to this one: openshift-install coreos print-stream-json | grep -Eo '"https.*(kernel.|initramfs.|rootfs.)\w+(\.img)?"' | grep x86_64 | xargs -n 1 curl -LO"#
|
||||
);
|
||||
|
||||
inquire::Confirm::new(
|
||||
&format!("push installer image files with `scp -r {}/* root@{}:/usr/local/http/scos/` until performance issue is resolved", okd_images_path.to_string_lossy(), topology.http_server.get_ip())).prompt().expect("Prompt error");
|
||||
|
||||
// let scos_http_path = PathBuf::from("scos");
|
||||
// StaticFilesHttpScore {
|
||||
// folder_to_serve: Some(Url::LocalFolder(
|
||||
// okd_images_path.to_string_lossy().to_string(),
|
||||
// )),
|
||||
// remote_path: Some(scos_http_path.to_string_lossy().to_string()),
|
||||
// files: vec![],
|
||||
// }
|
||||
// .interpret(inventory, topology)
|
||||
// .await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn configure_host_binding(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
let binding = HostBinding {
|
||||
logical_host: topology.bootstrap_host.clone(),
|
||||
physical_host: self.get_bootstrap_node().await?,
|
||||
};
|
||||
info!("Configuring host binding for bootstrap node {binding:?}");
|
||||
|
||||
DhcpHostBindingScore {
|
||||
host_binding: vec![binding],
|
||||
domain: Some(topology.domain_name.clone()),
|
||||
}
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn render_per_mac_pxe(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
let content = BootstrapIpxeTpl {
|
||||
http_ip: &topology.http_server.get_ip().to_string(),
|
||||
scos_path: "scos", // TODO use some constant
|
||||
ignition_http_path: "okd_ignition_files", // TODO use proper variable
|
||||
installation_device: "/dev/sda",
|
||||
// TODO do something smart based on the host drives
|
||||
// topology. Something like use the smallest device
|
||||
// above 200G that is an ssd
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let bootstrap_node = self.get_bootstrap_node().await?;
|
||||
let mac_address = bootstrap_node.get_mac_address();
|
||||
|
||||
info!("[Bootstrap] Rendering per-MAC PXE for bootstrap node");
|
||||
debug!("bootstrap ipxe content : {content}");
|
||||
debug!("bootstrap mac addresses : {mac_address:?}");
|
||||
|
||||
IPxeMacBootFileScore {
|
||||
mac_address,
|
||||
content,
|
||||
}
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn setup_bootstrap_load_balancer(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &HAClusterTopology,
|
||||
) -> Result<(), InterpretError> {
|
||||
let outcome = OKDBootstrapLoadBalancerScore::new(topology)
|
||||
.interpret(inventory, topology)
|
||||
.await?;
|
||||
info!("Successfully executed OKDBootstrapLoadBalancerScore : {outcome:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reboot_target(&self) -> Result<(), InterpretError> {
|
||||
// Placeholder: ssh reboot using the inventory ephemeral key
|
||||
info!("[Bootstrap] Rebooting bootstrap node via SSH");
|
||||
// TODO reboot programatically, there are some logical checks and refactoring to do such as
|
||||
// accessing the bootstrap node config (ip address) from the inventory
|
||||
let confirmation = inquire::Confirm::new(
|
||||
"Now reboot the bootstrap node so it picks up its pxe boot file. Press enter when ready.",
|
||||
)
|
||||
.with_default(true)
|
||||
.prompt()
|
||||
.expect("Unexpected prompt error");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_bootstrap_complete(&self) -> Result<(), InterpretError> {
|
||||
// Placeholder: wait-for bootstrap-complete
|
||||
info!("[Bootstrap] Waiting for bootstrap-complete …");
|
||||
todo!("[Bootstrap] Waiting for bootstrap-complete …")
|
||||
}
|
||||
|
||||
async fn create_file(&self, path: &PathBuf, content: &[u8]) -> Result<(), InterpretError> {
|
||||
let mut install_config_file = File::create(path).await.map_err(|e| {
|
||||
InterpretError::new(format!(
|
||||
"Could not create file {} : {e}",
|
||||
path.to_string_lossy()
|
||||
))
|
||||
})?;
|
||||
install_config_file.write(content).await.map_err(|e| {
|
||||
InterpretError::new(format!(
|
||||
"Could not write file {} : {e}",
|
||||
path.to_string_lossy()
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDSetup02BootstrapInterpret {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDSetup02Bootstrap")
|
||||
}
|
||||
|
||||
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> {
|
||||
self.configure_host_binding(inventory, topology).await?;
|
||||
self.prepare_ignition_files(inventory, topology).await?;
|
||||
self.render_per_mac_pxe(inventory, topology).await?;
|
||||
self.setup_bootstrap_load_balancer(inventory, topology)
|
||||
.await?;
|
||||
|
||||
// TODO https://docs.okd.io/latest/installing/installing_bare_metal/upi/installing-bare-metal.html#installation-user-provisioned-validating-dns_installing-bare-metal
|
||||
// self.validate_dns_config(inventory, topology).await?;
|
||||
|
||||
self.reboot_target().await?;
|
||||
self.wait_for_bootstrap_complete().await?;
|
||||
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
"Bootstrap phase complete".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Step 03: Control Plane
|
||||
// - Render per-MAC PXE & ignition for cp0/cp1/cp2.
|
||||
// - Persist bonding via MachineConfigs (or NNCP) once SCOS is active.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, new)]
|
||||
struct OKDSetup03ControlPlaneScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDSetup03ControlPlaneScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDSetup03ControlPlaneInterpret::new(self.clone()))
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDSetup03ControlPlaneScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct OKDSetup03ControlPlaneInterpret {
|
||||
score: OKDSetup03ControlPlaneScore,
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDSetup03ControlPlaneInterpret {
|
||||
pub fn new(score: OKDSetup03ControlPlaneScore) -> Self {
|
||||
let version = Version::from("1.0.0").unwrap();
|
||||
Self {
|
||||
version,
|
||||
score,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_and_reboot(&self) -> Result<(), InterpretError> {
|
||||
info!("[ControlPlane] Rendering per-MAC PXE for masters and rebooting");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn persist_network_bond(&self) -> Result<(), InterpretError> {
|
||||
// Generate MC or NNCP from inventory NIC data; apply via ignition or post-join.
|
||||
info!("[ControlPlane] Ensuring persistent bonding via MachineConfig/NNCP");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDSetup03ControlPlaneInterpret {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDSetup03ControlPlane")
|
||||
}
|
||||
|
||||
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> {
|
||||
self.render_and_reboot().await?;
|
||||
self.persist_network_bond().await?;
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
"Control plane provisioned".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Step 04: Workers
|
||||
// - Render per-MAC PXE & ignition for workers; join nodes.
|
||||
// - Persist bonding via MC/NNCP as required (same approach as masters).
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, new)]
|
||||
struct OKDSetup04WorkersScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDSetup04WorkersScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDSetup04WorkersInterpret::new(self.clone()))
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDSetup04WorkersScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct OKDSetup04WorkersInterpret {
|
||||
score: OKDSetup04WorkersScore,
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDSetup04WorkersInterpret {
|
||||
pub fn new(score: OKDSetup04WorkersScore) -> Self {
|
||||
let version = Version::from("1.0.0").unwrap();
|
||||
Self {
|
||||
version,
|
||||
score,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_and_reboot(&self) -> Result<(), InterpretError> {
|
||||
info!("[Workers] Rendering per-MAC PXE for workers and rebooting");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDSetup04WorkersInterpret {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDSetup04Workers")
|
||||
}
|
||||
|
||||
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> {
|
||||
self.render_and_reboot().await?;
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
"Workers provisioned".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Step 05: Sanity Check
|
||||
// - Validate API reachability, ClusterOperators, ingress, and SDN status.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, new)]
|
||||
struct OKDSetup05SanityCheckScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDSetup05SanityCheckScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDSetup05SanityCheckInterpret::new(self.clone()))
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDSetup05SanityCheckScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct OKDSetup05SanityCheckInterpret {
|
||||
score: OKDSetup05SanityCheckScore,
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDSetup05SanityCheckInterpret {
|
||||
pub fn new(score: OKDSetup05SanityCheckScore) -> Self {
|
||||
let version = Version::from("1.0.0").unwrap();
|
||||
Self {
|
||||
version,
|
||||
score,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_checks(&self) -> Result<(), InterpretError> {
|
||||
info!("[Sanity] Checking API, COs, Ingress, and SDN health …");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDSetup05SanityCheckInterpret {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDSetup05SanityCheck")
|
||||
}
|
||||
|
||||
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> {
|
||||
self.run_checks().await?;
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
"Sanity checks passed".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Step 06: Installation Report
|
||||
// - Emit JSON and concise human summary of nodes, roles, versions, and health.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, new)]
|
||||
struct OKDSetup06InstallationReportScore {}
|
||||
|
||||
impl Score<HAClusterTopology> for OKDSetup06InstallationReportScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<HAClusterTopology>> {
|
||||
Box::new(OKDSetup06InstallationReportInterpret::new(self.clone()))
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"OKDSetup06InstallationReportScore".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct OKDSetup06InstallationReportInterpret {
|
||||
score: OKDSetup06InstallationReportScore,
|
||||
version: Version,
|
||||
status: InterpretStatus,
|
||||
}
|
||||
|
||||
impl OKDSetup06InstallationReportInterpret {
|
||||
pub fn new(score: OKDSetup06InstallationReportScore) -> Self {
|
||||
let version = Version::from("1.0.0").unwrap();
|
||||
Self {
|
||||
version,
|
||||
score,
|
||||
status: InterpretStatus::QUEUED,
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate(&self) -> Result<(), InterpretError> {
|
||||
info!("[Report] Generating OKD installation report",);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interpret<HAClusterTopology> for OKDSetup06InstallationReportInterpret {
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("OKDSetup06InstallationReport")
|
||||
}
|
||||
|
||||
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> {
|
||||
self.generate().await?;
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
"Installation report generated".into(),
|
||||
))
|
||||
pub struct OKDInstallationPipeline;
|
||||
|
||||
impl OKDInstallationPipeline {
|
||||
pub async fn get_all_scores() -> Vec<Box<dyn Score<HAClusterTopology>>> {
|
||||
vec![
|
||||
Box::new(OKDSetup01InventoryScore::new()),
|
||||
Box::new(OKDSetup02BootstrapScore::new()),
|
||||
Box::new(OKDSetup03ControlPlaneScore::new()),
|
||||
Box::new(OKDSetup04WorkersScore::new()),
|
||||
Box::new(OKDSetup05SanityCheckScore::new()),
|
||||
Box::new(OKDSetup06InstallationReportScore::new()),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,9 @@
|
||||
mod bootstrap_01_prepare;
|
||||
mod bootstrap_02_bootstrap;
|
||||
mod bootstrap_03_control_plane;
|
||||
mod bootstrap_04_workers;
|
||||
mod bootstrap_05_sanity_check;
|
||||
mod bootstrap_06_installation_report;
|
||||
pub mod bootstrap_dhcp;
|
||||
pub mod bootstrap_load_balancer;
|
||||
pub mod dhcp;
|
||||
@ -7,3 +13,9 @@ pub mod ipxe;
|
||||
pub mod load_balancer;
|
||||
pub mod templates;
|
||||
pub mod upgrade;
|
||||
pub use bootstrap_01_prepare::*;
|
||||
pub use bootstrap_02_bootstrap::*;
|
||||
pub use bootstrap_03_control_plane::*;
|
||||
pub use bootstrap_04_workers::*;
|
||||
pub use bootstrap_05_sanity_check::*;
|
||||
pub use bootstrap_06_installation_report::*;
|
||||
|
@ -16,4 +16,5 @@ pub struct BootstrapIpxeTpl<'a> {
|
||||
pub scos_path: &'a str,
|
||||
pub installation_device: &'a str,
|
||||
pub ignition_http_path: &'a str,
|
||||
pub ignition_file_name: &'static str,
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ set http_ip {{ http_ip }}
|
||||
set scos_path {{ scos_path }}
|
||||
set inst_dev {{ installation_device }}
|
||||
set ign_path {{ ignition_http_path }}
|
||||
set ign_file bootstrap.ign
|
||||
set ign_file {{ ignition_file_name }}
|
||||
|
||||
# --- Derived Variables ---
|
||||
set base-url http://${http_ip}:8080
|
||||
|
Loading…
Reference in New Issue
Block a user