harmony/harmony/src/modules/application/features/packaging_deployment.rs
Jean-Gabriel Gill-Couture 4df451bc41
All checks were successful
Run Check Script / check (pull_request) Successful in 1m2s
Merge remote-tracking branch 'origin/master' into fix/demo
2025-09-10 12:59:58 -04:00

236 lines
8.9 KiB
Rust

use std::{io::Write, process::Command, sync::Arc};
use async_trait::async_trait;
use log::info;
use serde_yaml::Value;
use tempfile::NamedTempFile;
use crate::{
config::HARMONY_DATA_DIR,
data::Version,
inventory::Inventory,
modules::application::{
ApplicationFeature, HelmPackage, InstallationError, InstallationOutcome, OCICompliant,
features::{ArgoApplication, ArgoHelmScore},
},
score::Score,
topology::{
DeploymentTarget, HelmCommand, K8sclient, MultiTargetTopology, Topology, ingress::Ingress,
},
};
/// ContinuousDelivery in Harmony provides this functionality :
///
/// - **Package** the application
/// - **Push** to an artifact registry
/// - **Deploy** to a testing environment
/// - **Deploy** to a production environment
///
/// It is intended to be used as an application feature passed down to an ApplicationInterpret. For
/// example :
///
/// ```rust,ignore
/// let app = RustApplicationScore {
/// name: "My Rust App".to_string(),
/// features: vec![ContinuousDelivery::default()],
/// };
/// ```
///
/// *Note :*
///
/// By default, the Harmony Opinionated Pipeline is built using these technologies :
///
/// - Gitea Action (executes pipeline steps)
/// - Docker to build an OCI container image
/// - Helm chart to package Kubernetes resources
/// - Harbor as artifact registru
/// - ArgoCD to install/upgrade/rollback/inspect k8s resources
/// - Kubernetes for runtime orchestration
#[derive(Debug, Default, Clone)]
pub struct PackagingDeployment<A: OCICompliant + HelmPackage> {
pub application: Arc<A>,
}
impl<A: OCICompliant + HelmPackage> PackagingDeployment<A> {
async fn deploy_to_local_k3d(
&self,
app_name: String,
chart_url: String,
image_name: String,
) -> Result<(), String> {
// TODO: This works only with local k3d installations, which is fine only for current demo purposes. We assume usage of K8sAnywhereTopology"
// https://git.nationtech.io/NationTech/harmony/issues/106
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]
impl<
A: OCICompliant + HelmPackage + Clone + 'static,
T: Topology + HelmCommand + MultiTargetTopology + K8sclient + Ingress + 'static,
> ApplicationFeature<T> for PackagingDeployment<A>
{
async fn ensure_installed(
&self,
topology: &T,
) -> Result<InstallationOutcome, InstallationError> {
let image = self.application.image_name();
let domain = topology
.get_domain(&self.application.name())
.await
.map_err(|e| e.to_string())?;
// 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, &domain)
.await?;
// TODO: Make building image configurable/skippable if image already exists (prompt)")
// https://git.nationtech.io/NationTech/harmony/issues/104
let image = self.application.build_push_oci_image().await?;
// 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.
//
// That said, this will require some careful architectural decisions, since the concept of
// deployment targets / profiles is probably a layer of complexity that we won't be
// completely able to avoid
//
// I'll try something for now that must be thought through after : att a deployment_profile
// function to the topology trait that returns a profile, then anybody who needs it can
// access it. This forces every Topology to understand the concept of targets though... So
// instead I'll create a new Capability which is MultiTargetTopology and we'll see how it
// goes. It still does not feel right though.
//
// https://git.nationtech.io/NationTech/harmony/issues/106
match topology.current_target() {
DeploymentTarget::LocalDev => {
info!("Deploying {} locally...", self.application.name());
self.deploy_to_local_k3d(self.application.name(), helm_chart, image)
.await?;
}
target => {
info!("Deploying {} to target {target:?}", self.application.name());
let score = ArgoHelmScore {
namespace: format!("{}", self.application.name()),
openshift: true,
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: format!("{}-chart", self.application.name()),
values_overrides: None,
name: format!("{}", self.application.name()),
namespace: format!("{}", self.application.name()),
})],
};
score
.interpret(&Inventory::empty(), topology)
.await
.unwrap();
}
};
Ok(InstallationOutcome::success_with_details(vec![format!(
"{}: http://{domain}",
self.application.name()
)]))
}
fn name(&self) -> String {
"ContinuousDelivery".to_string()
}
}
/// For now this is entirely bound to K8s / ArgoCD, will have to be revisited when we support
/// more CD systems
pub struct CDApplicationConfig {
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 {
fn get_config(&self) -> CDApplicationConfig;
}