feat(example): added an example of packaging a rust app from github
All checks were successful
Run Check Script / check (pull_request) Successful in 1m0s

This commit is contained in:
Willem 2025-08-29 13:12:39 -04:00
parent 3ca31179d0
commit aebde3a382
7 changed files with 203 additions and 91 deletions

3
.gitmodules vendored Normal file
View File

@ -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

View File

@ -29,6 +29,7 @@ async fn main() {
domain: Url::Url(url::Url::parse("https://rustapp.harmony.example.com").unwrap()), domain: Url::Url(url::Url::parse("https://rustapp.harmony.example.com").unwrap()),
project_root: PathBuf::from("./examples/rust/webapp"), project_root: PathBuf::from("./examples/rust/webapp"),
framework: Some(RustWebFramework::Leptos), framework: Some(RustWebFramework::Leptos),
service_port: 3000,
}); });
let webhook_receiver = WebhookReceiver { let webhook_receiver = WebhookReceiver {

View File

@ -19,8 +19,9 @@ async fn main() {
let application = Arc::new(RustWebapp { let application = Arc::new(RustWebapp {
name: "harmony-example-rust-webapp".to_string(), name: "harmony-example-rust-webapp".to_string(),
domain: Url::Url(url::Url::parse("https://rustapp.harmony.example.com").unwrap()), 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), framework: Some(RustWebFramework::Leptos),
service_port: 3000,
}); });
let discord_receiver = DiscordWebhook { let discord_receiver = DiscordWebhook {

View File

@ -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

View File

@ -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();
}

@ -0,0 +1 @@
Subproject commit 0f9ba145172867f467e5320b37d07a5bbb7dd438

View File

@ -61,6 +61,7 @@ pub struct RustWebapp {
pub domain: Url, pub domain: Url,
/// The path to the root of the Rust project to be containerized. /// The path to the root of the Rust project to be containerized.
pub project_root: PathBuf, pub project_root: PathBuf,
pub service_port: u32,
pub framework: Option<RustWebFramework>, pub framework: Option<RustWebFramework>,
} }
@ -160,45 +161,56 @@ impl RustWebapp {
image_name: &str, image_name: &str,
) -> Result<String, Box<dyn std::error::Error>> { ) -> Result<String, Box<dyn std::error::Error>> {
debug!("Generating Dockerfile for '{}'", self.name); 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 docker = Docker::connect_with_socket_defaults().unwrap();
let quiet = !log_enabled!(log::Level::Debug); let quiet = !log_enabled!(log::Level::Debug);
match dockerfile
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()
.unwrap() .unwrap()
.map(|entry| entry.unwrap().path().unwrap().into_owned()) .file_name()
.collect::<Vec<_>>(); .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::<Vec<_>>();
debug!("files in docker tar: {:#?}", archived_files); debug!("files in docker tar: {:#?}", archived_files);
let mut image_build_stream = docker.build_image( let mut image_build_stream = docker.build_image(
build_image_options.build(), build_image_options.build(),
None, None,
Some(body_full(archive.into())), Some(body_full(archive.into())),
); );
while let Some(msg) = image_build_stream.next().await { while let Some(msg) = image_build_stream.next().await {
debug!("Message: {msg:?}"); 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. /// 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", "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(ENV::from(format!(
dockerfile.push(EXPOSE::from("3000/tcp")); "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")); dockerfile.push(WORKDIR::from("/home/appuser"));
// Copy static files // Copy static files
@ -396,7 +411,7 @@ image:
service: service:
type: ClusterIP type: ClusterIP
port: 3000 port: {}
ingress: ingress:
enabled: true enabled: true
@ -416,112 +431,123 @@ ingress:
- chart-example.local - 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)?; fs::write(chart_dir.join("values.yaml"), values_yaml)?;
// Create templates/_helpers.tpl // Create templates/_helpers.tpl
let helpers_tpl = r#" let helpers_tpl = format!(
{{/* r#"
{{{{/*
Expand the name of the chart. Expand the name of the chart.
*/}} */}}}}
{{- define "chart.name" -}} {{{{- define "chart.name" -}}}}
{{- default .Chart.Name $.Values.nameOverride | trunc 63 | trimSuffix "-" }} {{{{- default .Chart.Name $.Values.nameOverride | trunc 63 | trimSuffix "-" }}}}
{{- end }} {{{{- end }}}}
{{/* {{{{/*
Create a default fully qualified app name. 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). We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
*/}} */}}}}
{{- define "chart.fullname" -}} {{{{- define "chart.fullname" -}}}}
{{- $name := default .Chart.Name $.Values.nameOverride }} {{{{- $name := default .Chart.Name $.Values.nameOverride }}}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} {{{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}}}
{{- end }} {{{{- end }}}}
"#; "#
);
fs::write(templates_dir.join("_helpers.tpl"), helpers_tpl)?; fs::write(templates_dir.join("_helpers.tpl"), helpers_tpl)?;
// Create templates/service.yaml // Create templates/service.yaml
let service_yaml = r#" let service_yaml = format!(
r#"
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: {{ include "chart.fullname" . }} name: {{{{ include "chart.fullname" . }}}}
spec: spec:
type: {{ $.Values.service.type }} type: {{{{ $.Values.service.type }}}}
ports: ports:
- name: main - name: main
port: {{ $.Values.service.port | default 3000 }} port: {{{{ $.Values.service.port | default {} }}}}
targetPort: {{ $.Values.service.port | default 3000 }} targetPort: {{{{ $.Values.service.port | default {} }}}}
protocol: TCP protocol: TCP
selector: selector:
app: {{ include "chart.name" . }} app: {{{{ include "chart.name" . }}}}
"#; "#,
self.service_port, self.service_port
);
fs::write(templates_dir.join("service.yaml"), service_yaml)?; fs::write(templates_dir.join("service.yaml"), service_yaml)?;
// Create templates/deployment.yaml // Create templates/deployment.yaml
let deployment_yaml = r#" let deployment_yaml = format!(
r#"
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: {{ include "chart.fullname" . }} name: {{{{ include "chart.fullname" . }}}}
spec: spec:
replicas: {{ $.Values.replicaCount }} replicas: {{{{ $.Values.replicaCount }}}}
selector: selector:
matchLabels: matchLabels:
app: {{ include "chart.name" . }} app: {{{{ include "chart.name" . }}}}
template: template:
metadata: metadata:
labels: labels:
app: {{ include "chart.name" . }} app: {{{{ include "chart.name" . }}}}
spec: spec:
containers: containers:
- name: {{ .Chart.Name }} - name: {{{{ .Chart.Name }}}}
image: "{{ $.Values.image.repository }}:{{ $.Values.image.tag | default .Chart.AppVersion }}" image: "{{{{ $.Values.image.repository }}}}:{{{{ $.Values.image.tag | default .Chart.AppVersion }}}}"
imagePullPolicy: {{ $.Values.image.pullPolicy }} imagePullPolicy: {{{{ $.Values.image.pullPolicy }}}}
ports: ports:
- name: main - name: main
containerPort: {{ $.Values.service.port | default 3000 }} containerPort: {{{{ $.Values.service.port | default {} }}}}
protocol: TCP protocol: TCP
"#; "#,
self.service_port
);
fs::write(templates_dir.join("deployment.yaml"), deployment_yaml)?; fs::write(templates_dir.join("deployment.yaml"), deployment_yaml)?;
// Create templates/ingress.yaml // Create templates/ingress.yaml
let ingress_yaml = r#" let ingress_yaml = format!(
{{- if $.Values.ingress.enabled -}} r#"
{{{{- if $.Values.ingress.enabled -}}}}
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: {{ include "chart.fullname" . }} name: {{{{ include "chart.fullname" . }}}}
annotations: annotations:
{{- toYaml $.Values.ingress.annotations | nindent 4 }} {{{{- toYaml $.Values.ingress.annotations | nindent 4 }}}}
spec: spec:
{{- if $.Values.ingress.tls }} {{{{- if $.Values.ingress.tls }}}}
tls: tls:
{{- range $.Values.ingress.tls }} {{{{- range $.Values.ingress.tls }}}}
- hosts: - hosts:
{{- range .hosts }} {{{{- range .hosts }}}}
- {{ . | quote }} - {{{{ . | quote }}}}
{{- end }} {{{{- end }}}}
secretName: {{ .secretName }} secretName: {{{{ .secretName }}}}
{{- end }} {{{{- end }}}}
{{- end }} {{{{- end }}}}
rules: rules:
{{- range $.Values.ingress.hosts }} {{{{- range $.Values.ingress.hosts }}}}
- host: {{ .host | quote }} - host: {{{{ .host | quote }}}}
http: http:
paths: paths:
{{- range .paths }} {{{{- range .paths }}}}
- path: {{ .path }} - path: {{{{ .path }}}}
pathType: {{ .pathType }} pathType: {{{{ .pathType }}}}
backend: backend:
service: service:
name: {{ include "chart.fullname" $ }} name: {{{{ include "chart.fullname" $ }}}}
port: port:
number: {{ $.Values.service.port | default 3000 }} number: {{{{ $.Values.service.port | default {} }}}}
{{- end }} {{{{- end }}}}
{{- end }} {{{{- end }}}}
{{- end }} {{{{- end }}}}
"#; "#,
self.service_port
);
fs::write(templates_dir.join("ingress.yaml"), ingress_yaml)?; fs::write(templates_dir.join("ingress.yaml"), ingress_yaml)?;
Ok(chart_dir) Ok(chart_dir)
@ -573,7 +599,6 @@ spec:
let chart_file_name = packaged_chart_path.file_stem().unwrap().to_str().unwrap(); 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_push_url = format!("oci://{}/{}", *REGISTRY_URL, *REGISTRY_PROJECT);
let oci_pull_url = format!("{oci_push_url}/{}-chart", self.name); let oci_pull_url = format!("{oci_push_url}/{}-chart", self.name);
debug!( debug!(
"Pushing Helm chart {} to {}", "Pushing Helm chart {} to {}",
packaged_chart_path.to_string_lossy(), packaged_chart_path.to_string_lossy(),
@ -592,4 +617,20 @@ spec:
debug!("push url {oci_push_url}"); debug!("push url {oci_push_url}");
Ok(format!("{}:{}", oci_pull_url, version)) Ok(format!("{}:{}", oci_pull_url, version))
} }
fn get_or_build_dockerfile(&self) -> Result<PathBuf, Box<dyn std::error::Error>> {
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()
}
} }