feat: harmony now defaults to using local k3d cluster. Also created OCICompliant: Application trait to make building images cleaner #76

Merged
johnride merged 8 commits from feat/oci into master 2025-07-03 19:37:51 +00:00
12 changed files with 442 additions and 140 deletions
Showing only changes of commit 5a89495c61 - Show all commits

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
target
private_repos
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::{
inventory::Inventory,
maestro::Maestro,
modules::application::{RustWebapp, RustWebappScore, features::ContinuousDelivery},
modules::application::{
RustWebFramework, RustWebapp, RustWebappScore, features::ContinuousDelivery,
},
topology::{K8sAnywhereTopology, Url},
};
@ -13,6 +15,7 @@ async fn main() {
let application = RustWebapp {
name: "harmony-example-rust-webapp".to_string(),
project_root: PathBuf::from("./examples/rust/webapp"),
framework: Some(RustWebFramework::Leptos),
};
let app = RustWebappScore {
name: "Example Rust Webapp".to_string(),

View File

@ -1,5 +1,5 @@
[package]
name = "webapp"
name = "harmony-example-rust-webapp"
version = "0.1.0"
edition = "2021"
@ -41,7 +41,7 @@ panic = "abort"
[package.metadata.leptos]
# 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.
site-root = "target/site"
# 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.
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.
site-addr = "127.0.0.1:3000"
site-addr = "0.0.0.0:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
# [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
WORKDIR /app
COPY . .
RUN cargo build --release --locked
FROM debian:bullseye-slim
RUN cargo leptos build --release -vv
FROM debian:bookworm-slim
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
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! {
// injects a stylesheet into the document <head>
// 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
<Title text="Welcome to Leptos"/>

View File

@ -7,7 +7,7 @@ async fn main() -> std::io::Result<()> {
use leptos::config::get_configuration;
use leptos_meta::MetaTags;
use leptos_actix::{generate_route_list, LeptosRoutes};
use webapp::app::*;
use harmony_example_rust_webapp::app::*;
let conf = get_configuration(None).unwrap();
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`
// prefer using `cargo leptos serve` instead
// to run: `trunk serve --open --features csr`
use webapp::app::*;
use harmony_example_rust_webapp::app::*;
console_error_panic_hook::set_once();

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)
Review

For OCI, we get the (local) image name from the trait directly (self.image_name() & self.local_image_name()). Why is it different here?

Also, could (should?) it be generated automatically from the application name instead? It doesn't seem like we actually need it from outside.

For OCI, we get the (local) image name from the trait directly (`self.image_name()` & `self.local_image_name()`). Why is it different here? Also, could (should?) it be generated automatically from the application name instead? It doesn't seem like we actually need it from outside.
Review

This is actually the container image url. The chart uses it to reference the image correctly.

But yes, this is a common pain with image urls, helm chart names, application names, etc. Perhaps we could easily create an ApplicationIdentifier or ApplicationLocator concept that is used instead of a String that provides a robust way to get container image location, helm chart location, app name, etc, etc.

This is actually the container image url. The chart uses it to reference the image correctly. But yes, this is a common pain with image urls, helm chart names, application names, etc. Perhaps we could easily create an ApplicationIdentifier or ApplicationLocator concept that is used instead of a String that provides a robust way to get container image location, helm chart location, app name, etc, etc.
.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))
}
}