diff --git a/.gitignore b/.gitignore index 0ef832c..7b1e9f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target private_repos log/ +*.tgz diff --git a/examples/lamp/auth/kubeconfig b/examples/lamp/auth/kubeconfig deleted file mode 100644 index 9c35782..0000000 --- a/examples/lamp/auth/kubeconfig +++ /dev/null @@ -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= diff --git a/examples/lamp/php/Dockerfile b/examples/lamp/php/Dockerfile deleted file mode 100644 index df5f4fa..0000000 --- a/examples/lamp/php/Dockerfile +++ /dev/null @@ -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 \ No newline at end of file diff --git a/examples/lamp/php/docker-php.ini b/examples/lamp/php/docker-php.ini deleted file mode 100644 index 3745ad5..0000000 --- a/examples/lamp/php/docker-php.ini +++ /dev/null @@ -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 diff --git a/examples/rust/src/main.rs b/examples/rust/src/main.rs index 4575c5f..e56a30f 100644 --- a/examples/rust/src/main.rs +++ b/examples/rust/src/main.rs @@ -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(), diff --git a/examples/rust/webapp/Cargo.toml b/examples/rust/webapp/Cargo.toml index 5a60ede..1df4876 100644 --- a/examples/rust/webapp/Cargo.toml +++ b/examples/rust/webapp/Cargo.toml @@ -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. diff --git a/examples/rust/webapp/Dockerfile.harmony b/examples/rust/webapp/Dockerfile.harmony index 63f781c..dffa3ac 100644 --- a/examples/rust/webapp/Dockerfile.harmony +++ b/examples/rust/webapp/Dockerfile.harmony @@ -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 \ No newline at end of file +CMD /home/appuser/harmony-example-rust-webapp \ No newline at end of file diff --git a/examples/rust/webapp/src/app.rs b/examples/rust/webapp/src/app.rs index 6baa388..f8af923 100644 --- a/examples/rust/webapp/src/app.rs +++ b/examples/rust/webapp/src/app.rs @@ -13,7 +13,7 @@ pub fn App() -> impl IntoView { view! { // injects a stylesheet into the document // id=leptos means cargo-leptos will hot-reload this stylesheet - + // sets the document title diff --git a/examples/rust/webapp/src/main.rs b/examples/rust/webapp/src/main.rs index 3fbc276..4aa3935 100644 --- a/examples/rust/webapp/src/main.rs +++ b/examples/rust/webapp/src/main.rs @@ -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(); diff --git a/harmony/src/modules/application/features/continuous_delivery.rs b/harmony/src/modules/application/features/continuous_delivery.rs index fef126d..a779498 100644 --- a/harmony/src/modules/application/features/continuous_delivery.rs +++ b/harmony/src/modules/application/features/continuous_delivery.rs @@ -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 { diff --git a/harmony/src/modules/application/oci.rs b/harmony/src/modules/application/oci.rs index 4eb1d7e..bf9f393 100644 --- a/harmony/src/modules/application/oci.rs +++ b/harmony/src/modules/application/oci.rs @@ -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>; } diff --git a/harmony/src/modules/application/rust.rs b/harmony/src/modules/application/rust.rs index 5fc60c9..39c48c5 100644 --- a/harmony/src/modules/application/rust.rs +++ b/harmony/src/modules/application/rust.rs @@ -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 you’re 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)) + } }