Compare commits

...

39 Commits

Author SHA1 Message Date
Ian Letourneau
2f7c4924c1 wip 2025-04-29 16:30:54 -04:00
254f392cb5 feat(HelmScore): Add values yaml option to helm chart score (#23)
Co-authored-by: tahahawa <tahahawa@gmail.com>
Reviewed-on: #23
2025-04-29 16:09:04 +00:00
db9c8d83e6 update adr 2025-04-28 15:09:11 -04:00
20551b4a80 adr for monitoring and alerting 2025-04-28 14:11:44 -04:00
5c026ae6dd chore: improved error message for helm unavailable 2025-04-28 10:11:57 -04:00
76c0cacc1b Merge pull request 'feat: LampScore implement dockerfile generation and image building' (#22) from feat/lampDocker into master
Reviewed-on: #22
Reviewed-by: wjro <wrolleman@nationtech.io>
2025-04-27 19:56:29 +00:00
f17948397f feat: escape PHP_ERROR_REPORTING value in Dockerfile
Escapes the value of the PHP_ERROR_REPORTING environment variable in the Dockerfile to prevent potential issues with shell interpretation. Uses EnvBuilder for a more structured approach.
2025-04-27 15:55:12 -04:00
16a665241e feat: LampScore implement dockerfile generation and image building
- Added `build_dockerfile` function to generate a Dockerfile based on the LAMP stack for the given project.
- Implemented `build_docker_image` to execute the docker build command and create the image.
- Configured user and permissions for apache.
- Included necessary apache configuration for security.
- Added error handling for docker build failures.
- Exposed port 80 for external access.
- Added basic serialization to Config struct.
2025-04-25 14:34:57 -04:00
065e3904b8 Merge pull request 'fix(k8s_anywhere): Ensure k3d cluster is started before use' (#21) from feat/k3d into master
Reviewed-on: #21
Reviewed-by: wjro <wrolleman@nationtech.io>
Reviewed-by: taha <taha@noreply.git.nationtech.io>
2025-04-25 16:46:28 +00:00
22752960f9 fix(k8s_anywhere): Ensure k3d cluster is started before use
- Refactor k3d cluster management to explicitly start the cluster.
- Introduce `start_cluster` function to ensure cluster is running before operations.
- Improve error handling and logging during cluster startup.
- Update `create_cluster` and other related functions to utilize the new startup mechanism.
- Enhance reliability and prevent potential issues caused by an uninitialized cluster.
- Add `run_k3d_command` to handle k3d commands with logging and error handling.
2025-04-25 12:45:02 -04:00
23971ecd7c Merge pull request 'feat: implement k3d cluster management' (#20) from feat/k3d into master
Reviewed-on: #20
Reviewed-by: wjro <wrolleman@nationtech.io>
Reviewed-by: taha <taha@noreply.git.nationtech.io>
2025-04-25 15:33:13 +00:00
fbcd3e4f7f feat: implement k3d cluster management
- Adds functionality to download, install, and manage k3d clusters.
- Includes methods for downloading the latest release, creating clusters, and verifying cluster existence.
- Implements `ensure_k3d_installed`, `get_latest_release_tag`, `download_latest_release`, `is_k3d_installed`, `verify_cluster_exists`, `create_cluster` and `create_kubernetes_client`.
- Provides a `get_client` method to access the Kubernetes client.
- Includes unit tests for download and installation.
- Adds handling for different operating systems.
- Improves error handling and logging.
- Introduces a `K3d` struct to encapsulate k3d cluster management logic.
- Adds the ability to specify the cluster name during K3d initialization.
2025-04-24 17:36:01 -04:00
d307893f15 fix: small-fixes (#19)
Reviewed-on: #19
Reviewed-by: johnride <jg@nationtech.io>
Co-authored-by: Taha Hawa <taha@taha.dev>
Co-committed-by: Taha Hawa <taha@taha.dev>
2025-04-24 18:47:47 +00:00
00c0566533 Merge pull request 'feat: introduce Maestro::initialize function that creates the maestro instance and ensure_ready the topology as well. Also refactor all relevant examples to use this new initialize function' (#18) from feat/maestroinitialize into master
Reviewed-on: #18
Reviewed-by: taha <taha@noreply.git.nationtech.io>
2025-04-24 17:43:31 +00:00
f5e3f1aaea feat: Add check.sh helper script to make sure code looks OK before pushing 2025-04-24 13:16:20 -04:00
508b97ca7c chore: Fix more warnings 2025-04-24 13:14:35 -04:00
80bdd0ee8a feat: introduce Maestro::initialize function that creates the maestro instance and ensure_ready the topology as well. Also refactor all relevant examples to use this new initialize function 2025-04-24 12:58:41 -04:00
6c06a4ae07 feat: update ensure_ready to check helm is available (#17)
I want to make sure the changes I'm working on in the ensure_ready don't break anything

Reviewed-on: #17
Reviewed-by: taha <taha@noreply.git.nationtech.io>
Co-authored-by: Willem <wrolleman@nationtech.io>
Co-committed-by: Willem <wrolleman@nationtech.io>
2025-04-24 15:51:28 +00:00
ad1aa897b1 Merge pull request 'chore: Fix all warnings in the project, ignore unused variables mostly' (#16) from chore/warnings into master
Reviewed-on: #16
Reviewed-by: wjro <wrolleman@nationtech.io>
2025-04-24 14:28:14 +00:00
dccc9c04f5 chore: Fix all warnings in the project, ignore unused variables mostly 2025-04-24 10:22:53 -04:00
9345e63a32 fix: couple of changes to get a test working 2025-04-23 15:31:02 -04:00
ff830486af Merge pull request 'fix(cli): remove need for debug in harmony-cli' (#15) from harmony-cli-remove-debug into master
Reviewed-on: #15
Reviewed-by: johnride <jg@nationtech.io>
2025-04-23 18:55:06 +00:00
da83019d85 remove need for debug in harmony-cli 2025-04-23 14:53:36 -04:00
53aa47f91e feat: Initial helm score using helm-wrapper-rs (#14)
Reviewed-on: #14
Co-authored-by: Taha Hawa <taha@taha.dev>
Co-committed-by: Taha Hawa <taha@taha.dev>
2025-04-23 18:22:27 +00:00
8f470278a7 Merge pull request 'feat: introduce topology readiness and initialization' (#10) from feat/topologyDependencies into master
Reviewed-on: #10
Reviewed-by: taha <taha@noreply.git.nationtech.io>
2025-04-23 15:58:31 +00:00
213fb25686 feat: Use inquire::Confirm instead of raw std::io::Read for K8sAnywhere installation confirmation prompt 2025-04-23 11:56:55 -04:00
45668638e1 feat: TUI does not require Topology to implement Debug anymore 2025-04-23 11:17:03 -04:00
0857aba039 Switch HAClusterTopology for K8sAnywhereTopology in lamp example 2025-04-23 11:08:36 -04:00
452ebc2614 feat: add k3d installation interpret
Adds a new interpret for k3d installation. This includes defining the `K3dInstallationInterpret` struct, implementing the `Interpret` trait for it, and adding the `K3dInstallation` variant to the `InterpretName` enum. The implementation currently contains `todo!()` placeholders for the actual logic.
2025-04-23 10:54:54 -04:00
9e456bb4f5 chore: Refactor DownloadableAsset tests to use httptest instead of a local TcpListener 2025-04-23 10:54:54 -04:00
83ba0e1044 fix: Initialize K3DInstallationScore correctly 2025-04-23 10:54:54 -04:00
2229e9d7af chore: Cargo fmt 2025-04-23 10:54:54 -04:00
15785dd219 feat: download and install k3d latest release
- Implemented functionality to fetch the latest k3d release tag from GitHub.
- Added logic to determine the appropriate binary URL based on the current platform.
- Implemented downloading and saving the binary to a specified directory.
- Included unit tests to verify the download and installation process.
- Added a `K3D_BIN_FILE_NAME` constant for clarity.
- Added logging for better debugging.
2025-04-23 10:54:54 -04:00
847d84b46f wip: Started work on k3d crate 2025-04-23 10:54:54 -04:00
3f6f1fa0d4 wip: Implement basic K8sAnywhere setup with K3d support
- Added initial K8sAnywhere topology and related modules.
- Implemented a basic K3d installation score for cluster bootstrapping.
- Introduced LocalhostTopology for local development and testing.
- Added necessary module structure and dependencies.
- Implemented user prompt for K3d installation confirmation.
- Added basic error handling and logging.
- Refactored existing code to improve modularity and maintainability.
- Included necessary tests to ensure functionality.
2025-04-23 10:54:54 -04:00
6812d05849 feat: Introduce K8sAnywhereTopology and refactor Kubernetes interactions
This commit introduces a new topology, `K8sAnywhereTopology`, designed to handle Kubernetes deployments more flexibly.

Key changes include:

- Introduced `K8sAnywhereTopology` to encapsulate Kubernetes client management and configuration.
- Refactored existing Kubernetes-related code to utilize the new topology.
- Updated `OcK8sclient` to `K8sclient` across modules (k8s, lamp, deployment, resource) for consistency.
- Ensured all relevant modules now interface with Kubernetes through the `K8sclient` trait.

This change promotes a more modular and maintainable codebase for Kubernetes integrations within Harmony.
2025-04-23 10:54:54 -04:00
027114c48c feat: introduce topology readiness and initialization
Adds a `ensure_ready` method to the `Topology` trait to ensure the infrastructure is prepared before score execution.

- Introduces a new `Outcome` status to indicate the result of the readiness check.
- Implements a `topology_preparation_result` field in `Maestro` to track initialization status.
- Adds a check in `interpret` to warn if the topology isn't fully initialized.
- Provides detailed documentation for the `Topology` trait and `ensure_ready` method, including recommended patterns for complex setups.
- Adds `async_trait` dependency.
2025-04-23 10:54:54 -04:00
eeafa086f3 feat: Improve output of tui. From p-r tui-score-info (#11)
WIP: formatted score debug print into a table with a name header and the score information below
Co-authored-by: Jean-Gabriel Gill-Couture <jg@nationtech.io>
Reviewed-on: #11
Reviewed-by: johnride <jg@nationtech.io>
Co-authored-by: Willem <wrolleman@nationtech.io>
Co-committed-by: Willem <wrolleman@nationtech.io>
2025-04-23 14:54:32 +00:00
abd20b96a2 feat: harmony-cli v0.1 #8 (#9)
Co-authored-by: tahahawa <tahahawa@gmail.com>
Reviewed-on: #9
Reviewed-by: johnride <jg@nationtech.io>
Co-authored-by: Taha Hawa <taha@taha.dev>
Co-committed-by: Taha Hawa <taha@taha.dev>
2025-04-19 01:13:40 +00:00
52 changed files with 3735 additions and 512 deletions

1775
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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
]

View File

@@ -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
![Harmony Core Architecture](docs/diagrams/Harmony_Core_Architecture.drawio.svg)
````

View 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
View 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
View 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
View 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();
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 }

View File

@@ -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();
}

View File

@@ -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 :

View File

@@ -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),

View File

@@ -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(),
],
}
}

View File

@@ -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"

View 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");
}

View File

@@ -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;

View File

@@ -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
}

View File

@@ -1,3 +1,4 @@
pub mod config;
pub mod data;
pub mod executors;
pub mod filter;

View File

@@ -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);
}
}

View File

@@ -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";

View File

@@ -0,0 +1 @@
pub trait HelmCommand {}

View File

@@ -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?,

View 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 {}

View File

@@ -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,
}

View 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 {}

View File

@@ -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;

View File

@@ -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]

View File

@@ -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")
}

View 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!()
}
}

View File

@@ -0,0 +1 @@
pub mod chart;

View 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!()
}
}

View File

@@ -0,0 +1,2 @@
mod install;
pub use install::*;

View File

@@ -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!(
{

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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;

View File

@@ -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!()
}
}

View File

@@ -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(),
}
}
}

View File

@@ -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,
{

View File

@@ -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
View 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
View 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);
}
}

View File

@@ -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"

View File

@@ -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");

View File

@@ -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
View 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"

View 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
View 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
}
}

View File

@@ -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,
{

View File

@@ -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";

View File

@@ -1,2 +1,3 @@
[package]
name = "example"
edition = "2024"