From aebde3a382fb7326ac77955efba8ff05476b75d0 Mon Sep 17 00:00:00 2001 From: Willem Date: Fri, 29 Aug 2025 13:12:39 -0400 Subject: [PATCH] feat(example): added an example of packaging a rust app from github --- .gitmodules | 3 + .../src/main.rs | 1 + examples/rust/src/main.rs | 3 +- examples/try_rust_webapp/Cargo.toml | 12 + examples/try_rust_webapp/src/main.rs | 53 +++++ examples/try_rust_webapp/tryrust.org | 1 + harmony/src/modules/application/rust.rs | 221 +++++++++++------- 7 files changed, 203 insertions(+), 91 deletions(-) create mode 100644 .gitmodules create mode 100644 examples/try_rust_webapp/Cargo.toml create mode 100644 examples/try_rust_webapp/src/main.rs create mode 160000 examples/try_rust_webapp/tryrust.org diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4438aa2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "examples/try_rust_webapp/tryrust.org"] + path = examples/try_rust_webapp/tryrust.org + url = https://github.com/rust-dd/tryrust.org.git diff --git a/examples/application_monitoring_with_tenant/src/main.rs b/examples/application_monitoring_with_tenant/src/main.rs index 9ab0bf6..4691bf9 100644 --- a/examples/application_monitoring_with_tenant/src/main.rs +++ b/examples/application_monitoring_with_tenant/src/main.rs @@ -29,6 +29,7 @@ async fn main() { domain: Url::Url(url::Url::parse("https://rustapp.harmony.example.com").unwrap()), project_root: PathBuf::from("./examples/rust/webapp"), framework: Some(RustWebFramework::Leptos), + service_port: 3000, }); let webhook_receiver = WebhookReceiver { diff --git a/examples/rust/src/main.rs b/examples/rust/src/main.rs index feb92ef..d3d52ec 100644 --- a/examples/rust/src/main.rs +++ b/examples/rust/src/main.rs @@ -19,8 +19,9 @@ async fn main() { let application = Arc::new(RustWebapp { name: "harmony-example-rust-webapp".to_string(), domain: Url::Url(url::Url::parse("https://rustapp.harmony.example.com").unwrap()), - project_root: PathBuf::from("./webapp"), // Relative from 'harmony-path' param + project_root: PathBuf::from("./webapp"), framework: Some(RustWebFramework::Leptos), + service_port: 3000, }); let discord_receiver = DiscordWebhook { diff --git a/examples/try_rust_webapp/Cargo.toml b/examples/try_rust_webapp/Cargo.toml new file mode 100644 index 0000000..883de79 --- /dev/null +++ b/examples/try_rust_webapp/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "example-try-rust-webapp" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true + +[dependencies] +harmony = { version = "0.1.0", path = "../../harmony" } +harmony_cli = { version = "0.1.0", path = "../../harmony_cli" } +tokio.workspace = true +url.workspace = true diff --git a/examples/try_rust_webapp/src/main.rs b/examples/try_rust_webapp/src/main.rs new file mode 100644 index 0000000..22175e3 --- /dev/null +++ b/examples/try_rust_webapp/src/main.rs @@ -0,0 +1,53 @@ +use std::{path::PathBuf, sync::Arc}; + +use harmony::{ + inventory::Inventory, + modules::{ + application::{ + ApplicationScore, RustWebFramework, RustWebapp, + features::{ContinuousDelivery, Monitoring}, + }, + monitoring::alert_channel::{ + discord_alert_channel::DiscordWebhook, webhook_receiver::WebhookReceiver, + }, + }, + topology::{K8sAnywhereTopology, Url}, +}; + +#[tokio::main] +async fn main() { + let application = Arc::new(RustWebapp { + name: "harmony-example-tryrust".to_string(), + domain: Url::Url(url::Url::parse("https://tryrust.harmony.example.com").unwrap()), + project_root: PathBuf::from("./tryrust.org"), + framework: Some(RustWebFramework::Leptos), + service_port: 8080, + }); + + let discord_receiver = DiscordWebhook { + name: "test-discord".to_string(), + url: Url::Url(url::Url::parse("https://discord.doesnt.exist.com").unwrap()), + }; + + let app = ApplicationScore { + features: vec![ + Box::new(ContinuousDelivery { + application: application.clone(), + }), + Box::new(Monitoring { + application: application.clone(), + alert_receiver: vec![Box::new(discord_receiver)], + }), + ], + application, + }; + + harmony_cli::run( + Inventory::autoload(), + K8sAnywhereTopology::from_env(), + vec![Box::new(app)], + None, + ) + .await + .unwrap(); +} diff --git a/examples/try_rust_webapp/tryrust.org b/examples/try_rust_webapp/tryrust.org new file mode 160000 index 0000000..0f9ba14 --- /dev/null +++ b/examples/try_rust_webapp/tryrust.org @@ -0,0 +1 @@ +Subproject commit 0f9ba145172867f467e5320b37d07a5bbb7dd438 diff --git a/harmony/src/modules/application/rust.rs b/harmony/src/modules/application/rust.rs index da1e594..9a4eddf 100644 --- a/harmony/src/modules/application/rust.rs +++ b/harmony/src/modules/application/rust.rs @@ -61,6 +61,7 @@ pub struct RustWebapp { pub domain: Url, /// The path to the root of the Rust project to be containerized. pub project_root: PathBuf, + pub service_port: u32, pub framework: Option, } @@ -160,45 +161,56 @@ impl RustWebapp { image_name: &str, ) -> Result> { debug!("Generating Dockerfile for '{}'", self.name); - let _dockerfile_path = self.build_dockerfile()?; - + let dockerfile = self.get_or_build_dockerfile(); let docker = Docker::connect_with_socket_defaults().unwrap(); - let quiet = !log_enabled!(log::Level::Debug); - - let build_image_options = bollard::query_parameters::BuildImageOptionsBuilder::default() - .dockerfile("Dockerfile.harmony") - .t(image_name) - .q(quiet) - .version(bollard::query_parameters::BuilderVersion::BuilderV1) - .platform("linux/x86_64"); - - let mut temp_tar_builder = tar::Builder::new(Vec::new()); - temp_tar_builder - .append_dir_all("", self.project_root.clone()) - .unwrap(); - let archive = temp_tar_builder - .into_inner() - .expect("couldn't finish creating tar"); - let archived_files = Archive::new(archive.as_slice()) - .entries() + match dockerfile .unwrap() - .map(|entry| entry.unwrap().path().unwrap().into_owned()) - .collect::>(); + .file_name() + .and_then(|os_str| os_str.to_str()) + { + Some(path_str) => { + debug!("Building from dockerfile {}", path_str); + let build_image_options = + bollard::query_parameters::BuildImageOptionsBuilder::default() + .dockerfile(path_str) + .t(image_name) + .q(quiet) + .version(bollard::query_parameters::BuilderVersion::BuilderV1) + .platform("linux/x86_64"); + let mut temp_tar_builder = tar::Builder::new(Vec::new()); + temp_tar_builder + .append_dir_all("", self.project_root.clone()) + .unwrap(); + let archive = temp_tar_builder + .into_inner() + .expect("couldn't finish creating tar"); + let archived_files = Archive::new(archive.as_slice()) + .entries() + .unwrap() + .map(|entry| entry.unwrap().path().unwrap().into_owned()) + .collect::>(); - debug!("files in docker tar: {:#?}", archived_files); + debug!("files in docker tar: {:#?}", archived_files); - let mut image_build_stream = docker.build_image( - build_image_options.build(), - None, - Some(body_full(archive.into())), - ); + let mut image_build_stream = docker.build_image( + build_image_options.build(), + None, + Some(body_full(archive.into())), + ); - while let Some(msg) = image_build_stream.next().await { - debug!("Message: {msg:?}"); + while let Some(msg) = image_build_stream.next().await { + debug!("Message: {msg:?}"); + } + + Ok(image_name.to_string()) + } + + None => Err(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Path is not valid UTF-8", + ))), } - - Ok(image_name.to_string()) } /// Tags and pushes a Docker image to the configured remote registry. @@ -274,8 +286,11 @@ impl RustWebapp { "groupadd -r appgroup && useradd -r -s /bin/false -g appgroup appuser", )); - dockerfile.push(ENV::from("LEPTOS_SITE_ADDR=0.0.0.0:3000")); - dockerfile.push(EXPOSE::from("3000/tcp")); + dockerfile.push(ENV::from(format!( + "LEPTOS_SITE_ADDR=0.0.0.0:{}", + self.service_port + ))); + dockerfile.push(EXPOSE::from(format!("{}/tcp", self.service_port))); dockerfile.push(WORKDIR::from("/home/appuser")); // Copy static files @@ -396,7 +411,7 @@ image: service: type: ClusterIP - port: 3000 + port: {} ingress: enabled: true @@ -416,112 +431,123 @@ ingress: - chart-example.local "#, - chart_name, image_repo, image_tag, self.name + chart_name, image_repo, image_tag, self.service_port, self.name ); fs::write(chart_dir.join("values.yaml"), values_yaml)?; // Create templates/_helpers.tpl - let helpers_tpl = r#" -{{/* + let helpers_tpl = format!( + r#" +{{{{/* Expand the name of the chart. -*/}} -{{- define "chart.name" -}} -{{- default .Chart.Name $.Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} +*/}}}} +{{{{- 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 }} -"#; +*/}}}} +{{{{- 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#" + let service_yaml = format!( + r#" apiVersion: v1 kind: Service metadata: - name: {{ include "chart.fullname" . }} + name: {{{{ include "chart.fullname" . }}}} spec: - type: {{ $.Values.service.type }} + type: {{{{ $.Values.service.type }}}} ports: - name: main - port: {{ $.Values.service.port | default 3000 }} - targetPort: {{ $.Values.service.port | default 3000 }} + port: {{{{ $.Values.service.port | default {} }}}} + targetPort: {{{{ $.Values.service.port | default {} }}}} protocol: TCP selector: - app: {{ include "chart.name" . }} -"#; + app: {{{{ include "chart.name" . }}}} +"#, + self.service_port, self.service_port + ); fs::write(templates_dir.join("service.yaml"), service_yaml)?; // Create templates/deployment.yaml - let deployment_yaml = r#" + let deployment_yaml = format!( + r#" apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "chart.fullname" . }} + name: {{{{ include "chart.fullname" . }}}} spec: - replicas: {{ $.Values.replicaCount }} + replicas: {{{{ $.Values.replicaCount }}}} selector: matchLabels: - app: {{ include "chart.name" . }} + app: {{{{ include "chart.name" . }}}} template: metadata: labels: - app: {{ include "chart.name" . }} + app: {{{{ include "chart.name" . }}}} spec: containers: - - name: {{ .Chart.Name }} - image: "{{ $.Values.image.repository }}:{{ $.Values.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ $.Values.image.pullPolicy }} + - name: {{{{ .Chart.Name }}}} + image: "{{{{ $.Values.image.repository }}}}:{{{{ $.Values.image.tag | default .Chart.AppVersion }}}}" + imagePullPolicy: {{{{ $.Values.image.pullPolicy }}}} ports: - name: main - containerPort: {{ $.Values.service.port | default 3000 }} + containerPort: {{{{ $.Values.service.port | default {} }}}} protocol: TCP -"#; +"#, + self.service_port + ); fs::write(templates_dir.join("deployment.yaml"), deployment_yaml)?; // Create templates/ingress.yaml - let ingress_yaml = r#" -{{- if $.Values.ingress.enabled -}} + let ingress_yaml = format!( + r#" +{{{{- if $.Values.ingress.enabled -}}}} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: {{ include "chart.fullname" . }} + name: {{{{ include "chart.fullname" . }}}} annotations: - {{- toYaml $.Values.ingress.annotations | nindent 4 }} + {{{{- toYaml $.Values.ingress.annotations | nindent 4 }}}} spec: - {{- if $.Values.ingress.tls }} + {{{{- if $.Values.ingress.tls }}}} tls: - {{- range $.Values.ingress.tls }} + {{{{- range $.Values.ingress.tls }}}} - hosts: - {{- range .hosts }} - - {{ . | quote }} - {{- end }} - secretName: {{ .secretName }} - {{- end }} - {{- end }} + {{{{- range .hosts }}}} + - {{{{ . | quote }}}} + {{{{- end }}}} + secretName: {{{{ .secretName }}}} + {{{{- end }}}} + {{{{- end }}}} rules: - {{- range $.Values.ingress.hosts }} - - host: {{ .host | quote }} + {{{{- range $.Values.ingress.hosts }}}} + - host: {{{{ .host | quote }}}} http: paths: - {{- range .paths }} - - path: {{ .path }} - pathType: {{ .pathType }} + {{{{- range .paths }}}} + - path: {{{{ .path }}}} + pathType: {{{{ .pathType }}}} backend: service: - name: {{ include "chart.fullname" $ }} + name: {{{{ include "chart.fullname" $ }}}} port: - number: {{ $.Values.service.port | default 3000 }} - {{- end }} - {{- end }} -{{- end }} -"#; + number: {{{{ $.Values.service.port | default {} }}}} + {{{{- end }}}} + {{{{- end }}}} +{{{{- end }}}} +"#, + self.service_port + ); fs::write(templates_dir.join("ingress.yaml"), ingress_yaml)?; Ok(chart_dir) @@ -573,7 +599,6 @@ spec: let chart_file_name = packaged_chart_path.file_stem().unwrap().to_str().unwrap(); let oci_push_url = format!("oci://{}/{}", *REGISTRY_URL, *REGISTRY_PROJECT); let oci_pull_url = format!("{oci_push_url}/{}-chart", self.name); - debug!( "Pushing Helm chart {} to {}", packaged_chart_path.to_string_lossy(), @@ -592,4 +617,20 @@ spec: debug!("push url {oci_push_url}"); Ok(format!("{}:{}", oci_pull_url, version)) } + + fn get_or_build_dockerfile(&self) -> Result> { + let existing_dockerfile = self.project_root.join("Dockerfile"); + + debug!("project_root = {:?}", self.project_root); + + debug!("checking = {:?}", existing_dockerfile); + if existing_dockerfile.exists() { + debug!( + "Checking path {:#?} for existing Dockerfile", + self.project_root.clone() + ); + return Ok(existing_dockerfile); + } + self.build_dockerfile() + } }