diff --git a/Cargo.lock b/Cargo.lock index 63648ba..71a1a70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -656,6 +656,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.0", + "windows-sys 0.60.2", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1143,6 +1156,12 @@ dependencies = [ "serde", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1775,7 +1794,6 @@ dependencies = [ "helm-wrapper-rs", "hex", "http 1.3.1", - "inquire", "k3d-rs", "k8s-openapi", "kube", @@ -1784,6 +1802,7 @@ dependencies = [ "libredfish", "log", "non-blank-string-rs", + "once_cell", "opnsense-config", "opnsense-config-xml", "pretty_assertions", @@ -1816,10 +1835,15 @@ version = "0.1.0" dependencies = [ "assert_cmd", "clap", + "console", "env_logger", "harmony", "harmony_tui", + "indicatif", + "indicatif-log-bridge", "inquire", + "lazy_static", + "log", "tokio", ] @@ -1830,10 +1854,16 @@ dependencies = [ "bollard", "cargo_metadata", "clap", + "console", "current_platform", "env_logger", "futures-util", + "harmony_cli", + "indicatif", + "indicatif-log-bridge", + "lazy_static", "log", + "once_cell", "serde_json", "tokio", ] @@ -2429,6 +2459,29 @@ dependencies = [ "serde", ] +[[package]] +name = "indicatif" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a646d946d06bedbbc4cac4c218acf4bbf2d87757a784857025f4d447e4e1cd" +dependencies = [ + "console", + "portable-atomic", + "unicode-width 0.2.0", + "unit-prefix", + "web-time", +] + +[[package]] +name = "indicatif-log-bridge" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63703cf9069b85dbe6fe26e1c5230d013dee99d3559cd3d02ba39e099ef7ab02" +dependencies = [ + "indicatif", + "log", +] + [[package]] name = "indoc" version = "2.0.6" @@ -5261,6 +5314,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/README.md b/README.md index b31f541..e77718e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ -# Harmony : Open-source infrastructure orchestration that treats your platform like first-class code. -*By [NationTech](https://nationtech.io)* +# Harmony : Open-source infrastructure orchestration that treats your platform like first-class code + +_By [NationTech](https://nationtech.io)_ [![Build](https://git.nationtech.io/NationTech/harmony/actions/workflows/check.yml/badge.svg)](https://git.nationtech.io/nationtech/harmony) [![License](https://img.shields.io/badge/license-AGPLv3-blue?style=flat-square)](LICENSE) @@ -23,11 +24,11 @@ From a **developer laptop** to a **global production cluster**, a single **sourc Infrastructure is essential, but it shouldn’t be your core business. Harmony is built on three guiding principles that make modern platforms reliable, repeatable, and easy to reason about. -| Principle | What it means for you | -|-----------|-----------------------| -| **Infrastructure as Resilient Code** | Replace sprawling YAML and bash scripts with type-safe Rust. Test, refactor, and version your platform just like application code. | -| **Prove It Works — Before You Deploy** | Harmony uses the compiler to verify that your application’s needs match the target environment’s capabilities at **compile-time**, eliminating an entire class of runtime outages. | -| **One Unified Model** | Software and infrastructure are a single system. Harmony models them together, enabling deep automation—from bare-metal servers to Kubernetes workloads—with zero context switching. | +| Principle | What it means for you | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Infrastructure as Resilient Code** | Replace sprawling YAML and bash scripts with type-safe Rust. Test, refactor, and version your platform just like application code. | +| **Prove It Works — Before You Deploy** | Harmony uses the compiler to verify that your application’s needs match the target environment’s capabilities at **compile-time**, eliminating an entire class of runtime outages. | +| **One Unified Model** | Software and infrastructure are a single system. Harmony models them together, enabling deep automation—from bare-metal servers to Kubernetes workloads—with zero context switching. | These principles surface as simple, ergonomic Rust APIs that let teams focus on their product while trusting the platform underneath. @@ -63,22 +64,20 @@ async fn main() { }, }; - // 2. Pick where it should run - let mut maestro = Maestro::::initialize( - Inventory::autoload(), // auto-detect hardware / kube-config - K8sAnywhereTopology::from_env(), // local k3d, CI, staging, prod… - ) - .await - .unwrap(); - - // 3. Enhance with extra scores (monitoring, CI/CD, …) + // 2. Enhance with extra scores (monitoring, CI/CD, …) let mut monitoring = MonitoringAlertingStackScore::new(); monitoring.namespace = Some(lamp_stack.config.namespace.clone()); - maestro.register_all(vec![Box::new(lamp_stack), Box::new(monitoring)]); - - // 4. Launch an interactive CLI / TUI - harmony_cli::init(maestro, None).await.unwrap(); + // 3. Run your scores on the desired topology & inventory + harmony_cli::run( + Inventory::autoload(), // auto-detect hardware / kube-config + K8sAnywhereTopology::from_env(), // local k3d, CI, staging, prod… + vec![ + Box::new(lamp_stack), + Box::new(monitoring) + ], + None + ).await.unwrap(); } ``` @@ -94,13 +93,13 @@ Harmony analyses the code, shows an execution plan in a TUI, and applies it once ## 3 · Core Concepts -| Term | One-liner | -|------|-----------| -| **Score** | Declarative description of the desired state (e.g., `LAMPScore`). | -| **Interpret** | Imperative logic that realises a `Score` on a specific environment. | -| **Topology** | An environment (local k3d, AWS, bare-metal) exposing verified *Capabilities* (Kubernetes, DNS, …). | -| **Maestro** | Orchestrator that compiles Scores + Topology, ensuring all capabilities line up **at compile-time**. | -| **Inventory** | Optional catalogue of physical assets for bare-metal and edge deployments. | +| Term | One-liner | +| ---------------- | ---------------------------------------------------------------------------------------------------- | +| **Score** | Declarative description of the desired state (e.g., `LAMPScore`). | +| **Interpret** | Imperative logic that realises a `Score` on a specific environment. | +| **Topology** | An environment (local k3d, AWS, bare-metal) exposing verified _Capabilities_ (Kubernetes, DNS, …). | +| **Maestro** | Orchestrator that compiles Scores + Topology, ensuring all capabilities line up **at compile-time**. | +| **Inventory** | Optional catalogue of physical assets for bare-metal and edge deployments. | A visual overview is in the diagram below. @@ -112,9 +111,9 @@ A visual overview is in the diagram below. Prerequisites: -* Rust -* Docker (if you deploy locally) -* `kubectl` / `helm` for Kubernetes-based topologies +- Rust +- Docker (if you deploy locally) +- `kubectl` / `helm` for Kubernetes-based topologies ```bash git clone https://git.nationtech.io/nationtech/harmony @@ -126,15 +125,15 @@ cargo build --release # builds the CLI, TUI and libraries ## 5 · Learning More -* **Architectural Decision Records** – dive into the rationale - - [ADR-001 · Why Rust](adr/001-rust.md) - - [ADR-003 · Infrastructure Abstractions](adr/003-infrastructure-abstractions.md) - - [ADR-006 · Secret Management](adr/006-secret-management.md) +- **Architectural Decision Records** – dive into the rationale + - [ADR-001 · Why Rust](adr/001-rust.md) + - [ADR-003 · Infrastructure Abstractions](adr/003-infrastructure-abstractions.md) + - [ADR-006 · Secret Management](adr/006-secret-management.md) - [ADR-011 · Multi-Tenant Cluster](adr/011-multi-tenant-cluster.md) -* **Extending Harmony** – write new Scores / Interprets, add hardware like OPNsense firewalls, or embed Harmony in your own tooling (`/docs`). +- **Extending Harmony** – write new Scores / Interprets, add hardware like OPNsense firewalls, or embed Harmony in your own tooling (`/docs`). -* **Community** – discussions and roadmap live in [GitLab issues](https://git.nationtech.io/nationtech/harmony/-/issues). PRs, ideas, and feedback are welcome! +- **Community** – discussions and roadmap live in [GitLab issues](https://git.nationtech.io/nationtech/harmony/-/issues). PRs, ideas, and feedback are welcome! --- @@ -148,4 +147,4 @@ See [LICENSE](LICENSE) for the full text. --- -*Made with ❤️ & 🦀 by the NationTech and the Harmony community* +_Made with ❤️ & 🦀 by the NationTech and the Harmony community_ diff --git a/examples/cli/src/main.rs b/examples/cli/src/main.rs index 58674ff..34a032b 100644 --- a/examples/cli/src/main.rs +++ b/examples/cli/src/main.rs @@ -1,20 +1,21 @@ 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(); + harmony_cli::run( + Inventory::autoload(), + LocalhostTopology::new(), + vec![ + Box::new(SuccessScore {}), + Box::new(ErrorScore {}), + Box::new(PanicScore {}), + ], + None, + ) + .await + .unwrap(); } diff --git a/examples/lamp/src/main.rs b/examples/lamp/src/main.rs index 51816e6..6662f4c 100644 --- a/examples/lamp/src/main.rs +++ b/examples/lamp/src/main.rs @@ -1,7 +1,6 @@ use harmony::{ data::Version, inventory::Inventory, - maestro::Maestro, modules::lamp::{LAMPConfig, LAMPScore}, topology::{K8sAnywhereTopology, Url}, }; @@ -43,15 +42,13 @@ async fn main() { // K8sAnywhereTopology as it is the most automatic one that enables you to easily deploy // locally, to development environment from a CI, to staging, and to production with settings // that automatically adapt to each environment grade. - let mut maestro = Maestro::::initialize( + harmony_cli::run( Inventory::autoload(), K8sAnywhereTopology::from_env(), + vec![Box::new(lamp_stack)], + None, ) .await .unwrap(); - - maestro.register_all(vec![Box::new(lamp_stack)]); - // Here we bootstrap the CLI, this gives some nice features if you need them - harmony_cli::init(maestro, None).await.unwrap(); } // That's it, end of the infra as code. diff --git a/examples/monitoring/src/main.rs b/examples/monitoring/src/main.rs index 6d3a5b0..b0a3939 100644 --- a/examples/monitoring/src/main.rs +++ b/examples/monitoring/src/main.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use harmony::{ inventory::Inventory, - maestro::Maestro, modules::{ monitoring::{ alert_channel::discord_alert_channel::DiscordWebhook, @@ -74,13 +73,13 @@ async fn main() { rules: vec![Box::new(additional_rules), Box::new(additional_rules2)], service_monitors: vec![service_monitor], }; - let mut maestro = Maestro::::initialize( + + harmony_cli::run( Inventory::autoload(), K8sAnywhereTopology::from_env(), + vec![Box::new(alerting_score)], + None, ) .await .unwrap(); - - maestro.register_all(vec![Box::new(alerting_score)]); - harmony_cli::init(maestro, None).await.unwrap(); } diff --git a/examples/monitoring_with_tenant/src/main.rs b/examples/monitoring_with_tenant/src/main.rs index 035d071..baa8cd5 100644 --- a/examples/monitoring_with_tenant/src/main.rs +++ b/examples/monitoring_with_tenant/src/main.rs @@ -3,7 +3,6 @@ use std::collections::HashMap; use harmony::{ data::Id, inventory::Inventory, - maestro::Maestro, modules::{ monitoring::{ alert_channel::discord_alert_channel::DiscordWebhook, @@ -78,13 +77,13 @@ async fn main() { rules: vec![Box::new(additional_rules)], service_monitors: vec![service_monitor], }; - let mut maestro = Maestro::::initialize( + + harmony_cli::run( Inventory::autoload(), K8sAnywhereTopology::from_env(), + vec![Box::new(tenant), Box::new(alerting_score)], + None, ) .await .unwrap(); - - maestro.register_all(vec![Box::new(tenant), Box::new(alerting_score)]); - harmony_cli::init(maestro, None).await.unwrap(); } diff --git a/examples/ntfy/src/main.rs b/examples/ntfy/src/main.rs index fc04e6e..3601706 100644 --- a/examples/ntfy/src/main.rs +++ b/examples/ntfy/src/main.rs @@ -1,20 +1,18 @@ use harmony::{ - inventory::Inventory, maestro::Maestro, modules::monitoring::ntfy::ntfy::NtfyScore, - topology::K8sAnywhereTopology, + inventory::Inventory, modules::monitoring::ntfy::ntfy::NtfyScore, topology::K8sAnywhereTopology, }; #[tokio::main] async fn main() { - let mut maestro = Maestro::::initialize( + harmony_cli::run( Inventory::autoload(), K8sAnywhereTopology::from_env(), + vec![Box::new(NtfyScore { + namespace: "monitoring".to_string(), + host: "localhost".to_string(), + })], + None, ) .await .unwrap(); - - maestro.register_all(vec![Box::new(NtfyScore { - namespace: "monitoring".to_string(), - host: "localhost".to_string(), - })]); - harmony_cli::init(maestro, None).await.unwrap(); } diff --git a/examples/rust/src/main.rs b/examples/rust/src/main.rs index 4227683..feb92ef 100644 --- a/examples/rust/src/main.rs +++ b/examples/rust/src/main.rs @@ -2,7 +2,6 @@ use std::{path::PathBuf, sync::Arc}; use harmony::{ inventory::Inventory, - maestro::Maestro, modules::{ application::{ ApplicationScore, RustWebFramework, RustWebapp, @@ -17,13 +16,6 @@ use harmony::{ #[tokio::main] async fn main() { - env_logger::init(); - - let topology = K8sAnywhereTopology::from_env(); - let mut maestro = Maestro::initialize(Inventory::autoload(), topology) - .await - .unwrap(); - let application = Arc::new(RustWebapp { name: "harmony-example-rust-webapp".to_string(), domain: Url::Url(url::Url::parse("https://rustapp.harmony.example.com").unwrap()), @@ -55,6 +47,12 @@ async fn main() { application, }; - maestro.register_all(vec![Box::new(app)]); - harmony_cli::init(maestro, None).await.unwrap(); + harmony_cli::run( + Inventory::autoload(), + K8sAnywhereTopology::from_env(), + vec![Box::new(app)], + None, + ) + .await + .unwrap(); } diff --git a/examples/tenant/src/main.rs b/examples/tenant/src/main.rs index e5ac36c..b5202b2 100644 --- a/examples/tenant/src/main.rs +++ b/examples/tenant/src/main.rs @@ -1,7 +1,6 @@ use harmony::{ data::Id, inventory::Inventory, - maestro::Maestro, modules::tenant::TenantScore, topology::{K8sAnywhereTopology, tenant::TenantConfig}, }; @@ -16,15 +15,14 @@ async fn main() { }, }; - let mut maestro = Maestro::::initialize( + harmony_cli::run( Inventory::autoload(), K8sAnywhereTopology::from_env(), + vec![Box::new(tenant)], + None, ) .await .unwrap(); - - maestro.register_all(vec![Box::new(tenant)]); - harmony_cli::init(maestro, None).await.unwrap(); } // TODO write tests diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index 2cebcbc..3d53bba 100644 --- a/harmony/Cargo.toml +++ b/harmony/Cargo.toml @@ -32,7 +32,6 @@ 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" } @@ -44,12 +43,12 @@ convert_case.workspace = true email_address = "0.2.9" chrono.workspace = true fqdn = { version = "0.4.6", features = [ - "domain-label-cannot-start-or-end-with-hyphen", - "domain-label-length-limited-to-63", - "domain-name-without-special-chars", - "domain-name-length-limited-to-255", - "punycode", - "serde", + "domain-label-cannot-start-or-end-with-hyphen", + "domain-label-length-limited-to-63", + "domain-name-without-special-chars", + "domain-name-length-limited-to-255", + "punycode", + "serde", ] } temp-dir = "0.1.14" dyn-clone = "1.0.19" @@ -64,6 +63,7 @@ kube-derive = "1.1.0" bollard.workspace = true tar.workspace = true base64.workspace = true +once_cell = "1.21.3" [dev-dependencies] pretty_assertions.workspace = true diff --git a/harmony/src/domain/instrumentation.rs b/harmony/src/domain/instrumentation.rs new file mode 100644 index 0000000..3d45722 --- /dev/null +++ b/harmony/src/domain/instrumentation.rs @@ -0,0 +1,63 @@ +use log::debug; +use once_cell::sync::Lazy; +use tokio::sync::broadcast; + +use super::interpret::{InterpretError, Outcome}; + +#[derive(Debug, Clone)] +pub enum HarmonyEvent { + HarmonyStarted, + PrepareTopologyStarted { + topology: String, + }, + TopologyPrepared { + topology: String, + outcome: Outcome, + }, + InterpretExecutionStarted { + topology: String, + interpret: String, + message: String, + }, + InterpretExecutionFinished { + topology: String, + interpret: String, + outcome: Result, + }, +} + +static HARMONY_EVENT_BUS: Lazy> = Lazy::new(|| { + // TODO: Adjust channel capacity + let (tx, _rx) = broadcast::channel(100); + tx +}); + +pub fn instrument(event: HarmonyEvent) -> Result<(), &'static str> { + match HARMONY_EVENT_BUS.send(event) { + Ok(_) => Ok(()), + Err(_) => Err("send error: no subscribers"), + } +} + +pub async fn subscribe(name: &str, mut handler: F) +where + F: FnMut(HarmonyEvent) -> Fut + Send + 'static, + Fut: Future + Send, +{ + let mut rx = HARMONY_EVENT_BUS.subscribe(); + debug!("[{name}] Service started. Listening for events..."); + loop { + match rx.recv().await { + Ok(event) => { + if !handler(event).await { + debug!("[{name}] Handler requested exit."); + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + debug!("[{name}] Lagged behind by {n} messages."); + } + Err(_) => break, + } + } +} diff --git a/harmony/src/domain/maestro/mod.rs b/harmony/src/domain/maestro/mod.rs index 8a90eed..4d1053e 100644 --- a/harmony/src/domain/maestro/mod.rs +++ b/harmony/src/domain/maestro/mod.rs @@ -1,6 +1,8 @@ use std::sync::{Arc, Mutex, RwLock}; -use log::{info, warn}; +use log::{debug, warn}; + +use crate::instrumentation::{self, HarmonyEvent}; use super::{ interpret::{InterpretError, InterpretStatus, Outcome}, @@ -40,13 +42,18 @@ impl 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 { - info!("Ensuring topology '{}' is ready...", self.topology.name()); + instrumentation::instrument(HarmonyEvent::PrepareTopologyStarted { + topology: self.topology.name().to_string(), + }) + .unwrap(); + let outcome = self.topology.ensure_ready().await?; - info!( - "Topology '{}' readiness check complete: {}", - self.topology.name(), - outcome.status - ); + + instrumentation::instrument(HarmonyEvent::TopologyPrepared { + topology: self.topology.name().to_string(), + outcome: outcome.clone(), + }) + .unwrap(); self.topology_preparation_result .lock() @@ -80,11 +87,11 @@ impl Maestro { self.topology.name(), ); } - info!("Running score {score:?}"); + debug!("Running score {score:?}"); let interpret = score.create_interpret(); - info!("Launching interpret {interpret:?}"); + debug!("Launching interpret {interpret:?}"); let result = interpret.execute(&self.inventory, &self.topology).await; - info!("Got result {result:?}"); + debug!("Got result {result:?}"); result } diff --git a/harmony/src/domain/mod.rs b/harmony/src/domain/mod.rs index 349191a..028fa7f 100644 --- a/harmony/src/domain/mod.rs +++ b/harmony/src/domain/mod.rs @@ -3,6 +3,7 @@ pub mod data; pub mod executors; pub mod filter; pub mod hardware; +pub mod instrumentation; pub mod interpret; pub mod inventory; pub mod maestro; diff --git a/harmony/src/domain/topology/k8s_anywhere.rs b/harmony/src/domain/topology/k8s_anywhere.rs index f1aec74..1f95204 100644 --- a/harmony/src/domain/topology/k8s_anywhere.rs +++ b/harmony/src/domain/topology/k8s_anywhere.rs @@ -1,7 +1,6 @@ use std::{process::Command, sync::Arc}; use async_trait::async_trait; -use inquire::Confirm; use log::{debug, info, warn}; use serde::Serialize; use tokio::sync::OnceCell; @@ -10,7 +9,6 @@ use crate::{ executors::ExecutorError, interpret::{InterpretError, InterpretStatus, Outcome}, inventory::Inventory, - maestro::Maestro, modules::{ k3d::K3DInstallationScore, monitoring::kube_prometheus::crd::{ @@ -23,7 +21,6 @@ use crate::{ }, }, score::Score, - topology::LocalhostTopology, }; use super::{ @@ -143,9 +140,8 @@ impl K8sAnywhereTopology { 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()); + debug!("Helm version: {}", version_output.trim()); Ok(()) } @@ -163,9 +159,10 @@ impl K8sAnywhereTopology { } 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?; + self.get_k3d_installation_score() + .create_interpret() + .execute(&Inventory::empty(), self) + .await?; Ok(()) } @@ -175,7 +172,7 @@ impl K8sAnywhereTopology { // TODO this deserves some refactoring, it is becoming a bit hard to figure out // be careful when making modifications here if k8s_anywhere_config.use_local_k3d { - info!("Using local k3d cluster because of use_local_k3d set to true"); + debug!("Using local k3d cluster because of use_local_k3d set to true"); } else { if let Some(kubeconfig) = &k8s_anywhere_config.kubeconfig { debug!("Loading kubeconfig {kubeconfig}"); @@ -207,22 +204,13 @@ impl K8sAnywhereTopology { } if !k8s_anywhere_config.autoinstall { - debug!("Autoinstall confirmation prompt"); - let confirmation = Confirm::new( "Harmony autoinstallation is not activated, do you wish to launch autoinstallation? : ") - .with_default(false) - .prompt() - .expect("Unexpected prompt error"); - debug!("Autoinstall confirmation {confirmation}"); - - if !confirmation { - warn!( - "Installation cancelled, K8sAnywhere could not initialize a valid Kubernetes client" - ); - return Ok(None); - } + warn!( + "Installation cancelled, K8sAnywhere could not initialize a valid Kubernetes client" + ); + return Ok(None); } - info!("Starting K8sAnywhere installation"); + debug!("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 @@ -235,7 +223,7 @@ impl K8sAnywhereTopology { Ok(client) => K8sState { client: Arc::new(K8sClient::new(client)), source: K8sSource::LocalK3d, - message: "Successfully installed K3D cluster and acquired client".to_string(), + message: "K8s client ready".to_string(), }, Err(_) => todo!(), }; @@ -329,7 +317,7 @@ pub struct K8sAnywhereConfig { /// /// When enabled, autoinstall will setup a K3D cluster on the localhost. https://k3d.io/stable/ /// - /// Default: false + /// Default: true pub autoinstall: bool, /// Whether to use local k3d cluster. @@ -348,7 +336,7 @@ impl K8sAnywhereConfig { 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)), + .map_or_else(|_| true, |v| v.parse().ok().unwrap_or(false)), // TODO harmony_profile should be managed at a more core level than this harmony_profile: std::env::var("HARMONY_PROFILE").map_or_else( |_| "dev".to_string(), diff --git a/harmony/src/modules/application/features/continuous_delivery.rs b/harmony/src/modules/application/features/continuous_delivery.rs index 346a749..065f0ee 100644 --- a/harmony/src/modules/application/features/continuous_delivery.rs +++ b/harmony/src/modules/application/features/continuous_delivery.rs @@ -1,7 +1,7 @@ use std::{io::Write, process::Command, sync::Arc}; use async_trait::async_trait; -use log::{error, info}; +use log::{debug, error}; use serde_yaml::Value; use tempfile::NamedTempFile; @@ -63,7 +63,7 @@ impl ContinuousDelivery { error!("TODO hardcoded k3d bin path is wrong"); let k3d_bin_path = (*HARMONY_DATA_DIR).join("k3d").join("k3d"); // --- 1. Import the container image into the k3d cluster --- - info!( + debug!( "Importing image '{}' into k3d cluster 'harmony'", image_name ); @@ -80,7 +80,7 @@ impl ContinuousDelivery { } // --- 2. Get the kubeconfig for the k3d cluster and write it to a temp file --- - info!("Retrieving kubeconfig for k3d cluster 'harmony'"); + debug!("Retrieving kubeconfig for k3d cluster 'harmony'"); let kubeconfig_output = Command::new(&k3d_bin_path) .args(["kubeconfig", "get", "harmony"]) .output() @@ -101,7 +101,7 @@ impl ContinuousDelivery { let kubeconfig_path = temp_kubeconfig.path().to_str().unwrap(); // --- 3. Install or upgrade the Helm chart in the cluster --- - info!( + debug!( "Deploying Helm chart '{}' to namespace '{}'", chart_url, app_name ); @@ -131,7 +131,7 @@ impl ContinuousDelivery { )); } - info!("Successfully deployed '{}' to local k3d cluster.", app_name); + debug!("Successfully deployed '{}' to local k3d cluster.", app_name); Ok(()) } } @@ -145,24 +145,19 @@ impl< async fn ensure_installed(&self, topology: &T) -> Result<(), String> { let image = self.application.image_name(); - // TODO - error!( - "TODO reverse helm chart packaging and docker image build. I put helm package first for faster iterations" - ); - // TODO Write CI/CD workflow files // we can autotedect the CI type using the remote url (default to github action for github // url, etc..) // Or ask for it when unknown let helm_chart = self.application.build_push_helm_package(&image).await?; - info!("Pushed new helm chart {helm_chart}"); + debug!("Pushed new helm chart {helm_chart}"); error!("TODO Make building image configurable/skippable if image already exists (prompt)"); let image = self.application.build_push_oci_image().await?; - info!("Pushed new docker image {image}"); + debug!("Pushed new docker image {image}"); - info!("Installing ContinuousDelivery feature"); + debug!("Installing ContinuousDelivery feature"); // TODO this is a temporary hack for demo purposes, the deployment target should be driven // by the topology only and we should not have to know how to perform tasks like this for // which the topology should be responsible. @@ -182,7 +177,7 @@ impl< .await?; } target => { - info!("Deploying to target {target:?}"); + debug!("Deploying to target {target:?}"); let score = ArgoHelmScore { namespace: "harmonydemo-staging".to_string(), openshift: false, diff --git a/harmony/src/modules/application/mod.rs b/harmony/src/modules/application/mod.rs index 63f3460..ec00834 100644 --- a/harmony/src/modules/application/mod.rs +++ b/harmony/src/modules/application/mod.rs @@ -5,7 +5,7 @@ mod rust; use std::sync::Arc; pub use feature::*; -use log::info; +use log::debug; pub use oci::*; pub use rust::*; @@ -37,7 +37,7 @@ impl Interpret for Application topology: &T, ) -> Result { let app_name = self.application.name(); - info!( + debug!( "Preparing {} features [{}] for application {app_name}", self.features.len(), self.features @@ -47,7 +47,7 @@ impl Interpret for Application .join(", ") ); for feature in self.features.iter() { - info!( + debug!( "Installing feature {} for application {app_name}", feature.name() ); diff --git a/harmony/src/modules/application/rust.rs b/harmony/src/modules/application/rust.rs index ff67754..6fc72cb 100644 --- a/harmony/src/modules/application/rust.rs +++ b/harmony/src/modules/application/rust.rs @@ -10,7 +10,7 @@ use dockerfile_builder::Dockerfile; use dockerfile_builder::instruction::{CMD, COPY, ENV, EXPOSE, FROM, RUN, USER, WORKDIR}; use dockerfile_builder::instruction_builder::CopyBuilder; use futures_util::StreamExt; -use log::{debug, error, info}; +use log::{debug, error, log_enabled}; use serde::Serialize; use tar::Archive; @@ -73,19 +73,19 @@ impl Application for RustWebapp { #[async_trait] impl HelmPackage for RustWebapp { async fn build_push_helm_package(&self, image_url: &str) -> Result { - info!("Starting Helm chart build and push for '{}'", self.name); + debug!("Starting Helm chart build and push for '{}'", self.name); // 1. Create the Helm chart files on disk. let chart_dir = self .create_helm_chart_files(image_url) .map_err(|e| format!("Failed to create Helm chart files: {}", e))?; - info!("Successfully created Helm chart files in {:?}", chart_dir); + debug!("Successfully created Helm chart files in {:?}", chart_dir); // 2. Package the chart into a .tgz archive. let packaged_chart_path = self .package_helm_chart(&chart_dir) .map_err(|e| format!("Failed to package Helm chart: {}", e))?; - info!( + debug!( "Successfully packaged Helm chart: {}", packaged_chart_path.to_string_lossy() ); @@ -94,7 +94,7 @@ impl HelmPackage for RustWebapp { let oci_chart_url = self .push_helm_chart(&packaged_chart_path) .map_err(|e| format!("Failed to push Helm chart: {}", e))?; - info!("Successfully pushed Helm chart to: {}", oci_chart_url); + debug!("Successfully pushed Helm chart to: {}", oci_chart_url); Ok(oci_chart_url) } @@ -107,20 +107,20 @@ impl OCICompliant for RustWebapp { async fn build_push_oci_image(&self) -> Result { // This function orchestrates the build and push process. // It's async to match the trait definition, though the underlying docker commands are blocking. - info!("Starting OCI image build and push for '{}'", self.name); + debug!("Starting OCI image build and push for '{}'", self.name); // 1. Build the image by calling the synchronous helper function. let image_tag = self.image_name(); self.build_docker_image(&image_tag) .await .map_err(|e| format!("Failed to build Docker image: {}", e))?; - info!("Successfully built Docker image: {}", image_tag); + debug!("Successfully built Docker image: {}", image_tag); // 2. Push the image to the registry. self.push_docker_image(&image_tag) .await .map_err(|e| format!("Failed to push Docker image: {}", e))?; - info!("Successfully pushed Docker image to: {}", image_tag); + debug!("Successfully pushed Docker image to: {}", image_tag); Ok(image_tag) } @@ -159,15 +159,17 @@ impl RustWebapp { &self, image_name: &str, ) -> Result> { - info!("Generating Dockerfile for '{}'", self.name); + debug!("Generating Dockerfile for '{}'", self.name); let _dockerfile_path = self.build_dockerfile()?; let docker = Docker::connect_with_socket_defaults().unwrap(); + let quiet = !log_enabled!(log::Level::Debug); + let build_image_options = bollard::query_parameters::BuildImageOptionsBuilder::default() .dockerfile("Dockerfile.harmony") .t(image_name) - .q(false) + .q(quiet) .version(bollard::query_parameters::BuilderVersion::BuilderV1) .platform("linux/x86_64"); @@ -204,7 +206,7 @@ impl RustWebapp { &self, image_tag: &str, ) -> Result> { - info!("Pushing docker image {image_tag}"); + debug!("Pushing docker image {image_tag}"); let docker = Docker::connect_with_socket_defaults().unwrap(); @@ -533,7 +535,7 @@ spec: chart_dir: &PathBuf, ) -> Result> { let chart_dirname = chart_dir.file_name().expect("Should find a chart dirname"); - info!( + debug!( "Launching `helm package {}` cli with CWD {}", chart_dirname.to_string_lossy(), &self @@ -578,7 +580,7 @@ spec: let oci_push_url = format!("oci://{}/{}", *REGISTRY_URL, *REGISTRY_PROJECT); let oci_pull_url = format!("{oci_push_url}/{}-chart", self.name); - info!( + debug!( "Pushing Helm chart {} to {}", packaged_chart_path.to_string_lossy(), oci_push_url diff --git a/harmony/src/modules/k3d/install.rs b/harmony/src/modules/k3d/install.rs index 18b91a0..6441305 100644 --- a/harmony/src/modules/k3d/install.rs +++ b/harmony/src/modules/k3d/install.rs @@ -1,12 +1,13 @@ use std::path::PathBuf; use async_trait::async_trait; -use log::info; +use log::{debug, info}; use serde::Serialize; use crate::{ config::HARMONY_DATA_DIR, data::{Id, Version}, + instrumentation::{self, HarmonyEvent}, interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}, inventory::Inventory, score::Score, @@ -50,22 +51,38 @@ impl Interpret for K3dInstallationInterpret { async fn execute( &self, _inventory: &Inventory, - _topology: &T, + topology: &T, ) -> Result { + instrumentation::instrument(HarmonyEvent::InterpretExecutionStarted { + topology: topology.name().into(), + interpret: "k3d-installation".into(), + message: "installing k3d...".into(), + }) + .unwrap(); + let k3d = k3d_rs::K3d::new( self.score.installation_path.clone(), Some(self.score.cluster_name.clone()), ); - match k3d.ensure_installed().await { + let outcome = match k3d.ensure_installed().await { Ok(_client) => { - let msg = format!("k3d cluster {} is installed ", self.score.cluster_name); - info!("{msg}"); + let msg = format!("k3d cluster '{}' installed ", self.score.cluster_name); + debug!("{msg}"); Ok(Outcome::success(msg)) } Err(msg) => Err(InterpretError::new(format!( - "K3dInstallationInterpret failed to ensure k3d is installed : {msg}" + "failed to ensure k3d is installed : {msg}" ))), - } + }; + + instrumentation::instrument(HarmonyEvent::InterpretExecutionFinished { + topology: topology.name().into(), + interpret: "k3d-installation".into(), + outcome: outcome.clone(), + }) + .unwrap(); + + outcome } fn get_name(&self) -> InterpretName { InterpretName::K3dInstallation diff --git a/harmony_cli/Cargo.toml b/harmony_cli/Cargo.toml index e7d7398..227b39e 100644 --- a/harmony_cli/Cargo.toml +++ b/harmony_cli/Cargo.toml @@ -13,6 +13,11 @@ harmony_tui = { path = "../harmony_tui", optional = true } inquire.workspace = true tokio.workspace = true env_logger.workspace = true +console = "0.16.0" +indicatif = "0.18.0" +lazy_static = "1.5.0" +log.workspace = true +indicatif-log-bridge = "0.2.3" [features] diff --git a/harmony_cli/src/cli_logger.rs b/harmony_cli/src/cli_logger.rs new file mode 100644 index 0000000..ebe8f09 --- /dev/null +++ b/harmony_cli/src/cli_logger.rs @@ -0,0 +1,116 @@ +use harmony::instrumentation::{self, HarmonyEvent}; +use indicatif::{MultiProgress, ProgressBar}; +use indicatif_log_bridge::LogWrapper; +use std::{ + collections::{HashMap, hash_map}, + sync::{Arc, Mutex}, +}; + +use crate::progress; + +pub fn init() -> tokio::task::JoinHandle<()> { + configure_logger(); + let handle = tokio::spawn(handle_events()); + + loop { + if instrumentation::instrument(HarmonyEvent::HarmonyStarted).is_ok() { + break; + } + } + + handle +} + +fn configure_logger() { + let logger = + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).build(); + let level = logger.filter(); + let multi = MultiProgress::new(); + LogWrapper::new(multi.clone(), logger).try_init().unwrap(); + log::set_max_level(level); +} + +async fn handle_events() { + instrumentation::subscribe("Harmony CLI Logger", { + let sections: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + let progress_bars: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + + move |event| { + let sections_clone = Arc::clone(§ions); + let progress_bars_clone = Arc::clone(&progress_bars); + + async move { + let mut sections = sections_clone.lock().unwrap(); + let mut progress_bars = progress_bars_clone.lock().unwrap(); + + match event { + HarmonyEvent::HarmonyStarted => {} + HarmonyEvent::PrepareTopologyStarted { topology: name } => { + let section = progress::new_section(format!( + "{} Preparing environment: {name}...", + crate::theme::EMOJI_TOPOLOGY, + )); + (*sections).insert(name, section); + } + HarmonyEvent::TopologyPrepared { + topology: name, + outcome, + } => { + let section = (*sections).get(&name).unwrap(); + let progress = progress::add_spinner(section, "".into()); + + match outcome.status { + harmony::interpret::InterpretStatus::SUCCESS => { + progress::success(section, Some(progress), outcome.message); + } + harmony::interpret::InterpretStatus::FAILURE => { + progress::error(section, Some(progress), outcome.message); + } + harmony::interpret::InterpretStatus::RUNNING => todo!(), + harmony::interpret::InterpretStatus::QUEUED => todo!(), + harmony::interpret::InterpretStatus::BLOCKED => todo!(), + harmony::interpret::InterpretStatus::NOOP => { + progress::skip(section, Some(progress), outcome.message); + } + } + } + HarmonyEvent::InterpretExecutionStarted { + interpret: name, + topology, + message, + } => { + let section = (*sections).get(&topology).unwrap(); + let progress_bar = progress::add_spinner(section, message); + + (*progress_bars).insert(name, progress_bar); + } + HarmonyEvent::InterpretExecutionFinished { + topology, + interpret: name, + outcome, + } => { + let section = (*sections).get(&topology).unwrap(); + let progress_bar = (*progress_bars).get(&name).cloned(); + + let _ = section.clear(); + + match outcome { + Ok(outcome) => { + progress::success(section, progress_bar, outcome.message); + } + Err(err) => { + progress::error(section, progress_bar, err.to_string()); + } + } + + (*progress_bars).remove(&name); + } + } + true + } + } + }) + .await; +} diff --git a/harmony_cli/src/lib.rs b/harmony_cli/src/lib.rs index 52b59e0..1fea271 100644 --- a/harmony_cli/src/lib.rs +++ b/harmony_cli/src/lib.rs @@ -1,8 +1,14 @@ use clap::Parser; use clap::builder::ArgPredicate; -use harmony; +use harmony::inventory::Inventory; +use harmony::maestro::Maestro; use harmony::{score::Score, topology::Topology}; use inquire::Confirm; +use log::debug; + +pub mod cli_logger; // FIXME: Don't make me pub +pub mod progress; +pub mod theme; #[cfg(feature = "tui")] use harmony_tui; @@ -80,7 +86,24 @@ fn list_scores_with_index(scores_vec: &Vec>>) -> S return display_str; } -pub async fn init( +pub async fn run( + inventory: Inventory, + topology: T, + scores: Vec>>, + args_struct: Option, +) -> Result<(), Box> { + let cli_logger_handle = cli_logger::init(); + + let mut maestro = Maestro::initialize(inventory, topology).await.unwrap(); + maestro.register_all(scores); + + let result = init(maestro, args_struct).await; + + let _ = tokio::try_join!(cli_logger_handle); + result +} + +async fn init( maestro: harmony::maestro::Maestro, args_struct: Option, ) -> Result<(), Box> { @@ -134,7 +157,7 @@ pub async fn init( // Run filtered scores for s in scores_vec { - println!("Running: {}", s.name()); + debug!("Running: {}", s.name()); maestro.interpret(s).await?; } diff --git a/harmony_cli/src/progress.rs b/harmony_cli/src/progress.rs new file mode 100644 index 0000000..4008bc8 --- /dev/null +++ b/harmony_cli/src/progress.rs @@ -0,0 +1,50 @@ +use std::time::Duration; + +use indicatif::{MultiProgress, ProgressBar}; + +pub fn new_section(title: String) -> MultiProgress { + let multi_progress = MultiProgress::new(); + let _ = multi_progress.println(title); + + multi_progress +} + +pub fn add_spinner(multi_progress: &MultiProgress, message: String) -> ProgressBar { + let progress = multi_progress.add(ProgressBar::new_spinner()); + + progress.set_style(crate::theme::SPINNER_STYLE.clone()); + progress.set_message(message); + progress.enable_steady_tick(Duration::from_millis(100)); + + progress +} + +pub fn success(multi_progress: &MultiProgress, progress: Option, message: String) { + if let Some(progress) = progress { + multi_progress.remove(&progress) + } + + let progress = multi_progress.add(ProgressBar::new_spinner()); + progress.set_style(crate::theme::SUCCESS_SPINNER_STYLE.clone()); + progress.finish_with_message(message); +} + +pub fn error(multi_progress: &MultiProgress, progress: Option, message: String) { + if let Some(progress) = progress { + multi_progress.remove(&progress) + } + + let progress = multi_progress.add(ProgressBar::new_spinner()); + progress.set_style(crate::theme::ERROR_SPINNER_STYLE.clone()); + progress.finish_with_message(message); +} + +pub fn skip(multi_progress: &MultiProgress, progress: Option, message: String) { + if let Some(progress) = progress { + multi_progress.remove(&progress) + } + + let progress = multi_progress.add(ProgressBar::new_spinner()); + progress.set_style(crate::theme::SKIP_SPINNER_STYLE.clone()); + progress.finish_with_message(message); +} diff --git a/harmony_cli/src/theme.rs b/harmony_cli/src/theme.rs new file mode 100644 index 0000000..ee25077 --- /dev/null +++ b/harmony_cli/src/theme.rs @@ -0,0 +1,26 @@ +use console::Emoji; +use indicatif::ProgressStyle; +use lazy_static::lazy_static; + +pub static EMOJI_HARMONY: Emoji<'_, '_> = Emoji("🎼", ""); +pub static EMOJI_SUCCESS: Emoji<'_, '_> = Emoji("✅", ""); +pub static EMOJI_SKIP: Emoji<'_, '_> = Emoji("⏭️", ""); +pub static EMOJI_ERROR: Emoji<'_, '_> = Emoji("⚠️", ""); +pub static EMOJI_DEPLOY: Emoji<'_, '_> = Emoji("🚀", ""); +pub static EMOJI_TOPOLOGY: Emoji<'_, '_> = Emoji("📦", ""); + +lazy_static! { + pub static ref SPINNER_STYLE: ProgressStyle = ProgressStyle::default_spinner() + .template(" {spinner:.green} {msg}") + .unwrap() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); + pub static ref SUCCESS_SPINNER_STYLE: ProgressStyle = SPINNER_STYLE + .clone() + .tick_strings(&[format!("{}", EMOJI_SUCCESS).as_str()]); + pub static ref SKIP_SPINNER_STYLE: ProgressStyle = SPINNER_STYLE + .clone() + .tick_strings(&[format!("{}", EMOJI_SKIP).as_str()]); + pub static ref ERROR_SPINNER_STYLE: ProgressStyle = SPINNER_STYLE + .clone() + .tick_strings(&[format!("{}", EMOJI_ERROR).as_str()]); +} diff --git a/harmony_composer/Cargo.toml b/harmony_composer/Cargo.toml index 6d8c5be..df62591 100644 --- a/harmony_composer/Cargo.toml +++ b/harmony_composer/Cargo.toml @@ -15,3 +15,9 @@ current_platform = "0.2.0" futures-util = "0.3.31" serde_json = "1.0.140" cargo_metadata = "0.20.0" +indicatif = "0.18.0" +console = "0.16.0" +lazy_static = "1.5.0" +once_cell = "1.21.3" +harmony_cli = { path = "../harmony_cli" } +indicatif-log-bridge = "0.2.3" diff --git a/harmony_composer/src/harmony_composer_logger.rs b/harmony_composer/src/harmony_composer_logger.rs new file mode 100644 index 0000000..d8003e6 --- /dev/null +++ b/harmony_composer/src/harmony_composer_logger.rs @@ -0,0 +1,112 @@ +use indicatif::{MultiProgress, ProgressBar}; +use indicatif_log_bridge::LogWrapper; +use log::error; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use crate::instrumentation::{self, HarmonyComposerEvent}; + +pub fn init() -> tokio::task::JoinHandle<()> { + configure_logger(); + let handle = tokio::spawn(handle_events()); + + loop { + if instrumentation::instrument(HarmonyComposerEvent::HarmonyComposerStarted).is_ok() { + break; + } + } + + handle +} + +fn configure_logger() { + let logger = + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).build(); + let level = logger.filter(); + let multi = MultiProgress::new(); + LogWrapper::new(multi.clone(), logger).try_init().unwrap(); + log::set_max_level(level); +} + +pub async fn handle_events() { + const PROGRESS_SETUP: &str = "project-initialization"; + const PROGRESS_DEPLOYMENT: &str = "deployment"; + + instrumentation::subscribe("Harmony Composer Logger", { + let progresses: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + let compilation_progress = Arc::new(Mutex::new(None::)); + + move |event| { + let progresses_clone = Arc::clone(&progresses); + let compilation_progress_clone = Arc::clone(&compilation_progress); + + async move { + let mut progresses_guard = progresses_clone.lock().unwrap(); + let mut compilation_progress_guard = compilation_progress_clone.lock().unwrap(); + + match event { + HarmonyComposerEvent::HarmonyComposerStarted => {} + HarmonyComposerEvent::ProjectInitializationStarted => { + let multi_progress = harmony_cli::progress::new_section(format!( + "{} Initializing Harmony project...", + harmony_cli::theme::EMOJI_HARMONY, + )); + (*progresses_guard).insert(PROGRESS_SETUP.to_string(), multi_progress); + } + HarmonyComposerEvent::ProjectInitialized => println!("\n"), + HarmonyComposerEvent::ProjectCompilationStarted { details } => { + let initialization_progress = + (*progresses_guard).get(PROGRESS_SETUP).unwrap(); + let _ = initialization_progress.clear(); + + let progress = + harmony_cli::progress::add_spinner(initialization_progress, details); + *compilation_progress_guard = Some(progress); + } + HarmonyComposerEvent::ProjectCompiled => { + let initialization_progress = + (*progresses_guard).get(PROGRESS_SETUP).unwrap(); + + harmony_cli::progress::success( + initialization_progress, + (*compilation_progress_guard).take(), + "project compiled".to_string(), + ); + } + HarmonyComposerEvent::ProjectCompilationFailed { details } => { + let initialization_progress = + (*progresses_guard).get(PROGRESS_SETUP).unwrap(); + + harmony_cli::progress::error( + initialization_progress, + (*compilation_progress_guard).take(), + "failed to compile project".to_string(), + ); + + error!("{details}"); + } + HarmonyComposerEvent::DeploymentStarted { target } => { + let multi_progress = harmony_cli::progress::new_section(format!( + "{} Starting deployment to {target}...\n\n", + harmony_cli::theme::EMOJI_DEPLOY + )); + (*progresses_guard).insert(PROGRESS_DEPLOYMENT.to_string(), multi_progress); + } + HarmonyComposerEvent::DeploymentCompleted { details } => println!("\n"), + HarmonyComposerEvent::Shutdown => { + for (_, progresses) in (*progresses_guard).iter() { + progresses.clear().unwrap(); + } + + return false; + } + } + true + } + } + }) + .await +} diff --git a/harmony_composer/src/instrumentation.rs b/harmony_composer/src/instrumentation.rs new file mode 100644 index 0000000..f1cdc8f --- /dev/null +++ b/harmony_composer/src/instrumentation.rs @@ -0,0 +1,53 @@ +use log::debug; +use once_cell::sync::Lazy; +use tokio::sync::broadcast; + +#[derive(Debug, Clone)] +pub enum HarmonyComposerEvent { + HarmonyComposerStarted, + ProjectInitializationStarted, + ProjectInitialized, + ProjectCompilationStarted { details: String }, + ProjectCompiled, + ProjectCompilationFailed { details: String }, + DeploymentStarted { target: String }, + DeploymentCompleted { details: String }, + Shutdown, +} + +static HARMONY_COMPOSER_EVENT_BUS: Lazy> = + Lazy::new(|| { + // TODO: Adjust channel capacity + let (tx, _rx) = broadcast::channel(16); + tx + }); + +pub fn instrument(event: HarmonyComposerEvent) -> Result<(), &'static str> { + match HARMONY_COMPOSER_EVENT_BUS.send(event) { + Ok(_) => Ok(()), + Err(_) => Err("send error: no subscribers"), + } +} + +pub async fn subscribe(name: &str, mut handler: F) +where + F: FnMut(HarmonyComposerEvent) -> Fut + Send + 'static, + Fut: Future + Send, +{ + let mut rx = HARMONY_COMPOSER_EVENT_BUS.subscribe(); + debug!("[{name}] Service started. Listening for events..."); + loop { + match rx.recv().await { + Ok(event) => { + if !handler(event).await { + debug!("[{name}] Handler requested exit."); + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + debug!("[{name}] Lagged behind by {n} messages."); + } + Err(_) => break, + } + } +} diff --git a/harmony_composer/src/main.rs b/harmony_composer/src/main.rs index ef2756f..4ccccf6 100644 --- a/harmony_composer/src/main.rs +++ b/harmony_composer/src/main.rs @@ -7,12 +7,16 @@ use bollard::secret::HostConfig; use cargo_metadata::{Artifact, Message, MetadataCommand}; use clap::{Args, Parser, Subcommand}; use futures_util::StreamExt; -use log::info; +use instrumentation::HarmonyComposerEvent; +use log::{debug, info, log_enabled}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use tokio::fs; +mod harmony_composer_logger; +mod instrumentation; + #[derive(Parser)] #[command(version, about, long_about = None, flatten_help = true, propagate_version = true)] struct GlobalArgs { @@ -66,13 +70,15 @@ struct AllArgs { #[tokio::main] async fn main() { - env_logger::init(); + let hc_logger_handle = harmony_composer_logger::init(); let cli_args = GlobalArgs::parse(); let harmony_path = Path::new(&cli_args.harmony_path) .try_exists() .expect("couldn't check if path exists"); + instrumentation::instrument(HarmonyComposerEvent::ProjectInitializationStarted).unwrap(); + let harmony_bin_path: PathBuf = match harmony_path { true => { compile_harmony( @@ -85,6 +91,8 @@ async fn main() { false => todo!("implement autodetect code"), }; + instrumentation::instrument(HarmonyComposerEvent::ProjectInitialized).unwrap(); + match cli_args.command { Some(command) => match command { Commands::Check(args) => { @@ -116,19 +124,31 @@ async fn main() { } Commands::Deploy(args) => { let deploy = if args.staging { + instrumentation::instrument(HarmonyComposerEvent::DeploymentStarted { + target: "staging".to_string(), + }) + .unwrap(); todo!("implement staging deployment") } else if args.prod { + instrumentation::instrument(HarmonyComposerEvent::DeploymentStarted { + target: "prod".to_string(), + }) + .unwrap(); todo!("implement prod deployment") } else { + instrumentation::instrument(HarmonyComposerEvent::DeploymentStarted { + target: "dev".to_string(), + }) + .unwrap(); Command::new(harmony_bin_path).arg("-y").arg("-a").spawn() } .expect("failed to run harmony deploy"); let deploy_output = deploy.wait_with_output().unwrap(); - println!( - "deploy output: {}", - String::from_utf8(deploy_output.stdout).expect("couldn't parse from utf8") - ); + instrumentation::instrument(HarmonyComposerEvent::DeploymentCompleted { + details: String::from_utf8(deploy_output.stdout).unwrap(), + }) + .unwrap(); } Commands::All(_args) => todo!( "take all previous match arms and turn them into separate functions, and call them all one after the other" @@ -137,6 +157,10 @@ async fn main() { }, None => todo!("run interactively, ask for info on CLI"), } + + instrumentation::instrument(HarmonyComposerEvent::Shutdown).unwrap(); + + let _ = tokio::try_join!(hc_logger_handle); } #[derive(Clone, Debug, clap::ValueEnum)] @@ -166,17 +190,32 @@ async fn compile_harmony( Some(m) => m, None => { if cargo_exists { - return compile_cargo(platform, harmony_location).await; + CompileMethod::LocalCargo } else { - return compile_docker(platform, harmony_location).await; + CompileMethod::Docker } } }; - match method { - CompileMethod::LocalCargo => return compile_cargo(platform, harmony_location).await, - CompileMethod::Docker => return compile_docker(platform, harmony_location).await, + let path = match method { + CompileMethod::LocalCargo => { + instrumentation::instrument(HarmonyComposerEvent::ProjectCompilationStarted { + details: "compiling project with cargo".to_string(), + }) + .unwrap(); + compile_cargo(platform, harmony_location).await + } + CompileMethod::Docker => { + instrumentation::instrument(HarmonyComposerEvent::ProjectCompilationStarted { + details: "compiling project with docker".to_string(), + }) + .unwrap(); + compile_docker(platform, harmony_location).await + } }; + + instrumentation::instrument(HarmonyComposerEvent::ProjectCompiled).unwrap(); + path } // TODO: make sure this works with cargo workspaces @@ -186,6 +225,12 @@ async fn compile_cargo(platform: String, harmony_location: String) -> PathBuf { .exec() .unwrap(); + let stderr = if log_enabled!(log::Level::Debug) { + Stdio::inherit() + } else { + Stdio::piped() + }; + let mut cargo_build = Command::new("cargo") .current_dir(&harmony_location) .args(vec![ @@ -195,6 +240,7 @@ async fn compile_cargo(platform: String, harmony_location: String) -> PathBuf { "--message-format=json-render-diagnostics", ]) .stdout(Stdio::piped()) + .stderr(stderr) .spawn() .expect("run cargo command failed"); @@ -210,18 +256,20 @@ async fn compile_cargo(platform: String, harmony_location: String) -> PathBuf { .expect("failed to get root package") .manifest_path { - println!("{:?}", artifact); + debug!("{:?}", artifact); artifacts.push(artifact); } } Message::BuildScriptExecuted(_script) => (), Message::BuildFinished(finished) => { - println!("{:?}", finished); + debug!("{:?}", finished); } _ => (), // Unknown message } } + cargo_build.wait().expect("run cargo command failed"); + let bin = artifacts .last() .expect("no binaries built") @@ -237,7 +285,8 @@ async fn compile_cargo(platform: String, harmony_location: String) -> PathBuf { bin_out = PathBuf::from(format!("{}/harmony", harmony_location)); let _copy_res = fs::copy(&bin, &bin_out).await; } - return bin_out; + + bin_out } async fn compile_docker(platform: String, harmony_location: String) -> PathBuf { diff --git a/k3d/src/lib.rs b/k3d/src/lib.rs index ff63704..99ddacc 100644 --- a/k3d/src/lib.rs +++ b/k3d/src/lib.rs @@ -2,7 +2,7 @@ mod downloadable_asset; use downloadable_asset::*; use kube::Client; -use log::{debug, info, warn}; +use log::{debug, warn}; use std::path::PathBuf; const K3D_BIN_FILE_NAME: &str = "k3d"; @@ -90,7 +90,7 @@ impl K3d { 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:#?}"); + debug!("Foudn K3d binary to install : {release_binary:#?}"); release_binary.download_to_path(self.base_dir.clone()).await } @@ -175,7 +175,7 @@ impl K3d { Err(_) => return Err("Could not get cluster_name, cannot initialize".to_string()), }; - info!("Initializing k3d cluster '{}'", cluster_name); + debug!("Initializing k3d cluster '{}'", cluster_name); self.create_cluster(cluster_name)?; self.create_kubernetes_client().await @@ -205,7 +205,7 @@ impl K3d { /// - `Err(String)` - Error message if any step failed pub async fn ensure_installed(&self) -> Result { if !self.is_installed() { - info!("K3d is not installed, downloading latest release"); + debug!("K3d is not installed, downloading latest release"); self.download_latest_release() .await .map_err(|e| format!("Failed to download k3d: {}", e))?; @@ -216,13 +216,13 @@ impl K3d { } if !self.is_cluster_initialized() { - info!("Cluster is not initialized, initializing now"); + debug!("Cluster is not initialized, initializing now"); return self.initialize_cluster().await; } self.start_cluster().await?; - info!("K3d and cluster are already properly set up"); + debug!("K3d and cluster are already properly set up"); self.create_kubernetes_client().await } @@ -325,12 +325,12 @@ impl K3d { return Err(format!("Failed to create cluster: {}", stderr)); } - info!("Successfully created k3d cluster '{}'", cluster_name); + debug!("Successfully created k3d cluster '{}'", cluster_name); Ok(()) } async fn create_kubernetes_client(&self) -> Result { - 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"); + // TODO: Connect the client to the right k3d cluster (see https://git.nationtech.io/NationTech/harmony/issues/92) Client::try_default() .await .map_err(|e| format!("Failed to create Kubernetes client: {}", e)) @@ -352,7 +352,7 @@ impl K3d { return Err(format!("Failed to start cluster: {}", stderr)); } - info!("Successfully started k3d cluster '{}'", cluster_name); + debug!("Successfully started k3d cluster '{}'", cluster_name); Ok(()) } }