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:
Jean-Gabriel Gill-Couture 2025-07-03 01:14:26 -04:00
parent fb7849c010
commit 5a89495c61
12 changed files with 442 additions and 140 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
target target
private_repos private_repos
log/ log/
*.tgz

View File

@ -1,19 +0,0 @@
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJlRENDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQWpNU0V3SHdZRFZRUUREQmhyTTNNdGMyVnkKZG1WeUxXTmhRREUzTkRZM01qYzROell3SGhjTk1qVXdOVEE0TVRneE1URTJXaGNOTXpVd05UQTJNVGd4TVRFMgpXakFqTVNFd0h3WURWUVFEREJock0zTXRjMlZ5ZG1WeUxXTmhRREUzTkRZM01qYzROell3V1RBVEJnY3Foa2pPClBRSUJCZ2dxaGtqT1BRTUJCd05DQUFUdm8rYjhqbmZmeHpTWlBvdWt0MUdWQStBcE9nRTRsd3pXd0tLVU1LdTMKemdLYUJnTDJrdmkxRnZEZGlMZ0RhcUJENmYzYTVQWWd4QWViZXA2Nk5odmRvMEl3UURBT0JnTlZIUThCQWY4RQpCQU1DQXFRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFRmdRVWZSS1ByVlZmZ3VrQmhQQWJZMmEwCkNiYkFnenN3Q2dZSUtvWkl6ajBFQXdJRFNRQXdSZ0loQU5yeFFXaWowektuOTRJeXpjMnRPNTQ5Wnk0YlpSU3kKQllNeVRWT3I1QWREQWlFQWhrWW8zdDFiMFhwLzg4Tkt0cVRCY0V4NGtrZ24za0FBWXEweTRUTXU5QW89Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
server: https://0.0.0.0:40437
name: k3d-harmony
contexts:
- context:
cluster: k3d-harmony
user: admin@k3d-harmony
name: k3d-harmony
current-context: k3d-harmony
kind: Config
preferences: {}
users:
- name: admin@k3d-harmony
user:
client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJrRENDQVRlZ0F3SUJBZ0lJRVc5bnVqeDdDV2N3Q2dZSUtvWkl6ajBFQXdJd0l6RWhNQjhHQTFVRUF3d1kKYXpOekxXTnNhV1Z1ZEMxallVQXhOelEyTnpJM09EYzJNQjRYRFRJMU1EVXdPREU0TVRFeE5sb1hEVEkyTURVdwpPREU0TVRFeE5sb3dNREVYTUJVR0ExVUVDaE1PYzNsemRHVnRPbTFoYzNSbGNuTXhGVEFUQmdOVkJBTVRESE41CmMzUmxiVHBoWkcxcGJqQlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDlBd0VIQTBJQUJEUW5CM2FOZU5CU2FySjUKV1VpRjd1TFMwVmpWT3A4R3FxV1JjMUhNb0s3eVluUlFEWm0veFgwMkZ5Vkh6cjBvNmJtN1lRTkQvVTYwMVo1YwprTVhqOTNLalNEQkdNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUtCZ2dyQmdFRkJRY0RBakFmCkJnTlZIU01FR0RBV2dCUTVJWjFCMkhKQWYxOEMwTFJ0NE5EVkQxdmFOekFLQmdncWhrak9QUVFEQWdOSEFEQkUKQWlCUGMzQ1doRlJSQUFmUDhBU0NtaWMxaFRXQ1FnbjVuUUpNNjBEbm9xWkZVQUlnVXdDWlpmK2p1enlTcGhCSApqNUFpS0psaUJZSklUZ1pETnFWS2VIZ0l3VG89Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0KLS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJkekNDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQWpNU0V3SHdZRFZRUUREQmhyTTNNdFkyeHAKWlc1MExXTmhRREUzTkRZM01qYzROell3SGhjTk1qVXdOVEE0TVRneE1URTJXaGNOTXpVd05UQTJNVGd4TVRFMgpXakFqTVNFd0h3WURWUVFEREJock0zTXRZMnhwWlc1MExXTmhRREUzTkRZM01qYzROell3V1RBVEJnY3Foa2pPClBRSUJCZ2dxaGtqT1BRTUJCd05DQUFRQVQ0VXkvbm5YVjhmN2xtSTEwTHk1NjNmOStBL0VOeUYyWGVlVnFKNVQKVENCaVNncERIZ09ncE82MEZrMVdhRkRJWmZYcU9RTWI0Q1hjT2wrSVJyWS9vMEl3UURBT0JnTlZIUThCQWY4RQpCQU1DQXFRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFRmdRVU9TR2RRZGh5UUg5ZkF0QzBiZURRCjFROWIyamN3Q2dZSUtvWkl6ajBFQXdJRFNBQXdSUUlnZEVYS0RBcjNlT0QzYWI0ZXZHMzgvbHplMEpoTXJIOFoKR25EUTRob2NncnNDSVFEejZFbGZtNWYvL0x1akdUUEFQT3BpR293SFFoMEI4Mk9kbFlMcDN3SEt3QT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
client-key-data: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSU1zVUt1MXVBZ0k5VWR1ek9Jc2VvRjRFNGwvMlMzYnJhRlVvTGtuWXpwbGZvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFTkNjSGRvMTQwRkpxc25sWlNJWHU0dExSV05VNm53YXFwWkZ6VWN5Z3J2SmlkRkFObWIvRgpmVFlYSlVmT3ZTanB1YnRoQTBQOVRyVFZubHlReGVQM2NnPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo=

