feat: implement helm chart generation and publishing
All checks were successful
Run Check Script / check (pull_request) Successful in -4s

- Added functionality to generate a Helm chart for the application.
- Implemented chart packaging and pushing to an OCI registry.
- Utilized `helm package` and `helm push` commands.
- Included configurable registry URL and project name.
- Added tests to verify chart generation and packaging.
- Improved error handling and logging.
This commit is contained in:
2025-07-03 01:14:26 -04:00
parent fb7849c010
commit 5a89495c61
12 changed files with 442 additions and 140 deletions

View File

@@ -1,14 +1,14 @@
use std::sync::Arc;
use async_trait::async_trait;
use log::info;
use log::{error, info};
use serde_json::Value;
use crate::{
data::Version,
inventory::Inventory,
modules::{
application::{Application, ApplicationFeature, OCICompliant},
application::{Application, ApplicationFeature, HelmPackage, OCICompliant},
helm::chart::HelmChartScore,
},
score::Score,
@@ -43,16 +43,27 @@ use crate::{
/// - ArgoCD to install/upgrade/rollback/inspect k8s resources
/// - Kubernetes for runtime orchestration
#[derive(Debug, Default, Clone)]
pub struct ContinuousDelivery<A: OCICompliant> {
pub struct ContinuousDelivery<A: OCICompliant + HelmPackage> {
pub application: Arc<A>,
}
#[async_trait]
impl<A: OCICompliant + Clone + 'static, T: Topology + HelmCommand + 'static> ApplicationFeature<T>
for ContinuousDelivery<A>
impl<A: OCICompliant + HelmPackage + Clone + 'static, T: Topology + HelmCommand + '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"
);
let helm_chart = self.application.build_push_helm_package(&image).await?;
info!("Pushed new helm chart {helm_chart}");
let image = self.application.build_push_oci_image().await?;
info!("Pushed new docker image {image}");
info!("Installing ContinuousDelivery feature");
let cd_server = HelmChartScore {

View File

@@ -5,4 +5,17 @@ use super::Application;
#[async_trait]
pub trait OCICompliant: Application {
async fn build_push_oci_image(&self) -> Result<String, String>; // TODO consider using oci-spec and friends crates here
fn image_name(&self) -> String;
fn local_image_name(&self) -> String;
}
#[async_trait]
pub trait HelmPackage: Application {
/// Generates, packages, and pushes a Helm chart for the web application to an OCI registry.
///
/// # Arguments
/// * `image_url` - The full URL of the OCI container image to be used in the Deployment.
async fn build_push_helm_package(&self, image_url: &str) -> Result<String, String>;
}

View File

@@ -5,9 +5,9 @@ use std::sync::Arc;
use async_trait::async_trait;
use dockerfile_builder::Dockerfile;
use dockerfile_builder::instruction::{CMD, COPY, FROM, RUN, USER, WORKDIR};
use dockerfile_builder::instruction::{CMD, COPY, ENV, EXPOSE, FROM, RUN, USER, WORKDIR};
use dockerfile_builder::instruction_builder::CopyBuilder;
use log::{debug, info};
use log::{debug, error, info};
use serde::Serialize;
use crate::config::{REGISTRY_PROJECT, REGISTRY_URL};
@@ -16,7 +16,7 @@ use crate::{
topology::{Topology, Url},
};
use super::{Application, ApplicationFeature, ApplicationInterpret, OCICompliant};
use super::{Application, ApplicationFeature, ApplicationInterpret, HelmPackage, OCICompliant};
#[derive(Debug, Serialize, Clone)]
pub struct RustWebappScore<T: Topology + Clone + Serialize> {
@@ -58,6 +58,36 @@ 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);
// 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);
// 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!(
"Successfully packaged Helm chart: {}",
packaged_chart_path.to_string_lossy()
);
// 3. Push the packaged chart to the OCI registry.
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);
Ok(oci_chart_url)
}
}
#[async_trait]
impl OCICompliant for RustWebapp {
/// Builds a Docker image for the Rust web application using a multi-stage build,
@@ -68,22 +98,35 @@ impl OCICompliant for RustWebapp {
info!("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
.build_docker_image()
let local_image_name = self.local_image_name();
self.build_docker_image(&local_image_name)
.map_err(|e| format!("Failed to build Docker image: {}", e))?;
info!(
"Successfully built local Docker image: {}",
local_image_name
);
let remote_image_name = self.image_name();
// 2. Push the image to the registry.
let remote_image_name = self
.push_docker_image(&local_image_name)
self.push_docker_image(&local_image_name, &remote_image_name)
.map_err(|e| format!("Failed to push Docker image: {}", e))?;
info!("Successfully pushed Docker image to: {}", remote_image_name);
Ok(remote_image_name)
}
fn local_image_name(&self) -> String {
self.name.clone()
}
fn image_name(&self) -> String {
format!(
"{}/{}/{}",
*REGISTRY_URL,
*REGISTRY_PROJECT,
&self.local_image_name()
)
}
}
/// Implementation of helper methods for building and pushing the Docker image.
@@ -94,33 +137,6 @@ impl RustWebapp {
self.build_builder_image(&mut dockerfile);
// --- Stage 2: Final Image ---
// Use a minimal, non-Alpine base image for the final container.
dockerfile.push(FROM::from("debian:bullseye-slim"));
// Create a non-root user for security.
dockerfile.push(RUN::from(
"groupadd -r appgroup && useradd -r -s /bin/false -g appgroup appuser",
));
// Copy only the compiled binary from the builder stage.
let binary_path_in_builder = format!("/app/target/release/{}", self.name);
let binary_path_in_final = format!("/usr/local/bin/{}", self.name);
dockerfile.push(
CopyBuilder::builder()
.from("builder")
.src(binary_path_in_builder)
.dest(&binary_path_in_final)
.build()
.unwrap(),
);
// Run as the non-root user.
dockerfile.push(USER::from("appuser"));
// Set the command to run the application.
dockerfile.push(CMD::from(binary_path_in_final));
// Save the Dockerfile to a uniquely named file in the project root to avoid conflicts.
let dockerfile_path = self.project_root.join("Dockerfile.harmony");
fs::write(&dockerfile_path, dockerfile.to_string())?;
@@ -129,7 +145,10 @@ impl RustWebapp {
}
/// Builds the Docker image using the generated Dockerfile.
pub fn build_docker_image(&self) -> Result<String, Box<dyn std::error::Error>> {
pub 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()?;
@@ -138,8 +157,6 @@ impl RustWebapp {
dockerfile_path.to_string_lossy(),
self.project_root.to_string_lossy()
);
let image_name = format!("{}-webapp", self.name);
let output = process::Command::new("docker")
.args([
"build",
@@ -154,30 +171,34 @@ impl RustWebapp {
self.check_output(&output, "Failed to build Docker image")?;
Ok(image_name)
Ok(image_name.to_string())
}
/// Tags and pushes a Docker image to the configured remote registry.
fn push_docker_image(&self, image_name: &str) -> Result<String, Box<dyn std::error::Error>> {
let full_tag = format!("{}/{}/{}", *REGISTRY_URL, *REGISTRY_PROJECT, &image_name);
fn push_docker_image(
&self,
image_name: &str,
full_tag: &str,
) -> Result<String, Box<dyn std::error::Error>> {
info!("Pushing docker image {full_tag}");
// Tag the image for the remote registry.
let output = process::Command::new("docker")
.args(["tag", image_name, &full_tag])
.output()?;
.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)
);
todo!("Are we good?");
// Push the image.
let output = process::Command::new("docker")
.args(["push", &full_tag])
.output()?;
.spawn()?
.wait_with_output()?;
self.check_output(&output, "Pushing docker image failed")?;
debug!(
"docker push output: stdout: {}, stderr: {}",
@@ -185,7 +206,7 @@ impl RustWebapp {
String::from_utf8_lossy(&output.stderr)
);
Ok(full_tag)
Ok(full_tag.to_string())
}
/// Checks the output of a process command for success.
@@ -203,40 +224,76 @@ impl RustWebapp {
fn build_builder_image(&self, dockerfile: &mut Dockerfile) {
match self.framework {
Some(RustWebFramework::Leptos) => {todo!(r#"
# Get started with a build env with Rust nightly
FROM rustlang/rust:nightly-bookworm as builder
Some(RustWebFramework::Leptos) => {
// --- Stage 1: Builder for Leptos ---
dockerfile.push(FROM::from("rust:bookworm as builder"));
# If youre using stable, use this instead
# FROM rust:1.86-bullseye as builder
// Install dependencies, cargo-binstall, and clean up in one layer
dockerfile.push(RUN::from(
"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/*"
));
# Install cargo-binstall, which makes it easier to install other
# cargo extensions like cargo-leptos
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN cp cargo-binstall /usr/local/cargo/bin
// Install cargo-leptos
dockerfile.push(RUN::from("cargo binstall cargo-leptos -y"));
# Install required tools
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends clang
// Add the WASM target
dockerfile.push(RUN::from("rustup target add wasm32-unknown-unknown"));
# Install cargo-leptos
RUN cargo binstall cargo-leptos -y
// Set up workdir, copy source, and build
dockerfile.push(WORKDIR::from("/app"));
dockerfile.push(COPY::from(". ."));
dockerfile.push(RUN::from("cargo leptos build --release -vv"));
// --- Stage 2: Final Image ---
dockerfile.push(FROM::from("debian:bookworm-slim"));
# Add the WASM target
RUN rustup target add wasm32-unknown-unknown
// Create a non-root user for security.
dockerfile.push(RUN::from(
"groupadd -r appgroup && useradd -r -s /bin/false -g appgroup appuser",
));
# Make an /app dir, which everything will eventually live in
RUN mkdir -p /app
WORKDIR /app
COPY . .
dockerfile.push(ENV::from("LEPTOS_SITE_ADDR=0.0.0.0:3000"));
dockerfile.push(EXPOSE::from("3000/tcp"));
dockerfile.push(WORKDIR::from("/home/appuser"));
# Build the app
RUN cargo leptos build --release -vv
"#)}
// Copy static files
dockerfile.push(
CopyBuilder::builder()
.from("builder")
.src("/app/target/site/pkg")
.dest("/home/appuser/pkg")
.build()
.unwrap(),
);
// Copy the compiled binary from the builder stage.
error!(
"FIXME Should not be using score name here, instead should use name from Cargo.toml"
);
let binary_path_in_builder = format!("/app/target/release/{}", self.name);
let binary_path_in_final = format!("/home/appuser/{}", self.name);
dockerfile.push(
CopyBuilder::builder()
.from("builder")
.src(binary_path_in_builder)
.dest(&binary_path_in_final)
.build()
.unwrap(),
);
// Run as the non-root user.
dockerfile.push(USER::from("appuser"));
// Set the command to run the application.
dockerfile.push(CMD::from(binary_path_in_final));
}
None => {
// --- Stage 1: Builder ---
// Use the official Rust image as the build environment.
// --- Stage 1: Builder for a generic Rust app ---
dockerfile.push(FROM::from("rust:latest as builder"));
// Install the wasm32 target as required.
@@ -246,7 +303,271 @@ RUN cargo leptos build --release -vv
// Copy the source code and build the application.
dockerfile.push(COPY::from(". ."));
dockerfile.push(RUN::from("cargo build --release --locked"));
// --- Stage 2: Final Image ---
dockerfile.push(FROM::from("debian:bookworm-slim"));
// Create a non-root user for security.
dockerfile.push(RUN::from(
"groupadd -r appgroup && useradd -r -s /bin/false -g appgroup appuser",
));
// Copy only the compiled binary from the builder stage.
error!(
"FIXME Should not be using score name here, instead should use name from Cargo.toml"
);
let binary_path_in_builder = format!("/app/target/release/{}", self.name);
let binary_path_in_final = format!("/usr/local/bin/{}", self.name);
dockerfile.push(
CopyBuilder::builder()
.from("builder")
.src(binary_path_in_builder)
.dest(&binary_path_in_final)
.build()
.unwrap(),
);
// Run as the non-root user.
dockerfile.push(USER::from("appuser"));
// Set the command to run the application.
dockerfile.push(CMD::from(binary_path_in_final));
}
}
}
/// Creates all necessary files for a basic Helm chart.
fn create_helm_chart_files(
&self,
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 templates_dir = chart_dir.join("templates");
fs::create_dir_all(&templates_dir)?;
let (image_repo, image_tag) = image_url.rsplit_once(':').unwrap_or((image_url, "latest"));
// Create Chart.yaml
let chart_yaml = format!(
r#"
apiVersion: v2
name: {}
description: A Helm chart for the {} web application.
type: application
version: 0.1.0
appVersion: "{}"
"#,
chart_name, self.name, image_tag
);
fs::write(chart_dir.join("Chart.yaml"), chart_yaml)?;
// Create values.yaml
let values_yaml = format!(
r#"
# Default values for {}.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: {}
pullPolicy: IfNotPresent
# Overridden by the chart's appVersion
tag: "{}"
service:
type: ClusterIP
port: 80
ingress:
enabled: false
# Annotations for cert-manager to handle SSL.
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
# Add other annotations like nginx ingress class if needed
# kubernetes.io/ingress.class: nginx
hosts:
- host: chart-example.local
paths:
- path: /
pathType: ImplementationSpecific
tls:
- secretName: {}-tls
hosts:
- chart-example.local
"#,
chart_name, image_repo, image_tag, self.name
);
fs::write(chart_dir.join("values.yaml"), values_yaml)?;
// Create templates/_helpers.tpl
let helpers_tpl = r#"
{{/*
Expand the name of the chart.
*/}}
{{- define "chart.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
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 }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
"#;
fs::write(templates_dir.join("_helpers.tpl"), helpers_tpl)?;
// Create templates/service.yaml
let service_yaml = r#"
apiVersion: v1
kind: Service
metadata:
name: {{ include "chart.fullname" . }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
app: {{ include "chart.name" . }}
"#;
fs::write(templates_dir.join("service.yaml"), service_yaml)?;
// Create templates/deployment.yaml
let deployment_yaml = r#"
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "chart.fullname" . }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ include "chart.name" . }}
template:
metadata:
labels:
app: {{ include "chart.name" . }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8080 # Assuming the rust app listens on 8080
protocol: TCP
"#;
fs::write(templates_dir.join("deployment.yaml"), deployment_yaml)?;
// Create templates/ingress.yaml
let ingress_yaml = r#"
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "chart.fullname" . }}
annotations:
{{- toYaml .Values.ingress.annotations | nindent 4 }}
spec:
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "chart.fullname" $ }}
port:
name: http
{{- end }}
{{- end }}
{{- end }}
"#;
fs::write(templates_dir.join("ingress.yaml"), ingress_yaml)?;
Ok(chart_dir)
}
/// Packages a Helm chart directory into a .tgz file.
fn package_helm_chart(
&self,
chart_dir: &PathBuf,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let chart_dirname = chart_dir.file_name().expect("Should find a chart dirname");
info!(
"Launching `helm package {}` cli with CWD {}",
chart_dirname.to_string_lossy(),
&self.project_root.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
.output()?;
self.check_output(&output, "Failed to package Helm chart")?;
// Helm prints the path of the created chart to stdout.
let tgz_name = String::from_utf8(output.stdout)?
.trim()
.split_whitespace()
.last()
.unwrap_or_default()
.to_string();
if tgz_name.is_empty() {
return Err("Could not determine packaged chart filename.".into());
}
// The output from helm is relative, so we join it with the execution directory.
Ok(self.project_root.join("helm").join(tgz_name))
}
/// Pushes a packaged Helm chart to an OCI registry.
fn push_helm_chart(
&self,
packaged_chart_path: &PathBuf,
) -> Result<String, Box<dyn std::error::Error>> {
// 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 oci_url = format!(
"oci://{}/{}/{}-chart",
*REGISTRY_URL, *REGISTRY_PROJECT, self.name
);
info!(
"Pushing Helm chart {} to {}",
packaged_chart_path.to_string_lossy(),
oci_url
);
let output = process::Command::new("helm")
.args(["push", packaged_chart_path.to_str().unwrap(), &oci_url])
.output()?;
self.check_output(&output, "Pushing Helm chart failed")?;
// The final URL includes the version tag, which is part of the file name
let version = chart_file_name.rsplit_once('-').unwrap().1;
Ok(format!("{}:{}", oci_url, version))
}
}