feat/cd/localdeploymentdemo #79

Merged
johnride merged 2 commits from feat/cd/localdeploymentdemo into feat/oci 2025-07-03 19:34:33 +00:00
9 changed files with 190 additions and 40 deletions

1
Cargo.lock generated
View File

@ -1765,6 +1765,7 @@ dependencies = [
"strum 0.27.1", "strum 0.27.1",
"temp-dir", "temp-dir",
"temp-file", "temp-file",
"tempfile",
"tokio", "tokio",
"tokio-util", "tokio-util",
"url", "url",

View File

@ -17,6 +17,7 @@ async fn main() {
project_root: PathBuf::from("./examples/rust/webapp"), project_root: PathBuf::from("./examples/rust/webapp"),
framework: Some(RustWebFramework::Leptos), framework: Some(RustWebFramework::Leptos),
}; };
// TODO RustWebappScore should simply take a RustWebApp as config
let app = RustWebappScore { let app = RustWebappScore {
name: "Example Rust Webapp".to_string(), name: "Example Rust Webapp".to_string(),
domain: Url::Url(url::Url::parse("https://rustapp.harmony.example.com").unwrap()), domain: Url::Url(url::Url::parse("https://rustapp.harmony.example.com").unwrap()),

View File

@ -57,3 +57,4 @@ similar.workspace = true
futures-util = "0.3.31" futures-util = "0.3.31"
tokio-util = "0.7.15" tokio-util = "0.7.15"
strum = { version = "0.27.1", features = ["derive"] } strum = { version = "0.27.1", features = ["derive"] }
tempfile = "3.20.0"

View File

@ -2,7 +2,7 @@ use lazy_static::lazy_static;
use std::path::PathBuf; use std::path::PathBuf;
lazy_static! { lazy_static! {
pub static ref HARMONY_CONFIG_DIR: PathBuf = directories::BaseDirs::new() pub static ref HARMONY_DATA_DIR: PathBuf = directories::BaseDirs::new()
.unwrap() .unwrap()
.data_dir() .data_dir()
.join("harmony"); .join("harmony");

View File

@ -16,7 +16,7 @@ use crate::{
}; };
use super::{ use super::{
HelmCommand, K8sclient, Topology, DeploymentTarget, HelmCommand, K8sclient, MultiTargetTopology, Topology,
k8s::K8sClient, k8s::K8sClient,
tenant::{TenantConfig, TenantManager, k8s::K8sTenantManager}, tenant::{TenantConfig, TenantManager, k8s::K8sTenantManager},
}; };
@ -246,6 +246,7 @@ pub struct K8sAnywhereConfig {
/// ///
/// default: true /// default: true
pub use_local_k3d: bool, pub use_local_k3d: bool,
harmony_profile: String,
} }
impl K8sAnywhereConfig { impl K8sAnywhereConfig {
@ -256,6 +257,11 @@ impl K8sAnywhereConfig {
.map_or_else(|_| false, |v| v.parse().ok().unwrap_or(false)), .map_or_else(|_| false, |v| v.parse().ok().unwrap_or(false)),
autoinstall: std::env::var("HARMONY_AUTOINSTALL") autoinstall: std::env::var("HARMONY_AUTOINSTALL")
.map_or_else(|_| false, |v| v.parse().ok().unwrap_or(false)), .map_or_else(|_| false, |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(),
|v| v.parse().ok().unwrap_or("dev".to_string()),
),
use_local_k3d: std::env::var("HARMONY_USE_LOCAL_K3D") use_local_k3d: std::env::var("HARMONY_USE_LOCAL_K3D")
.map_or_else(|_| true, |v| v.parse().ok().unwrap_or(true)), .map_or_else(|_| true, |v| v.parse().ok().unwrap_or(true)),
} }
@ -292,6 +298,20 @@ impl Topology for K8sAnywhereTopology {
} }
} }
impl MultiTargetTopology for K8sAnywhereTopology {
fn current_target(&self) -> DeploymentTarget {
if self.config.use_local_k3d {
return DeploymentTarget::LocalDev;
}
match self.config.harmony_profile.to_lowercase().as_str() {
"staging" => DeploymentTarget::Staging,
"production" => DeploymentTarget::Production,
_ => todo!("HARMONY_PROFILE must be set when use_local_k3d is not set"),
}
}
}
impl HelmCommand for K8sAnywhereTopology {} impl HelmCommand for K8sAnywhereTopology {}
#[async_trait] #[async_trait]