View File

@ -1,18 +0,0 @@
FROM php:8.4-apache
ENV PHP_MEMORY_LIMIT=256M
ENV PHP_MAX_EXECUTION_TIME=30
ENV PHP_ERROR_REPORTING="E_ERROR | E_WARNING | E_PARSE"
RUN apt-get update && apt-get install -y --no-install-recommends libfreetype6-dev libjpeg62-turbo-dev libpng-dev libzip-dev unzip && apt-get clean && rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-configure gd --with-freetype --with-jpeg && docker-php-ext-install -j$(nproc) gd mysqli pdo_mysql zip opcache
RUN sed -i 's/VirtualHost \*:80/VirtualHost *:8080/' /etc/apache2/sites-available/000-default.conf && \
sed -i 's/^Listen 80$/Listen 8080/' /etc/apache2/ports.conf
RUN mkdir -p /usr/local/etc/php/conf.d/
COPY docker-php.ini /usr/local/etc/php/conf.d/docker-php.ini
RUN a2enmod headers && a2enmod rewrite && sed -i 's/ServerTokens OS/ServerTokens Prod/' /etc/apache2/conf-enabled/security.conf && sed -i 's/ServerSignature On/ServerSignature Off/' /etc/apache2/conf-enabled/security.conf
RUN echo 'PassEnv MYSQL_PASSWORD' >> /etc/apache2/sites-available/000-default.conf && echo 'PassEnv MYSQL_USER' >> /etc/apache2/sites-available/000-default.conf && echo 'PassEnv MYSQL_HOST' >> /etc/apache2/sites-available/000-default.conf
RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m -s /bin/bash appuser && chown -R appuser:appuser /var/www/html
WORKDIR /var/www/html
COPY . /var/www/html
RUN chown -R appuser:appuser /var/www/html
EXPOSE 8080/tcp
CMD apache2-foreground

View File

@ -1,16 +0,0 @@
memory_limit = ${PHP_MEMORY_LIMIT}
max_execution_time = ${PHP_MAX_EXECUTION_TIME}
error_reporting = ${PHP_ERROR_REPORTING}
display_errors = Off
log_errors = On
error_log = /dev/stderr
date.timezone = UTC
; Opcache configuration for production
opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=4000
opcache.revalidate_freq=2
opcache.fast_shutdown=1

View File

