diff --git a/Cargo.lock b/Cargo.lock index 776066d..f84d847 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -524,6 +524,15 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1383,6 +1392,7 @@ version = "0.1.0" dependencies = [ "async-trait", "cidr", + "convert_case", "derive-new", "directories", "dockerfile_builder", diff --git a/Cargo.toml b/Cargo.toml index 48fe426..8dd08bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ serde_yaml = "0.9.34" serde-value = "0.7.0" http = "1.2.0" inquire = "0.7.5" +convert_case = "0.8.0" [workspace.dependencies.uuid] version = "1.11.0" diff --git a/examples/lamp/src/main.rs b/examples/lamp/src/main.rs index 9d51d0b..41adfb4 100644 --- a/examples/lamp/src/main.rs +++ b/examples/lamp/src/main.rs @@ -8,17 +8,30 @@ use harmony::{ #[tokio::main] async fn main() { - // let _ = env_logger::Builder::from_default_env().filter_level(log::LevelFilter::Info).try_init(); + // This here is the whole configuration to + // - setup a local K3D cluster + // - Build a docker image with the PHP project builtin and production grade settings + // - Deploy a mariadb database using a production grade helm chart + // - Deploy the new container using a kubernetes deployment + // - Configure networking between the PHP container and the database + // - Provision a public route and an SSL certificate automatically on production environments + // + // Enjoy :) let lamp_stack = LAMPScore { name: "harmony-lamp-demo".to_string(), domain: Url::Url(url::Url::parse("https://lampdemo.harmony.nationtech.io").unwrap()), php_version: Version::from("8.4.4").unwrap(), + // This config can be extended as needed for more complicated configurations config: LAMPConfig { project_root: "./php".into(), ..Default::default() }, }; + // You can choose the type of Topology you want, we suggest starting with the + // K8sAnywhereTopology as it is the most automatic one that enables you to easily deploy + // locally, to development environment from a CI, to staging, and to production with settings + // that automatically adapt to each environment grade. let mut maestro = Maestro::::initialize( Inventory::autoload(), K8sAnywhereTopology::new(), @@ -26,5 +39,7 @@ async fn main() { .await .unwrap(); maestro.register_all(vec![Box::new(lamp_stack)]); + // Here we bootstrap the CLI, this gives some nice features if you need them harmony_cli::init(maestro, None).await.unwrap(); } +// That's it, end of the infra as code. diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index 959f2da..02a0ce7 100644 --- a/harmony/Cargo.toml +++ b/harmony/Cargo.toml @@ -13,23 +13,23 @@ rust-ipmi = "0.1.1" semver = "1.0.23" serde = { version = "1.0.209", features = ["derive"] } serde_json = "1.0.127" -tokio = { workspace = true } -derive-new = { workspace = true } -log = { workspace = true } -env_logger = { workspace = true } -async-trait = { workspace = true } -cidr = { workspace = true } +tokio.workspace = true +derive-new.workspace = true +log.workspace = true +env_logger.workspace = true +async-trait.workspace = true +cidr.workspace = true opnsense-config = { path = "../opnsense-config" } opnsense-config-xml = { path = "../opnsense-config-xml" } harmony_macros = { path = "../harmony_macros" } harmony_types = { path = "../harmony_types" } -uuid = { workspace = true } -url = { workspace = true } -kube = { workspace = true } -k8s-openapi = { workspace = true } -serde_yaml = { workspace = true } -http = { workspace = true } -serde-value = { workspace = true } +uuid.workspace = true +url.workspace = true +kube.workspace = true +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" @@ -38,3 +38,4 @@ directories = "6.0.0" lazy_static = "1.5.0" dockerfile_builder = "0.1.5" temp-file = "0.1.9" +convert_case.workspace = true diff --git a/harmony/src/domain/config.rs b/harmony/src/domain/config.rs index 320e9a0..0fa059f 100644 --- a/harmony/src/domain/config.rs +++ b/harmony/src/domain/config.rs @@ -6,4 +6,8 @@ lazy_static! { .unwrap() .data_dir() .join("harmony"); + pub static ref REGISTRY_URL: String = std::env::var("HARMONY_REGISTRY_URL") + .unwrap_or_else(|_| "hub.nationtech.io".to_string()); + pub static ref REGISTRY_PROJECT: String = + std::env::var("HARMONY_REGISTRY_PROJECT").unwrap_or_else(|_| "harmony".to_string()); } diff --git a/harmony/src/domain/topology/k8s.rs b/harmony/src/domain/topology/k8s.rs index beecbf0..57b3668 100644 --- a/harmony/src/domain/topology/k8s.rs +++ b/harmony/src/domain/topology/k8s.rs @@ -38,7 +38,7 @@ impl K8sClient { Ok(result) } - pub async fn apply_namespaced(&self, resource: &Vec) -> Result + pub async fn apply_namespaced(&self, resource: &Vec, ns: Option<&str>) -> Result where K: Resource + Clone @@ -49,7 +49,10 @@ impl K8sClient { ::DynamicType: Default, { for r in resource.iter() { - let api: Api = Api::default_namespaced(self.client.clone()); + let api: Api = match ns { + Some(ns) => Api::namespaced(self.client.clone(), ns), + None => Api::default_namespaced(self.client.clone()), + }; api.create(&PostParams::default(), &r).await?; } todo!("") diff --git a/harmony/src/modules/k8s/deployment.rs b/harmony/src/modules/k8s/deployment.rs index 9e7178f..55f581f 100644 --- a/harmony/src/modules/k8s/deployment.rs +++ b/harmony/src/modules/k8s/deployment.rs @@ -1,4 +1,5 @@ -use k8s_openapi::api::apps::v1::Deployment; +use k8s_openapi::{DeepMerge, api::apps::v1::Deployment}; +use log::debug; use serde::Serialize; use serde_json::json; @@ -14,11 +15,13 @@ use super::resource::{K8sResourceInterpret, K8sResourceScore}; pub struct K8sDeploymentScore { pub name: String, pub image: String, + pub namespace: Option, + pub env_vars: serde_json::Value, } impl Score for K8sDeploymentScore { fn create_interpret(&self) -> Box> { - let deployment: Deployment = serde_json::from_value(json!( + let deployment = json!( { "metadata": { "name": self.name @@ -38,18 +41,21 @@ impl Score for K8sDeploymentScore { "spec": { "containers": [ { - "image": self.image, - "name": self.image + "image": self.image, + "name": self.name, + "imagePullPolicy": "IfNotPresent", + "env": self.env_vars, } ] } } } } - )) - .unwrap(); + ); + + let deployment: Deployment = serde_json::from_value(deployment).unwrap(); Box::new(K8sResourceInterpret { - score: K8sResourceScore::single(deployment.clone()), + score: K8sResourceScore::single(deployment.clone(), self.namespace.clone()), }) } diff --git a/harmony/src/modules/k8s/resource.rs b/harmony/src/modules/k8s/resource.rs index 4e54be7..6880292 100644 --- a/harmony/src/modules/k8s/resource.rs +++ b/harmony/src/modules/k8s/resource.rs @@ -14,12 +14,14 @@ use crate::{ #[derive(Debug, Clone, Serialize)] pub struct K8sResourceScore { pub resource: Vec, + pub namespace: Option, } impl K8sResourceScore { - pub fn single(resource: K) -> Self { + pub fn single(resource: K, namespace: Option) -> Self { Self { resource: vec![resource], + namespace, } } } @@ -77,7 +79,7 @@ where .k8s_client() .await .expect("Environment should provide enough information to instanciate a client") - .apply_namespaced(&self.score.resource) + .apply_namespaced(&self.score.resource, self.score.namespace.as_deref()) .await?; Ok(Outcome::success( diff --git a/harmony/src/modules/lamp.rs b/harmony/src/modules/lamp.rs index 5cb948f..2110904 100644 --- a/harmony/src/modules/lamp.rs +++ b/harmony/src/modules/lamp.rs @@ -1,14 +1,17 @@ +use convert_case::{Case, Casing}; use dockerfile_builder::instruction::{CMD, COPY, ENV, EXPOSE, FROM, RUN, WORKDIR}; use dockerfile_builder::{Dockerfile, instruction_builder::EnvBuilder}; use non_blank_string_rs::NonBlankString; +use serde_json::json; use std::fs; use std::path::{Path, PathBuf}; use std::str::FromStr; use async_trait::async_trait; -use log::info; +use log::{debug, info}; use serde::Serialize; +use crate::config::{REGISTRY_PROJECT, REGISTRY_URL}; use crate::topology::HelmCommand; use crate::{ data::{Id, Version}, @@ -80,22 +83,49 @@ impl Interpret for LAMPInterpret { }; info!("LAMP docker image built {image_name}"); + let remote_name = match self.push_docker_image(&image_name) { + Ok(remote_name) => remote_name, + Err(e) => { + return Err(InterpretError::new(format!( + "Could not push docker image {e}" + ))); + } + }; + info!("LAMP docker image pushed to {remote_name}"); + info!("Deploying database"); self.deploy_database(inventory, topology).await?; + let base_name = self.score.name.to_case(Case::Kebab); + let secret_name = format!("{}-database-mariadb", base_name); + let deployment_score = K8sDeploymentScore { - name: >::name(&self.score), - image: image_name, + name: >::name(&self.score).to_case(Case::Kebab), + image: remote_name, + namespace: self.get_namespace().map(|nbs| nbs.to_string()), + env_vars: json!([ + { + "name": "MYSQL_PASSWORD", + "valueFrom": { + "secretKeyRef": { + "name": secret_name, + "key": "mariadb-root-password" + } + } + }, + ]), }; + info!("Deploying score {deployment_score:#?}"); + deployment_score .create_interpret() .execute(inventory, topology) .await?; info!("LAMP deployment_score {deployment_score:?}"); - todo!("1. Use HelmChartScore to deploy mariadb - 2. Use deploymentScore to deploy lamp docker container + todo!("1. [x] Use HelmChartScore to deploy mariadb + 2. [x] Use deploymentScore to deploy lamp docker container 3. for remote clusters, push the image to some registry (use nationtech's for demos? push to the cluster's registry?)"); } @@ -258,6 +288,43 @@ opcache.fast_shutdown=1 Ok(dockerfile_path) } + fn check_output( + &self, + output: &std::process::Output, + msg: &str, + ) -> Result<(), Box> { + if !output.status.success() { + return Err(format!("{msg}: {}", String::from_utf8_lossy(&output.stderr)).into()); + } + Ok(()) + } + + fn push_docker_image(&self, image_name: &str) -> Result> { + let full_tag = format!("{}/{}/{}", *REGISTRY_URL, *REGISTRY_PROJECT, &image_name); + let output = std::process::Command::new("docker") + .args(["tag", image_name, &full_tag]) + .output()?; + self.check_output(&output, "Tagging docker image failed")?; + + debug!( + "docker tag output {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let output = std::process::Command::new("docker") + .args(["push", &full_tag]) + .output()?; + self.check_output(&output, "Pushing docker image failed")?; + debug!( + "docker push output {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + Ok(full_tag) + } + pub fn build_docker_image(&self) -> Result> { info!("Generating Dockerfile"); let dockerfile = self.build_dockerfile(&self.score)?;