harmony/k3d/src/lib.rs
Ian Letourneau 06aab1f57f
All checks were successful
Run Check Script / check (push) Successful in -37s
Compile and package harmony_composer / package_harmony_composer (push) Successful in 9m6s
fix(cli): reduce noise & better track progress within Harmony (#91)
Introduce a way to instrument what happens within Harmony and around Harmony (e.g. in the CLI or in Composer).

The goal is to provide visual feedback to the end users and inform them of the progress of their tasks (e.g. deployment) as clearly as possible. It is important to also let them know of the outcome of their tasks (what was created, where to access stuff, etc.).

<img src="https://media.discordapp.net/attachments/1295353830300713062/1400289618636574741/demo.gif?ex=688c18d5&is=688ac755&hm=2c70884aacb08f7bd15cbb65a7562a174846906718aa15294bbb238e64febbce&=" />

## Changes

### Instrumentation architecture
Extensibility and ease of use is key here, while preserving type safety as much as possible.

The proposed API is quite simple:
```rs
// Emit an event
instrumentation::instrument(
    HarmonyEvent::TopologyPrepared {
        topology: "k8s-anywhere",
        outcome: Outcome::success("yay")
    }
);

// Consume events
instrumentation::subscribe("Harmony CLI Logger", async |event| {
    match event {
        HarmonyEvent::TopologyPrepared { name, outcome } => todo!(),
    }
});
```

#### Current limitations
* this API is not very extensible, but it could be easily changed to allow end users to define custom events in addition to Harmony core events
* we use a tokio broadcast channel behind the scene so only in process communication can happen, but it could be easily changed to a more flexible communication mechanism as implementation details are hidden

### `harmony_composer` VS `harmony_cli`
As Harmony Composer launches commands from Harmony (CLI), they both live in different processes. And because of this, we cannot easily make all the logging happens in one place (Harmony Composer) and get rid of Harmony CLI. At least not without introducing additional complexity such as communication through a server, unix socket, etc.

So for the time being, it was decided to preserve both `harmony_composer` and `harmony_cli` and let them independently log their stuff and handle their own responsibilities:
* `harmony_composer`: takes care only of setting up & packaging a project, delegates everything else to `harmony_cli`
* `harmony_cli`: takes care of configuring & running Harmony

### Logging & prompts
* [indicatif](https://github.com/console-rs/indicatif) is used to create progress bars and track progress within Harmony, Harmony CLI, and Harmony Composer
* [inquire](https://github.com/mikaelmello/inquire) is preserved, but was removed from `harmony` (core) as UI concerns shouldn't go that deep
  * note: for now the only prompt we had was simply deleted, we'll have to find a better way to prompt stuff in the future

## Todos
* [ ] Update/Create ADRs
* [ ] Continue instrumentation for missing branches
* [ ] Allow instrumentation to emit and subscribe to custom events

Co-authored-by: Ian Letourneau <letourneau.ian@gmail.com>
Reviewed-on: https://git.nationtech.io/NationTech/harmony/pulls/91
Reviewed-by: johnride <jg@nationtech.io>
2025-07-31 19:35:33 +00:00

411 lines
14 KiB
Rust

mod downloadable_asset;
use downloadable_asset::*;
use kube::Client;
use log::{debug, warn};
use std::path::PathBuf;
const K3D_BIN_FILE_NAME: &str = "k3d";
pub struct K3d {
base_dir: PathBuf,
cluster_name: Option<String>,
}
impl K3d {
pub fn new(base_dir: PathBuf, cluster_name: Option<String>) -> Self {
Self {
base_dir,
cluster_name,
}
}
async fn get_binary_for_current_platform(
&self,
latest_release: octocrab::models::repos::Release,
) -> DownloadableAsset {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
debug!("Detecting platform: OS={}, ARCH={}", os, arch);
let binary_pattern = match (os, arch) {
("linux", "x86") => "k3d-linux-386",
("linux", "x86_64") => "k3d-linux-amd64",
("linux", "arm") => "k3d-linux-arm",
("linux", "aarch64") => "k3d-linux-arm64",
("windows", "x86_64") => "k3d-windows-amd64.exe",
("macos", "x86_64") => "k3d-darwin-amd64",
("macos", "aarch64") => "k3d-darwin-arm64",
_ => panic!("Unsupported platform: {}-{}", os, arch),
};
debug!("Looking for binary matching pattern: {}", binary_pattern);
let binary_asset = latest_release
.assets
.iter()
.find(|asset| asset.name == binary_pattern)
.unwrap_or_else(|| panic!("No matching binary found for {}", binary_pattern));
let binary_url = binary_asset.browser_download_url.clone();
let checksums_asset = latest_release
.assets
.iter()
.find(|asset| asset.name == "checksums.txt")
.expect("Checksums file not found in release assets");
let checksums_url = checksums_asset.browser_download_url.clone();
let body = reqwest::get(checksums_url)
.await
.unwrap()
.text()
.await
.unwrap();
println!("body: {body}");
let checksum = body
.lines()
.find_map(|line| {
if line.ends_with(&binary_pattern) {
Some(line.split_whitespace().next().unwrap_or("").to_string())
} else {
None
}
})
.unwrap_or_else(|| panic!("Checksum not found for {}", binary_pattern));
debug!("Found binary at {} with checksum {}", binary_url, checksum);
DownloadableAsset {
url: binary_url,
file_name: K3D_BIN_FILE_NAME.to_string(),
checksum,
}
}
pub async fn download_latest_release(&self) -> Result<PathBuf, String> {
let latest_release = self.get_latest_release_tag().await.unwrap();
let release_binary = self.get_binary_for_current_platform(latest_release).await;
debug!("Foudn K3d binary to install : {release_binary:#?}");
release_binary.download_to_path(self.base_dir.clone()).await
}
// TODO : Make sure this will only find actual released versions, no prereleases or test
// builds
pub async fn get_latest_release_tag(&self) -> Result<octocrab::models::repos::Release, String> {
let octo = octocrab::instance();
let latest_release = octo
.repos("k3d-io", "k3d")
.releases()
.get_latest()
.await
.map_err(|e| e.to_string())?;
// debug!("Got k3d releases {releases:#?}");
println!("Got k3d first releases {latest_release:#?}");
Ok(latest_release)
}
/// Checks if k3d binary exists and is executable
///
/// Verifies that:
/// 1. The k3d binary exists in the base directory
/// 2. It has proper executable permissions (on Unix systems)
/// 3. It responds correctly to a simple command (`k3d --version`)
pub fn is_installed(&self) -> bool {
let binary_path = self.get_k3d_binary_path();
if !binary_path.exists() {
debug!("K3d binary not found at {:?}", binary_path);
return false;
}
if !self.ensure_binary_executable(&binary_path) {
return false;
}
self.can_execute_binary_check(&binary_path)
}
/// Verifies if the specified cluster is already created
///
/// Executes `k3d cluster list <cluster_name>` and checks for a successful response,
/// indicating that the cluster exists and is registered with k3d.
pub fn is_cluster_initialized(&self) -> bool {
let cluster_name = match self.get_cluster_name() {
Ok(name) => name,
Err(_) => {
debug!("Could not get cluster name, can't verify if cluster is initialized");
return false;
}
};
let binary_path = self.base_dir.join(K3D_BIN_FILE_NAME);
if !binary_path.exists() {
return false;
}
self.verify_cluster_exists(&binary_path, cluster_name)
}
fn get_cluster_name(&self) -> Result<&String, String> {
match &self.cluster_name {
Some(name) => Ok(name),
None => Err("No cluster name available".to_string()),
}
}
/// Creates a new k3d cluster with the specified name
///
/// This method:
/// 1. Creates a new k3d cluster using `k3d cluster create <cluster_name>`
/// 2. Waits for the cluster to initialize
/// 3. Returns a configured Kubernetes client connected to the cluster
///
/// # Returns
/// - `Ok(Client)` - Successfully created cluster and connected client
/// - `Err(String)` - Error message detailing what went wrong
pub async fn initialize_cluster(&self) -> Result<Client, String> {
let cluster_name = match self.get_cluster_name() {
Ok(name) => name,
Err(_) => return Err("Could not get cluster_name, cannot initialize".to_string()),
};
debug!("Initializing k3d cluster '{}'", cluster_name);
self.create_cluster(cluster_name)?;
self.create_kubernetes_client().await
}
fn get_k3d_binary_path(&self) -> PathBuf {
self.base_dir.join(K3D_BIN_FILE_NAME)
}
fn get_k3d_binary(&self) -> Result<PathBuf, String> {
let path = self.get_k3d_binary_path();
if !path.exists() {
return Err(format!("K3d binary not found at {:?}", path));
}
Ok(path)
}
/// Ensures k3d is installed and the cluster is initialized
///
/// This method provides a complete setup flow:
/// 1. Checks if k3d is installed, downloads and installs it if needed
/// 2. Verifies if the specified cluster exists, creates it if not
/// 3. Returns a Kubernetes client connected to the cluster
///
/// # Returns
/// - `Ok(Client)` - Successfully ensured k3d and cluster are ready
/// - `Err(String)` - Error message if any step failed
pub async fn ensure_installed(&self) -> Result<Client, String> {
if !self.is_installed() {
debug!("K3d is not installed, downloading latest release");
self.download_latest_release()
.await
.map_err(|e| format!("Failed to download k3d: {}", e))?;
if !self.is_installed() {
return Err("Failed to install k3d properly".to_string());
}
}
if !self.is_cluster_initialized() {
debug!("Cluster is not initialized, initializing now");
return self.initialize_cluster().await;
}
self.start_cluster().await?;
debug!("K3d and cluster are already properly set up");
self.create_kubernetes_client().await
}
// Private helper methods
#[cfg(not(target_os = "windows"))]
fn ensure_binary_executable(&self, binary_path: &PathBuf) -> bool {
use std::os::unix::fs::PermissionsExt;
let mut perms = match std::fs::metadata(binary_path) {
Ok(metadata) => metadata.permissions(),
Err(e) => {
debug!("Failed to get binary metadata: {}", e);
return false;
}
};
perms.set_mode(0o755);
if let Err(e) = std::fs::set_permissions(binary_path, perms) {
debug!("Failed to set executable permissions on k3d binary: {}", e);
return false;
}
true
}
#[cfg(target_os = "windows")]
fn ensure_binary_executable(&self, _binary_path: &PathBuf) -> bool {
// Windows doesn't use executable file permissions
true
}
fn can_execute_binary_check(&self, binary_path: &PathBuf) -> bool {
match std::process::Command::new(binary_path)
.arg("--version")
.output()
{
Ok(output) => {
if output.status.success() {
debug!("K3d binary is installed and working");
true
} else {
debug!("K3d binary check failed: {:?}", output);
false
}
}
Err(e) => {
debug!("Failed to execute K3d binary: {}", e);
false
}
}
}
fn verify_cluster_exists(&self, binary_path: &PathBuf, cluster_name: &str) -> bool {
match std::process::Command::new(binary_path)
.args(["cluster", "list", cluster_name, "--no-headers"])
.output()
{
Ok(output) => {
if output.status.success() && !output.stdout.is_empty() {
debug!("Cluster '{}' is initialized", cluster_name);
true
} else {
debug!("Cluster '{}' is not initialized", cluster_name);
false
}
}
Err(e) => {
debug!("Failed to check cluster initialization: {}", e);
false
}
}
}
pub fn run_k3d_command<I, S>(&self, args: I) -> Result<std::process::Output, String>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let binary_path = self.get_k3d_binary()?;
let output = std::process::Command::new(binary_path).args(args).output();
match output {
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!("stderr : {}", stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
debug!("stdout : {}", stdout);
Ok(output)
}
Err(e) => Err(format!("Failed to execute k3d command: {}", e)),
}
}
fn create_cluster(&self, cluster_name: &str) -> Result<(), String> {
let output = self.run_k3d_command(["cluster", "create", cluster_name])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to create cluster: {}", stderr));
}
debug!("Successfully created k3d cluster '{}'", cluster_name);
Ok(())
}
async fn create_kubernetes_client(&self) -> Result<Client, String> {
// 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))
}
pub async fn get_client(&self) -> Result<Client, String> {
match self.is_cluster_initialized() {
true => Ok(self.create_kubernetes_client().await?),
false => Err("Cannot get client! Cluster not initialized yet".to_string()),
}
}
async fn start_cluster(&self) -> Result<(), String> {
let cluster_name = self.get_cluster_name()?;
let output = self.run_k3d_command(["cluster", "start", cluster_name])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to start cluster: {}", stderr));
}
debug!("Successfully started k3d cluster '{}'", cluster_name);
Ok(())
}
}
#[cfg(test)]
mod test {
use regex::Regex;
use std::path::PathBuf;
use crate::{K3d, K3D_BIN_FILE_NAME};
#[tokio::test]
async fn k3d_latest_release_should_get_latest() {
let dir = get_clean_test_directory();
assert_eq!(dir.join(K3D_BIN_FILE_NAME).exists(), false);
let k3d = K3d::new(dir.clone(), None);
let latest_release = k3d.get_latest_release_tag().await.unwrap();
let tag_regex = Regex::new(r"^v\d+\.\d+\.\d+$").unwrap();
assert!(tag_regex.is_match(&latest_release.tag_name));
assert!(!latest_release.tag_name.is_empty());
}
#[tokio::test]
async fn k3d_download_latest_release_should_get_latest_bin() {
let dir = get_clean_test_directory();
assert_eq!(dir.join(K3D_BIN_FILE_NAME).exists(), false);
let k3d = K3d::new(dir.clone(), None);
let bin_file_path = k3d.download_latest_release().await.unwrap();
assert_eq!(bin_file_path, dir.join(K3D_BIN_FILE_NAME));
assert_eq!(dir.join(K3D_BIN_FILE_NAME).exists(), true);
}
fn get_clean_test_directory() -> PathBuf {
let dir = PathBuf::from("/tmp/harmony-k3d-test-dir");
if dir.exists() {
if let Err(e) = std::fs::remove_dir_all(&dir) {
// TODO sometimes this fails because of the race when running multiple tests at
// once
panic!("Failed to clean up test directory: {}", e);
}
}
if let Err(e) = std::fs::create_dir_all(&dir) {
panic!("Failed to create test directory: {}", e);
}
dir
}
}