@ -3,7 +3,9 @@ use std::{path::PathBuf, sync::Arc};
use harmony::{ use harmony::{
inventory::Inventory, inventory::Inventory,
maestro::Maestro, maestro::Maestro,
modules::application::{RustWebapp, RustWebappScore, features::ContinuousDelivery}, modules::application::{
RustWebFramework, RustWebapp, RustWebappScore, features::ContinuousDelivery,
},
topology::{K8sAnywhereTopology, Url}, topology::{K8sAnywhereTopology, Url},
}; };
@ -13,6 +15,7 @@ async fn main() {
let application = RustWebapp { let application = RustWebapp {
name: "harmony-example-rust-webapp".to_string(), name: "harmony-example-rust-webapp".to_string(),
project_root: PathBuf::from("./examples/rust/webapp"), project_root: PathBuf::from("./examples/rust/webapp"),
framework: Some(RustWebFramework::Leptos),
}; };
let app = RustWebappScore { let app = RustWebappScore {
name: "Example Rust Webapp".to_string(), name: "Example Rust Webapp".to_string(),

View File

@ -1,5 +1,5 @@
[package] [package]
name = "webapp" name = "harmony-example-rust-webapp"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
@ -41,7 +41,7 @@ panic = "abort"
[package.metadata.leptos] [package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name # The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "webapp" output-name = "harmony-example-rust-webapp"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup. # The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site" site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written # The site-root relative folder where all compiled output (JS, WASM and CSS) is written
@ -55,7 +55,7 @@ style-file = "style/main.scss"
# Optional. Env: LEPTOS_ASSETS_DIR. # Optional. Env: LEPTOS_ASSETS_DIR.
assets-dir = "assets" assets-dir = "assets"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup. # The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000" site-addr = "0.0.0.0:3000"
# The port to use for automatic reload monitoring # The port to use for automatic reload monitoring
reload-port = 3001 reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir. # [Optional] Command to use when running end2end tests. It will run in the end2end dir.

View File

@ -1,10 +1,16 @@
FROM rust:latest as builder 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 RUN rustup target add wasm32-unknown-unknown
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN cargo build --release --locked RUN cargo leptos build --release -vv
FROM debian:bullseye-slim FROM debian:bookworm-slim
RUN groupadd -r appgroup && useradd -r -s /bin/false -g appgroup appuser RUN groupadd -r appgroup && useradd -r -s /bin/false -g appgroup appuser
COPY --from=builder /app/target/release/harmony-example-rust-webapp /usr/local/bin/harmony-example-rust-webapp 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 USER appuser
CMD /usr/local/bin/harmony-example-rust-webapp CMD /home/appuser/harmony-example-rust-webapp

View File

@ -13,7 +13,7 @@ pub fn App() -> impl IntoView {
view! { view! {
// injects a stylesheet into the document <head> // injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet // id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg/webapp.css"/> <Stylesheet id="leptos" href="/pkg/harmony-example-rust-webapp.css"/>
// sets the document title // sets the document title
<Title text="Welcome to Leptos"/> <Title text="Welcome to Leptos"/>

View File

@ -7,7 +7,7 @@ async fn main() -> std::io::Result<()> {
use leptos::config::get_configuration; use leptos::config::get_configuration;
use leptos_meta::MetaTags; use leptos_meta::MetaTags;
use leptos_actix::{generate_route_list, LeptosRoutes}; use leptos_actix::{generate_route_list, LeptosRoutes};
use webapp::app::*; use harmony_example_rust_webapp::app::*;
let conf = get_configuration(None).unwrap(); let conf = get_configuration(None).unwrap();
let addr = conf.leptos_options.site_addr; let addr = conf.leptos_options.site_addr;
@ -80,7 +80,7 @@ pub fn main() {
// a client-side main function is required for using `trunk serve` // a client-side main function is required for using `trunk serve`
// prefer using `cargo leptos serve` instead // prefer using `cargo leptos serve` instead
// to run: `trunk serve --open --features csr` // to run: `trunk serve --open --features csr`
use webapp::app::*; use harmony_example_rust_webapp::app::*;
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();

View File

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

View File

@ -5,4 +5,17 @@ use super::Application;
#[async_trait] #[async_trait]
pub trait OCICompliant: Application { pub trait OCICompliant: Application {
async fn build_push_oci_image(&self) -> Result<String, String>; // TODO consider using oci-spec and friends crates here 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 async_trait::async_trait;
use dockerfile_builder::Dockerfile; 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 dockerfile_builder::instruction_builder::CopyBuilder;
use log::{debug, info}; use log::{debug, error, info};
use serde::Serialize; use serde::Serialize;
use crate::config::{REGISTRY_PROJECT, REGISTRY_URL}; use crate::config::{REGISTRY_PROJECT, REGISTRY_URL};
@ -16,7 +16,7 @@ use crate::{
topology::{Topology, Url}, topology::{Topology, Url},
}; };
use super::{Application, ApplicationFeature, ApplicationInterpret, OCICompliant}; use super::{Application, ApplicationFeature, ApplicationInterpret, HelmPackage, OCICompliant};
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
pub struct RustWebappScore<T: Topology + Clone + Serialize> { 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] #[async_trait]
impl OCICompliant for RustWebapp { impl OCICompliant for RustWebapp {
/// Builds a Docker image for the Rust web application using a multi-stage build, /// 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); info!("Starting OCI image build and push for '{}'", self.name);
// 1. Build the local image by calling the synchronous helper function. // 1. Build the local image by calling the synchronous helper function.
let local_image_name = self let local_image_name = self.local_image_name();
.build_docker_image() self.build_docker_image(&local_image_name)
.map_err(|e| format!("Failed to build Docker image: {}", e))?; .map_err(|e| format!("Failed to build Docker image: {}", e))?;
info!( info!(
"Successfully built local Docker image: {}", "Successfully built local Docker image: {}",
local_image_name local_image_name
); );
let remote_image_name = self.image_name();
// 2. Push the image to the registry. // 2. Push the image to the registry.
let remote_image_name = self self.push_docker_image(&local_image_name, &remote_image_name)
.push_docker_image(&local_image_name)
.map_err(|e| format!("Failed to push Docker image: {}", e))?; .map_err(|e| format!("Failed to push Docker image: {}", e))?;
info!("Successfully pushed Docker image to: {}", remote_image_name); info!("Successfully pushed Docker image to: {}", remote_image_name);
Ok(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. /// Implementation of helper methods for building and pushing the Docker image.
@ -94,33 +137,6 @@ impl RustWebapp {
self.build_builder_image(&mut dockerfile); 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. // Save the Dockerfile to a uniquely named file in the project root to avoid conflicts.
let dockerfile_path = self.project_root.join("Dockerfile.harmony"); let dockerfile_path = self.project_root.join("Dockerfile.harmony");
fs::write(&dockerfile_path, dockerfile.to_string())?; fs::write(&dockerfile_path, dockerfile.to_string())?;
@ -129,7 +145,10 @@ impl RustWebapp {
} }
/// Builds the Docker image using the generated Dockerfile. /// 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); info!("Generating Dockerfile for '{}'", self.name);
let dockerfile_path = self.build_dockerfile()?; let dockerfile_path = self.build_dockerfile()?;
@ -138,8 +157,6 @@ impl RustWebapp {
dockerfile_path.to_string_lossy(), dockerfile_path.to_string_lossy(),
self.project_root.to_string_lossy() self.project_root.to_string_lossy()
); );
let image_name = format!("{}-webapp", self.name);
let output = process::Command::new("docker") let output = process::Command::new("docker")
.args([ .args([
"build", "build",
@ -154,30 +171,34 @@ impl RustWebapp {
self.check_output(&output, "Failed to build Docker image")?; 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. /// 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>> { fn push_docker_image(
let full_tag = format!("{}/{}/{}", *REGISTRY_URL, *REGISTRY_PROJECT, &image_name); &self,
image_name: &str,
full_tag: &str,
) -> Result<String, Box<dyn std::error::Error>> {
info!("Pushing docker image {full_tag}"); info!("Pushing docker image {full_tag}");
// Tag the image for the remote registry. // Tag the image for the remote registry.
let output = process::Command::new("docker") let output = process::Command::new("docker")
.args(["tag", image_name, &full_tag]) .args(["tag", image_name, &full_tag])
.output()?; .spawn()?
.wait_with_output()?;
self.check_output(&output, "Tagging docker image failed")?; self.check_output(&output, "Tagging docker image failed")?;
debug!( debug!(
"docker tag output: stdout: {}, stderr: {}", "docker tag output: stdout: {}, stderr: {}",
String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr) String::from_utf8_lossy(&output.stderr)
); );
todo!("Are we good?");
// Push the image. // Push the image.
let output = process::Command::new("docker") let output = process::Command::new("docker")
.args(["push", &full_tag]) .args(["push", &full_tag])
.output()?; .spawn()?
.wait_with_output()?;
self.check_output(&output, "Pushing docker image failed")?; self.check_output(&output, "Pushing docker image failed")?;
debug!( debug!(
"docker push output: stdout: {}, stderr: {}", "docker push output: stdout: {}, stderr: {}",
@ -185,7 +206,7 @@ impl RustWebapp {
String::from_utf8_lossy(&output.stderr) String::from_utf8_lossy(&output.stderr)
); );
Ok(full_tag) Ok(full_tag.to_string())
} }
/// Checks the output of a process command for success. /// Checks the output of a process command for success.
@ -203,40 +224,76 @@ impl RustWebapp {
fn build_builder_image(&self, dockerfile: &mut Dockerfile) { fn build_builder_image(&self, dockerfile: &mut Dockerfile) {
match self.framework { match self.framework {
Some(RustWebFramework::Leptos) => {todo!(r#" Some(RustWebFramework::Leptos) => {
# Get started with a build env with Rust nightly // --- Stage 1: Builder for Leptos ---
FROM rustlang/rust:nightly-bookworm as builder dockerfile.push(FROM::from("rust:bookworm as builder"));
# If youre using stable, use this instead // Install dependencies, cargo-binstall, and clean up in one layer
# FROM rust:1.86-bullseye as builder 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 // Install cargo-leptos
# cargo extensions like cargo-leptos dockerfile.push(RUN::from("cargo binstall cargo-leptos -y"));
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 required tools // Add the WASM target
RUN apt-get update -y \ dockerfile.push(RUN::from("rustup target add wasm32-unknown-unknown"));
&& apt-get install -y --no-install-recommends clang
# Install cargo-leptos // Set up workdir, copy source, and build
RUN cargo binstall cargo-leptos -y 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 // Create a non-root user for security.
RUN rustup target add wasm32-unknown-unknown 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 dockerfile.push(ENV::from("LEPTOS_SITE_ADDR=0.0.0.0:3000"));
RUN mkdir -p /app dockerfile.push(EXPOSE::from("3000/tcp"));
WORKDIR /app dockerfile.push(WORKDIR::from("/home/appuser"));
COPY . .
# Build the app // Copy static files
RUN cargo leptos build --release -vv 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 => { None => {
// --- Stage 1: Builder --- // --- Stage 1: Builder for a generic Rust app ---
// Use the official Rust image as the build environment.
dockerfile.push(FROM::from("rust:latest as builder")); dockerfile.push(FROM::from("rust:latest as builder"));
// Install the wasm32 target as required. // Install the wasm32 target as required.
@ -246,7 +303,271 @@ RUN cargo leptos build --release -vv
// Copy the source code and build the application. // Copy the source code and build the application.
dockerfile.push(COPY::from(". .")); dockerfile.push(COPY::from(". ."));
dockerfile.push(RUN::from("cargo build --release --locked")); 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))
}
} }