Compare commits
39 Commits
feea9780ab
...
runtime-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f7c4924c1 | ||
| 254f392cb5 | |||
| db9c8d83e6 | |||
| 20551b4a80 | |||
| 5c026ae6dd | |||
| 76c0cacc1b | |||
| f17948397f | |||
| 16a665241e | |||
| 065e3904b8 | |||
| 22752960f9 | |||
| 23971ecd7c | |||
| fbcd3e4f7f | |||
| d307893f15 | |||
| 00c0566533 | |||
| f5e3f1aaea | |||
| 508b97ca7c | |||
| 80bdd0ee8a | |||
| 6c06a4ae07 | |||
| ad1aa897b1 | |||
| dccc9c04f5 | |||
| 9345e63a32 | |||
| ff830486af | |||
| da83019d85 | |||
| 53aa47f91e | |||
| 8f470278a7 | |||
| 213fb25686 | |||
| 45668638e1 | |||
| 0857aba039 | |||
| 452ebc2614 | |||
| 9e456bb4f5 | |||
| 83ba0e1044 | |||
| 2229e9d7af | |||
| 15785dd219 | |||
| 847d84b46f | |||
| 3f6f1fa0d4 | |||
| 6812d05849 | |||
| 027114c48c | |||
| eeafa086f3 | |||
| abd20b96a2 |
1775
Cargo.lock
generated
1775
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
13
Cargo.toml
@@ -9,6 +9,8 @@ members = [
|
||||
"harmony_tui",
|
||||
"opnsense-config",
|
||||
"opnsense-config-xml",
|
||||
"harmony_cli",
|
||||
"k3d",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -21,22 +23,23 @@ log = "0.4.22"
|
||||
env_logger = "0.11.5"
|
||||
derive-new = "0.7.0"
|
||||
async-trait = "0.1.82"
|
||||
tokio = { version = "1.40.0", features = ["io-std", "fs"] }
|
||||
tokio = { version = "1.40.0", features = ["io-std", "fs", "macros", "rt-multi-thread"] }
|
||||
cidr = "0.2.3"
|
||||
russh = "0.45.0"
|
||||
russh-keys = "0.45.0"
|
||||
rand = "0.8.5"
|
||||
url = "2.5.4"
|
||||
kube = "0.98.0"
|
||||
k8s-openapi = { version = "0.24.0", features = [ "v1_30" ] }
|
||||
k8s-openapi = { version = "0.24.0", features = ["v1_30"] }
|
||||
serde_yaml = "0.9.34"
|
||||
serde-value = "0.7.0"
|
||||
http = "1.2.0"
|
||||
inquire = "0.7.5"
|
||||
|
||||
[workspace.dependencies.uuid]
|
||||
version = "1.11.0"
|
||||
features = [
|
||||
"v4", # Lets you generate random UUIDs
|
||||
"fast-rng", # Use a faster (but still sufficiently random) RNG
|
||||
"macro-diagnostics", # Enable better diagnostics for compile-time UUIDs
|
||||
"v4", # Lets you generate random UUIDs
|
||||
"fast-rng", # Use a faster (but still sufficiently random) RNG
|
||||
"macro-diagnostics", # Enable better diagnostics for compile-time UUIDs
|
||||
]
|
||||
|
||||
20
README.md
20
README.md
@@ -8,6 +8,26 @@ This will launch Harmony's minimalist terminal ui which embeds a few demo scores
|
||||
|
||||
Usage instructions will be displayed at the bottom of the TUI.
|
||||
|
||||
`cargo run --bin example-cli -- --help`
|
||||
|
||||
This is the harmony CLI, a minimal implementation
|
||||
|
||||
The current help text:
|
||||
|
||||
````
|
||||
Usage: example-cli [OPTIONS]
|
||||
|
||||
Options:
|
||||
-y, --yes Run score(s) or not
|
||||
-f, --filter <FILTER> Filter query
|
||||
-i, --interactive Run interactive TUI or not
|
||||
-a, --all Run all or nth, defaults to all
|
||||
-n, --number <NUMBER> Run nth matching, zero indexed [default: 0]
|
||||
-l, --list list scores, will also be affected by run filter
|
||||
-h, --help Print help
|
||||
-V, --version Print version```
|
||||
|
||||
## Core architecture
|
||||
|
||||

|
||||
````
|
||||
|
||||
68
adr/010-monitoring-and-alerting.md
Normal file
68
adr/010-monitoring-and-alerting.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Architecture Decision Record: Monitoring and Alerting
|
||||
|
||||
Proposed by: Willem Rolleman
|
||||
Date: April 28 2025
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
A harmony user should be able to initialize a monitoring stack easily, either at the first run of Harmony, or that integrates with existing proects and infra without creating multiple instances of the monitoring stack or overwriting existing alerts/configurations.The user also needs a simple way to configure the stack so that it watches the projects. There should be reasonable defaults configured that are easily customizable for each project
|
||||
|
||||
## Decision
|
||||
|
||||
Create MonitoringStack score that creates a maestro to launch the monitoring stack or not if it is already present.
|
||||
The MonitoringStack score can be passed to the maestro in the vec! scores list
|
||||
|
||||
## Rationale
|
||||
|
||||
Having the score launch a maestro will allow the user to easily create a new monitoring stack and keeps composants grouped together. The MonitoringScore can handle all the logic for adding alerts, ensuring that the stack is running etc.
|
||||
|
||||
## Alerternatives considered
|
||||
|
||||
- ### Implement alerting and monitoring stack using existing HelmScore for each project
|
||||
- **Pros**:
|
||||
- Each project can choose to use the monitoring and alerting stack that they choose
|
||||
- Less overhead in terms of care harmony code
|
||||
- can add Box::new(grafana::grafanascore(namespace))
|
||||
- **Cons**:
|
||||
- No default solution implemented
|
||||
- Dev needs to chose what they use
|
||||
- Increases complexity of score projects
|
||||
- Each project will create a new monitoring and alerting instance rather than joining the existing one
|
||||
|
||||
|
||||
- ### Use OKD grafana and prometheus
|
||||
- **Pros**:
|
||||
- Minimal config to do in Harmony
|
||||
- **Cons**:
|
||||
- relies on OKD so will not working for local testing via k3d
|
||||
|
||||
- ### Create a monitoring and alerting crate similar to harmony tui
|
||||
- **Pros**:
|
||||
- Creates a default solution that can be implemented once by harmony
|
||||
- can create a join function that will allow a project to connect to the existing solution
|
||||
- eliminates risk of creating multiple instances of grafana or prometheus
|
||||
- **Cons**:
|
||||
- more complex than using a helm score
|
||||
- management of values files for individual functions becomes more complicated, ie how do you create alerts for one project via helm install that doesnt overwrite the other alerts
|
||||
|
||||
- ### Add monitoring to Maestro struct so whether the monitoring stack is used must be defined
|
||||
- **Pros**:
|
||||
- less for the user to define
|
||||
- may be easier to set defaults
|
||||
- **Cons**:
|
||||
- feels counterintuitive
|
||||
- would need to modify the structure of the maestro and how it operates which seems like a bad idea
|
||||
- unclear how to allow user to pass custom values/configs to the monitoring stack for subsequent projects
|
||||
|
||||
- ### Create MonitoringStack score to add to scores vec! which loads a maestro to install stack if not ready or add custom endpoints/alerts to existing stack
|
||||
- **Pros**:
|
||||
- Maestro already accepts a list of scores to initialize
|
||||
- leaving out the monitoring score simply means the user does not want monitoring
|
||||
- if the monitoring stack is already created, the MonitoringStack score doesn't necessarily need to be added to each project
|
||||
- composants of the monitoring stack are bundled together and can be expaned or modified from the same place
|
||||
- **Cons**:
|
||||
- maybe need to create
|
||||
5
check.sh
Normal file
5
check.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
cargo check --all-targets --all-features --keep-going
|
||||
cargo fmt --check
|
||||
cargo test
|
||||
19
examples/cli/Cargo.toml
Normal file
19
examples/cli/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "example-cli"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
harmony = { path = "../../harmony" }
|
||||
harmony_cli = { path = "../../harmony_cli" }
|
||||
harmony_types = { path = "../../harmony_types" }
|
||||
cidr = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
harmony_macros = { path = "../../harmony_macros" }
|
||||
log = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
url = { workspace = true }
|
||||
assert_cmd = "2.0.16"
|
||||
20
examples/cli/src/main.rs
Normal file
20
examples/cli/src/main.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use harmony::{
|
||||
inventory::Inventory,
|
||||
maestro::Maestro,
|
||||
modules::dummy::{ErrorScore, PanicScore, SuccessScore},
|
||||
topology::LocalhostTopology,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let inventory = Inventory::autoload();
|
||||
let topology = LocalhostTopology::new();
|
||||
let mut maestro = Maestro::initialize(inventory, topology).await.unwrap();
|
||||
|
||||
maestro.register_all(vec![
|
||||
Box::new(SuccessScore {}),
|
||||
Box::new(ErrorScore {}),
|
||||
Box::new(PanicScore {}),
|
||||
]);
|
||||
harmony_cli::init(maestro, None).await.unwrap();
|
||||
}
|
||||
@@ -18,3 +18,4 @@ kube = "0.98.0"
|
||||
k8s-openapi = { version = "0.24.0", features = [ "v1_30" ] }
|
||||
http = "1.2.0"
|
||||
serde_yaml = "0.9.34"
|
||||
inquire.workspace = true
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use harmony_macros::yaml;
|
||||
use inquire::Confirm;
|
||||
use k8s_openapi::{
|
||||
api::{
|
||||
apps::v1::{Deployment, DeploymentSpec},
|
||||
core::v1::{Container, Node, Pod, PodSpec, PodTemplateSpec},
|
||||
core::v1::{Container, PodSpec, PodTemplateSpec},
|
||||
},
|
||||
apimachinery::pkg::apis::meta::v1::LabelSelector,
|
||||
};
|
||||
use kube::{
|
||||
Api, Client, Config, ResourceExt,
|
||||
api::{ListParams, ObjectMeta, PostParams},
|
||||
Api, Client, ResourceExt,
|
||||
api::{ObjectMeta, PostParams},
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let confirmation = Confirm::new(
|
||||
"This will install various ressources to your default kubernetes cluster. Are you sure?",
|
||||
)
|
||||
.with_default(false)
|
||||
.prompt()
|
||||
.expect("Unexpected prompt error");
|
||||
|
||||
if !confirmation {
|
||||
return;
|
||||
}
|
||||
|
||||
let client = Client::try_default()
|
||||
.await
|
||||
.expect("Should instanciate client from defaults");
|
||||
@@ -42,8 +54,7 @@ async fn main() {
|
||||
// println!("found node {} status {:?}", n.name_any(), n.status.unwrap())
|
||||
// }
|
||||
|
||||
let nginxdeployment = nginx_deployment_2();
|
||||
let nginxdeployment = nginx_deployment_serde();
|
||||
assert_eq!(nginx_deployment(), nginx_macro());
|
||||
assert_eq!(nginx_deployment_2(), nginx_macro());
|
||||
assert_eq!(nginx_deployment_serde(), nginx_macro());
|
||||
let nginxdeployment = nginx_macro();
|
||||
@@ -149,6 +160,7 @@ fn nginx_deployment_2() -> Deployment {
|
||||
|
||||
deployment
|
||||
}
|
||||
|
||||
fn nginx_deployment() -> Deployment {
|
||||
let deployment = Deployment {
|
||||
metadata: ObjectMeta {
|
||||
|
||||
@@ -8,7 +8,7 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
harmony = { path = "../../harmony" }
|
||||
#harmony_tui = { path = "../../harmony_tui" }
|
||||
harmony_tui = { path = "../../harmony_tui" }
|
||||
harmony_types = { path = "../../harmony_types" }
|
||||
cidr = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use harmony::{
|
||||
data::Version,
|
||||
inventory::Inventory,
|
||||
maestro::Maestro,
|
||||
modules::lamp::{LAMPConfig, LAMPScore},
|
||||
topology::{HAClusterTopology, Url},
|
||||
modules::lamp::{LAMPConfig, LAMPProfile, LAMPScore},
|
||||
topology::{K8sAnywhereTopology, Url},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// let _ = env_logger::Builder::from_default_env().filter_level(log::LevelFilter::Info).try_init();
|
||||
let lamp_stack = LAMPScore {
|
||||
name: "harmony-lamp-demo".to_string(),
|
||||
domain: Url::Url(url::Url::parse("https://lampdemo.harmony.nationtech.io").unwrap()),
|
||||
@@ -15,10 +18,18 @@ async fn main() {
|
||||
project_root: "./php".into(),
|
||||
..Default::default()
|
||||
},
|
||||
profiles: HashMap::from([
|
||||
("dev", LAMPProfile { ssl_enabled: false }),
|
||||
("prod", LAMPProfile { ssl_enabled: true }),
|
||||
]),
|
||||
};
|
||||
|
||||
Maestro::<HAClusterTopology>::load_from_env()
|
||||
.interpret(Box::new(lamp_stack))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut maestro = Maestro::<K8sAnywhereTopology>::initialize(
|
||||
Inventory::autoload(),
|
||||
K8sAnywhereTopology::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
maestro.register_all(vec![Box::new(lamp_stack)]);
|
||||
harmony_tui::init(maestro).await.unwrap();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use harmony::{
|
||||
inventory::Inventory,
|
||||
maestro::Maestro,
|
||||
modules::{
|
||||
dummy::{ErrorScore, PanicScore, SuccessScore},
|
||||
k8s::deployment::K8sDeploymentScore,
|
||||
},
|
||||
modules::dummy::{ErrorScore, PanicScore, SuccessScore},
|
||||
topology::HAClusterTopology,
|
||||
};
|
||||
|
||||
@@ -12,7 +9,7 @@ use harmony::{
|
||||
async fn main() {
|
||||
let inventory = Inventory::autoload();
|
||||
let topology = HAClusterTopology::autoload();
|
||||
let mut maestro = Maestro::new(inventory, topology);
|
||||
let mut maestro = Maestro::initialize(inventory, topology).await.unwrap();
|
||||
|
||||
maestro.register_all(vec![
|
||||
// ADD scores :
|
||||
|
||||
@@ -84,7 +84,7 @@ async fn main() {
|
||||
let http_score = HttpScore::new(Url::LocalFolder(
|
||||
"./data/watchguard/pxe-http-files".to_string(),
|
||||
));
|
||||
let mut maestro = Maestro::new(inventory, topology);
|
||||
let mut maestro = Maestro::initialize(inventory, topology).await.unwrap();
|
||||
maestro.register_all(vec![
|
||||
Box::new(dns_score),
|
||||
Box::new(dhcp_score),
|
||||
|
||||
@@ -1,20 +1,70 @@
|
||||
use std::net::{SocketAddr, SocketAddrV4};
|
||||
|
||||
use harmony::{
|
||||
inventory::Inventory,
|
||||
maestro::Maestro,
|
||||
modules::dummy::{ErrorScore, PanicScore, SuccessScore},
|
||||
topology::HAClusterTopology,
|
||||
modules::{
|
||||
dns::DnsScore,
|
||||
dummy::{ErrorScore, PanicScore, SuccessScore},
|
||||
load_balancer::LoadBalancerScore,
|
||||
},
|
||||
topology::{
|
||||
BackendServer, DummyInfra, HealthCheck, HttpMethod, HttpStatusCode, LoadBalancerService,
|
||||
},
|
||||
};
|
||||
use harmony_macros::ipv4;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let inventory = Inventory::autoload();
|
||||
let topology = HAClusterTopology::autoload();
|
||||
let mut maestro = Maestro::new(inventory, topology);
|
||||
let topology = DummyInfra {};
|
||||
let mut maestro = Maestro::initialize(inventory, topology).await.unwrap();
|
||||
|
||||
maestro.register_all(vec![
|
||||
Box::new(SuccessScore {}),
|
||||
Box::new(ErrorScore {}),
|
||||
Box::new(PanicScore {}),
|
||||
Box::new(DnsScore::new(vec![], None)),
|
||||
Box::new(build_large_score()),
|
||||
]);
|
||||
harmony_tui::init(maestro).await.unwrap();
|
||||
}
|
||||
|
||||
fn build_large_score() -> LoadBalancerScore {
|
||||
let backend_server = BackendServer {
|
||||
address: "192.168.0.0".to_string(),
|
||||
port: 342,
|
||||
};
|
||||
let lb_service = LoadBalancerService {
|
||||
backend_servers: vec![
|
||||
backend_server.clone(),
|
||||
backend_server.clone(),
|
||||
backend_server.clone(),
|
||||
],
|
||||
listening_port: SocketAddr::V4(SocketAddrV4::new(ipv4!("192.168.0.0"), 49387)),
|
||||
health_check: Some(HealthCheck::HTTP(
|
||||
"/some_long_ass_path_to_see_how_it_is_displayed_but_it_has_to_be_even_longer"
|
||||
.to_string(),
|
||||
HttpMethod::GET,
|
||||
HttpStatusCode::Success2xx,
|
||||
)),
|
||||
};
|
||||
LoadBalancerScore {
|
||||
public_services: vec![
|
||||
lb_service.clone(),
|
||||
lb_service.clone(),
|
||||
lb_service.clone(),
|
||||
lb_service.clone(),
|
||||
lb_service.clone(),
|
||||
lb_service.clone(),
|
||||
],
|
||||
private_services: vec![
|
||||
lb_service.clone(),
|
||||
lb_service.clone(),
|
||||
lb_service.clone(),
|
||||
lb_service.clone(),
|
||||
lb_service.clone(),
|
||||
lb_service.clone(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
libredfish = "0.1.1"
|
||||
reqwest = {version = "0.11", features = ["blocking", "json"] }
|
||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||
russh = "0.45.0"
|
||||
rust-ipmi = "0.1.1"
|
||||
semver = "1.0.23"
|
||||
@@ -30,3 +30,11 @@ k8s-openapi = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
http = { workspace = true }
|
||||
serde-value = { workspace = true }
|
||||
inquire.workspace = true
|
||||
helm-wrapper-rs = "0.4.0"
|
||||
non-blank-string-rs = "1.0.4"
|
||||
k3d-rs = { path = "../k3d" }
|
||||
directories = "6.0.0"
|
||||
lazy_static = "1.5.0"
|
||||
dockerfile_builder = "0.1.5"
|
||||
temp-file = "0.1.9"
|
||||
|
||||
9
harmony/src/domain/config.rs
Normal file
9
harmony/src/domain/config.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use lazy_static::lazy_static;
|
||||
use std::path::PathBuf;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref HARMONY_CONFIG_DIR: PathBuf = directories::BaseDirs::new()
|
||||
.unwrap()
|
||||
.data_dir()
|
||||
.join("harmony");
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use super::{
|
||||
data::{Id, Version},
|
||||
executors::ExecutorError,
|
||||
inventory::Inventory,
|
||||
topology::Topology,
|
||||
};
|
||||
|
||||
pub enum InterpretName {
|
||||
@@ -19,6 +18,7 @@ pub enum InterpretName {
|
||||
Dummy,
|
||||
Panic,
|
||||
OPNSense,
|
||||
K3dInstallation,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InterpretName {
|
||||
@@ -32,14 +32,19 @@ impl std::fmt::Display for InterpretName {
|
||||
InterpretName::Dummy => f.write_str("Dummy"),
|
||||
InterpretName::Panic => f.write_str("Panic"),
|
||||
InterpretName::OPNSense => f.write_str("OPNSense"),
|
||||
InterpretName::K3dInstallation => f.write_str("K3dInstallation"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Interpret<T>: std::fmt::Debug + Send {
|
||||
async fn execute(&self, inventory: &Inventory, topology: &T)
|
||||
-> Result<Outcome, InterpretError>;
|
||||
async fn execute(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &T,
|
||||
profile: &String,
|
||||
) -> Result<Outcome, InterpretError>;
|
||||
fn get_name(&self) -> InterpretName;
|
||||
fn get_version(&self) -> Version;
|
||||
fn get_status(&self) -> InterpretStatus;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use log::info;
|
||||
use log::{info, warn};
|
||||
|
||||
use super::{
|
||||
interpret::{InterpretError, Outcome},
|
||||
interpret::{InterpretError, InterpretStatus, Outcome},
|
||||
inventory::Inventory,
|
||||
score::Score,
|
||||
topology::Topology,
|
||||
@@ -15,40 +15,44 @@ pub struct Maestro<T: Topology> {
|
||||
inventory: Inventory,
|
||||
topology: T,
|
||||
scores: Arc<RwLock<ScoreVec<T>>>,
|
||||
topology_preparation_result: Mutex<Option<Outcome>>,
|
||||
profile: String,
|
||||
}
|
||||
|
||||
impl<T: Topology> Maestro<T> {
|
||||
pub fn new(inventory: Inventory, topology: T) -> Self {
|
||||
pub fn new(inventory: Inventory, topology: T, profile: String) -> Self {
|
||||
Self {
|
||||
inventory,
|
||||
topology,
|
||||
scores: Arc::new(RwLock::new(Vec::new())),
|
||||
topology_preparation_result: None.into(),
|
||||
profile,
|
||||
}
|
||||
}
|
||||
|
||||
// Load the inventory and inventory from environment.
|
||||
// This function is able to discover the context that it is running in, such as k8s clusters, aws cloud, linux host, etc.
|
||||
// When the HARMONY_TOPOLOGY environment variable is not set, it will default to install k3s
|
||||
// locally (lazily, if not installed yet, when the first execution occurs) and use that as a topology
|
||||
// So, by default, the inventory is a single host that the binary is running on, and the
|
||||
// topology is a single node k3s
|
||||
//
|
||||
// By default :
|
||||
// - Linux => k3s
|
||||
// - macos, windows => docker compose
|
||||
//
|
||||
// To run more complex cases like OKDHACluster, either provide the default target in the
|
||||
// harmony infrastructure as code or as an environment variable
|
||||
pub fn load_from_env() -> Self {
|
||||
// Load env var HARMONY_TOPOLOGY
|
||||
match std::env::var("HARMONY_TOPOLOGY") {
|
||||
Ok(_) => todo!(),
|
||||
Err(_) => todo!(),
|
||||
}
|
||||
pub async fn initialize(inventory: Inventory, topology: T) -> Result<Self, InterpretError> {
|
||||
let profile = "dev".to_string(); // TODO: retrieve from env?
|
||||
let instance = Self::new(inventory, topology, profile);
|
||||
instance.prepare_topology().await?;
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
pub fn start(&mut self) {
|
||||
info!("Starting Maestro");
|
||||
/// Ensures the associated Topology is ready for operations.
|
||||
/// Delegates the readiness check and potential setup actions to the Topology.
|
||||
pub async fn prepare_topology(&self) -> Result<Outcome, InterpretError> {
|
||||
info!("Ensuring topology '{}' is ready...", self.topology.name());
|
||||
let outcome = self.topology.ensure_ready().await?;
|
||||
info!(
|
||||
"Topology '{}' readiness check complete: {}",
|
||||
self.topology.name(),
|
||||
outcome.status
|
||||
);
|
||||
|
||||
self.topology_preparation_result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace(outcome.clone());
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub fn register_all(&mut self, mut scores: ScoreVec<T>) {
|
||||
@@ -56,11 +60,32 @@ impl<T: Topology> Maestro<T> {
|
||||
score_mut.append(&mut scores);
|
||||
}
|
||||
|
||||
fn is_topology_initialized(&self) -> bool {
|
||||
let result = self.topology_preparation_result.lock().unwrap();
|
||||
if let Some(outcome) = result.as_ref() {
|
||||
match outcome.status {
|
||||
InterpretStatus::SUCCESS => return true,
|
||||
_ => return false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn interpret(&self, score: Box<dyn Score<T>>) -> Result<Outcome, InterpretError> {
|
||||
if !self.is_topology_initialized() {
|
||||
warn!(
|
||||
"Launching interpret for score {} but Topology {} is not fully initialized!",
|
||||
score.name(),
|
||||
self.topology.name(),
|
||||
);
|
||||
}
|
||||
info!("Running score {score:?}");
|
||||
let interpret = score.create_interpret();
|
||||
let interpret = score.apply_profile(&self.profile).create_interpret();
|
||||
info!("Launching interpret {interpret:?}");
|
||||
let result = interpret.execute(&self.inventory, &self.topology).await;
|
||||
let result = interpret
|
||||
.execute(&self.inventory, &self.topology, &self.profile)
|
||||
.await;
|
||||
info!("Got result {result:?}");
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod data;
|
||||
pub mod executors;
|
||||
pub mod filter;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_value::Value;
|
||||
|
||||
use super::{interpret::Interpret, topology::Topology};
|
||||
|
||||
pub trait Score<T: Topology>:
|
||||
std::fmt::Debug + Send + Sync + CloneBoxScore<T> + SerializeScore<T>
|
||||
std::fmt::Debug + ScoreToString<T> + Send + Sync + CloneBoxScore<T> + SerializeScore<T>
|
||||
{
|
||||
fn apply_profile(&self, profile: &String) -> Box<dyn Score<T>> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<T>>;
|
||||
fn name(&self) -> String;
|
||||
}
|
||||
@@ -39,3 +44,191 @@ where
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ScoreToString<T: Topology> {
|
||||
fn print_score_details(&self) -> String;
|
||||
fn format_value_as_string(&self, val: &Value, indent: usize) -> String;
|
||||
fn format_map(&self, map: &BTreeMap<Value, Value>, indent: usize) -> String;
|
||||
fn wrap_or_truncate(&self, s: &str, width: usize) -> Vec<String>;
|
||||
}
|
||||
|
||||
impl<S, T> ScoreToString<T> for S
|
||||
where
|
||||
T: Topology,
|
||||
S: Score<T> + 'static,
|
||||
{
|
||||
fn print_score_details(&self) -> String {
|
||||
let mut output = String::new();
|
||||
output += "\n";
|
||||
output += &self.format_value_as_string(&self.serialize(), 0);
|
||||
output += "\n";
|
||||
output
|
||||
}
|
||||
fn format_map(&self, map: &BTreeMap<Value, Value>, indent: usize) -> String {
|
||||
let pad = " ".repeat(indent * 2);
|
||||
let mut output = String::new();
|
||||
|
||||
output += &format!(
|
||||
"{}+--------------------------+--------------------------------------------------+\n",
|
||||
pad
|
||||
);
|
||||
output += &format!("{}| {:<24} | {:<48} |\n", pad, "score_name", self.name());
|
||||
output += &format!(
|
||||
"{}+--------------------------+--------------------------------------------------+\n",
|
||||
pad
|
||||
);
|
||||
|
||||
for (k, v) in map {
|
||||
let key_str = match k {
|
||||
Value::String(s) => s.clone(),
|
||||
other => format!("{:?}", other),
|
||||
};
|
||||
|
||||
let formatted_val = self.format_value_as_string(v, indent + 1);
|
||||
let lines = formatted_val.lines().map(|line| line.trim_start());
|
||||
|
||||
let wrapped_lines: Vec<_> = lines
|
||||
.flat_map(|line| self.wrap_or_truncate(line.trim_start(), 48))
|
||||
.collect();
|
||||
|
||||
if let Some(first) = wrapped_lines.first() {
|
||||
output += &format!("{}| {:<24} | {:<48} |\n", pad, key_str, first);
|
||||
for line in &wrapped_lines[1..] {
|
||||
output += &format!("{}| {:<24} | {:<48} |\n", pad, "", line);
|
||||
}
|
||||
}
|
||||
|
||||
// let first_line = lines.next().unwrap_or("");
|
||||
// output += &format!("{}| {:<24} | {:<48} |\n", pad, key_str, first_line);
|
||||
//
|
||||
// for line in lines {
|
||||
// output += &format!("{}| {:<24} | {:<48} |\n", pad, "", line);
|
||||
// }
|
||||
}
|
||||
|
||||
output += &format!(
|
||||
"{}+--------------------------+--------------------------------------------------+\n\n",
|
||||
pad
|
||||
);
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn wrap_or_truncate(&self, s: &str, width: usize) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut current = s;
|
||||
|
||||
while !current.is_empty() {
|
||||
if current.len() <= width {
|
||||
lines.push(current.to_string());
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to wrap at whitespace if possible
|
||||
let mut split_index = current[..width].rfind(' ').unwrap_or(width);
|
||||
if split_index == 0 {
|
||||
split_index = width;
|
||||
}
|
||||
|
||||
lines.push(current[..split_index].trim_end().to_string());
|
||||
current = current[split_index..].trim_start();
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn format_value_as_string(&self, val: &Value, indent: usize) -> String {
|
||||
let pad = " ".repeat(indent * 2);
|
||||
let mut output = String::new();
|
||||
|
||||
match val {
|
||||
Value::Bool(b) => output += &format!("{}{}\n", pad, b),
|
||||
Value::U8(u) => output += &format!("{}{}\n", pad, u),
|
||||
Value::U16(u) => output += &format!("{}{}\n", pad, u),
|
||||
Value::U32(u) => output += &format!("{}{}\n", pad, u),
|
||||
Value::U64(u) => output += &format!("{}{}\n", pad, u),
|
||||
Value::I8(i) => output += &format!("{}{}\n", pad, i),
|
||||
Value::I16(i) => output += &format!("{}{}\n", pad, i),
|
||||
Value::I32(i) => output += &format!("{}{}\n", pad, i),
|
||||
Value::I64(i) => output += &format!("{}{}\n", pad, i),
|
||||
Value::F32(f) => output += &format!("{}{}\n", pad, f),
|
||||
Value::F64(f) => output += &format!("{}{}\n", pad, f),
|
||||
Value::Char(c) => output += &format!("{}{}\n", pad, c),
|
||||
Value::String(s) => output += &format!("{}{:<48}\n", pad, s),
|
||||
Value::Unit => output += &format!("{}<unit>\n", pad),
|
||||
Value::Bytes(bytes) => output += &format!("{}{:?}\n", pad, bytes),
|
||||
|
||||
Value::Option(opt) => match opt {
|
||||
Some(inner) => {
|
||||
output += &format!("{}Option:\n", pad);
|
||||
output += &self.format_value_as_string(inner, indent + 1);
|
||||
}
|
||||
None => output += &format!("{}None\n", pad),
|
||||
},
|
||||
|
||||
Value::Newtype(inner) => {
|
||||
output += &format!("{}Newtype:\n", pad);
|
||||
output += &self.format_value_as_string(inner, indent + 1);
|
||||
}
|
||||
|
||||
Value::Seq(seq) => {
|
||||
if seq.is_empty() {
|
||||
output += &format!("{}[]\n", pad);
|
||||
} else {
|
||||
output += &format!("{}[\n", pad);
|
||||
for item in seq {
|
||||
output += &self.format_value_as_string(item, indent + 1);
|
||||
}
|
||||
output += &format!("{}]\n", pad);
|
||||
}
|
||||
}
|
||||
|
||||
Value::Map(map) => {
|
||||
if map.is_empty() {
|
||||
output += &format!("{}<empty map>\n", pad);
|
||||
} else if indent == 0 {
|
||||
output += &self.format_map(map, indent);
|
||||
} else {
|
||||
for (k, v) in map {
|
||||
let key_str = match k {
|
||||
Value::String(s) => s.clone(),
|
||||
other => format!("{:?}", other),
|
||||
};
|
||||
|
||||
let val_str = self
|
||||
.format_value_as_string(v, indent + 1)
|
||||
.trim()
|
||||
.to_string();
|
||||
let val_lines: Vec<_> = val_str.lines().collect();
|
||||
|
||||
output +=
|
||||
&format!("{}{}: {}\n", pad, key_str, val_lines.first().unwrap_or(&""));
|
||||
for line in val_lines.iter().skip(1) {
|
||||
output += &format!("{} {}\n", pad, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
//TODO write test to check that the output is what it should be
|
||||
//
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::modules::dns::DnsScore;
|
||||
use crate::topology::HAClusterTopology;
|
||||
|
||||
#[test]
|
||||
fn test_format_values_as_string() {
|
||||
let dns_score = Box::new(DnsScore::new(vec![], None));
|
||||
let print_score_output =
|
||||
<DnsScore as ScoreToString<HAClusterTopology>>::print_score_details(&dns_score);
|
||||
let expected_empty_dns_score_table = "\n+--------------------------+--------------------------------------------------+\n| score_name | DnsScore |\n+--------------------------+--------------------------------------------------+\n| dns_entries | [] |\n| register_dhcp_leases | None |\n+--------------------------+--------------------------------------------------+\n\n\n";
|
||||
assert_eq!(print_score_output, expected_empty_dns_score_table);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use async_trait::async_trait;
|
||||
use harmony_macros::ip;
|
||||
use harmony_types::net::MacAddress;
|
||||
use log::info;
|
||||
|
||||
use crate::executors::ExecutorError;
|
||||
use crate::interpret::InterpretError;
|
||||
use crate::interpret::Outcome;
|
||||
|
||||
use super::DHCPStaticEntry;
|
||||
use super::DhcpServer;
|
||||
@@ -12,16 +15,16 @@ use super::DnsServer;
|
||||
use super::Firewall;
|
||||
use super::HttpServer;
|
||||
use super::IpAddress;
|
||||
use super::K8sclient;
|
||||
use super::LoadBalancer;
|
||||
use super::LoadBalancerService;
|
||||
use super::LogicalHost;
|
||||
use super::OcK8sclient;
|
||||
use super::Router;
|
||||
use super::TftpServer;
|
||||
|
||||
use super::Topology;
|
||||
use super::Url;
|
||||
use super::openshift::OpenshiftClient;
|
||||
use super::k8s::K8sClient;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -40,16 +43,24 @@ pub struct HAClusterTopology {
|
||||
pub switch: Vec<LogicalHost>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Topology for HAClusterTopology {
|
||||
fn name(&self) -> &str {
|
||||
todo!()
|
||||
}
|
||||
async fn ensure_ready(&self) -> Result<Outcome, InterpretError> {
|
||||
todo!(
|
||||
"ensure_ready, not entirely sure what it should do here, probably something like verify that the hosts are reachable and all services are up and ready."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OcK8sclient for HAClusterTopology {
|
||||
async fn oc_client(&self) -> Result<Arc<OpenshiftClient>, kube::Error> {
|
||||
Ok(Arc::new(OpenshiftClient::try_default().await?))
|
||||
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())?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +226,20 @@ impl HttpServer for HAClusterTopology {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DummyInfra;
|
||||
pub struct DummyInfra;
|
||||
|
||||
#[async_trait]
|
||||
impl Topology for DummyInfra {
|
||||
fn name(&self) -> &str {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn ensure_ready(&self) -> Result<Outcome, InterpretError> {
|
||||
let dummy_msg = "This is a dummy infrastructure that does nothing";
|
||||
info!("{dummy_msg}");
|
||||
Ok(Outcome::success(dummy_msg.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
const UNIMPLEMENTED_DUMMY_INFRA: &str = "This is a dummy infrastructure, no operation is supported";
|
||||
|
||||
|
||||
1
harmony/src/domain/topology/helm_command.rs
Normal file
1
harmony/src/domain/topology/helm_command.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub trait HelmCommand {}
|
||||
@@ -1,12 +1,14 @@
|
||||
use derive_new::new;
|
||||
use k8s_openapi::NamespaceResourceScope;
|
||||
use kube::{Api, Client, Error, Resource, api::PostParams};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
pub struct OpenshiftClient {
|
||||
#[derive(new)]
|
||||
pub struct K8sClient {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl OpenshiftClient {
|
||||
impl K8sClient {
|
||||
pub async fn try_default() -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
client: Client::try_default().await?,
|
||||
202
harmony/src/domain/topology/k8s_anywhere.rs
Normal file
202
harmony/src/domain/topology/k8s_anywhere.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use std::{process::Command, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use inquire::Confirm;
|
||||
use log::{info, warn};
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use crate::{
|
||||
interpret::{InterpretError, Outcome},
|
||||
inventory::Inventory,
|
||||
maestro::Maestro,
|
||||
modules::k3d::K3DInstallationScore,
|
||||
topology::LocalhostTopology,
|
||||
};
|
||||
|
||||
use super::{HelmCommand, K8sclient, Topology, k8s::K8sClient};
|
||||
|
||||
struct K8sState {
|
||||
client: Arc<K8sClient>,
|
||||
_source: K8sSource,
|
||||
message: String,
|
||||
}
|
||||
|
||||
enum K8sSource {
|
||||
LocalK3d,
|
||||
}
|
||||
|
||||
pub struct K8sAnywhereTopology {
|
||||
k8s_state: OnceCell<Option<K8sState>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl K8sclient for K8sAnywhereTopology {
|
||||
async fn k8s_client(&self) -> Result<Arc<K8sClient>, String> {
|
||||
let state = match self.k8s_state.get() {
|
||||
Some(state) => state,
|
||||
None => return Err("K8s state not initialized yet".to_string()),
|
||||
};
|
||||
|
||||
let state = match state {
|
||||
Some(state) => state,
|
||||
None => return Err("K8s client initialized but empty".to_string()),
|
||||
};
|
||||
|
||||
Ok(state.client.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl K8sAnywhereTopology {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
k8s_state: OnceCell::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_helm_available(&self) -> Result<(), String> {
|
||||
let version_result = Command::new("helm")
|
||||
.arg("version")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute 'helm -version': {}", e))?;
|
||||
|
||||
if !version_result.status.success() {
|
||||
return Err("Failed to run 'helm -version'".to_string());
|
||||
}
|
||||
|
||||
// Print the version output
|
||||
let version_output = String::from_utf8_lossy(&version_result.stdout);
|
||||
println!("Helm version: {}", version_output.trim());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_load_system_kubeconfig(&self) -> Option<K8sClient> {
|
||||
todo!("Use kube-rs default behavior to load system kubeconfig");
|
||||
}
|
||||
|
||||
async fn try_load_kubeconfig(&self, path: &str) -> Option<K8sClient> {
|
||||
todo!("Use kube-rs to load kubeconfig at path {path}");
|
||||
}
|
||||
|
||||
fn get_k3d_installation_score(&self) -> K3DInstallationScore {
|
||||
K3DInstallationScore::default()
|
||||
}
|
||||
|
||||
async fn try_install_k3d(&self) -> Result<(), InterpretError> {
|
||||
let maestro = Maestro::initialize(Inventory::autoload(), LocalhostTopology::new()).await?;
|
||||
let k3d_score = self.get_k3d_installation_score();
|
||||
maestro.interpret(Box::new(k3d_score)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_get_or_install_k8s_client(&self) -> Result<Option<K8sState>, InterpretError> {
|
||||
let k8s_anywhere_config = K8sAnywhereConfig {
|
||||
kubeconfig: std::env::var("HARMONY_KUBECONFIG")
|
||||
.ok()
|
||||
.map(|v| v.to_string()),
|
||||
use_system_kubeconfig: std::env::var("HARMONY_USE_SYSTEM_KUBECONFIG")
|
||||
.map_or_else(|_| false, |v| v.parse().ok().unwrap_or(false)),
|
||||
autoinstall: std::env::var("HARMONY_AUTOINSTALL")
|
||||
.map_or_else(|_| false, |v| v.parse().ok().unwrap_or(false)),
|
||||
};
|
||||
|
||||
if k8s_anywhere_config.use_system_kubeconfig {
|
||||
match self.try_load_system_kubeconfig().await {
|
||||
Some(_client) => todo!(),
|
||||
None => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(kubeconfig) = k8s_anywhere_config.kubeconfig {
|
||||
match self.try_load_kubeconfig(&kubeconfig).await {
|
||||
Some(_client) => todo!(),
|
||||
None => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
info!("No kubernetes configuration found");
|
||||
|
||||
if !k8s_anywhere_config.autoinstall {
|
||||
let confirmation = Confirm::new( "Harmony autoinstallation is not activated, do you wish to launch autoinstallation? : ")
|
||||
.with_default(false)
|
||||
.prompt()
|
||||
.expect("Unexpected prompt error");
|
||||
|
||||
if !confirmation {
|
||||
warn!(
|
||||
"Installation cancelled, K8sAnywhere could not initialize a valid Kubernetes client"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Starting K8sAnywhere installation");
|
||||
self.try_install_k3d().await?;
|
||||
let k3d_score = self.get_k3d_installation_score();
|
||||
// I feel like having to rely on the k3d_rs crate here is a smell
|
||||
// I think we should have a way to interact more deeply with scores/interpret. Maybe the
|
||||
// K3DInstallationScore should expose a method to get_client ? Not too sure what would be a
|
||||
// good implementation due to the stateful nature of the k3d thing. Which is why I went
|
||||
// with this solution for now
|
||||
let k3d = k3d_rs::K3d::new(k3d_score.installation_path, Some(k3d_score.cluster_name));
|
||||
let state = match k3d.get_client().await {
|
||||
Ok(client) => K8sState {
|
||||
client: Arc::new(K8sClient::new(client)),
|
||||
_source: K8sSource::LocalK3d,
|
||||
message: "Successfully installed K3D cluster and acquired client".to_string(),
|
||||
},
|
||||
Err(_) => todo!(),
|
||||
};
|
||||
|
||||
Ok(Some(state))
|
||||
}
|
||||
}
|
||||
|
||||
struct K8sAnywhereConfig {
|
||||
/// The path of the KUBECONFIG file that Harmony should use to interact with the Kubernetes
|
||||
/// cluster
|
||||
///
|
||||
/// Default : None
|
||||
kubeconfig: Option<String>,
|
||||
|
||||
/// Whether to use the system KUBECONFIG, either the environment variable or the file in the
|
||||
/// default or configured location
|
||||
///
|
||||
/// Default : false
|
||||
use_system_kubeconfig: bool,
|
||||
|
||||
/// Whether to install automatically a kubernetes cluster
|
||||
///
|
||||
/// When enabled, autoinstall will setup a K3D cluster on the localhost. https://k3d.io/stable/
|
||||
///
|
||||
/// Default: true
|
||||
autoinstall: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Topology for K8sAnywhereTopology {
|
||||
fn name(&self) -> &str {
|
||||
"K8sAnywhereTopology"
|
||||
}
|
||||
|
||||
async fn ensure_ready(&self) -> Result<Outcome, InterpretError> {
|
||||
let k8s_state = self
|
||||
.k8s_state
|
||||
.get_or_try_init(|| self.try_get_or_install_k8s_client())
|
||||
.await?;
|
||||
|
||||
let k8s_state: &K8sState = k8s_state.as_ref().ok_or(InterpretError::new(
|
||||
"No K8s client could be found or installed".to_string(),
|
||||
))?;
|
||||
|
||||
match self.is_helm_available() {
|
||||
Ok(()) => Ok(Outcome::success(format!(
|
||||
"{} + helm available",
|
||||
k8s_state.message.clone()
|
||||
))),
|
||||
Err(e) => Err(InterpretError::new(format!("helm unavailable: {}", e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HelmCommand for K8sAnywhereTopology {}
|
||||
@@ -46,6 +46,7 @@ pub struct LoadBalancerService {
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Serialize)]
|
||||
pub struct BackendServer {
|
||||
// TODO should not be a string, probably IPAddress
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
25
harmony/src/domain/topology/localhost.rs
Normal file
25
harmony/src/domain/topology/localhost.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use async_trait::async_trait;
|
||||
use derive_new::new;
|
||||
|
||||
use crate::interpret::{InterpretError, Outcome};
|
||||
|
||||
use super::{HelmCommand, Topology};
|
||||
|
||||
#[derive(new)]
|
||||
pub struct LocalhostTopology;
|
||||
|
||||
#[async_trait]
|
||||
impl Topology for LocalhostTopology {
|
||||
fn name(&self) -> &str {
|
||||
"LocalHostTopology"
|
||||
}
|
||||
|
||||
async fn ensure_ready(&self) -> Result<Outcome, InterpretError> {
|
||||
Ok(Outcome::success(
|
||||
"Localhost is Chuck Norris, always ready.".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Delete this, temp for test
|
||||
impl HelmCommand for LocalhostTopology {}
|
||||
@@ -1,10 +1,15 @@
|
||||
mod ha_cluster;
|
||||
mod host_binding;
|
||||
mod http;
|
||||
mod k8s_anywhere;
|
||||
mod localhost;
|
||||
pub use k8s_anywhere::*;
|
||||
pub use localhost::*;
|
||||
pub mod k8s;
|
||||
mod load_balancer;
|
||||
pub mod openshift;
|
||||
mod router;
|
||||
mod tftp;
|
||||
use async_trait::async_trait;
|
||||
pub use ha_cluster::*;
|
||||
pub use load_balancer::*;
|
||||
pub use router::*;
|
||||
@@ -15,10 +20,43 @@ pub use network::*;
|
||||
use serde::Serialize;
|
||||
pub use tftp::*;
|
||||
|
||||
mod helm_command;
|
||||
pub use helm_command::*;
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
pub trait Topology {
|
||||
use super::interpret::{InterpretError, Outcome};
|
||||
|
||||
/// Represents a logical view of an infrastructure environment providing specific capabilities.
|
||||
///
|
||||
/// A Topology acts as a self-contained "package" responsible for managing access
|
||||
/// to its underlying resources and ensuring they are in a ready state before use.
|
||||
/// It defines the contract for the capabilities it provides through implemented
|
||||
/// capability traits (e.g., `HasK8sCapability`, `HasDnsServer`).
|
||||
#[async_trait]
|
||||
pub trait Topology: Send + Sync {
|
||||
/// Returns a unique identifier or name for this specific topology instance.
|
||||
/// This helps differentiate between multiple instances of potentially the same type.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Ensures that the topology and its required underlying components or services
|
||||
/// are ready to provide their declared capabilities.
|
||||
///
|
||||
/// Implementations of this method MUST be idempotent. Subsequent calls after a
|
||||
/// successful readiness check should ideally be cheap NO-OPs.
|
||||
///
|
||||
/// This method encapsulates the logic for:
|
||||
/// 1. **Checking Current State:** Assessing if the required resources/services are already running and configured.
|
||||
/// 2. **Discovery:** Identifying the runtime environment (e.g., local Docker, AWS, existing cluster).
|
||||
/// 3. **Initialization/Bootstrapping:** Performing necessary setup actions if not already ready. This might involve:
|
||||
/// * Making API calls.
|
||||
/// * Running external commands (e.g., `k3d`, `docker`).
|
||||
/// * **Internal Orchestration:** For complex topologies, this method might manage dependencies on other sub-topologies, ensuring *their* `ensure_ready` is called first. Using nested `Maestros` to run setup `Scores` against these sub-topologies is the recommended pattern for non-trivial bootstrapping, allowing reuse of Harmony's core orchestration logic.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(Outcome)`: Indicates the topology is now ready. The `Outcome` status might be `SUCCESS` if actions were taken, or `NOOP` if it was already ready. The message should provide context.
|
||||
/// - `Err(TopologyError)`: Indicates the topology could not reach a ready state due to configuration issues, discovery failures, bootstrap errors, or unsupported environments.
|
||||
async fn ensure_ready(&self) -> Result<Outcome, InterpretError>;
|
||||
}
|
||||
|
||||
pub type IpAddress = IpAddr;
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde::Serialize;
|
||||
|
||||
use crate::executors::ExecutorError;
|
||||
|
||||
use super::{IpAddress, LogicalHost, openshift::OpenshiftClient};
|
||||
use super::{IpAddress, LogicalHost, k8s::K8sClient};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DHCPStaticEntry {
|
||||
@@ -42,8 +42,8 @@ pub struct NetworkDomain {
|
||||
pub name: String,
|
||||
}
|
||||
#[async_trait]
|
||||
pub trait OcK8sclient: Send + Sync + std::fmt::Debug {
|
||||
async fn oc_client(&self) -> Result<Arc<OpenshiftClient>, kube::Error>;
|
||||
pub trait K8sclient: Send + Sync {
|
||||
async fn k8s_client(&self) -> Result<Arc<K8sClient>, String>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -75,6 +75,7 @@ impl<T: Topology> Interpret<T> for DummyInterpret {
|
||||
&self,
|
||||
_inventory: &Inventory,
|
||||
_topology: &T,
|
||||
_profile: &String,
|
||||
) -> Result<Outcome, InterpretError> {
|
||||
self.result.clone()
|
||||
}
|
||||
@@ -121,6 +122,7 @@ impl<T: Topology> Interpret<T> for PanicInterpret {
|
||||
&self,
|
||||
_inventory: &Inventory,
|
||||
_topology: &T,
|
||||
_profile: &String,
|
||||
) -> Result<Outcome, InterpretError> {
|
||||
panic!("Panic interpret always panics when executed")
|
||||
}
|
||||
|
||||
110
harmony/src/modules/helm/chart.rs
Normal file
110
harmony/src/modules/helm/chart.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use crate::data::{Id, Version};
|
||||
use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome};
|
||||
use crate::inventory::Inventory;
|
||||
use crate::score::Score;
|
||||
use crate::topology::{HelmCommand, Topology};
|
||||
use async_trait::async_trait;
|
||||
use helm_wrapper_rs;
|
||||
use helm_wrapper_rs::blocking::{DefaultHelmExecutor, HelmExecutor};
|
||||
pub use non_blank_string_rs::NonBlankString;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use temp_file::TempFile;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct HelmChartScore {
|
||||
pub namespace: Option<NonBlankString>,
|
||||
pub release_name: NonBlankString,
|
||||
pub chart_name: NonBlankString,
|
||||
pub chart_version: Option<NonBlankString>,
|
||||
pub values_overrides: Option<HashMap<NonBlankString, String>>,
|
||||
pub values_yaml: Option<String>,
|
||||
}
|
||||
|
||||
impl<T: Topology + HelmCommand> Score<T> for HelmChartScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||
Box::new(HelmChartInterpret {
|
||||
score: self.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
format!("{} {} HelmChartScore", self.release_name, self.chart_name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HelmChartInterpret {
|
||||
pub score: HelmChartScore,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Topology + HelmCommand> Interpret<T> for HelmChartInterpret {
|
||||
async fn execute(
|
||||
&self,
|
||||
_inventory: &Inventory,
|
||||
_topology: &T,
|
||||
) -> Result<Outcome, InterpretError> {
|
||||
let ns = self
|
||||
.score
|
||||
.namespace
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| todo!("Get namespace from active kubernetes cluster"));
|
||||
|
||||
let tf: TempFile;
|
||||
let yaml_path: Option<&Path> = match self.score.values_yaml.as_ref() {
|
||||
Some(yaml_str) => {
|
||||
tf = temp_file::with_contents(yaml_str.as_bytes());
|
||||
Some(tf.path())
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let helm_executor = DefaultHelmExecutor::new();
|
||||
let res = helm_executor.install_or_upgrade(
|
||||
&ns,
|
||||
&self.score.release_name,
|
||||
&self.score.chart_name,
|
||||
self.score.chart_version.as_ref(),
|
||||
self.score.values_overrides.as_ref(),
|
||||
yaml_path,
|
||||
None,
|
||||
);
|
||||
|
||||
let status = match res {
|
||||
Ok(status) => status,
|
||||
Err(err) => return Err(InterpretError::new(err.to_string())),
|
||||
};
|
||||
|
||||
match status {
|
||||
helm_wrapper_rs::HelmDeployStatus::Deployed => Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
"Helm Chart deployed".to_string(),
|
||||
)),
|
||||
helm_wrapper_rs::HelmDeployStatus::PendingInstall => Ok(Outcome::new(
|
||||
InterpretStatus::RUNNING,
|
||||
"Helm Chart Pending install".to_string(),
|
||||
)),
|
||||
helm_wrapper_rs::HelmDeployStatus::PendingUpgrade => Ok(Outcome::new(
|
||||
InterpretStatus::RUNNING,
|
||||
"Helm Chart pending upgrade".to_string(),
|
||||
)),
|
||||
helm_wrapper_rs::HelmDeployStatus::Failed => Err(InterpretError::new(
|
||||
"Failed to install helm chart".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
fn get_name(&self) -> InterpretName {
|
||||
todo!()
|
||||
}
|
||||
fn get_version(&self) -> Version {
|
||||
todo!()
|
||||
}
|
||||
fn get_status(&self) -> InterpretStatus {
|
||||
todo!()
|
||||
}
|
||||
fn get_children(&self) -> Vec<Id> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
1
harmony/src/modules/helm/mod.rs
Normal file
1
harmony/src/modules/helm/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod chart;
|
||||
82
harmony/src/modules/k3d/install.rs
Normal file
82
harmony/src/modules/k3d/install.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use log::info;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
config::HARMONY_CONFIG_DIR,
|
||||
data::{Id, Version},
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::Inventory,
|
||||
score::Score,
|
||||
topology::Topology,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct K3DInstallationScore {
|
||||
pub installation_path: PathBuf,
|
||||
pub cluster_name: String,
|
||||
}
|
||||
|
||||
impl Default for K3DInstallationScore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
installation_path: HARMONY_CONFIG_DIR.join("k3d"),
|
||||
cluster_name: "harmony".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Topology> Score<T> for K3DInstallationScore {
|
||||
fn create_interpret(&self) -> Box<dyn crate::interpret::Interpret<T>> {
|
||||
Box::new(K3dInstallationInterpret {
|
||||
score: self.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct K3dInstallationInterpret {
|
||||
score: K3DInstallationScore,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Topology> Interpret<T> for K3dInstallationInterpret {
|
||||
async fn execute(
|
||||
&self,
|
||||
_inventory: &Inventory,
|
||||
_topology: &T,
|
||||
) -> Result<Outcome, InterpretError> {
|
||||
let k3d = k3d_rs::K3d::new(
|
||||
self.score.installation_path.clone(),
|
||||
Some(self.score.cluster_name.clone()),
|
||||
);
|
||||
match k3d.ensure_installed().await {
|
||||
Ok(_client) => {
|
||||
let msg = format!("k3d cluster {} is installed ", self.score.cluster_name);
|
||||
info!("{msg}");
|
||||
Ok(Outcome::success(msg))
|
||||
}
|
||||
Err(msg) => Err(InterpretError::new(format!(
|
||||
"K3dInstallationInterpret failed to ensure k3d is installed : {msg}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::K3dInstallation
|
||||
}
|
||||
fn get_version(&self) -> Version {
|
||||
todo!()
|
||||
}
|
||||
fn get_status(&self) -> InterpretStatus {
|
||||
todo!()
|
||||
}
|
||||
fn get_children(&self) -> Vec<Id> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
2
harmony/src/modules/k3d/mod.rs
Normal file
2
harmony/src/modules/k3d/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod install;
|
||||
pub use install::*;
|
||||
@@ -5,7 +5,7 @@ use serde_json::json;
|
||||
use crate::{
|
||||
interpret::Interpret,
|
||||
score::Score,
|
||||
topology::{OcK8sclient, Topology},
|
||||
topology::{K8sclient, Topology},
|
||||
};
|
||||
|
||||
use super::resource::{K8sResourceInterpret, K8sResourceScore};
|
||||
@@ -16,7 +16,7 @@ pub struct K8sDeploymentScore {
|
||||
pub image: String,
|
||||
}
|
||||
|
||||
impl<T: Topology + OcK8sclient> Score<T> for K8sDeploymentScore {
|
||||
impl<T: Topology + K8sclient> Score<T> for K8sDeploymentScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||
let deployment: Deployment = serde_json::from_value(json!(
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::Inventory,
|
||||
score::Score,
|
||||
topology::{OcK8sclient, Topology},
|
||||
topology::{K8sclient, Topology},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -63,7 +63,7 @@ impl<
|
||||
+ Default
|
||||
+ Send
|
||||
+ Sync,
|
||||
T: Topology + OcK8sclient,
|
||||
T: Topology + K8sclient,
|
||||
> Interpret<T> for K8sResourceInterpret<K>
|
||||
where
|
||||
<K as kube::Resource>::DynamicType: Default,
|
||||
@@ -74,7 +74,7 @@ where
|
||||
topology: &T,
|
||||
) -> Result<Outcome, InterpretError> {
|
||||
topology
|
||||
.oc_client()
|
||||
.k8s_client()
|
||||
.await
|
||||
.expect("Environment should provide enough information to instanciate a client")
|
||||
.apply_namespaced(&self.score.resource)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use log::info;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
@@ -9,7 +12,7 @@ use crate::{
|
||||
inventory::Inventory,
|
||||
modules::k8s::deployment::K8sDeploymentScore,
|
||||
score::Score,
|
||||
topology::{OcK8sclient, Topology, Url},
|
||||
topology::{K8sclient, Topology, Url},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -18,6 +21,7 @@ pub struct LAMPScore {
|
||||
pub domain: Url,
|
||||
pub config: LAMPConfig,
|
||||
pub php_version: Version,
|
||||
pub profiles: HashMap<&'static str, LAMPProfile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -26,6 +30,11 @@ pub struct LAMPConfig {
|
||||
pub ssl_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LAMPProfile {
|
||||
pub ssl_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for LAMPConfig {
|
||||
fn default() -> Self {
|
||||
LAMPConfig {
|
||||
@@ -35,9 +44,28 @@ impl Default for LAMPConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Topology> Score<T> for LAMPScore {
|
||||
impl<T: Topology + K8sclient> Score<T> for LAMPScore {
|
||||
fn apply_profile(&self, profile: &String) -> Box<dyn Score<T>> {
|
||||
let profile = match self.profiles.get(profile.as_str()) {
|
||||
Some(profile) => profile,
|
||||
None => panic!("Not good"), // TODO: better handling
|
||||
};
|
||||
|
||||
let config = LAMPConfig {
|
||||
ssl_enabled: profile.ssl_enabled,
|
||||
..self.config.clone()
|
||||
};
|
||||
|
||||
Box::new(LAMPScore {
|
||||
config,
|
||||
..self.clone()
|
||||
})
|
||||
}
|
||||
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||
todo!()
|
||||
Box::new(LAMPInterpret {
|
||||
score: self.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
@@ -51,20 +79,34 @@ pub struct LAMPInterpret {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Topology + OcK8sclient> Interpret<T> for LAMPInterpret {
|
||||
impl<T: Topology + K8sclient> Interpret<T> for LAMPInterpret {
|
||||
async fn execute(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &T,
|
||||
profile: &String,
|
||||
) -> Result<Outcome, InterpretError> {
|
||||
let image_name = match self.build_docker_image() {
|
||||
Ok(name) => name,
|
||||
Err(e) => {
|
||||
return Err(InterpretError::new(format!(
|
||||
"Could not build LAMP docker image {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
info!("LAMP docker image built {image_name}");
|
||||
|
||||
let deployment_score = K8sDeploymentScore {
|
||||
name: <LAMPScore as Score<T>>::name(&self.score),
|
||||
image: "local_image".to_string(),
|
||||
image: image_name,
|
||||
};
|
||||
|
||||
info!("LAMP deployment_score {deployment_score:?}");
|
||||
todo!();
|
||||
deployment_score
|
||||
.apply_profile(profile)
|
||||
.create_interpret()
|
||||
.execute(inventory, topology)
|
||||
.execute(inventory, topology, profile)
|
||||
.await?;
|
||||
todo!()
|
||||
}
|
||||
@@ -85,3 +127,164 @@ impl<T: Topology + OcK8sclient> Interpret<T> for LAMPInterpret {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
use dockerfile_builder::instruction::{CMD, COPY, ENV, EXPOSE, FROM, RUN, WORKDIR};
|
||||
use dockerfile_builder::{Dockerfile, instruction_builder::EnvBuilder};
|
||||
use std::fs;
|
||||
|
||||
impl LAMPInterpret {
|
||||
pub fn build_dockerfile(
|
||||
&self,
|
||||
score: &LAMPScore,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let mut dockerfile = Dockerfile::new();
|
||||
|
||||
// Use the PHP version from the score to determine the base image
|
||||
let php_version = score.php_version.to_string();
|
||||
let php_major_minor = php_version
|
||||
.split('.')
|
||||
.take(2)
|
||||
.collect::<Vec<&str>>()
|
||||
.join(".");
|
||||
|
||||
// Base image selection - using official PHP image with Apache
|
||||
dockerfile.push(FROM::from(format!("php:{}-apache", php_major_minor)));
|
||||
|
||||
// Set environment variables for PHP configuration
|
||||
dockerfile.push(ENV::from("PHP_MEMORY_LIMIT=256M"));
|
||||
dockerfile.push(ENV::from("PHP_MAX_EXECUTION_TIME=30"));
|
||||
dockerfile.push(
|
||||
EnvBuilder::builder()
|
||||
.key("PHP_ERROR_REPORTING")
|
||||
.value("\"E_ERROR | E_WARNING | E_PARSE\"")
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
// Install necessary PHP extensions and dependencies
|
||||
dockerfile.push(RUN::from(
|
||||
"apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libfreetype6-dev \
|
||||
libjpeg62-turbo-dev \
|
||||
libpng-dev \
|
||||
libzip-dev \
|
||||
unzip \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*",
|
||||
));
|
||||
|
||||
dockerfile.push(RUN::from(
|
||||
"docker-php-ext-configure gd --with-freetype --with-jpeg && \
|
||||
docker-php-ext-install -j$(nproc) \
|
||||
gd \
|
||||
mysqli \
|
||||
pdo_mysql \
|
||||
zip \
|
||||
opcache",
|
||||
));
|
||||
|
||||
// Copy PHP configuration
|
||||
dockerfile.push(RUN::from("mkdir -p /usr/local/etc/php/conf.d/"));
|
||||
|
||||
// Create and copy a custom PHP configuration
|
||||
let php_config = r#"
|
||||
memory_limit = ${PHP_MEMORY_LIMIT}
|
||||
max_execution_time = ${PHP_MAX_EXECUTION_TIME}
|
||||
error_reporting = ${PHP_ERROR_REPORTING}
|
||||
display_errors = Off
|
||||
log_errors = On
|
||||
error_log = /dev/stderr
|
||||
date.timezone = UTC
|
||||
|
||||
; Opcache configuration for production
|
||||
opcache.enable=1
|
||||
opcache.memory_consumption=128
|
||||
opcache.interned_strings_buffer=8
|
||||
opcache.max_accelerated_files=4000
|
||||
opcache.revalidate_freq=2
|
||||
opcache.fast_shutdown=1
|
||||
"#;
|
||||
|
||||
// Save this configuration to a temporary file within the project root
|
||||
let config_path = Path::new(&score.config.project_root).join("docker-php.ini");
|
||||
fs::write(&config_path, php_config)?;
|
||||
|
||||
// Reference the file within the Docker context (where the build runs)
|
||||
dockerfile.push(COPY::from(
|
||||
"docker-php.ini /usr/local/etc/php/conf.d/docker-php.ini",
|
||||
));
|
||||
|
||||
// Security hardening
|
||||
dockerfile.push(RUN::from(
|
||||
"a2enmod headers && \
|
||||
a2enmod rewrite && \
|
||||
sed -i 's/ServerTokens OS/ServerTokens Prod/' /etc/apache2/conf-enabled/security.conf && \
|
||||
sed -i 's/ServerSignature On/ServerSignature Off/' /etc/apache2/conf-enabled/security.conf"
|
||||
));
|
||||
|
||||
// Create a dedicated user for running Apache
|
||||
dockerfile.push(RUN::from(
|
||||
"groupadd -g 1000 appuser && \
|
||||
useradd -u 1000 -g appuser -m -s /bin/bash appuser && \
|
||||
chown -R appuser:appuser /var/www/html",
|
||||
));
|
||||
|
||||
// Set the working directory
|
||||
dockerfile.push(WORKDIR::from("/var/www/html"));
|
||||
|
||||
// Copy application code from the project root to the container
|
||||
// Note: In Dockerfile, the COPY context is relative to the build context
|
||||
// We'll handle the actual context in the build_docker_image method
|
||||
dockerfile.push(COPY::from(". /var/www/html"));
|
||||
|
||||
// Fix permissions
|
||||
dockerfile.push(RUN::from("chown -R appuser:appuser /var/www/html"));
|
||||
|
||||
// Expose Apache port
|
||||
dockerfile.push(EXPOSE::from("80/tcp"));
|
||||
|
||||
// Set the default command
|
||||
dockerfile.push(CMD::from("apache2-foreground"));
|
||||
|
||||
// Save the Dockerfile to disk in the project root
|
||||
let dockerfile_path = Path::new(&score.config.project_root).join("Dockerfile");
|
||||
fs::write(&dockerfile_path, dockerfile.to_string())?;
|
||||
|
||||
Ok(dockerfile_path)
|
||||
}
|
||||
|
||||
pub fn build_docker_image(&self) -> Result<String, Box<dyn std::error::Error>> {
|
||||
info!("Generating Dockerfile");
|
||||
let dockerfile = self.build_dockerfile(&self.score)?;
|
||||
|
||||
info!(
|
||||
"Building Docker image with file {} from root {}",
|
||||
dockerfile.to_string_lossy(),
|
||||
self.score.config.project_root.to_string_lossy()
|
||||
);
|
||||
let image_name = format!("{}-php-apache", self.score.name);
|
||||
let project_root = &self.score.config.project_root;
|
||||
|
||||
let output = std::process::Command::new("docker")
|
||||
.args([
|
||||
"build",
|
||||
"--file",
|
||||
dockerfile.to_str().unwrap(),
|
||||
"-t",
|
||||
&image_name,
|
||||
project_root.to_str().unwrap(),
|
||||
])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"Failed to build Docker image: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(image_name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod dhcp;
|
||||
pub mod dns;
|
||||
pub mod dummy;
|
||||
pub mod helm;
|
||||
pub mod http;
|
||||
pub mod k3d;
|
||||
pub mod k8s;
|
||||
pub mod lamp;
|
||||
pub mod load_balancer;
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
};
|
||||
|
||||
impl std::fmt::Display for OKDLoadBalancerScore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ use crate::data::Version;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OKDUpgradeScore {
|
||||
current_version: Version,
|
||||
target_version: Version,
|
||||
_current_version: Version,
|
||||
_target_version: Version,
|
||||
}
|
||||
|
||||
impl OKDUpgradeScore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current_version: Version::from("4.17.0-okd-scos.0").unwrap(),
|
||||
target_version: Version::from("").unwrap(),
|
||||
_current_version: Version::from("4.17.0-okd-scos.0").unwrap(),
|
||||
_target_version: Version::from("").unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub struct OPNsenseShellCommandScore {
|
||||
}
|
||||
|
||||
impl Serialize for OPNsenseShellCommandScore {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ pub struct OPNSenseLaunchUpgrade {
|
||||
}
|
||||
|
||||
impl Serialize for OPNSenseLaunchUpgrade {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
|
||||
20
harmony_cli/Cargo.toml
Normal file
20
harmony_cli/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "harmony_cli"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
assert_cmd = "2.0.17"
|
||||
clap = { version = "4.5.35", features = ["derive"] }
|
||||
harmony = { path = "../harmony" }
|
||||
harmony_tui = { path = "../harmony_tui", optional = true }
|
||||
inquire.workspace = true
|
||||
tokio.workspace = true
|
||||
env_logger.workspace = true
|
||||
|
||||
|
||||
[features]
|
||||
default = ["tui"]
|
||||
tui = ["dep:harmony_tui"]
|
||||
313
harmony_cli/src/lib.rs
Normal file
313
harmony_cli/src/lib.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
use clap::Parser;
|
||||
use clap::builder::ArgPredicate;
|
||||
use harmony;
|
||||
use harmony::{score::Score, topology::Topology};
|
||||
use inquire::Confirm;
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
use harmony_tui;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Args {
|
||||
/// Run score(s) without prompt
|
||||
#[arg(short, long, default_value_t = false, conflicts_with = "interactive")]
|
||||
yes: bool,
|
||||
|
||||
/// Filter query
|
||||
#[arg(short, long, conflicts_with = "interactive")]
|
||||
filter: Option<String>,
|
||||
|
||||
/// Run interactive TUI or not
|
||||
#[arg(short, long, default_value_t = false)]
|
||||
interactive: bool,
|
||||
|
||||
/// Run all or nth, defaults to all
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
default_value_t = true,
|
||||
default_value_if("number", ArgPredicate::IsPresent, "false"),
|
||||
conflicts_with = "number",
|
||||
conflicts_with = "interactive"
|
||||
)]
|
||||
all: bool,
|
||||
|
||||
/// Run nth matching, zero indexed
|
||||
#[arg(short, long, default_value_t = 0, conflicts_with = "interactive")]
|
||||
number: usize,
|
||||
|
||||
/// list scores, will also be affected by run filter
|
||||
#[arg(short, long, default_value_t = false, conflicts_with = "interactive")]
|
||||
list: bool,
|
||||
}
|
||||
|
||||
fn maestro_scores_filter<T: Topology>(
|
||||
maestro: &harmony::maestro::Maestro<T>,
|
||||
all: bool,
|
||||
filter: Option<String>,
|
||||
number: usize,
|
||||
) -> Vec<Box<dyn Score<T>>> {
|
||||
let scores = maestro.scores();
|
||||
let scores_read = scores.read().expect("Should be able to read scores");
|
||||
let mut scores_vec: Vec<Box<dyn Score<T>>> = match filter {
|
||||
Some(f) => scores_read
|
||||
.iter()
|
||||
.filter(|s| s.name().contains(&f))
|
||||
.map(|s| s.clone_box())
|
||||
.collect(),
|
||||
None => scores_read.iter().map(|s| s.clone_box()).collect(),
|
||||
};
|
||||
|
||||
if !all {
|
||||
let score = scores_vec.get(number);
|
||||
match score {
|
||||
Some(s) => scores_vec = vec![s.clone_box()],
|
||||
None => return vec![],
|
||||
}
|
||||
};
|
||||
|
||||
return scores_vec;
|
||||
}
|
||||
|
||||
// TODO: consider adding doctest for this function
|
||||
fn list_scores_with_index<T: Topology>(scores_vec: &Vec<Box<dyn Score<T>>>) -> String {
|
||||
let mut display_str = String::new();
|
||||
for (i, s) in scores_vec.iter().enumerate() {
|
||||
let name = s.name();
|
||||
display_str.push_str(&format!("\n{i}: {name}"));
|
||||
}
|
||||
return display_str;
|
||||
}
|
||||
|
||||
pub async fn init<T: Topology + Send + Sync + 'static>(
|
||||
maestro: harmony::maestro::Maestro<T>,
|
||||
args_struct: Option<Args>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = match args_struct {
|
||||
Some(args) => args,
|
||||
None => Args::parse(),
|
||||
};
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
if args.interactive {
|
||||
return harmony_tui::init(maestro).await;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tui"))]
|
||||
if args.interactive {
|
||||
return Err("Not compiled with interactive support".into());
|
||||
}
|
||||
|
||||
let _ = env_logger::builder().try_init();
|
||||
|
||||
let scores_vec = maestro_scores_filter(&maestro, args.all, args.filter, args.number);
|
||||
|
||||
if scores_vec.len() == 0 {
|
||||
return Err("No score found".into());
|
||||
}
|
||||
|
||||
// if list option is specified, print filtered list and exit
|
||||
if args.list {
|
||||
println!("Available scores:");
|
||||
println!("{}", list_scores_with_index(&scores_vec));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// prompt user if --yes is not specified
|
||||
if !args.yes {
|
||||
let confirmation = Confirm::new(
|
||||
format!(
|
||||
"This will run the following scores: {}\n",
|
||||
list_scores_with_index(&scores_vec)
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
.with_default(true)
|
||||
.prompt()
|
||||
.expect("Unexpected prompt error");
|
||||
|
||||
if !confirmation {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Run filtered scores
|
||||
for s in scores_vec {
|
||||
println!("Running: {}", s.name());
|
||||
maestro.interpret(s).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use harmony::{
|
||||
inventory::Inventory,
|
||||
maestro::Maestro,
|
||||
modules::dummy::{ErrorScore, PanicScore, SuccessScore},
|
||||
topology::HAClusterTopology,
|
||||
};
|
||||
|
||||
fn init_test_maestro() -> Maestro<HAClusterTopology> {
|
||||
let inventory = Inventory::autoload();
|
||||
let topology = HAClusterTopology::autoload();
|
||||
let mut maestro = Maestro::new(inventory, topology);
|
||||
|
||||
maestro.register_all(vec![
|
||||
Box::new(SuccessScore {}),
|
||||
Box::new(ErrorScore {}),
|
||||
Box::new(PanicScore {}),
|
||||
]);
|
||||
|
||||
maestro
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_init_success_score() {
|
||||
let maestro = init_test_maestro();
|
||||
let res = crate::init(
|
||||
maestro,
|
||||
Some(crate::Args {
|
||||
yes: true,
|
||||
filter: Some("SuccessScore".to_owned()),
|
||||
interactive: false,
|
||||
all: true,
|
||||
number: 0,
|
||||
list: false,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_init_error_score() {
|
||||
let maestro = init_test_maestro();
|
||||
|
||||
let res = crate::init(
|
||||
maestro,
|
||||
Some(crate::Args {
|
||||
yes: true,
|
||||
filter: Some("ErrorScore".to_owned()),
|
||||
interactive: false,
|
||||
all: true,
|
||||
number: 0,
|
||||
list: false,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_init_number_score() {
|
||||
let maestro = init_test_maestro();
|
||||
|
||||
let res = crate::init(
|
||||
maestro,
|
||||
Some(crate::Args {
|
||||
yes: true,
|
||||
filter: None,
|
||||
interactive: false,
|
||||
all: false,
|
||||
number: 0,
|
||||
list: false,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_filter_fn_all() {
|
||||
let maestro = init_test_maestro();
|
||||
|
||||
let res = crate::maestro_scores_filter(&maestro, true, None, 0);
|
||||
|
||||
assert!(res.len() == 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_filter_fn_all_success() {
|
||||
let maestro = init_test_maestro();
|
||||
|
||||
let res = crate::maestro_scores_filter(&maestro, true, Some("Success".to_owned()), 0);
|
||||
|
||||
assert!(res.len() == 1);
|
||||
|
||||
assert!(
|
||||
maestro
|
||||
.interpret(res.get(0).unwrap().clone_box())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_filter_fn_all_error() {
|
||||
let maestro = init_test_maestro();
|
||||
|
||||
let res = crate::maestro_scores_filter(&maestro, true, Some("Error".to_owned()), 0);
|
||||
|
||||
assert!(res.len() == 1);
|
||||
|
||||
assert!(
|
||||
maestro
|
||||
.interpret(res.get(0).unwrap().clone_box())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_filter_fn_all_score() {
|
||||
let maestro = init_test_maestro();
|
||||
|
||||
let res = crate::maestro_scores_filter(&maestro, true, Some("Score".to_owned()), 0);
|
||||
|
||||
assert!(res.len() == 3);
|
||||
|
||||
assert!(
|
||||
maestro
|
||||
.interpret(res.get(0).unwrap().clone_box())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
maestro
|
||||
.interpret(res.get(1).unwrap().clone_box())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_filter_fn_number() {
|
||||
let maestro = init_test_maestro();
|
||||
|
||||
let res = crate::maestro_scores_filter(&maestro, false, None, 0);
|
||||
|
||||
assert!(res.len() == 1);
|
||||
|
||||
assert!(
|
||||
maestro
|
||||
.interpret(res.get(0).unwrap().clone_box())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_filter_fn_number_invalid() {
|
||||
let maestro = init_test_maestro();
|
||||
|
||||
let res = crate::maestro_scores_filter(&maestro, false, None, 11);
|
||||
|
||||
assert!(res.len() == 0);
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,5 @@ color-eyre = "0.6.3"
|
||||
tokio-stream = "0.1.17"
|
||||
tui-logger = "0.14.1"
|
||||
log-panics = "2.1.0"
|
||||
serde-value.workspace = true
|
||||
serde_json = "1.0.140"
|
||||
|
||||
@@ -3,7 +3,7 @@ mod widget;
|
||||
use log::{debug, error, info};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::StreamExt;
|
||||
use tui_logger::{TuiWidgetEvent, TuiWidgetState};
|
||||
use tui_logger::{TuiLoggerFile, TuiWidgetEvent, TuiWidgetState};
|
||||
use widget::{help::HelpWidget, score::ScoreListWidget};
|
||||
|
||||
use std::{panic, sync::Arc, time::Duration};
|
||||
@@ -36,13 +36,13 @@ pub mod tui {
|
||||
/// modules::dummy::{ErrorScore, PanicScore, SuccessScore},
|
||||
/// topology::HAClusterTopology,
|
||||
/// };
|
||||
///
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let inventory = Inventory::autoload();
|
||||
/// let topology = HAClusterTopology::autoload();
|
||||
/// let mut maestro = Maestro::new(inventory, topology);
|
||||
///
|
||||
/// let mut maestro = Maestro::new(inventory, topology, "local");
|
||||
///
|
||||
/// maestro.register_all(vec![
|
||||
/// Box::new(SuccessScore {}),
|
||||
/// Box::new(ErrorScore {}),
|
||||
@@ -51,7 +51,7 @@ pub mod tui {
|
||||
/// harmony_tui::init(maestro).await.unwrap();
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn init<T: Topology + std::fmt::Debug + Send + Sync + 'static>(
|
||||
pub async fn init<T: Topology + Send + Sync + 'static>(
|
||||
maestro: Maestro<T>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
HarmonyTUI::new(maestro).init().await
|
||||
@@ -63,12 +63,21 @@ pub struct HarmonyTUI<T: Topology> {
|
||||
tui_state: TuiWidgetState,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum HarmonyTuiEvent<T: Topology> {
|
||||
LaunchScore(Box<dyn Score<T>>),
|
||||
}
|
||||
|
||||
impl<T: Topology + std::fmt::Debug + Send + Sync + 'static> HarmonyTUI<T> {
|
||||
impl<T: Topology> std::fmt::Display for HarmonyTuiEvent<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let output = match self {
|
||||
HarmonyTuiEvent::LaunchScore(score) => format!("LaunchScore({})", score.name()),
|
||||
};
|
||||
|
||||
f.write_str(&output)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Topology + Send + Sync + 'static> HarmonyTUI<T> {
|
||||
pub fn new(maestro: Maestro<T>) -> Self {
|
||||
let maestro = Arc::new(maestro);
|
||||
let (_handle, sender) = Self::start_channel(maestro.clone());
|
||||
@@ -91,7 +100,7 @@ impl<T: Topology + std::fmt::Debug + Send + Sync + 'static> HarmonyTUI<T> {
|
||||
let handle = tokio::spawn(async move {
|
||||
info!("Starting message channel receiver loop");
|
||||
while let Some(event) = receiver.recv().await {
|
||||
info!("Received event {event:#?}");
|
||||
info!("Received event {event}");
|
||||
match event {
|
||||
HarmonyTuiEvent::LaunchScore(score_item) => {
|
||||
let maestro = maestro.clone();
|
||||
@@ -123,7 +132,7 @@ impl<T: Topology + std::fmt::Debug + Send + Sync + 'static> HarmonyTUI<T> {
|
||||
// Set default level for unknown targets to Trace
|
||||
tui_logger::set_default_level(log::LevelFilter::Info);
|
||||
std::fs::create_dir_all("log")?;
|
||||
tui_logger::set_log_file("log/harmony.log").unwrap();
|
||||
tui_logger::set_log_file(TuiLoggerFile::new("log/harmony.log"));
|
||||
|
||||
color_eyre::install()?;
|
||||
let mut terminal = ratatui::init();
|
||||
@@ -159,12 +168,13 @@ impl<T: Topology + std::fmt::Debug + Send + Sync + 'static> HarmonyTUI<T> {
|
||||
frame.render_widget(&help_block, help_area);
|
||||
frame.render_widget(HelpWidget::new(), help_block.inner(help_area));
|
||||
|
||||
let [list_area, output_area] =
|
||||
let [list_area, logger_area] =
|
||||
Layout::horizontal([Constraint::Min(30), Constraint::Percentage(100)]).areas(app_area);
|
||||
|
||||
let block = Block::default().borders(Borders::RIGHT);
|
||||
frame.render_widget(&block, list_area);
|
||||
self.score.render(list_area, frame);
|
||||
|
||||
let tui_logger = tui_logger::TuiLoggerWidget::default()
|
||||
.style_error(Style::default().fg(Color::Red))
|
||||
.style_warn(Style::default().fg(Color::LightRed))
|
||||
@@ -172,9 +182,9 @@ impl<T: Topology + std::fmt::Debug + Send + Sync + 'static> HarmonyTUI<T> {
|
||||
.style_debug(Style::default().fg(Color::Gray))
|
||||
.style_trace(Style::default().fg(Color::Gray))
|
||||
.state(&self.tui_state);
|
||||
frame.render_widget(tui_logger, output_area)
|
||||
}
|
||||
|
||||
frame.render_widget(tui_logger, logger_area);
|
||||
}
|
||||
fn scores_list(maestro: &Maestro<T>) -> Vec<Box<dyn Score<T>>> {
|
||||
let scores = maestro.scores();
|
||||
let scores_read = scores.read().expect("Should be able to read scores");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::HarmonyTuiEvent;
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind};
|
||||
use harmony::{score::Score, topology::Topology};
|
||||
use log::{info, warn};
|
||||
@@ -11,8 +12,6 @@ use ratatui::{
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::HarmonyTuiEvent;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ExecutionState {
|
||||
INITIATED,
|
||||
@@ -20,13 +19,21 @@ enum ExecutionState {
|
||||
CANCELED,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Execution<T: Topology> {
|
||||
state: ExecutionState,
|
||||
score: Box<dyn Score<T>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
impl<T: Topology> std::fmt::Display for Execution<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_fmt(format_args!(
|
||||
"Execution of {} status {:?}",
|
||||
self.score.name(),
|
||||
self.state
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ScoreListWidget<T: Topology> {
|
||||
list_state: Arc<RwLock<ListState>>,
|
||||
scores: Vec<Box<dyn Score<T>>>,
|
||||
@@ -35,7 +42,7 @@ pub(crate) struct ScoreListWidget<T: Topology> {
|
||||
sender: mpsc::Sender<HarmonyTuiEvent<T>>,
|
||||
}
|
||||
|
||||
impl<T: Topology + std::fmt::Debug> ScoreListWidget<T> {
|
||||
impl<T: Topology> ScoreListWidget<T> {
|
||||
pub(crate) fn new(
|
||||
scores: Vec<Box<dyn Score<T>>>,
|
||||
sender: mpsc::Sender<HarmonyTuiEvent<T>>,
|
||||
@@ -53,23 +60,27 @@ impl<T: Topology + std::fmt::Debug> ScoreListWidget<T> {
|
||||
}
|
||||
|
||||
pub(crate) fn launch_execution(&mut self) {
|
||||
let list_read = self.list_state.read().unwrap();
|
||||
if let Some(index) = list_read.selected() {
|
||||
let score = self
|
||||
.scores
|
||||
.get(index)
|
||||
.expect("List state should always match with internal Vec");
|
||||
|
||||
if let Some(score) = self.get_selected_score() {
|
||||
self.execution = Some(Execution {
|
||||
state: ExecutionState::INITIATED,
|
||||
score: score.clone_box(),
|
||||
});
|
||||
info!("{:#?}\n\nConfirm Execution (Press y/n)", score);
|
||||
info!("{}\n\nConfirm Execution (Press y/n)", score.name());
|
||||
info!("{}", score.print_score_details());
|
||||
} else {
|
||||
warn!("No Score selected, nothing to launch");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_selected_score(&self) -> Option<Box<dyn Score<T>>> {
|
||||
let list_read = self.list_state.read().unwrap();
|
||||
if let Some(index) = list_read.selected() {
|
||||
self.scores.get(index).map(|s| s.clone_box())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_down(&self) {
|
||||
self.list_state.write().unwrap().scroll_down_by(1);
|
||||
}
|
||||
@@ -96,7 +107,7 @@ impl<T: Topology + std::fmt::Debug> ScoreListWidget<T> {
|
||||
match confirm {
|
||||
true => {
|
||||
execution.state = ExecutionState::RUNNING;
|
||||
info!("Launch execution {:?}", execution);
|
||||
info!("Launch execution {execution}");
|
||||
self.sender
|
||||
.send(HarmonyTuiEvent::LaunchScore(execution.score.clone_box()))
|
||||
.await
|
||||
|
||||
23
k3d/Cargo.toml
Normal file
23
k3d/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "k3d-rs"
|
||||
edition = "2021"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
octocrab = "0.44.0"
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
url.workspace = true
|
||||
sha2 = "0.10.8"
|
||||
futures-util = "0.3.31"
|
||||
kube.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger = { workspace = true }
|
||||
httptest = "0.16.3"
|
||||
pretty_assertions = "1.4.1"
|
||||
303
k3d/src/downloadable_asset.rs
Normal file
303
k3d/src/downloadable_asset.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
use futures_util::StreamExt;
|
||||
use log::{debug, info, warn};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use url::Url;
|
||||
|
||||
const CHECKSUM_FAILED_MSG: &str = "Downloaded file failed checksum verification";
|
||||
|
||||
/// Represents an asset that can be downloaded from a URL with checksum verification.
|
||||
///
|
||||
/// This struct facilitates secure downloading of files from remote URLs by
|
||||
/// verifying the integrity of the downloaded content using SHA-256 checksums.
|
||||
/// It handles downloading the file, saving it to disk, and verifying the checksum matches
|
||||
/// the expected value.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// # use url::Url;
|
||||
/// # use std::path::PathBuf;
|
||||
///
|
||||
/// # async fn example() -> Result<(), String> {
|
||||
/// let asset = DownloadableAsset {
|
||||
/// url: Url::parse("https://example.com/file.zip").unwrap(),
|
||||
/// file_name: "file.zip".to_string(),
|
||||
/// checksum: "a1b2c3d4e5f6...".to_string(),
|
||||
/// };
|
||||
///
|
||||
/// let download_dir = PathBuf::from("/tmp/downloads");
|
||||
/// let file_path = asset.download_to_path(download_dir).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DownloadableAsset {
|
||||
pub(crate) url: Url,
|
||||
pub(crate) file_name: String,
|
||||
pub(crate) checksum: String,
|
||||
}
|
||||
|
||||
impl DownloadableAsset {
|
||||
fn verify_checksum(&self, file: PathBuf) -> bool {
|
||||
if !file.exists() {
|
||||
warn!("File does not exist: {:?}", file);
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut file = match std::fs::File::open(&file) {
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
warn!("Failed to open file for checksum verification: {:?}", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0; 1024 * 1024]; // 1MB buffer
|
||||
|
||||
loop {
|
||||
let bytes_read = match file.read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
warn!("Error reading file for checksum: {:?}", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
hasher.update(&buffer[..bytes_read]);
|
||||
}
|
||||
|
||||
let result = hasher.finalize();
|
||||
let calculated_hash = format!("{:x}", result);
|
||||
|
||||
debug!("Expected checksum: {}", self.checksum);
|
||||
debug!("Calculated checksum: {}", calculated_hash);
|
||||
|
||||
calculated_hash == self.checksum
|
||||
}
|
||||
|
||||
/// Downloads the asset to the specified directory, verifying its checksum.
|
||||
///
|
||||
/// This function will:
|
||||
/// 1. Create the target directory if it doesn't exist
|
||||
/// 2. Check if the file already exists with the correct checksum
|
||||
/// 3. If not, download the file from the URL
|
||||
/// 4. Verify the downloaded file's checksum matches the expected value
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `folder` - The directory path where the file should be saved
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(PathBuf)` - The path to the downloaded file on success
|
||||
/// * `Err(String)` - A descriptive error message if the download or verification fails
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function will return an error if:
|
||||
/// - The network request fails
|
||||
/// - The server responds with a non-success status code
|
||||
/// - Writing to disk fails
|
||||
/// - The checksum verification fails
|
||||
pub(crate) async fn download_to_path(&self, folder: PathBuf) -> Result<PathBuf, String> {
|
||||
if !folder.exists() {
|
||||
fs::create_dir_all(&folder)
|
||||
.await
|
||||
.expect("Failed to create download directory");
|
||||
}
|
||||
|
||||
let target_file_path = folder.join(&self.file_name);
|
||||
debug!("Downloading to path: {:?}", target_file_path);
|
||||
|
||||
if self.verify_checksum(target_file_path.clone()) {
|
||||
debug!("File already exists with correct checksum, skipping download");
|
||||
return Ok(target_file_path);
|
||||
}
|
||||
|
||||
debug!("Downloading from URL: {}", self.url);
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(self.url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download file: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to download file, status: {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let mut file = File::create(&target_file_path)
|
||||
.await
|
||||
.expect("Failed to create target file");
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result.expect("Error while downloading file");
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.expect("Failed to write data to file");
|
||||
}
|
||||
|
||||
file.flush().await.expect("Failed to flush file");
|
||||
drop(file);
|
||||
|
||||
if !self.verify_checksum(target_file_path.clone()) {
|
||||
return Err(CHECKSUM_FAILED_MSG.to_string());
|
||||
}
|
||||
|
||||
info!(
|
||||
"File downloaded and verified successfully: {}",
|
||||
target_file_path.to_string_lossy()
|
||||
);
|
||||
Ok(target_file_path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use httptest::{
|
||||
matchers::{self, request},
|
||||
responders, Expectation, Server,
|
||||
};
|
||||
|
||||
const BASE_TEST_PATH: &str = "/tmp/harmony-test-k3d-download";
|
||||
const TEST_CONTENT: &str = "This is a test file.";
|
||||
const TEST_CONTENT_HASH: &str =
|
||||
"f29bc64a9d3732b4b9035125fdb3285f5b6455778edca72414671e0ca3b2e0de";
|
||||
|
||||
fn setup_test() -> (PathBuf, Server) {
|
||||
let _ = env_logger::builder().try_init();
|
||||
|
||||
// Create unique test directory
|
||||
let test_id = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis();
|
||||
let download_path = format!("{}/test_{}", BASE_TEST_PATH, test_id);
|
||||
std::fs::create_dir_all(&download_path).unwrap();
|
||||
|
||||
(PathBuf::from(download_path), Server::run())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_to_path_success() {
|
||||
let (folder, server) = setup_test();
|
||||
|
||||
server.expect(
|
||||
Expectation::matching(request::method_path("GET", "/test.txt"))
|
||||
.respond_with(responders::status_code(200).body(TEST_CONTENT)),
|
||||
);
|
||||
|
||||
let asset = DownloadableAsset {
|
||||
url: Url::parse(&server.url("/test.txt").to_string()).unwrap(),
|
||||
file_name: "test.txt".to_string(),
|
||||
checksum: TEST_CONTENT_HASH.to_string(),
|
||||
};
|
||||
|
||||
let result = asset
|
||||
.download_to_path(folder.join("success"))
|
||||
.await
|
||||
.unwrap();
|
||||
let downloaded_content = std::fs::read_to_string(result).unwrap();
|
||||
assert_eq!(downloaded_content, TEST_CONTENT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_to_path_already_exists() {
|
||||
let (folder, server) = setup_test();
|
||||
|
||||
server.expect(
|
||||
Expectation::matching(matchers::any())
|
||||
.times(0)
|
||||
.respond_with(responders::status_code(200).body(TEST_CONTENT)),
|
||||
);
|
||||
|
||||
let asset = DownloadableAsset {
|
||||
url: Url::parse(&server.url("/test.txt").to_string()).unwrap(),
|
||||
file_name: "test.txt".to_string(),
|
||||
checksum: TEST_CONTENT_HASH.to_string(),
|
||||
};
|
||||
|
||||
let target_file_path = folder.join(&asset.file_name);
|
||||
std::fs::write(&target_file_path, TEST_CONTENT).unwrap();
|
||||
|
||||
let result = asset.download_to_path(folder).await.unwrap();
|
||||
let content = std::fs::read_to_string(result).unwrap();
|
||||
assert_eq!(content, TEST_CONTENT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_to_path_server_error() {
|
||||
let (folder, server) = setup_test();
|
||||
|
||||
server.expect(
|
||||
Expectation::matching(matchers::any()).respond_with(responders::status_code(404)),
|
||||
);
|
||||
|
||||
let asset = DownloadableAsset {
|
||||
url: Url::parse(&server.url("/test.txt").to_string()).unwrap(),
|
||||
file_name: "test.txt".to_string(),
|
||||
checksum: TEST_CONTENT_HASH.to_string(),
|
||||
};
|
||||
|
||||
let result = asset.download_to_path(folder.join("error")).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("status: 404"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_to_path_checksum_failure() {
|
||||
let (folder, server) = setup_test();
|
||||
|
||||
let invalid_content = "This is NOT the expected content";
|
||||
server.expect(
|
||||
Expectation::matching(matchers::any())
|
||||
.respond_with(responders::status_code(200).body(invalid_content)),
|
||||
);
|
||||
|
||||
let asset = DownloadableAsset {
|
||||
url: Url::parse(&server.url("/test.txt").to_string()).unwrap(),
|
||||
file_name: "test.txt".to_string(),
|
||||
checksum: TEST_CONTENT_HASH.to_string(),
|
||||
};
|
||||
|
||||
let join_handle =
|
||||
tokio::spawn(async move { asset.download_to_path(folder.join("failure")).await });
|
||||
|
||||
assert_eq!(
|
||||
join_handle.await.unwrap().err().unwrap(),
|
||||
CHECKSUM_FAILED_MSG
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_with_specific_path_matcher() {
|
||||
let (folder, server) = setup_test();
|
||||
|
||||
server.expect(
|
||||
Expectation::matching(matchers::request::path("/specific/path.txt"))
|
||||
.respond_with(responders::status_code(200).body(TEST_CONTENT)),
|
||||
);
|
||||
|
||||
let asset = DownloadableAsset {
|
||||
url: Url::parse(&server.url("/specific/path.txt").to_string()).unwrap(),
|
||||
file_name: "path.txt".to_string(),
|
||||
checksum: TEST_CONTENT_HASH.to_string(),
|
||||
};
|
||||
|
||||
let result = asset.download_to_path(folder).await.unwrap();
|
||||
let downloaded_content = std::fs::read_to_string(result).unwrap();
|
||||
assert_eq!(downloaded_content, TEST_CONTENT);
|
||||
}
|
||||
}
|
||||
410
k3d/src/lib.rs
Normal file
410
k3d/src/lib.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
mod downloadable_asset;
|
||||
use downloadable_asset::*;
|
||||
|
||||
use kube::Client;
|
||||
use log::{debug, info, warn};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const K3D_BIN_FILE_NAME: &str = "k3d";
|
||||
|
||||
pub struct K3d {
|
||||
base_dir: PathBuf,
|
||||
cluster_name: Option<String>,
|
||||
}
|
||||
|
||||
impl K3d {
|
||||
pub fn new(base_dir: PathBuf, cluster_name: Option<String>) -> Self {
|
||||
Self {
|
||||
base_dir,
|
||||
cluster_name,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_binary_for_current_platform(
|
||||
&self,
|
||||
latest_release: octocrab::models::repos::Release,
|
||||
) -> DownloadableAsset {
|
||||
let os = std::env::consts::OS;
|
||||
let arch = std::env::consts::ARCH;
|
||||
|
||||
debug!("Detecting platform: OS={}, ARCH={}", os, arch);
|
||||
|
||||
let binary_pattern = match (os, arch) {
|
||||
("linux", "x86") => "k3d-linux-386",
|
||||
("linux", "x86_64") => "k3d-linux-amd64",
|
||||
("linux", "arm") => "k3d-linux-arm",
|
||||
("linux", "aarch64") => "k3d-linux-arm64",
|
||||
("windows", "x86_64") => "k3d-windows-amd64.exe",
|
||||
("macos", "x86_64") => "k3d-darwin-amd64",
|
||||
("macos", "aarch64") => "k3d-darwin-arm64",
|
||||
_ => panic!("Unsupported platform: {}-{}", os, arch),
|
||||
};
|
||||
|
||||
debug!("Looking for binary matching pattern: {}", binary_pattern);
|
||||
|
||||
let binary_asset = latest_release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == binary_pattern)
|
||||
.unwrap_or_else(|| panic!("No matching binary found for {}", binary_pattern));
|
||||
|
||||
let binary_url = binary_asset.browser_download_url.clone();
|
||||
|
||||
let checksums_asset = latest_release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "checksums.txt")
|
||||
.expect("Checksums file not found in release assets");
|
||||
|
||||
let checksums_url = checksums_asset.browser_download_url.clone();
|
||||
|
||||
let body = reqwest::get(checksums_url)
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
println!("body: {body}");
|
||||
|
||||
let checksum = body
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
if line.ends_with(&binary_pattern) {
|
||||
Some(line.split_whitespace().next().unwrap_or("").to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| panic!("Checksum not found for {}", binary_pattern));
|
||||
|
||||
debug!("Found binary at {} with checksum {}", binary_url, checksum);
|
||||
|
||||
DownloadableAsset {
|
||||
url: binary_url,
|
||||
file_name: K3D_BIN_FILE_NAME.to_string(),
|
||||
checksum,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_latest_release(&self) -> Result<PathBuf, String> {
|
||||
let latest_release = self.get_latest_release_tag().await.unwrap();
|
||||
|
||||
let release_binary = self.get_binary_for_current_platform(latest_release).await;
|
||||
info!("Foudn K3d binary to install : {release_binary:#?}");
|
||||
release_binary.download_to_path(self.base_dir.clone()).await
|
||||
}
|
||||
|
||||
// TODO : Make sure this will only find actual released versions, no prereleases or test
|
||||
// builds
|
||||
pub async fn get_latest_release_tag(&self) -> Result<octocrab::models::repos::Release, String> {
|
||||
let octo = octocrab::instance();
|
||||
let latest_release = octo
|
||||
.repos("k3d-io", "k3d")
|
||||
.releases()
|
||||
.get_latest()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// debug!("Got k3d releases {releases:#?}");
|
||||
println!("Got k3d first releases {latest_release:#?}");
|
||||
|
||||
Ok(latest_release)
|
||||
}
|
||||
|
||||
/// Checks if k3d binary exists and is executable
|
||||
///
|
||||
/// Verifies that:
|
||||
/// 1. The k3d binary exists in the base directory
|
||||
/// 2. It has proper executable permissions (on Unix systems)
|
||||
/// 3. It responds correctly to a simple command (`k3d --version`)
|
||||
pub fn is_installed(&self) -> bool {
|
||||
let binary_path = self.get_k3d_binary_path();
|
||||
|
||||
if !binary_path.exists() {
|
||||
debug!("K3d binary not found at {:?}", binary_path);
|
||||
return false;
|
||||
}
|
||||
|
||||
if !self.ensure_binary_executable(&binary_path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.can_execute_binary_check(&binary_path)
|
||||
}
|
||||
|
||||
/// Verifies if the specified cluster is already created
|
||||
///
|
||||
/// Executes `k3d cluster list <cluster_name>` and checks for a successful response,
|
||||
/// indicating that the cluster exists and is registered with k3d.
|
||||
pub fn is_cluster_initialized(&self) -> bool {
|
||||
let cluster_name = match self.get_cluster_name() {
|
||||
Ok(name) => name,
|
||||
Err(_) => {
|
||||
debug!("Could not get cluster name, can't verify if cluster is initialized");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let binary_path = self.base_dir.join(K3D_BIN_FILE_NAME);
|
||||
if !binary_path.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.verify_cluster_exists(&binary_path, cluster_name)
|
||||
}
|
||||
|
||||
fn get_cluster_name(&self) -> Result<&String, String> {
|
||||
match &self.cluster_name {
|
||||
Some(name) => Ok(name),
|
||||
None => Err("No cluster name available".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new k3d cluster with the specified name
|
||||
///
|
||||
/// This method:
|
||||
/// 1. Creates a new k3d cluster using `k3d cluster create <cluster_name>`
|
||||
/// 2. Waits for the cluster to initialize
|
||||
/// 3. Returns a configured Kubernetes client connected to the cluster
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(Client)` - Successfully created cluster and connected client
|
||||
/// - `Err(String)` - Error message detailing what went wrong
|
||||
pub async fn initialize_cluster(&self) -> Result<Client, String> {
|
||||
let cluster_name = match self.get_cluster_name() {
|
||||
Ok(name) => name,
|
||||
Err(_) => return Err("Could not get cluster_name, cannot initialize".to_string()),
|
||||
};
|
||||
|
||||
info!("Initializing k3d cluster '{}'", cluster_name);
|
||||
|
||||
self.create_cluster(cluster_name)?;
|
||||
self.create_kubernetes_client().await
|
||||
}
|
||||
|
||||
fn get_k3d_binary_path(&self) -> PathBuf {
|
||||
self.base_dir.join(K3D_BIN_FILE_NAME)
|
||||
}
|
||||
|
||||
fn get_k3d_binary(&self) -> Result<PathBuf, String> {
|
||||
let path = self.get_k3d_binary_path();
|
||||
if !path.exists() {
|
||||
return Err(format!("K3d binary not found at {:?}", path));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Ensures k3d is installed and the cluster is initialized
|
||||
///
|
||||
/// This method provides a complete setup flow:
|
||||
/// 1. Checks if k3d is installed, downloads and installs it if needed
|
||||
/// 2. Verifies if the specified cluster exists, creates it if not
|
||||
/// 3. Returns a Kubernetes client connected to the cluster
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(Client)` - Successfully ensured k3d and cluster are ready
|
||||
/// - `Err(String)` - Error message if any step failed
|
||||
pub async fn ensure_installed(&self) -> Result<Client, String> {
|
||||
if !self.is_installed() {
|
||||
info!("K3d is not installed, downloading latest release");
|
||||
self.download_latest_release()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download k3d: {}", e))?;
|
||||
|
||||
if !self.is_installed() {
|
||||
return Err("Failed to install k3d properly".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !self.is_cluster_initialized() {
|
||||
info!("Cluster is not initialized, initializing now");
|
||||
return self.initialize_cluster().await;
|
||||
}
|
||||
|
||||
self.start_cluster().await?;
|
||||
|
||||
info!("K3d and cluster are already properly set up");
|
||||
self.create_kubernetes_client().await
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn ensure_binary_executable(&self, binary_path: &PathBuf) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mut perms = match std::fs::metadata(binary_path) {
|
||||
Ok(metadata) => metadata.permissions(),
|
||||
Err(e) => {
|
||||
debug!("Failed to get binary metadata: {}", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
perms.set_mode(0o755);
|
||||
|
||||
if let Err(e) = std::fs::set_permissions(binary_path, perms) {
|
||||
debug!("Failed to set executable permissions on k3d binary: {}", e);
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ensure_binary_executable(&self, _binary_path: &PathBuf) -> bool {
|
||||
// Windows doesn't use executable file permissions
|
||||
true
|
||||
}
|
||||
|
||||
fn can_execute_binary_check(&self, binary_path: &PathBuf) -> bool {
|
||||
match std::process::Command::new(binary_path)
|
||||
.arg("--version")
|
||||
.output()
|
||||
{
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
debug!("K3d binary is installed and working");
|
||||
true
|
||||
} else {
|
||||
debug!("K3d binary check failed: {:?}", output);
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to execute K3d binary: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_cluster_exists(&self, binary_path: &PathBuf, cluster_name: &str) -> bool {
|
||||
match std::process::Command::new(binary_path)
|
||||
.args(["cluster", "list", cluster_name, "--no-headers"])
|
||||
.output()
|
||||
{
|
||||
Ok(output) => {
|
||||
if output.status.success() && !output.stdout.is_empty() {
|
||||
debug!("Cluster '{}' is initialized", cluster_name);
|
||||
true
|
||||
} else {
|
||||
debug!("Cluster '{}' is not initialized", cluster_name);
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to check cluster initialization: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_k3d_command<I, S>(&self, args: I) -> Result<std::process::Output, String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let binary_path = self.get_k3d_binary()?;
|
||||
let output = std::process::Command::new(binary_path).args(args).output();
|
||||
match output {
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
debug!("stderr : {}", stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
debug!("stdout : {}", stdout);
|
||||
Ok(output)
|
||||
}
|
||||
Err(e) => Err(format!("Failed to execute k3d command: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_cluster(&self, cluster_name: &str) -> Result<(), String> {
|
||||
let output = self.run_k3d_command(["cluster", "create", cluster_name])?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to create cluster: {}", stderr));
|
||||
}
|
||||
|
||||
info!("Successfully created k3d cluster '{}'", cluster_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_kubernetes_client(&self) -> Result<Client, String> {
|
||||
warn!("TODO this method is way too dumb, it should make sure that the client is connected to the k3d cluster actually represented by this instance, not just any default client");
|
||||
Client::try_default()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create Kubernetes client: {}", e))
|
||||
}
|
||||
|
||||
pub async fn get_client(&self) -> Result<Client, String> {
|
||||
match self.is_cluster_initialized() {
|
||||
true => Ok(self.create_kubernetes_client().await?),
|
||||
false => Err("Cannot get client! Cluster not initialized yet".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_cluster(&self) -> Result<(), String> {
|
||||
let cluster_name = self.get_cluster_name()?;
|
||||
let output = self.run_k3d_command(["cluster", "start", cluster_name])?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to start cluster: {}", stderr));
|
||||
}
|
||||
|
||||
info!("Successfully started k3d cluster '{}'", cluster_name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use regex::Regex;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{K3d, K3D_BIN_FILE_NAME};
|
||||
|
||||
#[tokio::test]
|
||||
async fn k3d_latest_release_should_get_latest() {
|
||||
let dir = get_clean_test_directory();
|
||||
|
||||
assert_eq!(dir.join(K3D_BIN_FILE_NAME).exists(), false);
|
||||
|
||||
let k3d = K3d::new(dir.clone(), None);
|
||||
let latest_release = k3d.get_latest_release_tag().await.unwrap();
|
||||
|
||||
let tag_regex = Regex::new(r"^v\d+\.\d+\.\d+$").unwrap();
|
||||
assert!(tag_regex.is_match(&latest_release.tag_name));
|
||||
assert!(!latest_release.tag_name.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn k3d_download_latest_release_should_get_latest_bin() {
|
||||
let dir = get_clean_test_directory();
|
||||
|
||||
assert_eq!(dir.join(K3D_BIN_FILE_NAME).exists(), false);
|
||||
|
||||
let k3d = K3d::new(dir.clone(), None);
|
||||
let bin_file_path = k3d.download_latest_release().await.unwrap();
|
||||
assert_eq!(bin_file_path, dir.join(K3D_BIN_FILE_NAME));
|
||||
assert_eq!(dir.join(K3D_BIN_FILE_NAME).exists(), true);
|
||||
}
|
||||
|
||||
fn get_clean_test_directory() -> PathBuf {
|
||||
let dir = PathBuf::from("/tmp/harmony-k3d-test-dir");
|
||||
|
||||
if dir.exists() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&dir) {
|
||||
// TODO sometimes this fails because of the race when running multiple tests at
|
||||
// once
|
||||
panic!("Failed to clean up test directory: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
panic!("Failed to create test directory: {}", e);
|
||||
}
|
||||
|
||||
dir
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ pub struct Config {
|
||||
}
|
||||
|
||||
impl Serialize for Config {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
|
||||
@@ -10,10 +10,11 @@ mod test {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use crate::Config;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[cfg(opnsenseendtoend)]
|
||||
#[tokio::test]
|
||||
async fn test_public_sdk() {
|
||||
use pretty_assertions::assert_eq;
|
||||
let mac = "11:22:33:44:55:66";
|
||||
let ip = Ipv4Addr::new(10, 100, 8, 200);
|
||||
let hostname = "test_hostname";
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
[package]
|
||||
name = "example"
|
||||
edition = "2024"
|
||||
|
||||
Reference in New Issue
Block a user