diff --git a/Cargo.lock b/Cargo.lock index e19e6f3..529714b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -650,6 +650,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" @@ -1137,6 +1150,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" @@ -1757,7 +1776,6 @@ dependencies = [ "helm-wrapper-rs", "hex", "http 1.3.1", - "inquire", "k3d-rs", "k8s-openapi", "kube", @@ -1765,6 +1783,7 @@ dependencies = [ "libredfish", "log", "non-blank-string-rs", + "once_cell", "opnsense-config", "opnsense-config-xml", "pretty_assertions", @@ -1796,10 +1815,15 @@ version = "0.1.0" dependencies = [ "assert_cmd", "clap", + "console", "env_logger", "harmony", "harmony_tui", + "indicatif", + "indicatif-log-bridge", "inquire", + "lazy_static", + "log", "tokio", ] @@ -1810,10 +1834,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", ] @@ -2409,6 +2439,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" @@ -5181,6 +5234,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/examples/rust/src/main.rs b/examples/rust/src/main.rs index c89a9f5..94eb8a7 100644 --- a/examples/rust/src/main.rs +++ b/examples/rust/src/main.rs @@ -9,10 +9,11 @@ use harmony::{ }, topology::{K8sAnywhereTopology, Url}, }; +use harmony_cli::cli_logger; #[tokio::main] async fn main() { - env_logger::init(); + let cli_logger_handle = cli_logger::init(); let topology = K8sAnywhereTopology::from_env(); let mut maestro = Maestro::initialize(Inventory::autoload(), topology) @@ -40,4 +41,6 @@ async fn main() { maestro.register_all(vec![Box::new(app)]); harmony_cli::init(maestro, None).await.unwrap(); + + let _ = tokio::try_join!(cli_logger_handle); } diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index 87b97ac..e4686ef 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" @@ -62,6 +61,7 @@ serde_with = "3.14.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..a7f2b60 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, info, 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 adbfbec..6719eac 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; @@ -93,9 +92,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(()) } @@ -126,7 +124,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}"); @@ -158,22 +156,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 @@ -186,7 +175,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!(), }; @@ -237,7 +226,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. @@ -256,7 +245,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 3788217..fe98d46 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::*; @@ -36,7 +36,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 @@ -46,7 +46,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..4d71a6e 100644 --- a/harmony_cli/src/lib.rs +++ b/harmony_cli/src/lib.rs @@ -4,8 +4,13 @@ use harmony; use harmony::{score::Score, topology::Topology}; use inquire::Confirm; +pub mod cli_logger; // FIXME: Don't make me pub +pub mod progress; +pub mod theme; + #[cfg(feature = "tui")] use harmony_tui; +use log::debug; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -134,7 +139,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(()) } }