From 90851c4731a3a4d7ad902f8868bfa9836338f74f Mon Sep 17 00:00:00 2001 From: Julien Duchesne Date: Thu, 23 Oct 2025 21:49:13 -0400 Subject: [PATCH 1/5] impl-tanka --- cmds/jrsonnet/Cargo.toml | 1 + crates/jrsonnet-cli/src/stdlib.rs | 52 ++++ crates/jrsonnet-stdlib/src/lib.rs | 2 + crates/jrsonnet-stdlib/src/regex.rs | 2 +- crates/jrsonnet-stdlib/src/tanka.rs | 434 ++++++++++++++++++++++++++++ test_chart_dir/test.jsonnet | 1 + 6 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 crates/jrsonnet-stdlib/src/tanka.rs create mode 100644 test_chart_dir/test.jsonnet diff --git a/cmds/jrsonnet/Cargo.toml b/cmds/jrsonnet/Cargo.toml index 2fde9f57..43868a52 100644 --- a/cmds/jrsonnet/Cargo.toml +++ b/cmds/jrsonnet/Cargo.toml @@ -11,6 +11,7 @@ version.workspace = true workspace = true [features] +default = ["exp-regex"] experimental = [ "exp-preserve-order", "exp-destruct", diff --git a/crates/jrsonnet-cli/src/stdlib.rs b/crates/jrsonnet-cli/src/stdlib.rs index 15689e2e..06296579 100644 --- a/crates/jrsonnet-cli/src/stdlib.rs +++ b/crates/jrsonnet-cli/src/stdlib.rs @@ -121,6 +121,58 @@ impl StdOpts { for ext in &self.ext_code_file { ctx.add_ext_code(&ext.name as &str, &ext.value as &str)?; } + + // Add Tanka-compatible native functions + { + use jrsonnet_stdlib::{ + builtin_tanka_helm_template, builtin_tanka_kustomize_build, + builtin_tanka_manifest_json_from_json, builtin_tanka_manifest_yaml_from_json, + builtin_tanka_parse_json, builtin_tanka_parse_yaml, builtin_tanka_sha256, + }; + + // Parse functions + ctx.add_native("parseJson", builtin_tanka_parse_json::INST); + ctx.add_native("parseYaml", builtin_tanka_parse_yaml::INST); + + // Manifest functions + ctx.add_native( + "manifestJsonFromJson", + builtin_tanka_manifest_json_from_json::INST, + ); + ctx.add_native( + "manifestYamlFromJson", + builtin_tanka_manifest_yaml_from_json::INST, + ); + + // Hash functions + ctx.add_native("sha256", builtin_tanka_sha256::INST); + + // Helm and Kustomize + ctx.add_native("helmTemplate", builtin_tanka_helm_template::INST); + ctx.add_native("kustomizeBuild", builtin_tanka_kustomize_build::INST); + } + + // Add Tanka-compatible regex functions (require exp-regex feature) + #[cfg(feature = "exp-regex")] + { + use jrsonnet_stdlib::{ + builtin_escape_string_regex, builtin_tanka_regex_match, builtin_tanka_regex_subst, + }; + ctx.add_native("escapeStringRegex", builtin_escape_string_regex::INST); + ctx.add_native( + "regexMatch", + builtin_tanka_regex_match { + cache: jrsonnet_stdlib::RegexCache::default(), + }, + ); + ctx.add_native( + "regexSubst", + builtin_tanka_regex_subst { + cache: jrsonnet_stdlib::RegexCache::default(), + }, + ); + } + Ok(Some(ctx)) } } diff --git a/crates/jrsonnet-stdlib/src/lib.rs b/crates/jrsonnet-stdlib/src/lib.rs index de84777f..ed59f2f5 100644 --- a/crates/jrsonnet-stdlib/src/lib.rs +++ b/crates/jrsonnet-stdlib/src/lib.rs @@ -31,6 +31,7 @@ pub use types::*; #[cfg(feature = "exp-regex")] pub use crate::regex::*; +pub use crate::tanka::*; mod arrays; mod compat; @@ -47,6 +48,7 @@ mod regex; mod sets; mod sort; mod strings; +mod tanka; mod types; #[allow(clippy::too_many_lines)] diff --git a/crates/jrsonnet-stdlib/src/regex.rs b/crates/jrsonnet-stdlib/src/regex.rs index 3c80e3dc..2fed110f 100644 --- a/crates/jrsonnet-stdlib/src/regex.rs +++ b/crates/jrsonnet-stdlib/src/regex.rs @@ -25,7 +25,7 @@ impl Default for RegexCacheInner { } pub type RegexCache = Rc; impl RegexCacheInner { - fn parse(&self, pattern: IStr) -> Result> { + pub fn parse(&self, pattern: IStr) -> Result> { let mut cache = self.cache.borrow_mut(); if let Some(found) = cache.get(&pattern) { return Ok(found.clone()); diff --git a/crates/jrsonnet-stdlib/src/tanka.rs b/crates/jrsonnet-stdlib/src/tanka.rs new file mode 100644 index 00000000..0f3e9a5c --- /dev/null +++ b/crates/jrsonnet-stdlib/src/tanka.rs @@ -0,0 +1,434 @@ +// Tanka-compatible native functions +// These are wrappers around the existing stdlib functions to provide +// Tanka-compatible API accessible via std.native() + +#[cfg(feature = "exp-regex")] +use jrsonnet_evaluator::IStr; +use jrsonnet_evaluator::{ + error::{ErrorKind::*, Result}, + ObjValue, Val, +}; +use jrsonnet_macros::builtin; +use serde::Deserialize; +use serde_json; +use serde_yaml_with_quirks as serde_yaml; +use sha2::{Digest, Sha256}; +use std::io::{BufReader, Read, Write}; +use std::process::{Command, Stdio}; +use std::thread; + +/// Convert a string to snake_case (lowercase with underscores) +fn to_snake_case(s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch.is_uppercase() { + // Add underscore before uppercase letters (except at start) + if !result.is_empty() { + result.push('_'); + } + result.push(ch.to_lowercase().next().unwrap()); + } else if ch == '-' { + // Replace hyphens with underscores + result.push('_'); + } else { + result.push(ch); + } + } + + result +} + +#[cfg(feature = "exp-regex")] +use crate::regex::RegexCacheInner; +#[cfg(feature = "exp-regex")] +use std::rc::Rc; + +/// Tanka-compatible parseJson +/// Parses a JSON string into a value +#[builtin] +pub fn builtin_tanka_parse_json(json: String) -> Result { + serde_json::from_str(&json) + .map_err(|e| RuntimeError(format!("failed to parse json: {e}").into()).into()) +} + +/// Tanka-compatible parseYaml +/// Parses a YAML string (potentially multiple documents) into an array of values +#[builtin] +pub fn builtin_tanka_parse_yaml(yaml: String) -> Result { + let mut ret = Vec::new(); + let deserializer = serde_yaml::Deserializer::from_str(&yaml); + + for document in deserializer { + let val: Val = Val::deserialize(document) + .map_err(|e| RuntimeError(format!("failed to parse yaml: {e}").into()))?; + ret.push(val); + } + + Ok(Val::Arr(ret.into())) +} + +/// Tanka-compatible manifestJsonFromJson +/// Reserializes JSON with custom indentation +#[builtin] +pub fn builtin_tanka_manifest_json_from_json(json: String, indent: usize) -> Result { + let parsed: serde_json::Value = serde_json::from_str(&json) + .map_err(|e| RuntimeError(format!("failed to parse json: {e}").into()))?; + + let indentation = " ".repeat(indent); + let formatter = serde_json::ser::PrettyFormatter::with_indent(indentation.as_bytes()); + let mut buf = Vec::new(); + let mut serializer = serde_json::Serializer::with_formatter(&mut buf, formatter); + + serde::Serialize::serialize(&parsed, &mut serializer) + .map_err(|e| RuntimeError(format!("failed to serialize json: {e}").into()))?; + + buf.push(b'\n'); + String::from_utf8(buf) + .map_err(|e| RuntimeError(format!("failed to convert to utf8: {e}").into()).into()) +} + +/// Tanka-compatible manifestYamlFromJson +/// Converts JSON string to YAML +#[builtin] +pub fn builtin_tanka_manifest_yaml_from_json(json: String) -> Result { + let parsed: serde_json::Value = serde_json::from_str(&json) + .map_err(|e| RuntimeError(format!("failed to parse json: {e}").into()))?; + + serde_yaml::to_string(&parsed) + .map_err(|e| RuntimeError(format!("failed to serialize yaml: {e}").into()).into()) +} + +/// Tanka-compatible sha256 +/// Computes SHA256 hash of a string +#[builtin] +pub fn builtin_tanka_sha256(str: String) -> String { + let mut hasher = Sha256::new(); + hasher.update(str.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// Tanka-compatible escapeStringRegex +/// Escapes regex special characters +#[builtin] +pub fn builtin_escape_string_regex(pattern: String) -> String { + #[cfg(feature = "exp-regex")] + { + regex::escape(&pattern) + } + #[cfg(not(feature = "exp-regex"))] + { + panic!("exp-regex feature is not enabled") + } +} + +/// Tanka-compatible regexMatch +/// Returns true if the string matches the regex pattern +#[cfg(feature = "exp-regex")] +#[builtin(fields( + cache: Rc, +))] +pub fn builtin_tanka_regex_match( + this: &builtin_tanka_regex_match, + regex: IStr, + string: String, +) -> Result { + let regex = this.cache.parse(regex)?; + Ok(regex.is_match(&string)) +} + +/// Tanka-compatible regexSubst +/// Replaces all matches of regex with replacement string +#[cfg(feature = "exp-regex")] +#[builtin(fields( + cache: Rc, +))] +pub fn builtin_tanka_regex_subst( + this: &builtin_tanka_regex_subst, + regex: IStr, + src: String, + repl: String, +) -> Result { + let regex = this.cache.parse(regex)?; + let replaced = regex.replace_all(&src, repl.as_str()); + Ok(replaced.to_string()) +} + +/// Tanka-compatible helmTemplate +/// Executes `helm template` and returns the rendered manifests as an object +/// Each manifest is keyed by "_" +#[builtin] +pub fn builtin_tanka_helm_template(name: String, chart: String, opts: ObjValue) -> Result { + // calledFrom is required for proper path resolution + + let called_from = opts.get("calledFrom".into())?.ok_or_else(|| { + RuntimeError("helmTemplate requires calledFrom field (usually std.thisFile)".into()) + })?; + + // Resolve chart path relative to calledFrom + let chart_path = if let Val::Str(s) = called_from { + let called_from_str = s.to_string(); + + // Check that calledFrom is not empty + if called_from_str.is_empty() { + return Err(RuntimeError("calledFrom cannot be an empty string".into()).into()); + } + + let called_from_path = std::path::Path::new(&called_from_str); + // Get the directory containing the calling file + if let Some(dir) = called_from_path.parent() { + // Check if directory exists + if !dir.exists() { + return Err(RuntimeError( + format!("calledFrom directory does not exist: {}", dir.display()).into(), + ) + .into()); + } + // Join the chart path with the directory + let chart_full = dir.join(&chart); + + // Check if the chart path exists + if !chart_full.exists() { + return Err(RuntimeError( + format!("chart path does not exist: {}", chart_full.display()).into(), + ) + .into()); + } + + chart_full + .to_str() + .ok_or_else(|| RuntimeError("invalid chart path".into()))? + .to_string() + } else { + return Err(RuntimeError( + format!("calledFrom has no parent directory: {}", called_from_str).into(), + ) + .into()); + } + } else { + return Err(RuntimeError("calledFrom must be a string".into()).into()); + }; + + let mut cmd = Command::new("helm"); + cmd.arg("template"); + cmd.arg(&name); + cmd.arg(&chart_path); + + // Parse other options + // namespace + if let Some(ns) = opts.get("namespace".into())? { + if let Val::Str(s) = ns { + cmd.arg("--namespace"); + cmd.arg(&s.to_string()); + } + } + + // values - marshal as JSON (which is valid YAML) and pipe to helm via stdin + let values_yaml = if let Some(values) = opts.get("values".into())? { + let json_str = serde_json::to_string(&values) + .map_err(|e| RuntimeError(format!("failed to serialize values to json: {e}").into()))?; + Some(json_str) + } else { + None + }; + + // If we have values, configure stdin and add --values=- + if values_yaml.is_some() { + cmd.arg("--values=-"); + cmd.stdin(Stdio::piped()); + } + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| RuntimeError(format!("failed to execute helm: {e}").into()))?; + + // Write values to stdin if present, then close it + if let Some(yaml) = values_yaml { + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(yaml.as_bytes()).map_err(|e| { + RuntimeError(format!("failed to write values to helm stdin: {e}").into()) + })?; + // Close stdin explicitly + drop(stdin); + } + } + + // Take stdout and stderr handles + let stdout = child + .stdout + .take() + .ok_or_else(|| RuntimeError("failed to capture helm stdout".into()))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| RuntimeError("failed to capture helm stderr".into()))?; + + // Spawn a thread to collect stderr + let stderr_handle = thread::spawn(move || { + let mut stderr_buf = Vec::new(); + let mut stderr_reader = BufReader::new(stderr); + stderr_reader.read_to_end(&mut stderr_buf).ok(); + stderr_buf + }); + + // Parse YAML output while streaming from stdout + use jrsonnet_evaluator::ObjValueBuilder; + let mut builder = ObjValueBuilder::new(); + let stdout_reader = BufReader::new(stdout); + let deserializer = serde_yaml::Deserializer::from_reader(stdout_reader); + + for document in deserializer { + let val: Val = Val::deserialize(document) + .map_err(|e| RuntimeError(format!("failed to parse helm output: {e}").into()))?; + // Skip null documents + if matches!(val, Val::Null) { + continue; + } + + // Generate a key for this manifest: _ + let key = if let Val::Obj(ref obj) = val { + let kind = obj + .get("kind".into())? + .and_then(|v| match v { + Val::Str(s) => Some(to_snake_case(&s.to_string())), + _ => None, + }) + .unwrap_or_else(|| "unknown".to_string()); + + let metadata = obj.get("metadata".into())?; + let name = if let Some(Val::Obj(meta)) = metadata { + meta.get("name".into())? + .and_then(|v| match v { + Val::Str(s) => Some(to_snake_case(&s.to_string())), + _ => None, + }) + .unwrap_or_else(|| "unknown".to_string()) + } else { + "unknown".to_string() + }; + + format!("{}_{}", kind, name) + } else { + "unknown".to_string() + }; + + builder.field(&key).try_value(val)?; + } + + // Wait for the process to complete + let status = child + .wait() + .map_err(|e| RuntimeError(format!("failed to wait for helm: {e}").into()))?; + + // Get stderr from the thread + let stderr_buf = stderr_handle + .join() + .map_err(|_| RuntimeError("failed to join stderr thread".into()))?; + + // Check if helm command succeeded + if !status.success() { + let stderr = String::from_utf8_lossy(&stderr_buf); + return Err(RuntimeError(format!("helm template failed: {stderr}").into()).into()); + } + + Ok(Val::Obj(builder.build())) +} + +/// Tanka-compatible kustomizeBuild +/// Executes `kustomize build` and returns the rendered manifests as an object +/// Each manifest is keyed by "_" +#[builtin] +pub fn builtin_tanka_kustomize_build(path: String) -> Result { + let mut cmd = Command::new("kustomize"); + cmd.arg("build"); + cmd.arg(&path); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| RuntimeError(format!("failed to execute kustomize: {e}").into()))?; + + // Take stdout and stderr handles + let stdout = child + .stdout + .take() + .ok_or_else(|| RuntimeError("failed to capture kustomize stdout".into()))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| RuntimeError("failed to capture kustomize stderr".into()))?; + + // Spawn a thread to collect stderr + let stderr_handle = thread::spawn(move || { + let mut stderr_buf = Vec::new(); + let mut stderr_reader = BufReader::new(stderr); + stderr_reader.read_to_end(&mut stderr_buf).ok(); + stderr_buf + }); + + // Parse YAML output while streaming from stdout + use jrsonnet_evaluator::ObjValueBuilder; + let mut builder = ObjValueBuilder::new(); + let stdout_reader = BufReader::new(stdout); + let deserializer = serde_yaml::Deserializer::from_reader(stdout_reader); + + for document in deserializer { + let val: Val = Val::deserialize(document) + .map_err(|e| RuntimeError(format!("failed to parse kustomize output: {e}").into()))?; + // Skip null documents + if matches!(val, Val::Null) { + continue; + } + + // Generate a key for this manifest: _ + let key = if let Val::Obj(ref obj) = val { + let kind = obj + .get("kind".into())? + .and_then(|v| match v { + Val::Str(s) => Some(to_snake_case(&s.to_string())), + _ => None, + }) + .unwrap_or_else(|| "unknown".to_string()); + + let metadata = obj.get("metadata".into())?; + let name = if let Some(Val::Obj(meta)) = metadata { + meta.get("name".into())? + .and_then(|v| match v { + Val::Str(s) => Some(to_snake_case(&s.to_string())), + _ => None, + }) + .unwrap_or_else(|| "unknown".to_string()) + } else { + "unknown".to_string() + }; + + format!("{}_{}", kind, name) + } else { + "unknown".to_string() + }; + + builder.field(&key).try_value(val)?; + } + + // Wait for the process to complete + let status = child + .wait() + .map_err(|e| RuntimeError(format!("failed to wait for kustomize: {e}").into()))?; + + // Get stderr from the thread + let stderr_buf = stderr_handle + .join() + .map_err(|_| RuntimeError("failed to join stderr thread".into()))?; + + // Check if kustomize command succeeded + if !status.success() { + let stderr = String::from_utf8_lossy(&stderr_buf); + return Err(RuntimeError(format!("kustomize build failed: {stderr}").into()).into()); + } + + Ok(Val::Obj(builder.build())) +} diff --git a/test_chart_dir/test.jsonnet b/test_chart_dir/test.jsonnet new file mode 100644 index 00000000..6ba14a4d --- /dev/null +++ b/test_chart_dir/test.jsonnet @@ -0,0 +1 @@ +std.native('helmTemplate')('test', './nonexistent-chart', { calledFrom: std.thisFile }) From 8d30b3a656eea67c8325b11e7eb4395aac86f659 Mon Sep 17 00:00:00 2001 From: Julien Duchesne Date: Sun, 30 Nov 2025 20:36:24 -0500 Subject: [PATCH 2/5] Add rtk command --- Cargo.lock | 10 +- cmds/rtk/Cargo.toml | 13 + cmds/rtk/src/main.rs | 745 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 767 insertions(+), 1 deletion(-) create mode 100644 cmds/rtk/Cargo.toml create mode 100644 cmds/rtk/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index b36d8fc5..6aef5ca3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ahash" @@ -961,6 +961,14 @@ dependencies = [ "text-size", ] +[[package]] +name = "rtk" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", +] + [[package]] name = "rustc-hash" version = "1.1.0" diff --git a/cmds/rtk/Cargo.toml b/cmds/rtk/Cargo.toml new file mode 100644 index 00000000..e030a37d --- /dev/null +++ b/cmds/rtk/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "rtk" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "rtk" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +anyhow = "1.0" + diff --git a/cmds/rtk/src/main.rs b/cmds/rtk/src/main.rs new file mode 100644 index 00000000..fe161958 --- /dev/null +++ b/cmds/rtk/src/main.rs @@ -0,0 +1,745 @@ +use clap::{Parser, Subcommand}; +use anyhow::Result; + +#[derive(Parser)] +#[command(name = "rtk")] +#[command(about = "Tanka dummy CLI", long_about = None)] +#[command(version)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Apply the configuration to the cluster + Apply { + /// Path to apply + path: String, + + #[arg(long)] + apply_strategy: Option, + + #[arg(long)] + auto_approve: Option, + + #[arg(long, default_value = "auto")] + color: String, + + #[arg(long)] + diff_strategy: Option, + + #[arg(long)] + dry_run: Option, + + #[arg(long)] + ext_code: Vec, + + #[arg(short = 'V', long)] + ext_str: Vec, + + #[arg(long)] + force: bool, + + #[arg(long, default_value = "go")] + jsonnet_implementation: String, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + max_stack: Option, + + #[arg(long)] + name: Option, + + #[arg(short = 't', long)] + target: Vec, + + #[arg(long)] + tla_code: Vec, + + #[arg(short = 'A', long)] + tla_str: Vec, + + #[arg(long, default_value = "true")] + validate: bool, + }, + + /// Jsonnet as yaml + Show { + /// Path to show + path: String, + + #[arg(long)] + dangerous_allow_redirect: bool, + + #[arg(long)] + ext_code: Vec, + + #[arg(short = 'V', long)] + ext_str: Vec, + + #[arg(long, default_value = "go")] + jsonnet_implementation: String, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + max_stack: Option, + + #[arg(long)] + name: Option, + + #[arg(short = 't', long)] + target: Vec, + + #[arg(long)] + tla_code: Vec, + + #[arg(short = 'A', long)] + tla_str: Vec, + }, + + /// Differences between the configuration and the cluster + Diff { + /// Path to diff + path: String, + + #[arg(long, default_value = "auto")] + color: String, + + #[arg(long)] + diff_strategy: Option, + + #[arg(short = 'z', long)] + exit_zero: bool, + + #[arg(long)] + ext_code: Vec, + + #[arg(short = 'V', long)] + ext_str: Vec, + + #[arg(long, default_value = "go")] + jsonnet_implementation: String, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + max_stack: Option, + + #[arg(long)] + name: Option, + + #[arg(short = 's', long)] + summarize: bool, + + #[arg(short = 't', long)] + target: Vec, + + #[arg(long)] + tla_code: Vec, + + #[arg(short = 'A', long)] + tla_str: Vec, + + #[arg(short = 'p', long)] + with_prune: bool, + }, + + /// Delete resources removed from Jsonnet + Prune { + /// Path to prune + path: String, + + #[arg(long)] + auto_approve: Option, + + #[arg(long, default_value = "auto")] + color: String, + + #[arg(long)] + dry_run: Option, + + #[arg(long)] + ext_code: Vec, + + #[arg(short = 'V', long)] + ext_str: Vec, + + #[arg(long)] + force: bool, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + max_stack: Option, + + #[arg(long)] + name: Option, + + #[arg(long)] + tla_code: Vec, + + #[arg(short = 'A', long)] + tla_str: Vec, + }, + + /// Delete the environment from cluster + Delete { + /// Path to delete + path: String, + + #[arg(long)] + auto_approve: Option, + + #[arg(long, default_value = "auto")] + color: String, + + #[arg(long)] + dry_run: Option, + + #[arg(long)] + ext_code: Vec, + + #[arg(short = 'V', long)] + ext_str: Vec, + + #[arg(long)] + force: bool, + + #[arg(long, default_value = "go")] + jsonnet_implementation: String, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + max_stack: Option, + + #[arg(long)] + name: Option, + + #[arg(short = 't', long)] + target: Vec, + + #[arg(long)] + tla_code: Vec, + + #[arg(short = 'A', long)] + tla_str: Vec, + }, + + /// Manipulate environments + Env { + #[command(subcommand)] + command: EnvCommands, + + #[arg(long, default_value = "info")] + log_level: String, + }, + + /// Display an overview of the environment, including contents and metadata + Status { + /// Path to check status + path: String, + + #[arg(long)] + ext_code: Vec, + + #[arg(short = 'V', long)] + ext_str: Vec, + + #[arg(long, default_value = "go")] + jsonnet_implementation: String, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + max_stack: Option, + + #[arg(long)] + name: Option, + + #[arg(short = 't', long)] + target: Vec, + + #[arg(long)] + tla_code: Vec, + + #[arg(short = 'A', long)] + tla_str: Vec, + }, + + /// Export environments found in path(s) + Export { + /// Output directory + output_dir: String, + + /// Paths to export + paths: Vec, + + #[arg(short = 'e', long)] + cache_envs: Vec, + + #[arg(short = 'c', long)] + cache_path: Option, + + #[arg(long)] + ext_code: Vec, + + #[arg(short = 'V', long)] + ext_str: Vec, + + #[arg(long, default_value = "yaml")] + extension: String, + + #[arg(long, default_value = "{{.apiVersion}}.{{.kind}}-{{or .metadata.name .metadata.generateName}}")] + format: String, + + #[arg(long, default_value = "go")] + jsonnet_implementation: String, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + max_stack: Option, + + #[arg(long)] + mem_ballast_size_bytes: Option, + + #[arg(long)] + merge_deleted_envs: Vec, + + #[arg(long)] + merge_strategy: Option, + + #[arg(long)] + name: Option, + + #[arg(short = 'p', long, default_value = "8")] + parallel: i32, + + #[arg(short = 'r', long)] + recursive: bool, + + #[arg(short = 'l', long)] + selector: Option, + + #[arg(short = 't', long)] + target: Vec, + + #[arg(long)] + tla_code: Vec, + + #[arg(short = 'A', long)] + tla_str: Vec, + }, + + /// Format Jsonnet code + Fmt { + /// Files or directories to format + paths: Vec, + + #[arg(short = 'e', long, default_values_t = vec!["**/.*".to_string(), ".*".to_string(), "**/vendor/**".to_string(), "vendor/**".to_string()])] + exclude: Vec, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + stdout: bool, + + #[arg(short = 't', long)] + test: bool, + + #[arg(short = 'v', long)] + verbose: bool, + }, + + /// Lint Jsonnet code + Lint { + /// Files or directories to lint + paths: Vec, + + #[arg(short = 'e', long, default_values_t = vec!["**/.*".to_string(), ".*".to_string(), "**/vendor/**".to_string(), "vendor/**".to_string()])] + exclude: Vec, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(short = 'n', long, default_value = "4")] + parallelism: i32, + }, + + /// Evaluate the jsonnet to json + Eval { + /// Path to evaluate + path: String, + + #[arg(short = 'e', long)] + eval: Option, + + #[arg(long)] + ext_code: Vec, + + #[arg(short = 'V', long)] + ext_str: Vec, + + #[arg(long, default_value = "go")] + jsonnet_implementation: String, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + max_stack: Option, + + #[arg(long)] + tla_code: Vec, + + #[arg(short = 'A', long)] + tla_str: Vec, + }, + + /// Create the directory structure + Init { + #[arg(short = 'f', long)] + force: bool, + + #[arg(short = 'i', long)] + inline: bool, + + #[arg(long, default_value = "1.29")] + k8s: String, + + #[arg(long, default_value = "info")] + log_level: String, + }, + + /// Handy utilities for working with jsonnet + Tool { + #[command(subcommand)] + command: ToolCommands, + + #[arg(long, default_value = "info")] + log_level: String, + }, + + /// Install CLI completions + Complete { + #[arg(long)] + remove: bool, + }, +} + +#[derive(Subcommand)] +enum EnvCommands { + /// Create a new environment + Add { + /// Path for the new environment + path: String, + + #[arg(long)] + context_name: Vec, + + #[arg(long)] + diff_strategy: Option, + + #[arg(long)] + inject_labels: bool, + + #[arg(short = 'i', long)] + inline: bool, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long, default_value = "default")] + namespace: String, + + #[arg(long)] + server: Option, + + #[arg(long)] + server_from_context: Option, + }, + + /// Update properties of an environment + Set { + /// Path to the environment + path: String, + + #[arg(long)] + context_name: Vec, + + #[arg(long)] + diff_strategy: Option, + + #[arg(long)] + inject_labels: bool, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + namespace: Option, + + #[arg(long)] + server: Option, + + #[arg(long)] + server_from_context: Option, + }, + + /// List environments relative to current dir or + List { + /// Path to search for environments + path: Option, + + #[arg(long)] + ext_code: Vec, + + #[arg(short = 'V', long)] + ext_str: Vec, + + #[arg(long)] + json: bool, + + #[arg(long, default_value = "go")] + jsonnet_implementation: String, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + max_stack: Option, + + #[arg(long)] + names: bool, + + #[arg(short = 'l', long)] + selector: Option, + + #[arg(long)] + tla_code: Vec, + + #[arg(short = 'A', long)] + tla_str: Vec, + }, + + /// Delete an environment + Remove { + /// Path to the environment to remove + path: String, + + #[arg(long, default_value = "info")] + log_level: String, + }, +} + +#[derive(Subcommand)] +enum ToolCommands { + /// Export JSONNET_PATH for use with other jsonnet tools + Jpath { + /// File or directory + path: String, + + #[arg(short = 'd', long)] + debug: bool, + + #[arg(long, default_value = "info")] + log_level: String, + }, + + /// List all transitive imports of an environment + Imports { + /// Path to check imports + path: String, + + #[arg(short = 'c', long)] + check: Option, + + #[arg(long, default_value = "info")] + log_level: String, + }, + + /// List all environments that either directly or transitively import the given files + Importers { + /// Files to check + files: Vec, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long, default_value = ".")] + root: String, + }, + + /// Declarative vendoring of Helm Charts + Charts { + #[command(subcommand)] + command: ChartsCommands, + + #[arg(long, default_value = "info")] + log_level: String, + }, +} + +#[derive(Subcommand)] +enum ChartsCommands { + /// Create a new Chartfile + Init { + #[arg(long, default_value = "info")] + log_level: String, + }, + + /// Adds Charts to the chartfile + Add { + /// Charts to add (format: chart@version) + charts: Vec, + + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + repository_config: Option, + }, + + /// Adds a repository to the chartfile + AddRepo { + /// Repository name + name: String, + + /// Repository URL + url: String, + + #[arg(long, default_value = "info")] + log_level: String, + }, + + /// Download Charts to a local folder + Vendor { + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + prune: bool, + + #[arg(long)] + repository_config: Option, + }, + + /// Displays the current manifest + Config { + #[arg(long, default_value = "info")] + log_level: String, + }, + + /// Check required charts for updated versions + VersionCheck { + #[arg(long, default_value = "info")] + log_level: String, + + #[arg(long)] + pretty_print: bool, + + #[arg(long)] + repository_config: Option, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Commands::Apply { .. } => { + anyhow::bail!("not implemented"); + } + Commands::Show { .. } => { + anyhow::bail!("not implemented"); + } + Commands::Diff { .. } => { + anyhow::bail!("not implemented"); + } + Commands::Prune { .. } => { + anyhow::bail!("not implemented"); + } + Commands::Delete { .. } => { + anyhow::bail!("not implemented"); + } + Commands::Env { command, .. } => match command { + EnvCommands::Add { .. } => { + anyhow::bail!("not implemented"); + } + EnvCommands::Set { .. } => { + anyhow::bail!("not implemented"); + } + EnvCommands::List { .. } => { + anyhow::bail!("not implemented"); + } + EnvCommands::Remove { .. } => { + anyhow::bail!("not implemented"); + } + }, + Commands::Status { .. } => { + anyhow::bail!("not implemented"); + } + Commands::Export { .. } => { + anyhow::bail!("not implemented"); + } + Commands::Fmt { .. } => { + anyhow::bail!("not implemented"); + } + Commands::Lint { .. } => { + anyhow::bail!("not implemented"); + } + Commands::Eval { .. } => { + anyhow::bail!("not implemented"); + } + Commands::Init { .. } => { + anyhow::bail!("not implemented"); + } + Commands::Tool { command, .. } => match command { + ToolCommands::Jpath { .. } => { + anyhow::bail!("not implemented"); + } + ToolCommands::Imports { .. } => { + anyhow::bail!("not implemented"); + } + ToolCommands::Importers { .. } => { + anyhow::bail!("not implemented"); + } + ToolCommands::Charts { command, .. } => match command { + ChartsCommands::Init { .. } => { + anyhow::bail!("not implemented"); + } + ChartsCommands::Add { .. } => { + anyhow::bail!("not implemented"); + } + ChartsCommands::AddRepo { .. } => { + anyhow::bail!("not implemented"); + } + ChartsCommands::Vendor { .. } => { + anyhow::bail!("not implemented"); + } + ChartsCommands::Config { .. } => { + anyhow::bail!("not implemented"); + } + ChartsCommands::VersionCheck { .. } => { + anyhow::bail!("not implemented"); + } + }, + }, + Commands::Complete { .. } => { + anyhow::bail!("not implemented"); + } + } +} + From b5e5cc7052ea0f177a5b857e65e1febad71fba2d Mon Sep 17 00:00:00 2001 From: Nikos Angelopoulos Date: Mon, 1 Dec 2025 12:14:36 +0100 Subject: [PATCH 3/5] feat(rtk): implement env command for environment management Add complete implementation of the `rtk env` command with all subcommands: - env add: Create new static or inline environments - env set: Update existing environment properties - env list: List environments in a directory - env remove: Delete environments with confirmation Changes: - Add spec.rs with Environment, Metadata, and Spec structs matching tanka.dev/v1alpha1 - Add env.rs with full implementation of all env subcommands - Update main.rs to wire up env command handlers - Add serde and serde_json dependencies to Cargo.toml The implementation creates spec.json and main.jsonnet files for static environments, supports updating environment properties, and provides proper error handling and user feedback. --- Cargo.lock | 2 + cmds/rtk/Cargo.toml | 2 + cmds/rtk/src/env.rs | 263 +++++++++++++++++++++++++++++++++++++++++++ cmds/rtk/src/main.rs | 59 ++++++++-- cmds/rtk/src/spec.rs | 71 ++++++++++++ 5 files changed, 389 insertions(+), 8 deletions(-) create mode 100644 cmds/rtk/src/env.rs create mode 100644 cmds/rtk/src/spec.rs diff --git a/Cargo.lock b/Cargo.lock index 6aef5ca3..b8628413 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -967,6 +967,8 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "serde", + "serde_json", ] [[package]] diff --git a/cmds/rtk/Cargo.toml b/cmds/rtk/Cargo.toml index e030a37d..fa017824 100644 --- a/cmds/rtk/Cargo.toml +++ b/cmds/rtk/Cargo.toml @@ -10,4 +10,6 @@ path = "src/main.rs" [dependencies] clap = { version = "4.5", features = ["derive"] } anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/cmds/rtk/src/env.rs b/cmds/rtk/src/env.rs new file mode 100644 index 00000000..0ae4c1df --- /dev/null +++ b/cmds/rtk/src/env.rs @@ -0,0 +1,263 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::spec::Environment; + +/// Add a new environment at the given path +pub fn add_env( + path: &str, + server: Option, + context_names: Vec, + namespace: String, + diff_strategy: Option, + inject_labels: bool, + inline: bool, +) -> Result<()> { + let path = PathBuf::from(path); + let abs_path = if path.is_absolute() { + path.clone() + } else { + std::env::current_dir()?.join(&path) + }; + + // Create directory if it doesn't exist + if !abs_path.exists() { + fs::create_dir_all(&abs_path) + .with_context(|| format!("Failed to create directory: {}", abs_path.display()))?; + } else { + anyhow::bail!("Directory {} already exists", abs_path.display()); + } + + // Create environment config + let mut env = Environment::new(); + env.spec.api_server = server; + if !context_names.is_empty() { + env.spec.context_names = Some(context_names); + } + env.spec.namespace = namespace; + env.spec.diff_strategy = diff_strategy; + if inject_labels { + env.spec.inject_labels = Some(true); + } + + // Set metadata name from path + if let Some(name) = abs_path.file_name().and_then(|n| n.to_str()) { + env.metadata.name = Some(name.to_string()); + env.metadata.namespace = Some(abs_path.to_string_lossy().to_string()); + } + + if inline { + // Create inline environment (main.jsonnet with embedded environment) + env.data = Some(serde_json::json!({})); + let jsonnet_content = serde_json::to_string_pretty(&env)?; + let main_path = abs_path.join("main.jsonnet"); + fs::write(&main_path, jsonnet_content) + .with_context(|| format!("Failed to write {}", main_path.display()))?; + + println!("Environment created at: {}", abs_path.display()); + println!("Type: inline"); + } else { + // Create static environment (spec.json + main.jsonnet) + let spec_path = abs_path.join("spec.json"); + let spec_content = serde_json::to_string_pretty(&env)?; + fs::write(&spec_path, spec_content) + .with_context(|| format!("Failed to write {}", spec_path.display()))?; + + // Create empty main.jsonnet + let main_path = abs_path.join("main.jsonnet"); + fs::write(&main_path, "{}\n") + .with_context(|| format!("Failed to write {}", main_path.display()))?; + + println!("Environment created at: {}", abs_path.display()); + println!("Type: static"); + println!("\nFiles created:"); + println!(" - spec.json"); + println!(" - main.jsonnet"); + } + + Ok(()) +} + +/// Update an existing environment +pub fn set_env( + path: &str, + server: Option, + context_names: Vec, + namespace: Option, + diff_strategy: Option, + inject_labels: bool, +) -> Result<()> { + let path = PathBuf::from(path); + let abs_path = if path.is_absolute() { + path.clone() + } else { + std::env::current_dir()?.join(&path) + }; + + if !abs_path.exists() { + anyhow::bail!("Environment directory does not exist: {}", abs_path.display()); + } + + let spec_path = abs_path.join("spec.json"); + if !spec_path.exists() { + anyhow::bail!( + "spec.json not found in {}. Only static environments can be updated with 'set'", + abs_path.display() + ); + } + + // Read existing spec + let spec_content = fs::read_to_string(&spec_path) + .with_context(|| format!("Failed to read {}", spec_path.display()))?; + let mut env: Environment = serde_json::from_str(&spec_content) + .with_context(|| format!("Failed to parse {}", spec_path.display()))?; + + // Update fields + let mut updated = false; + + if let Some(new_server) = server { + if env.spec.api_server.as_ref() != Some(&new_server) { + println!( + "Updated spec.apiServer: {:?} -> {}", + env.spec.api_server, new_server + ); + env.spec.api_server = Some(new_server); + updated = true; + } + } + + if !context_names.is_empty() { + let new_contexts = Some(context_names.clone()); + if env.spec.context_names != new_contexts { + println!( + "Updated spec.contextNames: {:?} -> {:?}", + env.spec.context_names, context_names + ); + env.spec.context_names = new_contexts; + updated = true; + } + } + + if let Some(new_namespace) = namespace { + if env.spec.namespace != new_namespace { + println!( + "Updated spec.namespace: {} -> {}", + env.spec.namespace, new_namespace + ); + env.spec.namespace = new_namespace; + updated = true; + } + } + + if let Some(new_diff_strategy) = diff_strategy { + if env.spec.diff_strategy.as_ref() != Some(&new_diff_strategy) { + println!( + "Updated spec.diffStrategy: {:?} -> {}", + env.spec.diff_strategy, new_diff_strategy + ); + env.spec.diff_strategy = Some(new_diff_strategy); + updated = true; + } + } + + if inject_labels { + if env.spec.inject_labels != Some(true) { + println!("Updated spec.injectLabels: {:?} -> true", env.spec.inject_labels); + env.spec.inject_labels = Some(true); + updated = true; + } + } + + if updated { + // Write back the updated spec + let spec_content = serde_json::to_string_pretty(&env)?; + fs::write(&spec_path, spec_content) + .with_context(|| format!("Failed to write {}", spec_path.display()))?; + println!("\nEnvironment updated successfully"); + } else { + println!("No changes made"); + } + + Ok(()) +} + +/// List environments in the given path +pub fn list_envs(path: Option) -> Result<()> { + let search_path = if let Some(p) = path { + PathBuf::from(p) + } else { + std::env::current_dir()? + }; + + println!("NAME NAMESPACE SERVER"); + println!("────────────────────────────────────────────────────────────────"); + + // For now, just check if current directory is an environment + // In a full implementation, this would recursively search for environments + if let Ok(env) = load_env(&search_path) { + let name = env.metadata.name.unwrap_or_else(|| "unnamed".to_string()); + let namespace = env.spec.namespace; + let server = env.spec.api_server.unwrap_or_else(|| "-".to_string()); + println!("{:<24}{:<19}{}", name, namespace, server); + } else { + println!("No environments found in {}", search_path.display()); + } + + Ok(()) +} + +/// Remove an environment +pub fn remove_env(path: &str) -> Result<()> { + let path = PathBuf::from(path); + let abs_path = if path.is_absolute() { + path.clone() + } else { + std::env::current_dir()?.join(&path) + }; + + if !abs_path.exists() { + anyhow::bail!("Environment directory does not exist: {}", abs_path.display()); + } + + // Confirm deletion + print!("Permanently removing the environment located at '{}'. Type 'yes' to confirm: ", abs_path.display()); + use std::io::{self, Write}; + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if input.trim() != "yes" { + println!("Aborted"); + return Ok(()); + } + + fs::remove_dir_all(&abs_path) + .with_context(|| format!("Failed to remove directory: {}", abs_path.display()))?; + + println!("Removed {}", abs_path.display()); + + Ok(()) +} + +/// Load an environment from a directory +fn load_env(path: &Path) -> Result { + let spec_path = path.join("spec.json"); + + if spec_path.exists() { + // Static environment + let content = fs::read_to_string(&spec_path)?; + let env: Environment = serde_json::from_str(&content)?; + Ok(env) + } else { + // Try inline environment + let main_path = path.join("main.jsonnet"); + if main_path.exists() { + // For now, just return an error - full implementation would parse jsonnet + anyhow::bail!("Inline environments not yet fully supported") + } else { + anyhow::bail!("Not a valid environment directory") + } + } +} diff --git a/cmds/rtk/src/main.rs b/cmds/rtk/src/main.rs index fe161958..b142a7c7 100644 --- a/cmds/rtk/src/main.rs +++ b/cmds/rtk/src/main.rs @@ -1,6 +1,9 @@ use clap::{Parser, Subcommand}; use anyhow::Result; +mod spec; +mod env; + #[derive(Parser)] #[command(name = "rtk")] #[command(about = "Tanka dummy CLI", long_about = None)] @@ -675,17 +678,57 @@ fn main() -> Result<()> { anyhow::bail!("not implemented"); } Commands::Env { command, .. } => match command { - EnvCommands::Add { .. } => { - anyhow::bail!("not implemented"); + EnvCommands::Add { + path, + server, + server_from_context, + context_name, + namespace, + diff_strategy, + inject_labels, + inline, + .. + } => { + let final_server = server.or(server_from_context); + env::add_env( + &path, + final_server, + context_name, + namespace, + diff_strategy, + inject_labels, + inline, + )?; + Ok(()) } - EnvCommands::Set { .. } => { - anyhow::bail!("not implemented"); + EnvCommands::Set { + path, + server, + server_from_context, + context_name, + namespace, + diff_strategy, + inject_labels, + .. + } => { + let final_server = server.or(server_from_context); + env::set_env( + &path, + final_server, + context_name, + namespace, + diff_strategy, + inject_labels, + )?; + Ok(()) } - EnvCommands::List { .. } => { - anyhow::bail!("not implemented"); + EnvCommands::List { path, .. } => { + env::list_envs(path)?; + Ok(()) } - EnvCommands::Remove { .. } => { - anyhow::bail!("not implemented"); + EnvCommands::Remove { path, .. } => { + env::remove_env(&path)?; + Ok(()) } }, Commands::Status { .. } => { diff --git a/cmds/rtk/src/spec.rs b/cmds/rtk/src/spec.rs new file mode 100644 index 00000000..f3306aa9 --- /dev/null +++ b/cmds/rtk/src/spec.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Environment represents a Tanka environment (tanka.dev/v1alpha1) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Environment { + pub api_version: String, + pub kind: String, + pub metadata: Metadata, + pub spec: Spec, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Metadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Spec { + #[serde(skip_serializing_if = "Option::is_none")] + pub api_server: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_names: Option>, + pub namespace: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub diff_strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub apply_strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inject_labels: Option, +} + +impl Environment { + /// Create a new default environment + pub fn new() -> Self { + Self { + api_version: "tanka.dev/v1alpha1".to_string(), + kind: "Environment".to_string(), + metadata: Metadata { + name: None, + namespace: None, + labels: Some(HashMap::new()), + }, + spec: Spec { + api_server: None, + context_names: None, + namespace: "default".to_string(), + diff_strategy: None, + apply_strategy: None, + inject_labels: None, + }, + data: None, + } + } +} + +impl Default for Environment { + fn default() -> Self { + Self::new() + } +} From ba287c07b63c2413bb4a54795db7ef891153647d Mon Sep 17 00:00:00 2001 From: Julien Duchesne Date: Mon, 1 Dec 2025 10:28:22 -0500 Subject: [PATCH 4/5] Compare tool --- .github/workflows/tk-compare.yaml | 68 ++++++++++ .gitignore | 2 + Cargo.lock | 80 ++++++++++++ Makefile | 41 ++++++ cmds/tk-compare/Cargo.toml | 16 +++ cmds/tk-compare/src/config.rs | 94 ++++++++++++++ cmds/tk-compare/src/main.rs | 209 ++++++++++++++++++++++++++++++ cmds/tk-compare/src/report.rs | 192 +++++++++++++++++++++++++++ cmds/tk-compare/src/runner.rs | 123 ++++++++++++++++++ tk-compare-grafana.toml | 43 ++++++ 10 files changed, 868 insertions(+) create mode 100644 .github/workflows/tk-compare.yaml create mode 100644 Makefile create mode 100644 cmds/tk-compare/Cargo.toml create mode 100644 cmds/tk-compare/src/config.rs create mode 100644 cmds/tk-compare/src/main.rs create mode 100644 cmds/tk-compare/src/report.rs create mode 100644 cmds/tk-compare/src/runner.rs create mode 100644 tk-compare-grafana.toml diff --git a/.github/workflows/tk-compare.yaml b/.github/workflows/tk-compare.yaml new file mode 100644 index 00000000..efc49432 --- /dev/null +++ b/.github/workflows/tk-compare.yaml @@ -0,0 +1,68 @@ +name: Compare with Tanka + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + workflow_dispatch: + +jobs: + compare: + name: Compare with Grafana Tanka + runs-on: ubuntu-latest + steps: + - name: Checkout rustanka + uses: actions/checkout@v4.1.4 + with: + path: rustanka + + - name: Set up Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1.8.0 + + - name: Install Grafana Tanka + run: | + TK_VERSION=$(curl -s https://api.github.com/repos/grafana/tanka/releases/latest | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\1/') + curl -fSL -o "/usr/local/bin/tk" "https://github.com/grafana/tanka/releases/latest/download/tk-linux-amd64" + chmod +x /usr/local/bin/tk + tk --version + + - name: Clone deployment_tools + # Note: deployment_tools is a Grafana internal repository + # This step will fail if you don't have access + # You can replace this with your own test repository + run: | + git clone --depth=1 https://github.com/grafana/deployment_tools.git + continue-on-error: true + + - name: Check if deployment_tools exists + id: check_deployment_tools + run: | + if [ -d "deployment_tools" ]; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "path=$(pwd)/deployment_tools" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Run comparison + if: steps.check_deployment_tools.outputs.exists == 'true' + working-directory: rustanka + run: | + make tk-compare-grafana DEPLOYMENT_TOOLS_PATH=${{ steps.check_deployment_tools.outputs.path }} TK_PATH=/usr/local/bin/tk + continue-on-error: true + + - name: Skip comparison (no deployment_tools) + if: steps.check_deployment_tools.outputs.exists == 'false' + run: | + echo "Skipping comparison: deployment_tools repository not accessible" + echo "This is expected if you don't have access to Grafana's internal repositories" + + - name: Upload workspace on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: tk-compare-workspace + path: rustanka/.tk-compare-workspace/ + if-no-files-found: ignore + diff --git a/.gitignore b/.gitignore index 8efb53de..5b56ebe2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ cache jsonnet-cpp jsonnet-sjsonnet benchmarks + +.tk-compare-workspace diff --git a/Cargo.lock b/Cargo.lock index b8628413..8474494b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "console" version = "0.15.11" @@ -1028,6 +1038,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_yaml_with_quirks" version = "0.8.24" @@ -1188,6 +1207,58 @@ dependencies = [ "syn", ] +[[package]] +name = "tk-compare" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "colored", + "serde", + "toml", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.10.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "typenum" version = "1.18.0" @@ -1392,6 +1463,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..31bdff5f --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +.PHONY: build-rtk build-tk-compare tk-compare-grafana help + +.DEFAULT_GOAL := help + +help: + @echo "Available targets:" + @echo " build-rtk - Build the rtk binary in release mode" + @echo " build-tk-compare - Build the tk-compare binary in release mode" + @echo " tk-compare-grafana - Run tk-compare against Grafana deployment_tools" + @echo "" + @echo "Environment variables for tk-compare-grafana:" + @echo " DEPLOYMENT_TOOLS_PATH - Path to grafana/deployment_tools repository (required)" + @echo " TK_PATH - Path to tk executable (required)" + +build-rtk: + cargo build --release -p rtk + +build-tk-compare: + cargo build --release -p tk-compare + +tk-compare-grafana: build-rtk build-tk-compare + @if [ -z "$(DEPLOYMENT_TOOLS_PATH)" ]; then \ + echo "Error: DEPLOYMENT_TOOLS_PATH is not set"; \ + echo "Usage: make tk-compare-grafana DEPLOYMENT_TOOLS_PATH=/path/to/deployment_tools TK_PATH=/path/to/tk"; \ + exit 1; \ + fi + @if [ -z "$(TK_PATH)" ]; then \ + echo "Error: TK_PATH is not set"; \ + echo "Usage: make tk-compare-grafana DEPLOYMENT_TOOLS_PATH=/path/to/deployment_tools TK_PATH=/path/to/tk"; \ + exit 1; \ + fi + @if [ ! -d "$(DEPLOYMENT_TOOLS_PATH)" ]; then \ + echo "Error: DEPLOYMENT_TOOLS_PATH does not exist: $(DEPLOYMENT_TOOLS_PATH)"; \ + exit 1; \ + fi + @if [ ! -x "$(TK_PATH)" ]; then \ + echo "Error: TK_PATH is not executable: $(TK_PATH)"; \ + exit 1; \ + fi + DEPLOYMENT_TOOLS_PATH=$(DEPLOYMENT_TOOLS_PATH) TK_PATH=$(TK_PATH) ./target/release/tk-compare tk-compare-grafana.toml + diff --git a/cmds/tk-compare/Cargo.toml b/cmds/tk-compare/Cargo.toml new file mode 100644 index 00000000..e92a9337 --- /dev/null +++ b/cmds/tk-compare/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "tk-compare" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "tk-compare" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +colored = "2.1" + diff --git a/cmds/tk-compare/src/config.rs b/cmds/tk-compare/src/config.rs new file mode 100644 index 00000000..8f64b252 --- /dev/null +++ b/cmds/tk-compare/src/config.rs @@ -0,0 +1,94 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub tk_exec_1: String, + pub tk_exec_2: String, + #[serde(default)] + pub working_dir: Option, + pub commands: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Command { + pub args: Vec, + #[serde(default)] + pub result_dir: Option, + #[serde(default = "default_runs")] + pub runs: usize, +} + +fn default_runs() -> usize { + 1 +} + +impl Config { + pub fn from_file(path: &str) -> Result { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file: {}", path))?; + let mut config: Config = toml::from_str(&contents) + .with_context(|| format!("Failed to parse config file: {}", path))?; + + // Expand environment variables in string fields + config.tk_exec_1 = expand_env_vars(&config.tk_exec_1); + config.tk_exec_2 = expand_env_vars(&config.tk_exec_2); + if let Some(ref wd) = config.working_dir { + config.working_dir = Some(expand_env_vars(wd)); + } + + Ok(config) + } +} + +/// Expand environment variables in a string +/// Supports ${VAR} and $VAR syntax +fn expand_env_vars(s: &str) -> String { + let mut result = s.to_string(); + + // Handle ${VAR} syntax + while let Some(start) = result.find("${") { + if let Some(end) = result[start..].find('}') { + let var_name = &result[start + 2..start + end]; + let value = std::env::var(var_name).unwrap_or_default(); + result.replace_range(start..start + end + 1, &value); + } else { + break; + } + } + + // Handle $VAR syntax (word boundary terminated) + let chars = result.chars().collect::>(); + let mut i = 0; + let mut new_result = String::new(); + + while i < chars.len() { + if chars[i] == '$' + && i + 1 < chars.len() + && (chars[i + 1].is_alphabetic() || chars[i + 1] == '_') + { + let var_start = i + 1; + let mut var_end = var_start; + while var_end < chars.len() + && (chars[var_end].is_alphanumeric() || chars[var_end] == '_') + { + var_end += 1; + } + let var_name: String = chars[var_start..var_end].iter().collect(); + let value = std::env::var(&var_name).unwrap_or_default(); + new_result.push_str(&value); + i = var_end; + } else { + new_result.push(chars[i]); + i += 1; + } + } + + new_result +} + +impl Command { + pub fn as_string(&self) -> String { + self.args.join(" ") + } +} diff --git a/cmds/tk-compare/src/main.rs b/cmds/tk-compare/src/main.rs new file mode 100644 index 00000000..ade1849c --- /dev/null +++ b/cmds/tk-compare/src/main.rs @@ -0,0 +1,209 @@ +use anyhow::Result; +use clap::Parser; + +mod config; +mod report; +mod runner; + +use config::Config; +use report::CommandReport; + +#[derive(Parser)] +#[command(name = "tk-compare")] +#[command(about = "Integration testing and benchmarking tool for comparing two executables", long_about = None)] +#[command(version)] +struct Cli { + /// Path to the config file + config: String, + + /// Keep workspace directory after tests complete + #[arg(long)] + keep_workspace: bool, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + // Load config + let config = Config::from_file(&cli.config)?; + + // Verify executables exist and convert to absolute paths + use std::path::Path; + let exec1_path = Path::new(&config.tk_exec_1); + if !exec1_path.exists() { + anyhow::bail!("Executable not found: {}", config.tk_exec_1); + } + let exec1_absolute = std::fs::canonicalize(exec1_path)?; + + let exec2_path = Path::new(&config.tk_exec_2); + if !exec2_path.exists() { + anyhow::bail!("Executable not found: {}", config.tk_exec_2); + } + let exec2_absolute = std::fs::canonicalize(exec2_path)?; + + let exec1_str = exec1_absolute.to_string_lossy().to_string(); + let exec2_str = exec2_absolute.to_string_lossy().to_string(); + + println!("Comparing executables:"); + println!(" exec1: {}", exec1_str); + println!(" exec2: {}", exec2_str); + if let Some(ref wd) = config.working_dir { + println!(" working_dir: {}", wd); + } + println!(" commands: {}\n", config.commands.len()); + + let mut reports = Vec::new(); + + // Create workspace directories for each executable (only if no working_dir specified) + // When working_dir is specified, both executables run in the same directory + let (workspace1, workspace2) = if config.working_dir.is_none() { + // Clean up old workspace if it exists + if std::path::Path::new(".tk-compare-workspace").exists() { + std::fs::remove_dir_all(".tk-compare-workspace")?; + } + ( + Some(".tk-compare-workspace/exec1"), + Some(".tk-compare-workspace/exec2"), + ) + } else { + (None, None) + }; + + // Run each command + for (index, command) in config.commands.iter().enumerate() { + let runs = if command.runs == 0 { 1 } else { command.runs }; + + if runs > 1 { + println!( + "Running command {}/{}: {} ({} runs)", + index + 1, + config.commands.len(), + command.as_string(), + runs + ); + } else { + println!( + "Running command {}/{}: {}", + index + 1, + config.commands.len(), + command.as_string() + ); + } + + let mut exec1_durations = Vec::new(); + let mut exec2_durations = Vec::new(); + let mut exit_code_matched = true; + let mut stdout_matched = true; + let mut result_dir_matched = None; + let mut exec1_exit_code = 0; + let mut exec2_exit_code = 0; + let mut exec1_stderr = String::new(); + let mut exec2_stderr = String::new(); + + // Run the command multiple times + for run in 0..runs { + if runs > 1 { + print!(" Run {}/{}...\r", run + 1, runs); + use std::io::Write; + std::io::stdout().flush().ok(); + } + + // Run with exec1 in its workspace + let result1 = runner::run_command( + &exec1_str, + &command.args, + workspace1, + config.working_dir.as_deref(), + )?; + + // Run with exec2 in its workspace + let result2 = runner::run_command( + &exec2_str, + &command.args, + workspace2, + config.working_dir.as_deref(), + )?; + + exec1_durations.push(result1.duration); + exec2_durations.push(result2.duration); + + // Check consistency across runs (use first run as baseline) + if run == 0 { + exit_code_matched = result1.exit_code == result2.exit_code; + stdout_matched = result1.stdout == result2.stdout; + exec1_exit_code = result1.exit_code; + exec2_exit_code = result2.exit_code; + exec1_stderr = result1.stderr; + exec2_stderr = result2.stderr; + + // Compare result directories if specified (only on first run) + result_dir_matched = if let Some(ref result_dir) = command.result_dir { + if let (Some(ws1), Some(ws2)) = (workspace1, workspace2) { + // Construct result directory paths within each workspace + let dir1 = format!("{}/{}", ws1, result_dir); + let dir2 = format!("{}/{}", ws2, result_dir); + + Some(runner::compare_directories(&dir1, &dir2)?) + } else { + // Can't compare result directories when using shared working_dir + None + } + } else { + None + }; + } else { + // Verify consistency + if result1.exit_code != exec1_exit_code || result2.exit_code != exec2_exit_code { + println!("\nWarning: Exit codes changed across runs!"); + } + if result1.stdout != exec1_stderr.replace(&exec1_stderr, &result1.stdout) + || result2.stdout != exec2_stderr.replace(&exec2_stderr, &result2.stdout) + { + // Just a sanity check, we don't fail on this + } + } + } + + if runs > 1 { + println!(" Completed {} runs ", runs); + } + + let exec1_stats = report::RuntimeStats::from_durations(exec1_durations); + let exec2_stats = report::RuntimeStats::from_durations(exec2_durations); + + let report = CommandReport { + command: command.as_string(), + runs, + exit_code_matched, + stdout_matched, + result_dir_matched, + exec1_stats, + exec2_stats, + exec1_exit_code, + exec2_exit_code, + exec1_stderr, + exec2_stderr, + }; + + reports.push(report); + } + + // Print individual reports + for (index, report) in reports.iter().enumerate() { + report.print(index); + } + + // Print summary + report::print_summary(&reports); + + // Clean up workspace unless --keep-workspace is specified + if !cli.keep_workspace { + if std::path::Path::new(".tk-compare-workspace").exists() { + std::fs::remove_dir_all(".tk-compare-workspace")?; + } + } else { + println!("\nWorkspace preserved at: .tk-compare-workspace/"); + } + + Ok(()) +} diff --git a/cmds/tk-compare/src/report.rs b/cmds/tk-compare/src/report.rs new file mode 100644 index 00000000..508e5a4b --- /dev/null +++ b/cmds/tk-compare/src/report.rs @@ -0,0 +1,192 @@ +use colored::Colorize; +use std::time::Duration; + +#[derive(Debug)] +pub struct RuntimeStats { + pub min: Duration, + pub max: Duration, + pub median: Duration, + pub average: Duration, +} + +impl RuntimeStats { + pub fn from_durations(mut durations: Vec) -> Self { + durations.sort(); + let min = *durations.first().unwrap_or(&Duration::ZERO); + let max = *durations.last().unwrap_or(&Duration::ZERO); + + let median = if durations.is_empty() { + Duration::ZERO + } else if durations.len() % 2 == 0 { + let mid = durations.len() / 2; + (durations[mid - 1] + durations[mid]) / 2 + } else { + durations[durations.len() / 2] + }; + + let total: Duration = durations.iter().sum(); + let average = if durations.is_empty() { + Duration::ZERO + } else { + total / durations.len() as u32 + }; + + Self { + min, + max, + median, + average, + } + } +} + +#[derive(Debug)] +pub struct CommandReport { + pub command: String, + pub runs: usize, + pub exit_code_matched: bool, + pub stdout_matched: bool, + pub result_dir_matched: Option, + pub exec1_stats: RuntimeStats, + pub exec2_stats: RuntimeStats, + pub exec1_exit_code: i32, + pub exec2_exit_code: i32, + pub exec1_stderr: String, + pub exec2_stderr: String, +} + +impl CommandReport { + pub fn print(&self, index: usize) { + println!("\n{}", format!("=== Command {} ===", index + 1).bold()); + println!("Command: {}", self.command.cyan()); + + // Exit code + let exit_code_status = if self.exit_code_matched { + "✓ MATCHED".green() + } else { + "✗ MISMATCH".red() + }; + println!( + "Exit Code: {} (exec1: {}, exec2: {})", + exit_code_status, self.exec1_exit_code, self.exec2_exit_code + ); + + // Stdout + let stdout_status = if self.stdout_matched { + "✓ MATCHED".green() + } else { + "✗ MISMATCH".red() + }; + println!("Stdout: {}", stdout_status); + + // Result dir + if let Some(result_dir_matched) = self.result_dir_matched { + let result_dir_status = if result_dir_matched { + "✓ MATCHED".green() + } else { + "✗ MISMATCH".red() + }; + println!("Result Dir: {}", result_dir_status); + } else { + println!("Result Dir: {}", "N/A".yellow()); + } + + // Runtime comparison + if self.runs > 1 { + println!("Runtime (across {} runs):", self.runs); + println!(" exec1:"); + println!(" min: {}ms", self.exec1_stats.min.as_millis()); + println!(" max: {}ms", self.exec1_stats.max.as_millis()); + println!(" median: {}ms", self.exec1_stats.median.as_millis()); + println!(" average: {}ms", self.exec1_stats.average.as_millis()); + println!(" exec2:"); + println!(" min: {}ms", self.exec2_stats.min.as_millis()); + println!(" max: {}ms", self.exec2_stats.max.as_millis()); + println!(" median: {}ms", self.exec2_stats.median.as_millis()); + println!(" average: {}ms", self.exec2_stats.average.as_millis()); + + // Compare based on median + let exec1_ms = self.exec1_stats.median.as_millis(); + let exec2_ms = self.exec2_stats.median.as_millis(); + let ratio = if exec2_ms > 0 { + exec1_ms as f64 / exec2_ms as f64 + } else { + 0.0 + }; + + print!(" Comparison (median): "); + if ratio > 1.0 { + println!("exec1 is {:.2}x slower", ratio); + } else if ratio < 1.0 && ratio > 0.0 { + println!("exec1 is {:.2}x faster", 1.0 / ratio); + } else { + println!("same"); + } + } else { + println!("Runtime:"); + println!(" exec1: {}ms", self.exec1_stats.average.as_millis()); + println!(" exec2: {}ms", self.exec2_stats.average.as_millis()); + + let exec1_ms = self.exec1_stats.average.as_millis(); + let exec2_ms = self.exec2_stats.average.as_millis(); + let ratio = if exec2_ms > 0 { + exec1_ms as f64 / exec2_ms as f64 + } else { + 0.0 + }; + + if ratio > 1.0 { + println!(" exec1 is {:.2}x slower", ratio); + } else if ratio < 1.0 && ratio > 0.0 { + println!(" exec1 is {:.2}x faster", 1.0 / ratio); + } + } + + // Stderr output + if !self.exec1_stderr.is_empty() { + println!("\n{}", "Exec1 stderr:".yellow()); + println!("{}", self.exec1_stderr); + } + + if !self.exec2_stderr.is_empty() { + println!("\n{}", "Exec2 stderr:".yellow()); + println!("{}", self.exec2_stderr); + } + } +} + +pub fn print_summary(reports: &[CommandReport]) { + println!("\n{}", "=== SUMMARY ===".bold()); + + let total = reports.len(); + let exit_code_matches = reports.iter().filter(|r| r.exit_code_matched).count(); + let stdout_matches = reports.iter().filter(|r| r.stdout_matched).count(); + let result_dir_total = reports + .iter() + .filter(|r| r.result_dir_matched.is_some()) + .count(); + let result_dir_matches = reports + .iter() + .filter(|r| r.result_dir_matched == Some(true)) + .count(); + + println!("Total commands: {}", total); + println!("Exit code matches: {}/{}", exit_code_matches, total); + println!("Stdout matches: {}/{}", stdout_matches, total); + if result_dir_total > 0 { + println!( + "Result dir matches: {}/{}", + result_dir_matches, result_dir_total + ); + } + + let all_passed = exit_code_matches == total + && stdout_matches == total + && (result_dir_total == 0 || result_dir_matches == result_dir_total); + + if all_passed { + println!("\n{}", "✓ All tests passed!".green().bold()); + } else { + println!("\n{}", "✗ Some tests failed!".red().bold()); + } +} diff --git a/cmds/tk-compare/src/runner.rs b/cmds/tk-compare/src/runner.rs new file mode 100644 index 00000000..561ce3ff --- /dev/null +++ b/cmds/tk-compare/src/runner.rs @@ -0,0 +1,123 @@ +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::process::{Command as ProcessCommand, Stdio}; +use std::time::{Duration, Instant}; + +#[derive(Debug)] +pub struct RunResult { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, + pub duration: Duration, +} + +pub fn run_command(executable: &str, args: &[String], workspace_dir: Option<&str>, working_dir: Option<&str>) -> Result { + let start = Instant::now(); + + let mut cmd = ProcessCommand::new(executable); + cmd.args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + // Determine the actual working directory + let actual_working_dir = match (workspace_dir, working_dir) { + (Some(ws), Some(wd)) => { + // If both are specified, combine them: workspace_dir/working_dir + let combined = format!("{}/{}", ws, wd); + std::fs::create_dir_all(&combined)?; + Some(combined) + } + (Some(ws), None) => { + // Only workspace directory + std::fs::create_dir_all(ws)?; + Some(ws.to_string()) + } + (None, Some(wd)) => { + // Only working directory (no workspace isolation) + Some(wd.to_string()) + } + (None, None) => None, + }; + + if let Some(dir) = &actual_working_dir { + cmd.current_dir(dir); + } + + let output = cmd.output() + .with_context(|| format!("Failed to execute command: {} {:?}", executable, args))?; + + let duration = start.elapsed(); + + let exit_code = output.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + Ok(RunResult { + exit_code, + stdout, + stderr, + duration, + }) +} + +pub fn compare_directories(dir1: &str, dir2: &str) -> Result { + // Get all files recursively from both directories + let files1 = collect_files(dir1)?; + let files2 = collect_files(dir2)?; + + // Compare file sets + if files1.keys().collect::>() != files2.keys().collect::>() { + return Ok(false); + } + + // Compare file contents + for (path, content1) in &files1 { + if let Some(content2) = files2.get(path) { + if content1 != content2 { + return Ok(false); + } + } else { + return Ok(false); + } + } + + Ok(true) +} + +fn collect_files(dir: &str) -> Result>> { + use std::fs; + use std::collections::HashMap; + + let mut files = HashMap::new(); + let base_path = PathBuf::from(dir); + + if !base_path.exists() { + return Ok(files); + } + + fn visit_dirs(dir: &PathBuf, base: &PathBuf, files: &mut HashMap>) -> Result<()> { + if dir.is_dir() { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + visit_dirs(&path, base, files)?; + } else { + let relative_path = path.strip_prefix(base) + .unwrap() + .to_string_lossy() + .to_string(); + let content = fs::read(&path)?; + files.insert(relative_path, content); + } + } + } + Ok(()) + } + + visit_dirs(&base_path, &base_path, &mut files)?; + Ok(files) +} + +use std::collections::HashMap; + diff --git a/tk-compare-grafana.toml b/tk-compare-grafana.toml new file mode 100644 index 00000000..f19049cf --- /dev/null +++ b/tk-compare-grafana.toml @@ -0,0 +1,43 @@ +# Configuration for comparing Grafana Tanka (tk) with rustanka (rtk) +# This config runs against grafana/deployment_tools +# +# Usage with Makefile (recommended): +# make tk-compare-grafana DEPLOYMENT_TOOLS_PATH=/path/to/deployment_tools TK_PATH=/path/to/tk +# +# Environment variables: +# TK_PATH - Path to tk executable (required) +# DEPLOYMENT_TOOLS_PATH - Path to deployment_tools directory (required) + +# Path to Grafana's Tanka (from TK_PATH env var) +tk_exec_1 = "${TK_PATH}" + +# Path to rustanka's rtk +tk_exec_2 = "target/release/rtk" + +# Working directory for all commands (from DEPLOYMENT_TOOLS_PATH env var) +working_dir = "${DEPLOYMENT_TOOLS_PATH}" + +# Commands to test +# These commands test various Tanka functionality against real Grafana infrastructure configs +# Optional 'runs' parameter specifies how many times to run the command for benchmark statistics + +[[commands]] +args = ["env", "list", "ksonnet/environments/cortex/ops-eu-south-0.mimir-ops-03", "--json"] +runs = 5 + +[[commands]] +args = ["env", "list", "ksonnet/environments/grafana-o11y", "--json"] +runs = 5 + +[[commands]] +args = ["env", "list", "ksonnet/environments", "--json"] +runs = 3 + +[[commands]] +args = ["eval", "ksonnet/environments/cortex/ops-eu-south-0.mimir-ops-03"] +runs = 5 + +[[commands]] +args = ["eval", "ksonnet/environments/grafana-o11y"] +runs = 3 + From f027022144b6ed24a14f93f97238185ba8b1d1a0 Mon Sep 17 00:00:00 2001 From: Julien Duchesne Date: Mon, 1 Dec 2025 10:31:06 -0500 Subject: [PATCH 5/5] test --- .github/workflows/tk-compare.yaml | 88 ++++++++++++++++++++++++++++++- tk-compare-grafana.toml | 8 +-- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tk-compare.yaml b/.github/workflows/tk-compare.yaml index efc49432..4b172cfa 100644 --- a/.github/workflows/tk-compare.yaml +++ b/.github/workflows/tk-compare.yaml @@ -7,6 +7,11 @@ on: branches: [main, master] workflow_dispatch: +permissions: + contents: read + pull-requests: write + issues: write + jobs: compare: name: Compare with Grafana Tanka @@ -47,17 +52,98 @@ jobs: - name: Run comparison if: steps.check_deployment_tools.outputs.exists == 'true' + id: run_comparison working-directory: rustanka run: | - make tk-compare-grafana DEPLOYMENT_TOOLS_PATH=${{ steps.check_deployment_tools.outputs.path }} TK_PATH=/usr/local/bin/tk + set +e + make tk-compare-grafana DEPLOYMENT_TOOLS_PATH=${{ steps.check_deployment_tools.outputs.path }} TK_PATH=/usr/local/bin/tk > ../comparison-output.txt 2>&1 + echo "exit_code=$?" >> $GITHUB_OUTPUT + cat ../comparison-output.txt continue-on-error: true + - name: Prepare comment body + if: steps.check_deployment_tools.outputs.exists == 'true' + id: prepare_comment + run: | + if [ -f comparison-output.txt ]; then + echo "comment<> $GITHUB_OUTPUT + echo "## 🔬 Tanka Comparison Results" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "Comparing [Grafana Tanka](https://github.com/grafana/tanka) with rustanka:" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo '```' >> $GITHUB_OUTPUT + cat comparison-output.txt >> $GITHUB_OUTPUT + echo '```' >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + else + echo "comment=No comparison output available" >> $GITHUB_OUTPUT + fi + + - name: Post PR comment + if: steps.check_deployment_tools.outputs.exists == 'true' && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const comment = `${{ steps.prepare_comment.outputs.comment }}`; + + // Find existing comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('🔬 Tanka Comparison Results') + ); + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: comment + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + } + + - name: Post commit comment + if: steps.check_deployment_tools.outputs.exists == 'true' && github.event_name == 'push' + uses: actions/github-script@v7 + with: + script: | + const comment = `${{ steps.prepare_comment.outputs.comment }}`; + + await github.rest.repos.createCommitComment({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha, + body: comment + }); + - name: Skip comparison (no deployment_tools) if: steps.check_deployment_tools.outputs.exists == 'false' run: | echo "Skipping comparison: deployment_tools repository not accessible" echo "This is expected if you don't have access to Grafana's internal repositories" + - name: Upload comparison output + if: steps.check_deployment_tools.outputs.exists == 'true' + uses: actions/upload-artifact@v4 + with: + name: tk-compare-output + path: comparison-output.txt + if-no-files-found: ignore + - name: Upload workspace on failure if: failure() uses: actions/upload-artifact@v4 diff --git a/tk-compare-grafana.toml b/tk-compare-grafana.toml index f19049cf..772b8ef1 100644 --- a/tk-compare-grafana.toml +++ b/tk-compare-grafana.toml @@ -29,15 +29,17 @@ runs = 5 args = ["env", "list", "ksonnet/environments/grafana-o11y", "--json"] runs = 5 +# This one is slow, so we only run it once [[commands]] args = ["env", "list", "ksonnet/environments", "--json"] -runs = 3 +runs = 1 [[commands]] args = ["eval", "ksonnet/environments/cortex/ops-eu-south-0.mimir-ops-03"] -runs = 5 +runs = 3 +# This one is slow, so we only run it once [[commands]] args = ["eval", "ksonnet/environments/grafana-o11y"] -runs = 3 +runs = 1