Compare commits

..

37 Commits

Author SHA1 Message Date
39208c5e86 Merge branch 'master' into better-cli
All checks were successful
Run Check Script / check (pull_request) Successful in -44s
2025-07-31 13:24:34 +00:00
Ian Letourneau
6b36b1c7e9 rename some event attributes
All checks were successful
Run Check Script / check (pull_request) Successful in -38s
2025-07-31 09:24:05 -04:00
1ab66af718 Merge pull request 'refactor(topo/k8s_anywhere): simplify local installation of k3d' (#90) from simply-k3d-installation into master
Some checks failed
Run Check Script / check (push) Failing after -1m14s
Compile and package harmony_composer / package_harmony_composer (push) Successful in 3m23s
Reviewed-on: #90
Reviewed-by: wjro <wrolleman@nationtech.io>
2025-07-31 13:22:25 +00:00
Ian Letourneau
507556969a add k3d todo
All checks were successful
Run Check Script / check (pull_request) Successful in -35s
2025-07-31 08:05:06 -04:00
Ian Letourneau
68fde23f2c remove unused inquire dependency for Harmony
All checks were successful
Run Check Script / check (pull_request) Successful in -36s
2025-07-30 21:42:09 -04:00
Ian Letourneau
49f1e56599 add event to track progress of interprets, change a bunch of info! to debug!
All checks were successful
Run Check Script / check (pull_request) Successful in -34s
2025-07-30 21:34:27 -04:00
Ian Letourneau
ff7801a7c1 ensure event handlers are properly subscribed on init, extract duplicated progress functions, cleanup duplication
All checks were successful
Run Check Script / check (pull_request) Successful in -31s
2025-07-30 12:22:04 -04:00
Ian Letourneau
8fae9cf8c8 split instrumentation in 2 different places: harmony domain (for domain observability) & harmoy composer (for build/commands observability)
All checks were successful
Run Check Script / check (pull_request) Successful in -34s
2025-07-27 20:52:24 -04:00
Ian Letourneau
6f7e1640c1 fix(cli): reduce noise & better track progress within Harmony
All checks were successful
Run Check Script / check (pull_request) Successful in -35s
2025-07-27 17:41:43 -04:00
Ian Letourneau
0fff4ef566 refactor(topo/k8s_anywhere): simplify local installation of k3d
All checks were successful
Run Check Script / check (pull_request) Successful in -37s
A Maestro was initialized with a new inventory simply to provide a
localhost topology to install K3D locally. But in practice, the K3D
installation wasn't actually using the topology nor the inventory.

Directly installing K3D within the K8s Anywhere topology makes things
simpler and actually enforce the topology to provide the capabilities
required to install K3D.
2025-07-27 11:50:48 -04:00
d95e84d6fc Merge pull request 'fix(apps/rust): build & push using image tag instead of local VS remote image name' (#87) from fix-image-tag into master
All checks were successful
Run Check Script / check (push) Successful in -37s
Compile and package harmony_composer / package_harmony_composer (push) Successful in 13m2s
Reviewed-on: #87
Reviewed-by: johnride <jg@nationtech.io>
2025-07-27 14:10:19 +00:00
a47be890de Merge branch 'master' into fix-image-tag
All checks were successful
Run Check Script / check (pull_request) Successful in -38s
2025-07-27 14:09:24 +00:00
ee8dfa4a93 Merge pull request 'chore: cleanup of unnecessary files & adjust gitignores' (#88) from quick-cleanup into master
Some checks failed
Run Check Script / check (push) Successful in -37s
Compile and package harmony_composer / package_harmony_composer (push) Has been cancelled
Reviewed-on: #88
Reviewed-by: johnride <jg@nationtech.io>
2025-07-27 14:08:57 +00:00
5d41cc8380 Merge branch 'master' into quick-cleanup
All checks were successful
Run Check Script / check (pull_request) Successful in -34s
2025-07-27 14:07:55 +00:00
cef745b642 Merge pull request 'log(composer): Log check_path_str value when error' (#77) from log/composer into master
All checks were successful
Run Check Script / check (push) Successful in -31s
Compile and package harmony_composer / package_harmony_composer (push) Successful in 11m59s
Reviewed-on: #77
2025-07-21 18:04:57 +00:00
d9959378a6 log(composer): Log check_path_str value when error
All checks were successful
Run Check Script / check (pull_request) Successful in -37s
2025-07-21 09:15:41 -04:00
Ian Letourneau
07f1151e4c chore: cleanup of unncessary files & adjust gitignores
All checks were successful
Run Check Script / check (pull_request) Successful in -31s
2025-07-20 20:03:26 -04:00
Ian Letourneau
f7625f0484 fix(rust): push only the actual image tag
All checks were successful
Run Check Script / check (pull_request) Successful in -22s
2025-07-16 13:51:02 -04:00
tahahawa
537da5800f uncomment docker image build
All checks were successful
Run Check Script / check (push) Successful in 2m49s
Compile and package harmony_composer / package_harmony_composer (push) Successful in 5m20s
2025-07-11 10:34:37 -04:00
3be2fa246c fix: unjank the demo (#85)
Some checks failed
Run Check Script / check (push) Has been cancelled
Compile and package harmony_composer / package_harmony_composer (push) Has been cancelled
Co-authored-by: tahahawa <tahahawa@gmail.com>
Reviewed-on: #85
Reviewed-by: wjro <wrolleman@nationtech.io>
2025-07-11 14:32:16 +00:00
9452cf5616 Merge pull request 'fix/argoApplication' (#84) from fix/argoApplication into master
All checks were successful
Run Check Script / check (push) Successful in 1m41s
Compile and package harmony_composer / package_harmony_composer (push) Successful in 4m4s
Reviewed-on: #84
2025-07-05 01:19:05 +00:00
9b7456e148 Merge pull request 'feat/monitoring-application-feature' (#83) from feat/monitoring-application-feature into master
Some checks are pending
Compile and package harmony_composer / package_harmony_composer (push) Has started running
Run Check Script / check (push) Successful in 2s
Reviewed-on: #83
Reviewed-by: johnride <jg@nationtech.io>
2025-07-05 01:16:08 +00:00
98f3f82ad5 refact: Rename HttpScore into StaticFileHttpScore and add minimal documentation
All checks were successful
Run Check Script / check (pull_request) Successful in 1m43s
2025-07-04 21:05:32 -04:00
3eca409f8d Merge remote-tracking branch 'origin/feat/monitoring-application-feature' into fix/argoApplication 2025-07-04 16:44:03 -04:00
c11a31c7a9 wip: Fix ArgoApplication 2025-07-04 16:43:10 -04:00
1a6d72dc17 fix: uncommented example
All checks were successful
Run Check Script / check (pull_request) Successful in 1m37s
2025-07-04 16:30:13 -04:00
df9e21807e fix: git conflict
All checks were successful
Run Check Script / check (pull_request) Successful in -6s
2025-07-04 16:22:39 -04:00
b1bf4fd4d5 fix: cargo fmt
All checks were successful
Run Check Script / check (pull_request) Successful in 1m40s
2025-07-04 16:14:47 -04:00
f702ecd8c9 fix: deploys a lighter weight prometheus and grafana which is limited to their respective namespaces 2025-07-04 16:13:41 -04:00
a19b52e690 fix: properly append YAML in correct places in argoapplication (#80)
All checks were successful
Run Check Script / check (push) Successful in -7s
Compile and package harmony_composer / package_harmony_composer (push) Successful in 3m56s
Co-authored-by: tahahawa <tahahawa@gmail.com>
Reviewed-on: #80
2025-07-04 15:32:02 +00:00
b73f2e76d0 Merge pull request 'refact: Make RustWebappScore generic, it is now Application score and takes an application and list of features to attach to the application' (#81) from refact/application into master
All checks were successful
Run Check Script / check (push) Successful in -1s
Compile and package harmony_composer / package_harmony_composer (push) Successful in 3m38s
Reviewed-on: #81
Reviewed-by: wjro <wrolleman@nationtech.io>
2025-07-04 14:31:38 +00:00
b4534c6ee0 refact: Make RustWebappScore generic, it is now Application score and takes an application and list of features to attach to the application
All checks were successful
Run Check Script / check (pull_request) Successful in -8s
2025-07-04 10:27:16 -04:00
6149249a6c feat: create Argo interpret and kube client apply_yaml to install Argo Applications. Very messy implementation though, must be refactored/improved
All checks were successful
Run Check Script / check (push) Successful in -5s
Compile and package harmony_composer / package_harmony_composer (push) Successful in 4m13s
2025-07-04 09:49:43 -04:00
d9935e20cb Merge pull request 'feat: harmony now defaults to using local k3d cluster. Also created OCICompliant: Application trait to make building images cleaner' (#76) from feat/oci into master
All checks were successful
Run Check Script / check (push) Successful in -9s
Compile and package harmony_composer / package_harmony_composer (push) Successful in 4m4s
Reviewed-on: #76
2025-07-03 19:37:46 +00:00
7b0f3b79b1 Merge remote-tracking branch 'origin/master' into feat/oci
All checks were successful
Run Check Script / check (pull_request) Successful in -8s
2025-07-03 15:36:52 -04:00
e6612245a5 Merge pull request 'feat/cd/localdeploymentdemo' (#79) from feat/cd/localdeploymentdemo into feat/oci
All checks were successful
Run Check Script / check (pull_request) Successful in -9s
Reviewed-on: #79
2025-07-03 19:31:45 +00:00
b4f5b91a57 feat: WIP argocd_score (#78)
Some checks are pending
Compile and package harmony_composer / package_harmony_composer (push) Waiting to run
Run Check Script / check (push) Successful in -8s
Co-authored-by: tahahawa <tahahawa@gmail.com>
Reviewed-on: #78
Reviewed-by: johnride <jg@nationtech.io>
Co-authored-by: Taha Hawa <taha@taha.dev>
Co-committed-by: Taha Hawa <taha@taha.dev>
2025-07-03 19:30:00 +00:00
61 changed files with 3059 additions and 343 deletions

29
.gitignore vendored
View File

@@ -1,4 +1,25 @@
target
private_repos
log/
*.tgz
### General ###
private_repos/
### Harmony ###
harmony.log
### Helm ###
# Chart dependencies
**/charts/*.tgz
### Rust ###
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb

133
Cargo.lock generated
View File

@@ -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"
@@ -1355,6 +1374,7 @@ dependencies = [
name = "example-rust"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"env_logger",
"harmony",
"harmony_cli",
@@ -1427,6 +1447,18 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filetime"
version = "0.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586"
dependencies = [
"cfg-if",
"libc",
"libredox",
"windows-sys 0.59.0",
]
[[package]]
name = "flate2"
version = "1.1.2"
@@ -1726,6 +1758,8 @@ name = "harmony"
version = "0.1.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"bollard",
"chrono",
"cidr",
"convert_case",
@@ -1742,7 +1776,6 @@ dependencies = [
"helm-wrapper-rs",
"hex",
"http 1.3.1",
"inquire",
"k3d-rs",
"k8s-openapi",
"kube",
@@ -1750,8 +1783,10 @@ dependencies = [
"libredfish",
"log",
"non-blank-string-rs",
"once_cell",
"opnsense-config",
"opnsense-config-xml",
"pretty_assertions",
"rand 0.9.1",
"reqwest 0.11.27",
"russh",
@@ -1760,9 +1795,11 @@ dependencies = [
"serde",
"serde-value",
"serde_json",
"serde_with",
"serde_yaml",
"similar",
"strum 0.27.1",
"tar",
"temp-dir",
"temp-file",
"tempfile",
@@ -1778,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",
]
@@ -1792,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",
]
@@ -2391,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"
@@ -2726,6 +2797,7 @@ checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638"
dependencies = [
"bitflags 2.9.1",
"libc",
"redox_syscall",
]
[[package]]
@@ -4080,6 +4152,18 @@ dependencies = [
"serde_json",
]
[[package]]
name = "schemars"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1375ba8ef45a6f15d83fa8748f1079428295d403d6ea991d09ab100155fbc06d"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -4280,22 +4364,36 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.13.0"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf65a400f8f66fb7b0552869ad70157166676db75ed8181f8104ea91cf9d0b42"
checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5"
dependencies = [
"base64 0.22.1",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.10.0",
"schemars",
"schemars 0.9.0",
"schemars 1.0.3",
"serde",
"serde_derive",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
@@ -4668,6 +4766,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "temp-dir"
version = "0.1.16"
@@ -5125,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"
@@ -5713,6 +5828,16 @@ dependencies = [
"tap",
]
[[package]]
name = "xattr"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af3a19837351dc82ba89f8a125e22a3c475f05aba604acc023d62b2739ae2909"
dependencies = [
"libc",
"rustix 1.0.7",
]
[[package]]
name = "xml-rs"
version = "0.8.26"

View File

@@ -52,3 +52,7 @@ convert_case = "0.8"
chrono = "0.4"
similar = "2"
uuid = { version = "1.11", features = ["v4", "fast-rng", "macro-diagnostics"] }
pretty_assertions = "1.4.1"
bollard = "0.19.1"
base64 = "0.22.1"
tar = "0.4.44"

View File

@@ -10,7 +10,7 @@ use harmony::{
inventory::Inventory,
maestro::Maestro,
modules::{
http::HttpScore,
http::StaticFilesHttpScore,
ipxe::IpxeScore,
okd::{
bootstrap_dhcp::OKDBootstrapDhcpScore,
@@ -126,7 +126,7 @@ async fn main() {
harmony::modules::okd::load_balancer::OKDLoadBalancerScore::new(&topology);
let tftp_score = TftpScore::new(Url::LocalFolder("./data/watchguard/tftpboot".to_string()));
let http_score = HttpScore::new(Url::LocalFolder(
let http_score = StaticFilesHttpScore::new(Url::LocalFolder(
"./data/watchguard/pxe-http-files".to_string(),
));
let ipxe_score = IpxeScore::new();

View File

@@ -14,6 +14,7 @@ async fn main() {
maestro.register_all(vec![Box::new(NtfyScore {
namespace: "monitoring".to_string(),
host: "localhost".to_string(),
})]);
harmony_cli::init(maestro, None).await.unwrap();
}

View File

@@ -11,7 +11,7 @@ use harmony::{
maestro::Maestro,
modules::{
dummy::{ErrorScore, PanicScore, SuccessScore},
http::HttpScore,
http::StaticFilesHttpScore,
okd::{dhcp::OKDDhcpScore, dns::OKDDnsScore, load_balancer::OKDLoadBalancerScore},
opnsense::OPNsenseShellCommandScore,
tftp::TftpScore,
@@ -81,7 +81,7 @@ async fn main() {
let load_balancer_score = OKDLoadBalancerScore::new(&topology);
let tftp_score = TftpScore::new(Url::LocalFolder("./data/watchguard/tftpboot".to_string()));
let http_score = HttpScore::new(Url::LocalFolder(
let http_score = StaticFilesHttpScore::new(Url::LocalFolder(
"./data/watchguard/pxe-http-files".to_string(),
));
let mut maestro = Maestro::initialize(inventory, topology).await.unwrap();

3
examples/rust/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
Dockerfile.harmony
.harmony_generated
harmony

View File

@@ -12,3 +12,4 @@ tokio = { workspace = true }
log = { workspace = true }
env_logger = { workspace = true }
url = { workspace = true }
base64.workspace = true

View File

@@ -4,33 +4,43 @@ use harmony::{
inventory::Inventory,
maestro::Maestro,
modules::application::{
RustWebFramework, RustWebapp, RustWebappScore, features::ContinuousDelivery,
ApplicationScore, RustWebFramework, RustWebapp,
features::{ContinuousDelivery, Monitoring},
},
topology::{K8sAnywhereTopology, Url},
};
use harmony_cli::cli_logger;
#[tokio::main]
async fn main() {
env_logger::init();
let application = RustWebapp {
name: "harmony-example-rust-webapp".to_string(),
project_root: PathBuf::from("./examples/rust/webapp"),
framework: Some(RustWebFramework::Leptos),
};
// TODO RustWebappScore should simply take a RustWebApp as config
let app = RustWebappScore {
name: "Example Rust Webapp".to_string(),
domain: Url::Url(url::Url::parse("https://rustapp.harmony.example.com").unwrap()),
features: vec![Box::new(ContinuousDelivery {
application: Arc::new(application.clone()),
})],
application,
};
let cli_logger_handle = cli_logger::init();
let topology = K8sAnywhereTopology::from_env();
let mut maestro = Maestro::initialize(Inventory::autoload(), topology)
.await
.unwrap();
let application = Arc::new(RustWebapp {
name: "harmony-example-rust-webapp".to_string(),
domain: Url::Url(url::Url::parse("https://rustapp.harmony.example.com").unwrap()),
project_root: PathBuf::from("./webapp"), // Relative from 'harmony-path' param
framework: Some(RustWebFramework::Leptos),
});
let app = ApplicationScore {
features: vec![
Box::new(ContinuousDelivery {
application: application.clone(),
}),
Box::new(Monitoring {
application: application.clone(),
}), // TODO: add backups, multisite ha, etc.
],
application,
};
maestro.register_all(vec![Box::new(app)]);
harmony_cli::init(maestro, None).await.unwrap();
let _ = tokio::try_join!(cli_logger_handle);
}

View File

@@ -1,16 +0,0 @@
FROM rust:bookworm as builder
RUN apt-get update && apt-get install -y --no-install-recommends clang wget && wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz && tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz && cp cargo-binstall /usr/local/cargo/bin && rm cargo-binstall-x86_64-unknown-linux-musl.tgz cargo-binstall && apt-get clean && rm -rf /var/lib/apt/lists/*
RUN cargo binstall cargo-leptos -y
RUN rustup target add wasm32-unknown-unknown
WORKDIR /app
COPY . .
RUN cargo leptos build --release -vv
FROM debian:bookworm-slim
RUN groupadd -r appgroup && useradd -r -s /bin/false -g appgroup appuser
ENV LEPTOS_SITE_ADDR=0.0.0.0:3000
EXPOSE 3000/tcp
WORKDIR /home/appuser
COPY --from=builder /app/target/site/pkg /home/appuser/pkg
COPY --from=builder /app/target/release/harmony-example-rust-webapp /home/appuser/harmony-example-rust-webapp
USER appuser
CMD /home/appuser/harmony-example-rust-webapp

View File

@@ -13,7 +13,7 @@ reqwest = { version = "0.11", features = ["blocking", "json"] }
russh = "0.45.0"
rust-ipmi = "0.1.1"
semver = "1.0.23"
serde = { version = "1.0.209", features = ["derive"] }
serde = { version = "1.0.209", features = ["derive", "rc"] }
serde_json = "1.0.127"
tokio.workspace = true
derive-new.workspace = true
@@ -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"
@@ -58,3 +57,11 @@ futures-util = "0.3.31"
tokio-util = "0.7.15"
strum = { version = "0.27.1", features = ["derive"] }
tempfile = "3.20.0"
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

View File

@@ -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<Outcome, InterpretError>,
},
}
static HARMONY_EVENT_BUS: Lazy<broadcast::Sender<HarmonyEvent>> = 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<F, Fut>(name: &str, mut handler: F)
where
F: FnMut(HarmonyEvent) -> Fut + Send + 'static,
Fut: Future<Output = bool> + 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,
}
}
}

View File

@@ -22,6 +22,7 @@ pub enum InterpretName {
K3dInstallation,
TenantInterpret,
Application,
ArgoCD,
}
impl std::fmt::Display for InterpretName {
@@ -39,6 +40,7 @@ impl std::fmt::Display for InterpretName {
InterpretName::K3dInstallation => f.write_str("K3dInstallation"),
InterpretName::TenantInterpret => f.write_str("Tenant"),
InterpretName::Application => f.write_str("Application"),
InterpretName::ArgoCD => f.write_str("ArgoCD"),
}
}
}

View File

@@ -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<T: Topology> Maestro<T> {
/// Ensures the associated Topology is ready for operations.
/// Delegates the readiness check and potential setup actions to the Topology.
pub async fn prepare_topology(&self) -> Result<Outcome, InterpretError> {
info!("Ensuring topology '{}' is ready...", self.topology.name());
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<T: Topology> Maestro<T> {
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
}

View File

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

View File

@@ -0,0 +1,59 @@
////////////////////
/// Working idea
///
///
trait ScoreWithDep<T> {
fn create_interpret(&self) -> Box<dyn Interpret<T>>;
fn name(&self) -> String;
fn get_dependencies(&self) -> Vec<TypeId>; // Force T to impl Installer<TypeId> or something
// like that
}
struct PrometheusAlertScore;
impl <T> ScoreWithDep<T> for PrometheusAlertScore {
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
todo!()
}
fn name(&self) -> String {
todo!()
}
fn get_dependencies(&self) -> Vec<TypeId> {
// We have to find a way to constrait here so at compile time we are only allowed to return
// TypeId for types which can be installed by T
//
// This means, for example that T must implement HelmCommand if the impl <T: HelmCommand> Installable<T> for
// KubePrometheus calls for HelmCommand.
vec![TypeId::of::<KubePrometheus>()]
}
}
trait Installable{}
struct KubePrometheus;
impl Installable for KubePrometheus;
struct Maestro<T> {
topology: T
}
impl <T>Maestro<T> {
fn execute_store(&self, score: ScoreWithDep<T>) {
score.get_dependencies().iter().for_each(|dep| {
self.topology.ensure_dependency_ready(dep);
});
}
}
struct TopologyWithDep {
}
impl TopologyWithDep {
fn ensure_dependency_ready(&self, type_id: TypeId) -> Result<(), String> {
self.installer
}
}

View File

@@ -4,8 +4,6 @@ use k8s_openapi::{
ClusterResourceScope, NamespaceResourceScope,
api::{apps::v1::Deployment, core::v1::Pod},
};
use kube::runtime::conditions;
use kube::runtime::wait::await_condition;
use kube::{
Client, Config, Error, Resource,
api::{Api, AttachParams, ListParams, Patch, PatchParams, ResourceExt},
@@ -13,6 +11,11 @@ use kube::{
core::ErrorResponse,
runtime::reflector::Lookup,
};
use kube::{api::DynamicObject, runtime::conditions};
use kube::{
api::{ApiResource, GroupVersionKind},
runtime::wait::await_condition,
};
use log::{debug, error, trace};
use serde::de::DeserializeOwned;
use similar::{DiffableStr, TextDiff};
@@ -239,6 +242,70 @@ impl K8sClient {
Ok(result)
}
pub async fn apply_yaml_many(
&self,
yaml: &Vec<serde_yaml::Value>,
ns: Option<&str>,
) -> Result<(), Error> {
for y in yaml.iter() {
self.apply_yaml(y, ns).await?;
}
Ok(())
}
pub async fn apply_yaml(
&self,
yaml: &serde_yaml::Value,
ns: Option<&str>,
) -> Result<(), Error> {
let obj: DynamicObject = serde_yaml::from_value(yaml.clone()).expect("TODO do not unwrap");
let name = obj.metadata.name.as_ref().expect("YAML must have a name");
let api_version = yaml
.get("apiVersion")
.expect("couldn't get apiVersion from YAML")
.as_str()
.expect("couldn't get apiVersion as str");
let kind = yaml
.get("kind")
.expect("couldn't get kind from YAML")
.as_str()
.expect("couldn't get kind as str");
let split: Vec<&str> = api_version.splitn(2, "/").collect();
let g = split[0];
let v = split[1];
let gvk = GroupVersionKind::gvk(g, v, kind);
let api_resource = ApiResource::from_gvk(&gvk);
let namespace = match ns {
Some(n) => n,
None => obj
.metadata
.namespace
.as_ref()
.expect("YAML must have a namespace"),
};
// 5. Create a dynamic API client for this resource type.
let api: Api<DynamicObject> =
Api::namespaced_with(self.client.clone(), namespace, &api_resource);
// 6. Apply the object to the cluster using Server-Side Apply.
// This will create the resource if it doesn't exist, or update it if it does.
println!(
"Applying Argo Application '{}' in namespace '{}'...",
name, namespace
);
let patch_params = PatchParams::apply("harmony"); // Use a unique field manager name
let result = api.patch(name, &patch_params, &Patch::Apply(&obj)).await?;
println!("Successfully applied '{}'.", result.name_any());
Ok(())
}
pub(crate) async fn from_kubeconfig(path: &str) -> Option<K8sClient> {
let k = match Kubeconfig::read_from(path) {
Ok(k) => k,

View File

@@ -1,7 +1,6 @@
use std::{process::Command, sync::Arc};
use async_trait::async_trait;
use inquire::Confirm;
use log::{debug, info, warn};
use serde::Serialize;
use tokio::sync::OnceCell;
@@ -10,9 +9,8 @@ use crate::{
executors::ExecutorError,
interpret::{InterpretError, Outcome},
inventory::Inventory,
maestro::Maestro,
modules::k3d::K3DInstallationScore,
topology::LocalhostTopology,
score::Score,
};
use super::{
@@ -94,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(())
}
@@ -114,9 +111,10 @@ impl K8sAnywhereTopology {
}
async fn try_install_k3d(&self) -> Result<(), InterpretError> {
let maestro = Maestro::initialize(Inventory::autoload(), LocalhostTopology::new()).await?;
let k3d_score = self.get_k3d_installation_score();
maestro.interpret(Box::new(k3d_score)).await?;
self.get_k3d_installation_score()
.create_interpret()
.execute(&Inventory::empty(), self)
.await?;
Ok(())
}
@@ -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.
@@ -246,7 +235,7 @@ pub struct K8sAnywhereConfig {
///
/// default: true
pub use_local_k3d: bool,
harmony_profile: String,
pub harmony_profile: String,
}
impl K8sAnywhereConfig {
@@ -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(),

View File

@@ -3,7 +3,6 @@ use serde::Serialize;
use crate::topology::Topology;
use super::Application;
/// An ApplicationFeature provided by harmony, such as Backups, Monitoring, MultisiteAvailability,
/// ContinuousIntegration, ContinuousDelivery
#[async_trait]

View File

@@ -0,0 +1,354 @@
use log::debug;
use serde::Serialize;
use serde_with::skip_serializing_none;
use serde_yaml::Value;
use crate::modules::application::features::CDApplicationConfig;
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Helm {
pub pass_credentials: Option<bool>,
pub parameters: Vec<Value>,
pub file_parameters: Vec<Value>,
pub release_name: Option<String>,
pub value_files: Vec<String>,
pub ignore_missing_value_files: Option<bool>,
pub values: Option<String>,
pub values_object: Option<Value>,
pub skip_crds: Option<bool>,
pub skip_schema_validation: Option<bool>,
pub version: Option<String>,
pub kube_version: Option<String>,
pub api_versions: Vec<String>,
pub namespace: Option<String>,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Source {
// Using string for this because URL enforces a URL scheme at the beginning but Helm, ArgoCD, etc do not, and it can be counterproductive,
// as the only way I've found to get OCI working isn't by using oci:// but rather no scheme at all
#[serde(rename = "repoURL")]
pub repo_url: String,
pub target_revision: Option<String>,
pub chart: String,
pub helm: Helm,
pub path: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Automated {
pub prune: bool,
pub self_heal: bool,
pub allow_empty: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Backoff {
pub duration: String,
pub factor: u32,
pub max_duration: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Retry {
pub limit: u32,
pub backoff: Backoff,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncPolicy {
pub automated: Automated,
pub sync_options: Vec<String>,
pub retry: Retry,
}
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ArgoApplication {
pub name: String,
pub namespace: Option<String>,
pub project: String,
pub source: Source,
pub sync_policy: SyncPolicy,
pub revision_history_limit: u32,
}
impl Default for ArgoApplication {
fn default() -> Self {
Self {
name: Default::default(),
namespace: Default::default(),
project: Default::default(),
source: Source {
repo_url: "http://asdf".to_string(),
target_revision: None,
chart: "".to_string(),
helm: Helm {
pass_credentials: None,
parameters: vec![],
file_parameters: vec![],
release_name: None,
value_files: vec![],
ignore_missing_value_files: None,
values: None,
values_object: None,
skip_crds: None,
skip_schema_validation: None,
version: None,
kube_version: None,
api_versions: vec![],
namespace: None,
},
path: "".to_string(),
},
sync_policy: SyncPolicy {
automated: Automated {
prune: false,
self_heal: false,
allow_empty: false,
},
sync_options: vec![],
retry: Retry {
limit: 5,
backoff: Backoff {
duration: "5s".to_string(),
factor: 2,
max_duration: "3m".to_string(),
},
},
},
revision_history_limit: 10,
}
}
}
impl From<CDApplicationConfig> for ArgoApplication {
fn from(value: CDApplicationConfig) -> Self {
Self {
name: value.name,
namespace: Some(value.namespace),
project: "default".to_string(),
source: Source {
repo_url: value.helm_chart_repo_url,
target_revision: Some(value.version.to_string()),
chart: value.helm_chart_name.clone(),
path: value.helm_chart_name,
helm: Helm {
pass_credentials: None,
parameters: vec![],
file_parameters: vec![],
release_name: None,
value_files: vec![],
ignore_missing_value_files: None,
values: None,
values_object: value.values_overrides,
skip_crds: None,
skip_schema_validation: None,
version: None,
kube_version: None,
api_versions: vec![],
namespace: None,
},
},
sync_policy: SyncPolicy {
automated: Automated {
prune: false,
self_heal: false,
allow_empty: true,
},
sync_options: vec![],
retry: Retry {
limit: 5,
backoff: Backoff {
duration: "5s".to_string(),
factor: 2,
max_duration: "3m".to_string(),
},
},
},
..Self::default()
}
}
}
impl ArgoApplication {
pub fn to_yaml(&self) -> serde_yaml::Value {
let name = &self.name;
let namespace = if let Some(ns) = self.namespace.as_ref() {
&ns
} else {
"argocd"
};
let project = &self.project;
let source = &self.source;
let yaml_str = format!(
r#"
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: {name}
# You'll usually want to add your resources to the argocd namespace.
namespace: {namespace}
spec:
# The project the application belongs to.
project: {project}
# Destination cluster and namespace to deploy the application
destination:
# cluster API URL
server: https://kubernetes.default.svc
# or cluster name
# name: in-cluster
# The namespace will only be set for namespace-scoped resources that have not set a value for .metadata.namespace
namespace: {namespace}
"#
);
let mut yaml_value: Value =
serde_yaml::from_str(yaml_str.as_str()).expect("couldn't parse string to YAML");
let spec = yaml_value
.get_mut("spec")
.expect("couldn't get spec from yaml")
.as_mapping_mut()
.expect("couldn't unwrap spec as mutable mapping");
let source =
serde_yaml::to_value(&self.source).expect("couldn't serialize source to value");
let sync_policy = serde_yaml::to_value(&self.sync_policy)
.expect("couldn't serialize sync_policy to value");
let revision_history_limit = serde_yaml::to_value(&self.revision_history_limit)
.expect("couldn't serialize revision_history_limit to value");
spec.insert(
serde_yaml::to_value("source").expect("string to value failed"),
source,
);
spec.insert(
serde_yaml::to_value("syncPolicy").expect("string to value failed"),
sync_policy,
);
spec.insert(
serde_yaml::to_value("revisionHistoryLimit")
.expect("couldn't convert str to yaml value"),
revision_history_limit,
);
debug!("spec: {}", serde_yaml::to_string(spec).unwrap());
debug!(
"entire yaml_value: {}",
serde_yaml::to_string(&yaml_value).unwrap()
);
yaml_value
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use crate::modules::application::features::{
ArgoApplication, Automated, Backoff, Helm, Retry, Source, SyncPolicy,
};
#[test]
fn test_argo_application_to_yaml_happy_path() {
let app = ArgoApplication {
name: "test".to_string(),
namespace: Some("test-ns".to_string()),
project: "test-project".to_string(),
source: Source {
repo_url: "http://test".to_string(),
target_revision: None,
chart: "test-chart".to_string(),
helm: Helm {
pass_credentials: None,
parameters: vec![],
file_parameters: vec![],
release_name: Some("test-release-neame".to_string()),
value_files: vec![],
ignore_missing_value_files: None,
values: None,
values_object: None,
skip_crds: None,
skip_schema_validation: None,
version: None,
kube_version: None,
api_versions: vec![],
namespace: None,
},
path: "".to_string(),
},
sync_policy: SyncPolicy {
automated: Automated {
prune: false,
self_heal: false,
allow_empty: false,
},
sync_options: vec![],
retry: Retry {
limit: 5,
backoff: Backoff {
duration: "5s".to_string(),
factor: 2,
max_duration: "3m".to_string(),
},
},
},
revision_history_limit: 10,
};
let expected_yaml_output = r#"apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: test
namespace: test-ns
spec:
project: test-project
destination:
server: https://kubernetes.default.svc
namespace: test-ns
source:
repoURL: http://test
chart: test-chart
helm:
parameters: []
fileParameters: []
releaseName: test-release-neame
valueFiles: []
apiVersions: []
path: ''
syncPolicy:
automated:
prune: false
selfHeal: false
allowEmpty: false
syncOptions: []
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m
revisionHistoryLimit: 10"#;
assert_eq!(
expected_yaml_output.trim(),
serde_yaml::to_string(&app.clone().to_yaml())
.unwrap()
.trim()
);
}
}

View File

@@ -1,20 +1,20 @@
use std::{io::Write, process::Command, sync::Arc};
use async_trait::async_trait;
use log::{error, info};
use serde_json::Value;
use log::{debug, error};
use serde_yaml::Value;
use tempfile::NamedTempFile;
use crate::{
config::HARMONY_DATA_DIR,
data::Version,
inventory::Inventory,
modules::{
application::{Application, ApplicationFeature, HelmPackage, OCICompliant},
helm::chart::HelmChartScore,
modules::application::{
Application, ApplicationFeature, HelmPackage, OCICompliant,
features::{ArgoApplication, ArgoHelmScore},
},
score::Score,
topology::{DeploymentTarget, HelmCommand, MultiTargetTopology, Topology, Url},
topology::{DeploymentTarget, HelmCommand, K8sclient, MultiTargetTopology, Topology},
};
/// ContinuousDelivery in Harmony provides this functionality :
@@ -63,7 +63,7 @@ impl<A: OCICompliant + HelmPackage> ContinuousDelivery<A> {
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<A: OCICompliant + HelmPackage> ContinuousDelivery<A> {
}
// --- 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<A: OCICompliant + HelmPackage> ContinuousDelivery<A> {
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<A: OCICompliant + HelmPackage> ContinuousDelivery<A> {
));
}
info!("Successfully deployed '{}' to local k3d cluster.", app_name);
debug!("Successfully deployed '{}' to local k3d cluster.", app_name);
Ok(())
}
}
@@ -139,25 +139,25 @@ impl<A: OCICompliant + HelmPackage> ContinuousDelivery<A> {
#[async_trait]
impl<
A: OCICompliant + HelmPackage + Clone + 'static,
T: Topology + HelmCommand + MultiTargetTopology + 'static,
T: Topology + HelmCommand + MultiTargetTopology + K8sclient + 'static,
> ApplicationFeature<T> for ContinuousDelivery<A>
{
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}");
// let image = self.application.build_push_oci_image().await?;
// info!("Pushed new docker image {image}");
error!("uncomment above");
error!("TODO Make building image configurable/skippable if image already exists (prompt)");
let image = self.application.build_push_oci_image().await?;
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.
@@ -177,32 +177,29 @@ impl<
.await?;
}
target => {
info!("Deploying to target {target:?}");
let cd_server = HelmChartScore {
namespace: todo!(
"ArgoCD Helm chart with proper understanding of Tenant, see how Will did it for Monitoring for now"
),
release_name: todo!("argocd helm chart whatever"),
chart_name: todo!(),
chart_version: todo!(),
values_overrides: todo!(),
values_yaml: todo!(),
create_namespace: todo!(),
install_only: todo!(),
repository: todo!(),
debug!("Deploying to target {target:?}");
let score = ArgoHelmScore {
namespace: "harmonydemo-staging".to_string(),
openshift: false,
domain: "argo.harmonydemo.apps.st.mcd".to_string(),
argo_apps: vec![ArgoApplication::from(CDApplicationConfig {
// helm pull oci://hub.nationtech.io/harmony/harmony-example-rust-webapp-chart --version 0.1.0
version: Version::from("0.1.0").unwrap(),
helm_chart_repo_url: "hub.nationtech.io/harmony".to_string(),
helm_chart_name: "harmony-example-rust-webapp-chart".to_string(),
values_overrides: None,
name: "harmony-demo-rust-webapp".to_string(),
namespace: "harmonydemo-staging".to_string(),
})],
};
let interpret = cd_server.create_interpret();
interpret.execute(&Inventory::empty(), topology);
score
.create_interpret()
.execute(&Inventory::empty(), topology)
.await
.unwrap();
}
};
todo!("1. Create ArgoCD score that installs argo using helm chart, see if Taha's already done it
- [X] Package app (docker image, helm chart)
- [X] Push to registry
- [ ] Push only if staging or prod
- [ ] Deploy to local k3d when target is local
- [ ] Poke Argo
- [ ] Ensure app is up")
Ok(())
}
fn name(&self) -> String {
"ContinuousDelivery".to_string()
@@ -212,9 +209,12 @@ impl<
/// For now this is entirely bound to K8s / ArgoCD, will have to be revisited when we support
/// more CD systems
pub struct CDApplicationConfig {
version: Version,
helm_chart_url: Url,
values_overrides: Value,
pub version: Version,
pub helm_chart_repo_url: String,
pub helm_chart_name: String,
pub values_overrides: Option<Value>,
pub name: String,
pub namespace: String,
}
pub trait ContinuousDeliveryApplication {

View File

@@ -2,7 +2,7 @@ use async_trait::async_trait;
use log::info;
use crate::{
modules::application::{Application, ApplicationFeature},
modules::application::ApplicationFeature,
topology::{K8sclient, Topology},
};

File diff suppressed because it is too large Load Diff

View File

@@ -6,3 +6,9 @@ pub use monitoring::*;
mod continuous_delivery;
pub use continuous_delivery::*;
mod helm_argocd_score;
pub use helm_argocd_score::*;
mod argo_types;
pub use argo_types::*;

View File

@@ -1,19 +1,109 @@
use std::sync::Arc;
use async_trait::async_trait;
use log::info;
use base64::{Engine as _, engine::general_purpose};
use log::{debug, info};
use crate::{
modules::application::{Application, ApplicationFeature},
topology::{HelmCommand, Topology},
inventory::Inventory,
modules::{
application::{ApplicationFeature, OCICompliant},
monitoring::{
alert_channel::webhook_receiver::WebhookReceiver,
kube_prometheus::{
helm_prometheus_alert_score::HelmPrometheusAlertingScore,
types::{NamespaceSelector, ServiceMonitor},
},
ntfy::ntfy::NtfyScore,
},
},
score::Score,
topology::{HelmCommand, K8sclient, Topology, Url, tenant::TenantManager},
};
#[derive(Debug, Default, Clone)]
pub struct Monitoring {}
#[derive(Debug, Clone)]
pub struct Monitoring {
pub application: Arc<dyn OCICompliant>,
}
#[async_trait]
impl<T: Topology + HelmCommand + 'static> ApplicationFeature<T> for Monitoring {
async fn ensure_installed(&self, _topology: &T) -> Result<(), String> {
impl<T: Topology + HelmCommand + K8sclient + 'static + TenantManager> ApplicationFeature<T>
for Monitoring
{
async fn ensure_installed(&self, topology: &T) -> Result<(), String> {
info!("Ensuring monitoring is available for application");
todo!("create and execute k8s prometheus score, depends on Will's work")
let ntfy = NtfyScore {
// namespace: topology
// .get_tenant_config()
// .await
// .expect("couldn't get tenant config")
// .name,
namespace: self.application.name(),
host: "localhost".to_string(),
};
ntfy.create_interpret()
.execute(&Inventory::empty(), topology)
.await
.expect("couldn't create interpret for ntfy");
let ntfy_default_auth_username = "harmony";
let ntfy_default_auth_password = "harmony";
let ntfy_default_auth_header = format!(
"Basic {}",
general_purpose::STANDARD.encode(format!(
"{ntfy_default_auth_username}:{ntfy_default_auth_password}"
))
);
debug!("ntfy_default_auth_header: {ntfy_default_auth_header}");
let ntfy_default_auth_param = general_purpose::STANDARD
.encode(ntfy_default_auth_header)
.replace("=", "");
debug!("ntfy_default_auth_param: {ntfy_default_auth_param}");
let ntfy_receiver = WebhookReceiver {
name: "ntfy-webhook".to_string(),
url: Url::Url(
url::Url::parse(
format!(
"http://ntfy.{}.svc.cluster.local/rust-web-app?auth={ntfy_default_auth_param}",
self.application.name()
)
.as_str(),
)
.unwrap(),
),
};
let mut service_monitor = ServiceMonitor::default();
service_monitor.namespace_selector = Some(NamespaceSelector {
any: true,
match_names: vec![],
});
service_monitor.name = "rust-webapp".to_string();
// let alerting_score = ApplicationPrometheusMonitoringScore {
// receivers: vec![Box::new(ntfy_receiver)],
// rules: vec![],
// service_monitors: vec![service_monitor],
// };
let alerting_score = HelmPrometheusAlertingScore {
receivers: vec![Box::new(ntfy_receiver)],
rules: vec![],
service_monitors: vec![service_monitor],
};
alerting_score
.create_interpret()
.execute(&Inventory::empty(), topology)
.await
.unwrap();
Ok(())
}
fn name(&self) -> String {
"Monitoring".to_string()

View File

@@ -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::*;
@@ -23,20 +23,20 @@ pub trait Application: std::fmt::Debug + Send + Sync {
}
#[derive(Debug)]
pub struct ApplicationInterpret<T: Topology + std::fmt::Debug> {
pub struct ApplicationInterpret<A: Application, T: Topology + std::fmt::Debug> {
features: Vec<Box<dyn ApplicationFeature<T>>>,
application: Arc<Box<dyn Application>>,
application: Arc<A>,
}
#[async_trait]
impl<T: Topology + std::fmt::Debug> Interpret<T> for ApplicationInterpret<T> {
impl<A: Application, T: Topology + std::fmt::Debug> Interpret<T> for ApplicationInterpret<A, T> {
async fn execute(
&self,
_inventory: &Inventory,
topology: &T,
) -> Result<Outcome, InterpretError> {
let app_name = self.application.name();
info!(
debug!(
"Preparing {} features [{}] for application {app_name}",
self.features.len(),
self.features
@@ -46,7 +46,7 @@ impl<T: Topology + std::fmt::Debug> Interpret<T> for ApplicationInterpret<T> {
.join(", ")
);
for feature in self.features.iter() {
info!(
debug!(
"Installing feature {} for application {app_name}",
feature.name()
);
@@ -59,9 +59,7 @@ impl<T: Topology + std::fmt::Debug> Interpret<T> for ApplicationInterpret<T> {
}
};
}
todo!(
"Do I need to do anything more than this here?? I feel like the Application trait itself should expose something like ensure_ready but its becoming redundant. We'll see as this evolves."
)
Ok(Outcome::success("successfully created app".to_string()))
}
fn get_name(&self) -> InterpretName {

View File

@@ -4,11 +4,15 @@ use std::process;
use std::sync::Arc;
use async_trait::async_trait;
use bollard::query_parameters::PushImageOptionsBuilder;
use bollard::{Docker, body_full};
use dockerfile_builder::Dockerfile;
use dockerfile_builder::instruction::{CMD, COPY, ENV, EXPOSE, FROM, RUN, USER, WORKDIR};
use dockerfile_builder::instruction_builder::CopyBuilder;
use log::{debug, error, info};
use futures_util::StreamExt;
use log::{debug, error, log_enabled};
use serde::Serialize;
use tar::Archive;
use crate::config::{REGISTRY_PROJECT, REGISTRY_URL};
use crate::{
@@ -19,23 +23,30 @@ use crate::{
use super::{Application, ApplicationFeature, ApplicationInterpret, HelmPackage, OCICompliant};
#[derive(Debug, Serialize, Clone)]
pub struct RustWebappScore<T: Topology + Clone + Serialize> {
pub name: String,
pub domain: Url,
pub struct ApplicationScore<A: Application + Serialize, T: Topology + Clone + Serialize>
where
Arc<A>: Serialize + Clone,
{
pub features: Vec<Box<dyn ApplicationFeature<T>>>,
pub application: RustWebapp,
pub application: Arc<A>,
}
impl<T: Topology + std::fmt::Debug + Clone + Serialize + 'static> Score<T> for RustWebappScore<T> {
impl<
A: Application + Serialize + Clone + 'static,
T: Topology + std::fmt::Debug + Clone + Serialize + 'static,
> Score<T> for ApplicationScore<A, T>
where
Arc<A>: Serialize,
{
fn create_interpret(&self) -> Box<dyn crate::interpret::Interpret<T>> {
Box::new(ApplicationInterpret {
features: self.features.clone(),
application: Arc::new(Box::new(self.application.clone())),
application: self.application.clone(),
})
}
fn name(&self) -> String {
format!("{}-RustWebapp", self.name)
format!("Application: {}", self.application.name())
}
}
@@ -47,6 +58,7 @@ pub enum RustWebFramework {
#[derive(Debug, Clone, Serialize)]
pub struct RustWebapp {
pub name: String,
pub domain: Url,
/// The path to the root of the Rust project to be containerized.
pub project_root: PathBuf,
pub framework: Option<RustWebFramework>,
@@ -61,19 +73,19 @@ impl Application for RustWebapp {
#[async_trait]
impl HelmPackage for RustWebapp {
async fn build_push_helm_package(&self, image_url: &str) -> Result<String, String> {
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()
);
@@ -82,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)
}
@@ -95,24 +107,22 @@ impl OCICompliant for RustWebapp {
async fn build_push_oci_image(&self) -> Result<String, String> {
// 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 local image by calling the synchronous helper function.
let local_image_name = self.local_image_name();
self.build_docker_image(&local_image_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 local Docker image: {}",
local_image_name
);
debug!("Successfully built Docker image: {}", image_tag);
let remote_image_name = self.image_name();
// 2. Push the image to the registry.
self.push_docker_image(&local_image_name, &remote_image_name)
self.push_docker_image(&image_tag)
.await
.map_err(|e| format!("Failed to push Docker image: {}", e))?;
info!("Successfully pushed Docker image to: {}", remote_image_name);
debug!("Successfully pushed Docker image to: {}", image_tag);
Ok(remote_image_name)
Ok(image_tag)
}
fn local_image_name(&self) -> String {
@@ -145,68 +155,74 @@ impl RustWebapp {
}
/// Builds the Docker image using the generated Dockerfile.
pub fn build_docker_image(
pub async fn build_docker_image(
&self,
image_name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
info!("Generating Dockerfile for '{}'", self.name);
let dockerfile_path = self.build_dockerfile()?;
debug!("Generating Dockerfile for '{}'", self.name);
let _dockerfile_path = self.build_dockerfile()?;
info!(
"Building Docker image with file {} from root {}",
dockerfile_path.to_string_lossy(),
self.project_root.to_string_lossy()
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(quiet)
.version(bollard::query_parameters::BuilderVersion::BuilderV1)
.platform("linux/x86_64");
let mut temp_tar_builder = tar::Builder::new(Vec::new());
let _ = temp_tar_builder
.append_dir_all("", self.project_root.clone())
.unwrap();
let archive = temp_tar_builder
.into_inner()
.expect("couldn't finish creating tar");
let archived_files = Archive::new(archive.as_slice())
.entries()
.unwrap()
.map(|entry| entry.unwrap().path().unwrap().into_owned())
.collect::<Vec<_>>();
debug!("files in docker tar: {:#?}", archived_files);
let mut image_build_stream = docker.build_image(
build_image_options.build(),
None,
Some(body_full(archive.into())),
);
let output = process::Command::new("docker")
.args([
"build",
"--file",
dockerfile_path.to_str().unwrap(),
"-t",
&image_name,
self.project_root.to_str().unwrap(),
])
.spawn()?
.wait_with_output()?;
self.check_output(&output, "Failed to build Docker image")?;
while let Some(msg) = image_build_stream.next().await {
println!("Message: {msg:?}");
}
Ok(image_name.to_string())
}
/// Tags and pushes a Docker image to the configured remote registry.
fn push_docker_image(
async fn push_docker_image(
&self,
image_name: &str,
full_tag: &str,
image_tag: &str,
) -> Result<String, Box<dyn std::error::Error>> {
info!("Pushing docker image {full_tag}");
debug!("Pushing docker image {image_tag}");
// Tag the image for the remote registry.
let output = process::Command::new("docker")
.args(["tag", image_name, &full_tag])
.spawn()?
.wait_with_output()?;
self.check_output(&output, "Tagging docker image failed")?;
debug!(
"docker tag output: stdout: {}, stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
let docker = Docker::connect_with_socket_defaults().unwrap();
// let push_options = PushImageOptionsBuilder::new().tag(tag);
let mut push_image_stream = docker.push_image(
image_tag,
Some(PushImageOptionsBuilder::new().build()),
None,
);
// Push the image.
let output = process::Command::new("docker")
.args(["push", &full_tag])
.spawn()?
.wait_with_output()?;
self.check_output(&output, "Pushing docker image failed")?;
debug!(
"docker push output: stdout: {}, stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
while let Some(msg) = push_image_stream.next().await {
println!("Message: {msg:?}");
}
Ok(full_tag.to_string())
Ok(image_tag.to_string())
}
/// Checks the output of a process command for success.
@@ -341,7 +357,11 @@ impl RustWebapp {
image_url: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let chart_name = format!("{}-chart", self.name);
let chart_dir = self.project_root.join("helm").join(&chart_name);
let chart_dir = self
.project_root
.join(".harmony_generated")
.join("helm")
.join(&chart_name);
let templates_dir = chart_dir.join("templates");
fs::create_dir_all(&templates_dir)?;
@@ -408,7 +428,7 @@ ingress:
Expand the name of the chart.
*/}}
{{- define "chart.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- default .Chart.Name $.Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
@@ -416,7 +436,7 @@ Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
*/}}
{{- define "chart.fullname" -}}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- $name := default .Chart.Name $.Values.nameOverride }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
"#;
@@ -429,12 +449,12 @@ kind: Service
metadata:
name: {{ include "chart.fullname" . }}
spec:
type: {{ .Values.service.type }}
type: {{ $.Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 3000
- name: main
port: {{ $.Values.service.port | default 3000 }}
targetPort: {{ $.Values.service.port | default 3000 }}
protocol: TCP
name: http
selector:
app: {{ include "chart.name" . }}
"#;
@@ -447,7 +467,7 @@ kind: Deployment
metadata:
name: {{ include "chart.fullname" . }}
spec:
replicas: {{ .Values.replicaCount }}
replicas: {{ $.Values.replicaCount }}
selector:
matchLabels:
app: {{ include "chart.name" . }}
@@ -458,28 +478,28 @@ spec:
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
image: "{{ $.Values.image.repository }}:{{ $.Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ $.Values.image.pullPolicy }}
ports:
- name: http
containerPort: 3000
- name: main
containerPort: {{ $.Values.service.port | default 3000 }}
protocol: TCP
"#;
fs::write(templates_dir.join("deployment.yaml"), deployment_yaml)?;
// Create templates/ingress.yaml
let ingress_yaml = r#"
{{- if .Values.ingress.enabled -}}
{{- if $.Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "chart.fullname" . }}
annotations:
{{- toYaml .Values.ingress.annotations | nindent 4 }}
{{- toYaml $.Values.ingress.annotations | nindent 4 }}
spec:
{{- if .Values.ingress.tls }}
{{- if $.Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
{{- range $.Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
@@ -488,7 +508,7 @@ spec:
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
{{- range $.Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
@@ -499,7 +519,7 @@ spec:
service:
name: {{ include "chart.fullname" $ }}
port:
number: 3000
number: {{ $.Values.service.port | default 3000 }}
{{- end }}
{{- end }}
{{- end }}
@@ -515,14 +535,18 @@ spec:
chart_dir: &PathBuf,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
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.project_root.join("helm").to_string_lossy()
&self
.project_root
.join(".harmony_generated")
.join("helm")
.to_string_lossy()
);
let output = process::Command::new("helm")
.args(["package", chart_dirname.to_str().unwrap()])
.current_dir(&self.project_root.join("helm")) // Run package from the parent dir
.current_dir(&self.project_root.join(".harmony_generated").join("helm")) // Run package from the parent dir
.output()?;
self.check_output(&output, "Failed to package Helm chart")?;
@@ -539,7 +563,11 @@ spec:
}
// The output from helm is relative, so we join it with the execution directory.
Ok(self.project_root.join("helm").join(tgz_name))
Ok(self
.project_root
.join(".harmony_generated")
.join("helm")
.join(tgz_name))
}
/// Pushes a packaged Helm chart to an OCI registry.
@@ -552,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

View File

@@ -10,14 +10,25 @@ use crate::{
topology::{HttpServer, Topology, Url},
};
/// Configure an HTTP server that is provided by the Topology
///
/// This Score will let you easily specify a file path to be served by the HTTP server
///
/// For example, if you have a folder of assets at `/var/www/assets` simply do :
///
/// ```rust,ignore
/// StaticFilesHttpScore {
/// files_to_serve: url!("file:///var/www/assets"),
/// }
/// ```
#[derive(Debug, new, Clone, Serialize)]
pub struct HttpScore {
pub struct StaticFilesHttpScore {
files_to_serve: Url,
}
impl<T: Topology + HttpServer> Score<T> for HttpScore {
impl<T: Topology + HttpServer> Score<T> for StaticFilesHttpScore {
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(HttpInterpret::new(self.clone()))
Box::new(StaticFilesHttpInterpret::new(self.clone()))
}
fn name(&self) -> String {
@@ -26,12 +37,12 @@ impl<T: Topology + HttpServer> Score<T> for HttpScore {
}
#[derive(Debug, new, Clone)]
pub struct HttpInterpret {
score: HttpScore,
pub struct StaticFilesHttpInterpret {
score: StaticFilesHttpScore,
}
#[async_trait]
impl<T: Topology + HttpServer> Interpret<T> for HttpInterpret {
impl<T: Topology + HttpServer> Interpret<T> for StaticFilesHttpInterpret {
async fn execute(
&self,
_inventory: &Inventory,

View File

@@ -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<T: Topology> Interpret<T> for K3dInstallationInterpret {
async fn execute(
&self,
_inventory: &Inventory,
_topology: &T,
topology: &T,
) -> Result<Outcome, InterpretError> {
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

View File

@@ -4,9 +4,12 @@ use serde_yaml::{Mapping, Value};
use crate::{
interpret::{InterpretError, Outcome},
modules::monitoring::kube_prometheus::{
prometheus::{Prometheus, PrometheusReceiver},
types::{AlertChannelConfig, AlertManagerChannelConfig},
modules::monitoring::{
kube_prometheus::{
prometheus::{KubePrometheus, KubePrometheusReceiver},
types::{AlertChannelConfig, AlertManagerChannelConfig},
},
prometheus::prometheus::{Prometheus, PrometheusReceiver},
},
topology::{Url, oberservability::monitoring::AlertReceiver},
};
@@ -37,6 +40,26 @@ impl PrometheusReceiver for DiscordWebhook {
}
}
#[async_trait]
impl AlertReceiver<KubePrometheus> for DiscordWebhook {
async fn install(&self, sender: &KubePrometheus) -> Result<Outcome, InterpretError> {
sender.install_receiver(self).await
}
fn clone_box(&self) -> Box<dyn AlertReceiver<KubePrometheus>> {
Box::new(self.clone())
}
}
#[async_trait]
impl KubePrometheusReceiver for DiscordWebhook {
fn name(&self) -> String {
self.name.clone()
}
async fn configure_receiver(&self) -> AlertManagerChannelConfig {
self.get_config().await
}
}
#[async_trait]
impl AlertChannelConfig for DiscordWebhook {
async fn get_config(&self) -> AlertManagerChannelConfig {

View File

@@ -4,9 +4,12 @@ use serde_yaml::{Mapping, Value};
use crate::{
interpret::{InterpretError, Outcome},
modules::monitoring::kube_prometheus::{
prometheus::{Prometheus, PrometheusReceiver},
types::{AlertChannelConfig, AlertManagerChannelConfig},
modules::monitoring::{
kube_prometheus::{
prometheus::{KubePrometheus, KubePrometheusReceiver},
types::{AlertChannelConfig, AlertManagerChannelConfig},
},
prometheus::prometheus::{Prometheus, PrometheusReceiver},
},
topology::{Url, oberservability::monitoring::AlertReceiver},
};
@@ -36,6 +39,25 @@ impl PrometheusReceiver for WebhookReceiver {
self.get_config().await
}
}
#[async_trait]
impl AlertReceiver<KubePrometheus> for WebhookReceiver {
async fn install(&self, sender: &KubePrometheus) -> Result<Outcome, InterpretError> {
sender.install_receiver(self).await
}
fn clone_box(&self) -> Box<dyn AlertReceiver<KubePrometheus>> {
Box::new(self.clone())
}
}
#[async_trait]
impl KubePrometheusReceiver for WebhookReceiver {
fn name(&self) -> String {
self.name.clone()
}
async fn configure_receiver(&self) -> AlertManagerChannelConfig {
self.get_config().await
}
}
#[async_trait]
impl AlertChannelConfig for WebhookReceiver {

View File

@@ -5,13 +5,26 @@ use serde::Serialize;
use crate::{
interpret::{InterpretError, Outcome},
modules::monitoring::kube_prometheus::{
prometheus::{Prometheus, PrometheusRule},
types::{AlertGroup, AlertManagerAdditionalPromRules},
modules::monitoring::{
kube_prometheus::{
prometheus::{KubePrometheus, KubePrometheusRule},
types::{AlertGroup, AlertManagerAdditionalPromRules},
},
prometheus::prometheus::{Prometheus, PrometheusRule},
},
topology::oberservability::monitoring::AlertRule,
};
#[async_trait]
impl AlertRule<KubePrometheus> for AlertManagerRuleGroup {
async fn install(&self, sender: &KubePrometheus) -> Result<Outcome, InterpretError> {
sender.install_rule(&self).await
}
fn clone_box(&self) -> Box<dyn AlertRule<KubePrometheus>> {
Box::new(self.clone())
}
}
#[async_trait]
impl AlertRule<Prometheus> for AlertManagerRuleGroup {
async fn install(&self, sender: &Prometheus) -> Result<Outcome, InterpretError> {
@@ -41,6 +54,25 @@ impl PrometheusRule for AlertManagerRuleGroup {
}
}
}
#[async_trait]
impl KubePrometheusRule for AlertManagerRuleGroup {
fn name(&self) -> String {
self.name.clone()
}
async fn configure_rule(&self) -> AlertManagerAdditionalPromRules {
let mut additional_prom_rules = BTreeMap::new();
additional_prom_rules.insert(
self.name.clone(),
AlertGroup {
groups: vec![self.clone()],
},
);
AlertManagerAdditionalPromRules {
rules: additional_prom_rules,
}
}
}
impl AlertManagerRuleGroup {
pub fn new(name: &str, rules: Vec<PrometheusAlertRule>) -> AlertManagerRuleGroup {

View File

@@ -0,0 +1,44 @@
use std::sync::{Arc, Mutex};
use serde::Serialize;
use crate::{
modules::monitoring::{
kube_prometheus::types::ServiceMonitor,
prometheus::{prometheus::Prometheus, prometheus_config::PrometheusConfig},
},
score::Score,
topology::{
HelmCommand, Topology,
oberservability::monitoring::{AlertReceiver, AlertRule, AlertingInterpret},
tenant::TenantManager,
},
};
#[derive(Clone, Debug, Serialize)]
pub struct ApplicationPrometheusMonitoringScore {
pub receivers: Vec<Box<dyn AlertReceiver<Prometheus>>>,
pub rules: Vec<Box<dyn AlertRule<Prometheus>>>,
pub service_monitors: Vec<ServiceMonitor>,
}
impl<T: Topology + HelmCommand + TenantManager> Score<T> for ApplicationPrometheusMonitoringScore {
fn create_interpret(&self) -> Box<dyn crate::interpret::Interpret<T>> {
let mut prom_config = PrometheusConfig::new();
prom_config.alert_manager = true;
let config = Arc::new(Mutex::new(prom_config));
config
.try_lock()
.expect("couldn't lock config")
.additional_service_monitors = self.service_monitors.clone();
Box::new(AlertingInterpret {
sender: Prometheus::new(),
receivers: self.receivers.clone(),
rules: self.rules.clone(),
})
}
fn name(&self) -> String {
"ApplicationPrometheusMonitoringScore".to_string()
}
}

View File

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

View File

@@ -0,0 +1,28 @@
use non_blank_string_rs::NonBlankString;
use std::str::FromStr;
use crate::modules::helm::chart::HelmChartScore;
pub fn grafana_helm_chart_score(ns: &str) -> HelmChartScore {
let values = format!(
r#"
rbac:
namespaced: true
sidecar:
dashboards:
enabled: true
"#
);
HelmChartScore {
namespace: Some(NonBlankString::from_str(ns).unwrap()),
release_name: NonBlankString::from_str("grafana").unwrap(),
chart_name: NonBlankString::from_str("oci://ghcr.io/grafana/helm-charts/grafana").unwrap(),
chart_version: None,
values_overrides: None,
values_yaml: Some(values.to_string()),
create_namespace: true,
install_only: true,
repository: None,
}
}

View File

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

View File

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

View File

@@ -38,15 +38,15 @@ impl KubePrometheusConfig {
node_exporter: false,
prometheus: true,
kubernetes_service_monitors: true,
kubernetes_api_server: false,
kubernetes_api_server: true,
kubelet: true,
kube_controller_manager: false,
kube_etcd: false,
kube_proxy: false,
kube_controller_manager: true,
kube_etcd: true,
kube_proxy: true,
kube_state_metrics: true,
prometheus_operator: true,
core_dns: false,
kube_scheduler: false,
core_dns: true,
kube_scheduler: true,
alert_receiver_configs: vec![],
alert_rules: vec![],
additional_service_monitors: vec![],

View File

@@ -68,11 +68,14 @@ pub fn kube_prometheus_helm_chart_score(
let mut values = format!(
r#"
global:
rbac:
create: true
prometheus:
enabled: {prometheus}
prometheusSpec:
resources:
requests:
requests:
cpu: 100m
memory: 500Mi
limits:
@@ -118,7 +121,7 @@ defaultRules:
windowsMonitoring:
enabled: {windows_monitoring}
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
@@ -127,13 +130,13 @@ windowsMonitoring:
grafana:
enabled: {grafana}
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
cpu: 200m
memory: 250Mi
initChownData:
initChownData:
resources:
requests:
cpu: 10m
@@ -154,7 +157,7 @@ kubernetesServiceMonitors:
kubeApiServer:
enabled: {kubernetes_api_server}
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
@@ -163,7 +166,7 @@ kubeApiServer:
kubelet:
enabled: {kubelet}
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
@@ -172,7 +175,7 @@ kubelet:
kubeControllerManager:
enabled: {kube_controller_manager}
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
@@ -181,7 +184,7 @@ kubeControllerManager:
coreDns:
enabled: {core_dns}
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
@@ -190,7 +193,7 @@ coreDns:
kubeEtcd:
enabled: {kube_etcd}
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
@@ -199,7 +202,7 @@ kubeEtcd:
kubeScheduler:
enabled: {kube_scheduler}
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
@@ -208,7 +211,7 @@ kubeScheduler:
kubeProxy:
enabled: {kube_proxy}
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
@@ -218,7 +221,7 @@ kubeStateMetrics:
enabled: {kube_state_metrics}
kube-state-metrics:
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
@@ -227,7 +230,7 @@ kube-state-metrics:
nodeExporter:
enabled: {node_exporter}
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
@@ -235,16 +238,16 @@ nodeExporter:
memory: 250Mi
prometheus-node-exporter:
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
cpu: 200m
memory: 250Mi
prometheusOperator:
enabled: {prometheus_operator}
enabled: true
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
@@ -252,7 +255,7 @@ prometheusOperator:
memory: 200Mi
prometheusConfigReloader:
resources:
requests:
requests:
cpu: 100m
memory: 150Mi
limits:
@@ -264,7 +267,7 @@ prometheusOperator:
limits:
cpu: 10m
memory: 100Mi
requests:
requests:
cpu: 10m
memory: 100Mi
patch:
@@ -272,7 +275,7 @@ prometheusOperator:
limits:
cpu: 10m
memory: 100Mi
requests:
requests:
cpu: 10m
memory: 100Mi
"#,

View File

@@ -2,7 +2,7 @@ use std::sync::{Arc, Mutex};
use serde::Serialize;
use super::{helm::config::KubePrometheusConfig, prometheus::Prometheus};
use super::{helm::config::KubePrometheusConfig, prometheus::KubePrometheus};
use crate::{
modules::monitoring::kube_prometheus::types::ServiceMonitor,
score::Score,
@@ -15,8 +15,8 @@ use crate::{
#[derive(Clone, Debug, Serialize)]
pub struct HelmPrometheusAlertingScore {
pub receivers: Vec<Box<dyn AlertReceiver<Prometheus>>>,
pub rules: Vec<Box<dyn AlertRule<Prometheus>>>,
pub receivers: Vec<Box<dyn AlertReceiver<KubePrometheus>>>,
pub rules: Vec<Box<dyn AlertRule<KubePrometheus>>>,
pub service_monitors: Vec<ServiceMonitor>,
}
@@ -28,7 +28,7 @@ impl<T: Topology + HelmCommand + TenantManager> Score<T> for HelmPrometheusAlert
.expect("couldn't lock config")
.additional_service_monitors = self.service_monitors.clone();
Box::new(AlertingInterpret {
sender: Prometheus::new(),
sender: KubePrometheus { config },
receivers: self.receivers.clone(),
rules: self.rules.clone(),
})

View File

@@ -10,7 +10,7 @@ use crate::{
modules::monitoring::alert_rule::prometheus_alert_rule::AlertManagerRuleGroup,
score,
topology::{
HelmCommand, K8sAnywhereTopology, Topology,
HelmCommand, Topology,
installable::Installable,
oberservability::monitoring::{AlertReceiver, AlertRule, AlertSender},
tenant::TenantManager,
@@ -27,14 +27,14 @@ use super::{
};
#[async_trait]
impl AlertSender for Prometheus {
impl AlertSender for KubePrometheus {
fn name(&self) -> String {
"HelmKubePrometheus".to_string()
}
}
#[async_trait]
impl<T: Topology + HelmCommand + TenantManager> Installable<T> for Prometheus {
impl<T: Topology + HelmCommand + TenantManager> Installable<T> for KubePrometheus {
async fn configure(&self, _inventory: &Inventory, topology: &T) -> Result<(), InterpretError> {
self.configure_with_topology(topology).await;
Ok(())
@@ -51,11 +51,11 @@ impl<T: Topology + HelmCommand + TenantManager> Installable<T> for Prometheus {
}
#[derive(Debug)]
pub struct Prometheus {
pub struct KubePrometheus {
pub config: Arc<Mutex<KubePrometheusConfig>>,
}
impl Prometheus {
impl KubePrometheus {
pub fn new() -> Self {
Self {
config: Arc::new(Mutex::new(KubePrometheusConfig::new())),
@@ -75,7 +75,7 @@ impl Prometheus {
pub async fn install_receiver(
&self,
prometheus_receiver: &dyn PrometheusReceiver,
prometheus_receiver: &dyn KubePrometheusReceiver,
) -> Result<Outcome, InterpretError> {
let prom_receiver = prometheus_receiver.configure_receiver().await;
debug!(
@@ -120,12 +120,12 @@ impl Prometheus {
}
#[async_trait]
pub trait PrometheusReceiver: Send + Sync + std::fmt::Debug {
pub trait KubePrometheusReceiver: Send + Sync + std::fmt::Debug {
fn name(&self) -> String;
async fn configure_receiver(&self) -> AlertManagerChannelConfig;
}
impl Serialize for Box<dyn AlertReceiver<Prometheus>> {
impl Serialize for Box<dyn AlertReceiver<KubePrometheus>> {
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
@@ -134,19 +134,19 @@ impl Serialize for Box<dyn AlertReceiver<Prometheus>> {
}
}
impl Clone for Box<dyn AlertReceiver<Prometheus>> {
impl Clone for Box<dyn AlertReceiver<KubePrometheus>> {
fn clone(&self) -> Self {
self.clone_box()
}
}
#[async_trait]
pub trait PrometheusRule: Send + Sync + std::fmt::Debug {
pub trait KubePrometheusRule: Send + Sync + std::fmt::Debug {
fn name(&self) -> String;
async fn configure_rule(&self) -> AlertManagerAdditionalPromRules;
}
impl Serialize for Box<dyn AlertRule<Prometheus>> {
impl Serialize for Box<dyn AlertRule<KubePrometheus>> {
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
@@ -155,7 +155,7 @@ impl Serialize for Box<dyn AlertRule<Prometheus>> {
}
}
impl Clone for Box<dyn AlertRule<Prometheus>> {
impl Clone for Box<dyn AlertRule<KubePrometheus>> {
fn clone(&self) -> Self {
self.clone_box()
}

View File

@@ -212,7 +212,7 @@ pub struct ServiceMonitor {
pub name: String,
// # Additional labels to set used for the ServiceMonitorSelector. Together with standard labels from the chart
pub additional_labels: Option<Mapping>,
pub additional_labels: Option<HashMap<String, String>>,
// # Service label for use in assembling a job name of the form <label value>-<port>
// # If no label is specified, the service name is used.
@@ -240,7 +240,7 @@ pub struct ServiceMonitor {
// any: bool,
// # Explicit list of namespace names to select
// matchNames: Vec,
pub namespace_selector: Option<Mapping>,
pub namespace_selector: Option<NamespaceSelector>,
// # Endpoints of the selected service to be monitored
pub endpoints: Vec<ServiceMonitorEndpoint>,
@@ -250,6 +250,13 @@ pub struct ServiceMonitor {
pub fallback_scrape_protocol: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct NamespaceSelector {
pub any: bool,
pub match_names: Vec<String>,
}
impl Default for ServiceMonitor {
fn default() -> Self {
Self {

View File

@@ -1,4 +1,7 @@
pub mod alert_channel;
pub mod alert_rule;
pub mod application_monitoring;
pub mod grafana;
pub mod kube_prometheus;
pub mod ntfy;
pub mod prometheus;

View File

@@ -3,7 +3,7 @@ use std::str::FromStr;
use crate::modules::helm::chart::{HelmChartScore, HelmRepository};
pub fn ntfy_helm_chart_score(namespace: String) -> HelmChartScore {
pub fn ntfy_helm_chart_score(namespace: String, host: String) -> HelmChartScore {
let values = format!(
r#"
replicaCount: 1
@@ -28,12 +28,12 @@ service:
port: 80
ingress:
enabled: false
enabled: true
# annotations:
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
- host: ntfy.host.com
- host: {host}
paths:
- path: /
pathType: ImplementationSpecific

View File

@@ -17,6 +17,7 @@ use crate::{
#[derive(Debug, Clone, Serialize)]
pub struct NtfyScore {
pub namespace: String,
pub host: String,
}
impl<T: Topology + HelmCommand + K8sclient> Score<T> for NtfyScore {
@@ -126,7 +127,7 @@ impl<T: Topology + HelmCommand + K8sclient> Interpret<T> for NtfyInterpret {
inventory: &Inventory,
topology: &T,
) -> Result<Outcome, InterpretError> {
ntfy_helm_chart_score(self.score.namespace.clone())
ntfy_helm_chart_score(self.score.namespace.clone(), self.score.host.clone())
.create_interpret()
.execute(inventory, topology)
.await?;

View File

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

View File

@@ -0,0 +1,47 @@
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use non_blank_string_rs::NonBlankString;
use crate::modules::{
helm::chart::HelmChartScore, monitoring::prometheus::prometheus_config::PrometheusConfig,
};
pub fn prometheus_helm_chart_score(config: Arc<Mutex<PrometheusConfig>>) -> HelmChartScore {
let config = config.lock().unwrap();
let ns = config.namespace.clone().unwrap();
let values = format!(
r#"
rbac:
create: true
kube-state-metrics:
enabled: false
nodeExporter:
enabled: false
alertmanager:
enabled: false
pushgateway:
enabled: false
server:
serviceAccount:
create: false
rbac:
create: true
fullnameOverride: prometheus-{ns}
"#
);
HelmChartScore {
namespace: Some(NonBlankString::from_str(&config.namespace.clone().unwrap()).unwrap()),
release_name: NonBlankString::from_str("prometheus").unwrap(),
chart_name: NonBlankString::from_str(
"oci://ghcr.io/prometheus-community/charts/prometheus",
)
.unwrap(),
chart_version: None,
values_overrides: None,
values_yaml: Some(values.to_string()),
create_namespace: true,
install_only: true,
repository: None,
}
}

View File

@@ -0,0 +1,3 @@
pub mod helm;
pub mod prometheus;
pub mod prometheus_config;

View File

@@ -0,0 +1,190 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use log::{debug, error};
use serde::Serialize;
use crate::{
interpret::{InterpretError, Outcome},
inventory::Inventory,
modules::monitoring::{
alert_rule::prometheus_alert_rule::AlertManagerRuleGroup,
grafana::helm::helm_grafana::grafana_helm_chart_score,
kube_prometheus::types::{AlertManagerAdditionalPromRules, AlertManagerChannelConfig},
},
score::Score,
topology::{
HelmCommand, Topology,
installable::Installable,
oberservability::monitoring::{AlertReceiver, AlertRule, AlertSender},
tenant::TenantManager,
},
};
use super::{
helm::prometheus_helm::prometheus_helm_chart_score, prometheus_config::PrometheusConfig,
};
#[derive(Debug)]
pub struct Prometheus {
pub config: Arc<Mutex<PrometheusConfig>>,
}
#[async_trait]
impl AlertSender for Prometheus {
fn name(&self) -> String {
"Prometheus".to_string()
}
}
impl Prometheus {
pub fn new() -> Self {
Self {
config: Arc::new(Mutex::new(PrometheusConfig::new())),
}
}
pub async fn configure_with_topology<T: TenantManager>(&self, topology: &T) {
let ns = topology
.get_tenant_config()
.await
.map(|cfg| cfg.name.clone())
.unwrap_or_else(|| "monitoring".to_string());
error!("This must be refactored, see comments in pr #74");
debug!("NS: {}", ns);
self.config.lock().unwrap().namespace = Some(ns);
}
pub async fn install_receiver(
&self,
prometheus_receiver: &dyn PrometheusReceiver,
) -> Result<Outcome, InterpretError> {
let prom_receiver = prometheus_receiver.configure_receiver().await;
debug!(
"adding alert receiver to prometheus config: {:#?}",
&prom_receiver
);
let mut config = self.config.lock().unwrap();
config.alert_receiver_configs.push(prom_receiver);
let prom_receiver_name = prometheus_receiver.name();
debug!("installed alert receiver {}", &prom_receiver_name);
Ok(Outcome::success(format!(
"Sucessfully installed receiver {}",
prom_receiver_name
)))
}
pub async fn install_rule(
&self,
prometheus_rule: &AlertManagerRuleGroup,
) -> Result<Outcome, InterpretError> {
let prometheus_rule = prometheus_rule.configure_rule().await;
let mut config = self.config.lock().unwrap();
config.alert_rules.push(prometheus_rule.clone());
Ok(Outcome::success(format!(
"Successfully installed alert rule: {:#?},",
prometheus_rule
)))
}
pub async fn install_prometheus<T: Topology + HelmCommand + Send + Sync>(
&self,
inventory: &Inventory,
topology: &T,
) -> Result<Outcome, InterpretError> {
prometheus_helm_chart_score(self.config.clone())
.create_interpret()
.execute(inventory, topology)
.await
}
pub async fn install_grafana<T: Topology + HelmCommand + Send + Sync>(
&self,
inventory: &Inventory,
topology: &T,
) -> Result<Outcome, InterpretError> {
let namespace = {
let config = self.config.lock().unwrap();
config.namespace.clone()
};
if let Some(ns) = namespace.as_deref() {
grafana_helm_chart_score(ns)
.create_interpret()
.execute(inventory, topology)
.await
} else {
Err(InterpretError::new(format!(
"could not install grafana, missing namespace",
)))
}
}
}
#[async_trait]
impl<T: Topology + HelmCommand + TenantManager> Installable<T> for Prometheus {
async fn configure(&self, _inventory: &Inventory, topology: &T) -> Result<(), InterpretError> {
self.configure_with_topology(topology).await;
Ok(())
}
async fn ensure_installed(
&self,
inventory: &Inventory,
topology: &T,
) -> Result<(), InterpretError> {
self.install_prometheus(inventory, topology).await?;
let install_grafana = {
let config = self.config.lock().unwrap();
config.grafana
};
if install_grafana {
self.install_grafana(inventory, topology).await?;
}
Ok(())
}
}
#[async_trait]
pub trait PrometheusReceiver: Send + Sync + std::fmt::Debug {
fn name(&self) -> String;
async fn configure_receiver(&self) -> AlertManagerChannelConfig;
}
impl Serialize for Box<dyn AlertReceiver<Prometheus>> {
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
todo!()
}
}
impl Clone for Box<dyn AlertReceiver<Prometheus>> {
fn clone(&self) -> Self {
self.clone_box()
}
}
#[async_trait]
pub trait PrometheusRule: Send + Sync + std::fmt::Debug {
fn name(&self) -> String;
async fn configure_rule(&self) -> AlertManagerAdditionalPromRules;
}
impl Serialize for Box<dyn AlertRule<Prometheus>> {
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
todo!()
}
}
impl Clone for Box<dyn AlertRule<Prometheus>> {
fn clone(&self) -> Self {
self.clone_box()
}
}

View File

@@ -0,0 +1,34 @@
use crate::modules::monitoring::kube_prometheus::types::{
AlertManagerAdditionalPromRules, AlertManagerChannelConfig, ServiceMonitor,
};
#[derive(Debug)]
pub struct PrometheusConfig {
pub namespace: Option<String>,
pub default_rules: bool,
pub alert_manager: bool,
pub node_exporter: bool,
pub kube_state_metrics: bool,
pub grafana: bool,
pub prometheus_pushgateway: bool,
pub alert_receiver_configs: Vec<AlertManagerChannelConfig>,
pub alert_rules: Vec<AlertManagerAdditionalPromRules>,
pub additional_service_monitors: Vec<ServiceMonitor>,
}
impl PrometheusConfig {
pub fn new() -> Self {
Self {
namespace: None,
default_rules: true,
alert_manager: true,
node_exporter: false,
kube_state_metrics: false,
grafana: true,
prometheus_pushgateway: false,
alert_receiver_configs: vec![],
alert_rules: vec![],
additional_service_monitors: vec![],
}
}
}

View File

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

View File

@@ -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<Mutex<HashMap<String, MultiProgress>>> =
Arc::new(Mutex::new(HashMap::new()));
let progress_bars: Arc<Mutex<HashMap<String, ProgressBar>>> =
Arc::new(Mutex::new(HashMap::new()));
move |event| {
let sections_clone = Arc::clone(&sections);
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;
}

View File

@@ -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<T: Topology + Send + Sync + 'static>(
// Run filtered scores
for s in scores_vec {
println!("Running: {}", s.name());
debug!("Running: {}", s.name());
maestro.interpret(s).await?;
}

View File

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

26
harmony_cli/src/theme.rs Normal file
View File

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

View File

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

View File

@@ -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<Mutex<HashMap<String, MultiProgress>>> =
Arc::new(Mutex::new(HashMap::new()));
let compilation_progress = Arc::new(Mutex::new(None::<ProgressBar>));
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
}

View File

@@ -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<broadcast::Sender<HarmonyComposerEvent>> =
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<F, Fut>(name: &str, mut handler: F)
where
F: FnMut(HarmonyComposerEvent) -> Fut + Send + 'static,
Fut: Future<Output = bool> + 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,
}
}
}

View File

@@ -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) => {
@@ -98,7 +106,9 @@ async fn main() {
.expect("couldn't check if path exists")
{
true => (),
false => todo!("implement couldn't find path logic"),
false => todo!(
"path {check_script_str} not found. Other paths currently unsupported."
),
};
let check_output = Command::new(check_script)
@@ -114,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"
@@ -135,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)]
@@ -155,6 +181,7 @@ async fn compile_harmony(
let cargo_exists = Command::new("which")
.arg("cargo")
.stdout(Stdio::null())
.status()
.expect("couldn't get `which cargo` status")
.success();
@@ -163,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
@@ -183,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![
@@ -192,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");
@@ -207,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")
@@ -234,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 {

View File

@@ -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<Client, String> {
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<Client, String> {
warn!("TODO this method is way too dumb, it should make sure that the client is connected to the k3d cluster actually represented by this instance, not just any default client");
// 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(())
}
}

View File

@@ -22,4 +22,4 @@ tokio-util = { version = "0.7.13", features = [ "codec" ] }
tokio-stream = "0.1.17"
[dev-dependencies]
pretty_assertions = "1.4.1"
pretty_assertions.workspace = true