View File

@ -62,6 +62,17 @@ pub trait Topology: Send + Sync {
async fn ensure_ready(&self) -> Result<Outcome, InterpretError>; async fn ensure_ready(&self) -> Result<Outcome, InterpretError>;
} }
#[derive(Debug)]
pub enum DeploymentTarget {
LocalDev,
Staging,
Production,
}
pub trait MultiTargetTopology: Topology {
fn current_target(&self) -> DeploymentTarget;
}
pub type IpAddress = IpAddr; pub type IpAddress = IpAddr;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View File

@ -1,10 +1,12 @@
use std::sync::Arc; use std::{io::Write, process::Command, sync::Arc};
use async_trait::async_trait; use async_trait::async_trait;
use log::{error, info}; use log::{error, info};
use serde_json::Value; use serde_json::Value;
use tempfile::NamedTempFile;
use crate::{ use crate::{
config::HARMONY_DATA_DIR,
data::Version, data::Version,
inventory::Inventory, inventory::Inventory,
modules::{ modules::{
@ -12,7 +14,7 @@ use crate::{
helm::chart::HelmChartScore, helm::chart::HelmChartScore,
}, },
score::Score, score::Score,
topology::{HelmCommand, Topology, Url}, topology::{DeploymentTarget, HelmCommand, MultiTargetTopology, Topology, Url},
}; };
/// ContinuousDelivery in Harmony provides this functionality : /// ContinuousDelivery in Harmony provides this functionality :
@ -47,9 +49,98 @@ pub struct ContinuousDelivery<A: OCICompliant + HelmPackage> {
pub application: Arc<A>, pub application: Arc<A>,
} }
impl<A: OCICompliant + HelmPackage> ContinuousDelivery<A> {
async fn deploy_to_local_k3d(
&self,
app_name: String,
chart_url: String,
image_name: String,
) -> Result<(), String> {
error!(
"FIXME This works only with local k3d installations, which is fine only for current demo purposes. We assume usage of K8sAnywhereTopology"
);
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!(
"Importing image '{}' into k3d cluster 'harmony'",
image_name
);
let import_output = Command::new(&k3d_bin_path)
.args(["image", "import", &image_name, "--cluster", "harmony"])
.output()
.map_err(|e| format!("Failed to execute k3d image import: {}", e))?;
if !import_output.status.success() {
return Err(format!(
"Failed to import image to k3d: {}",
String::from_utf8_lossy(&import_output.stderr)
));
}
// --- 2. Get the kubeconfig for the k3d cluster and write it to a temp file ---
info!("Retrieving kubeconfig for k3d cluster 'harmony'");
let kubeconfig_output = Command::new(&k3d_bin_path)
.args(["kubeconfig", "get", "harmony"])
.output()
.map_err(|e| format!("Failed to execute k3d kubeconfig get: {}", e))?;
if !kubeconfig_output.status.success() {
return Err(format!(
"Failed to get kubeconfig from k3d: {}",
String::from_utf8_lossy(&kubeconfig_output.stderr)
));
}
let mut temp_kubeconfig = NamedTempFile::new()
.map_err(|e| format!("Failed to create temp file for kubeconfig: {}", e))?;
temp_kubeconfig
.write_all(&kubeconfig_output.stdout)
.map_err(|e| format!("Failed to write to temp kubeconfig file: {}", e))?;
let kubeconfig_path = temp_kubeconfig.path().to_str().unwrap();
// --- 3. Install or upgrade the Helm chart in the cluster ---
info!(
"Deploying Helm chart '{}' to namespace '{}'",
chart_url, app_name
);
let release_name = app_name.to_lowercase(); // Helm release names are often lowercase
let helm_output = Command::new("helm")
.args([
"upgrade",
"--install",
&release_name,
&chart_url,
"--namespace",
&app_name,
"--create-namespace",
"--wait", // Wait for the deployment to be ready
"--kubeconfig",
kubeconfig_path,
])
.spawn()
.map_err(|e| format!("Failed to execute helm upgrade: {}", e))?
.wait_with_output()
.map_err(|e| format!("Failed to execute helm upgrade: {}", e))?;
if !helm_output.status.success() {
return Err(format!(
"Failed to deploy Helm chart: {}",
String::from_utf8_lossy(&helm_output.stderr)
));
}
info!("Successfully deployed '{}' to local k3d cluster.", app_name);
Ok(())
}
}
#[async_trait] #[async_trait]
impl<A: OCICompliant + HelmPackage + Clone + 'static, T: Topology + HelmCommand + 'static> impl<
ApplicationFeature<T> for ContinuousDelivery<A> A: OCICompliant + HelmPackage + Clone + 'static,
T: Topology + HelmCommand + MultiTargetTopology + 'static,
> ApplicationFeature<T> for ContinuousDelivery<A>
{ {
async fn ensure_installed(&self, topology: &T) -> Result<(), String> { async fn ensure_installed(&self, topology: &T) -> Result<(), String> {
let image = self.application.image_name(); let image = self.application.image_name();
@ -62,31 +153,56 @@ impl<A: OCICompliant + HelmPackage + Clone + 'static, T: Topology + HelmCommand
let helm_chart = self.application.build_push_helm_package(&image).await?; let helm_chart = self.application.build_push_helm_package(&image).await?;
info!("Pushed new helm chart {helm_chart}"); info!("Pushed new helm chart {helm_chart}");
let image = self.application.build_push_oci_image().await?; // let image = self.application.build_push_oci_image().await?;
info!("Pushed new docker image {image}"); // info!("Pushed new docker image {image}");
error!("uncomment above");
info!("Installing ContinuousDelivery feature"); info!("Installing ContinuousDelivery feature");
let cd_server = HelmChartScore { // TODO this is a temporary hack for demo purposes, the deployment target should be driven
namespace: todo!( // by the topology only and we should not have to know how to perform tasks like this for
"ArgoCD Helm chart with proper understanding of Tenant, see how Will did it for Monitoring for now" // which the topology should be responsible.
), //
release_name: todo!("argocd helm chart whatever"), // That said, this will require some careful architectural decisions, since the concept of
chart_name: todo!(), // deployment targets / profiles is probably a layer of complexity that we won't be
chart_version: todo!(), // completely able to avoid
values_overrides: todo!(), //
values_yaml: todo!(), // I'll try something for now that must be thought through after : att a deployment_profile
create_namespace: todo!(), // function to the topology trait that returns a profile, then anybody who needs it can
install_only: todo!(), // access it. This forces every Topology to understand the concept of targets though... So
repository: todo!(), // instead I'll create a new Capability which is MultiTargetTopology and we'll see how it
// goes. It still does not feel right though.
match topology.current_target() {
DeploymentTarget::LocalDev => {
self.deploy_to_local_k3d(self.application.name(), helm_chart, image)
.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!(),
};
let interpret = cd_server.create_interpret();
interpret.execute(&Inventory::empty(), topology);
}
}; };
let interpret = cd_server.create_interpret();
interpret.execute(&Inventory::empty(), topology);
todo!("1. Create ArgoCD score that installs argo using helm chart, see if Taha's already done it todo!("1. Create ArgoCD score that installs argo using helm chart, see if Taha's already done it
2. Package app (docker image, helm chart) - [X] Package app (docker image, helm chart)
3. Push to registry if staging or prod - [X] Push to registry
4. Poke Argo - [ ] Push only if staging or prod
5. Ensure app is up") - [ ] Deploy to local k3d when target is local
- [ ] Poke Argo
- [ ] Ensure app is up")
} }
fn name(&self) -> String { fn name(&self) -> String {
"ContinuousDelivery".to_string() "ContinuousDelivery".to_string()

View File

@ -378,10 +378,10 @@ image:
service: service:
type: ClusterIP type: ClusterIP
port: 80 port: 3000
ingress: ingress:
enabled: false enabled: true
# Annotations for cert-manager to handle SSL. # Annotations for cert-manager to handle SSL.
annotations: annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod" cert-manager.io/cluster-issuer: "letsencrypt-prod"
@ -432,7 +432,7 @@ spec:
type: {{ .Values.service.type }} type: {{ .Values.service.type }}
ports: ports:
- port: {{ .Values.service.port }} - port: {{ .Values.service.port }}
targetPort: http targetPort: 3000
protocol: TCP protocol: TCP
name: http name: http
selector: selector:
@ -462,7 +462,7 @@ spec:
imagePullPolicy: {{ .Values.image.pullPolicy }} imagePullPolicy: {{ .Values.image.pullPolicy }}
ports: ports:
- name: http - name: http
containerPort: 8080 # Assuming the rust app listens on 8080 containerPort: 3000
protocol: TCP protocol: TCP
"#; "#;
fs::write(templates_dir.join("deployment.yaml"), deployment_yaml)?; fs::write(templates_dir.join("deployment.yaml"), deployment_yaml)?;
@ -499,7 +499,7 @@ spec:
service: service:
name: {{ include "chart.fullname" $ }} name: {{ include "chart.fullname" $ }}
port: port:
name: http number: 3000
{{- end }} {{- end }}
{{- end }} {{- end }}
{{- end }} {{- end }}
@ -549,25 +549,25 @@ spec:
) -> Result<String, Box<dyn std::error::Error>> { ) -> Result<String, Box<dyn std::error::Error>> {
// The chart name is the file stem of the .tgz file // The chart name is the file stem of the .tgz file
let chart_file_name = packaged_chart_path.file_stem().unwrap().to_str().unwrap(); let chart_file_name = packaged_chart_path.file_stem().unwrap().to_str().unwrap();
let oci_url = format!( let oci_push_url = format!("oci://{}/{}", *REGISTRY_URL, *REGISTRY_PROJECT);
"oci://{}/{}/{}-chart", let oci_pull_url = format!("{oci_push_url}/{}-chart", self.name);
*REGISTRY_URL, *REGISTRY_PROJECT, self.name
);
info!( info!(
"Pushing Helm chart {} to {}", "Pushing Helm chart {} to {}",
packaged_chart_path.to_string_lossy(), packaged_chart_path.to_string_lossy(),
oci_url oci_push_url
); );
let output = process::Command::new("helm") let output = process::Command::new("helm")
.args(["push", packaged_chart_path.to_str().unwrap(), &oci_url]) .args(["push", packaged_chart_path.to_str().unwrap(), &oci_push_url])
.output()?; .output()?;
self.check_output(&output, "Pushing Helm chart failed")?; self.check_output(&output, "Pushing Helm chart failed")?;
// The final URL includes the version tag, which is part of the file name // The final URL includes the version tag, which is part of the file name
let version = chart_file_name.rsplit_once('-').unwrap().1; let version = chart_file_name.rsplit_once('-').unwrap().1;
Ok(format!("{}:{}", oci_url, version)) debug!("pull url {oci_pull_url}");
debug!("push url {oci_push_url}");
Ok(format!("{}:{}", oci_pull_url, version))
} }
} }

View File

@ -5,7 +5,7 @@ use log::info;
use serde::Serialize; use serde::Serialize;
use crate::{ use crate::{
config::HARMONY_CONFIG_DIR, config::HARMONY_DATA_DIR,
data::{Id, Version}, data::{Id, Version},
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}, interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
inventory::Inventory, inventory::Inventory,
@ -22,7 +22,7 @@ pub struct K3DInstallationScore {
impl Default for K3DInstallationScore { impl Default for K3DInstallationScore {
fn default() -> Self { fn default() -> Self {
Self { Self {
installation_path: HARMONY_CONFIG_DIR.join("k3d"), installation_path: HARMONY_DATA_DIR.join("k3d"),
cluster_name: "harmony".to_string(), cluster_name: "harmony".to_string(),
} }
} }