feat(example): added an example of packaging a rust app from github (#124)
Some checks failed
Run Check Script / check (push) Successful in 1m9s
Compile and package harmony_composer / package_harmony_composer (push) Has been cancelled

* better caching when building docker images for app

Reviewed-on: https://git.nationtech.io/NationTech/harmony/pulls/124
Reviewed-by: johnride <jg@nationtech.io>
Co-authored-by: Willem <wrolleman@nationtech.io>
Co-committed-by: Willem <wrolleman@nationtech.io>
This commit is contained in:
Willem 2025-09-08 13:52:25 +00:00 committed by Ian Letourneau
parent 62fa3c2b10
commit ad2ae2e4f8
9 changed files with 299 additions and 93 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

44
Cargo.lock generated
View File

@ -1838,6 +1838,21 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "example-try-rust-webapp"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"env_logger",
"harmony",
"harmony_cli",
"harmony_macros",
"harmony_types",
"log",
"tokio",
"url",
]
[[package]] [[package]]
name = "example-tui" name = "example-tui"
version = "0.1.0" version = "0.1.0"
@ -2318,6 +2333,7 @@ dependencies = [
"tokio-util", "tokio-util",
"url", "url",
"uuid", "uuid",
"walkdir",
] ]
[[package]] [[package]]
@ -4955,6 +4971,15 @@ dependencies = [
"cipher", "cipher",
] ]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.27" version = "0.1.27"
@ -6494,6 +6519,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@ -6676,6 +6711,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22"
dependencies = [
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"

View File

@ -30,6 +30,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

@ -20,8 +20,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,17 @@
[package]
name = "example-try-rust-webapp"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
[dependencies]
harmony = { path = "../../harmony" }
harmony_cli = { path = "../../harmony_cli" }
harmony_types = { path = "../../harmony_types" }
harmony_macros = { path = "../../harmony_macros" }
tokio = { workspace = true }
log = { workspace = true }
env_logger = { workspace = true }
url = { workspace = true }
base64.workspace = true

View File

@ -0,0 +1,52 @@
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,
},
topology::K8sAnywhereTopology,
};
use harmony_types::net::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

@ -66,6 +66,7 @@ tar.workspace = true
base64.workspace = true base64.workspace = true
thiserror.workspace = true thiserror.workspace = true
once_cell = "1.21.3" once_cell = "1.21.3"
walkdir = "2.5.0"
harmony_inventory_agent = { path = "../harmony_inventory_agent" } harmony_inventory_agent = { path = "../harmony_inventory_agent" }
harmony_secret_derive = { version = "0.1.0", path = "../harmony_secret_derive" } harmony_secret_derive = { version = "0.1.0", path = "../harmony_secret_derive" }
askama.workspace = true askama.workspace = true

View File

@ -1,4 +1,5 @@
use std::fs; use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process; use std::process;
use std::sync::Arc; use std::sync::Arc;
@ -12,7 +13,8 @@ use dockerfile_builder::instruction_builder::CopyBuilder;
use futures_util::StreamExt; use futures_util::StreamExt;
use log::{debug, info, log_enabled}; use log::{debug, info, log_enabled};
use serde::Serialize; use serde::Serialize;
use tar::Archive; use tar::{Archive, Builder, Header};
use walkdir::WalkDir;
use crate::config::{REGISTRY_PROJECT, REGISTRY_URL}; use crate::config::{REGISTRY_PROJECT, REGISTRY_URL};
use crate::{score::Score, topology::Topology}; use crate::{score::Score, topology::Topology};
@ -59,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>,
} }
@ -158,38 +161,35 @@ 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 quiet = !log_enabled!(log::Level::Debug);
match dockerfile
.unwrap()
.file_name()
.and_then(|os_str| os_str.to_str())
{
Some(path_str) => {
debug!("Building from dockerfile {}", path_str);
let tar_data = self
.create_deterministic_tar(&self.project_root.clone())
.await
.unwrap();
let docker = Docker::connect_with_socket_defaults().unwrap(); let docker = Docker::connect_with_socket_defaults().unwrap();
let quiet = !log_enabled!(log::Level::Debug); let build_image_options =
bollard::query_parameters::BuildImageOptionsBuilder::default()
let build_image_options = bollard::query_parameters::BuildImageOptionsBuilder::default() .dockerfile(path_str)
.dockerfile("Dockerfile.harmony")
.t(image_name) .t(image_name)
.q(quiet) .q(quiet)
.version(bollard::query_parameters::BuilderVersion::BuilderV1) .version(bollard::query_parameters::BuilderVersion::BuilderV1)
.platform("linux/x86_64"); .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);
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(tar_data.into())),
); );
while let Some(msg) = image_build_stream.next().await { while let Some(msg) = image_build_stream.next().await {
@ -199,6 +199,63 @@ impl RustWebapp {
Ok(image_name.to_string()) Ok(image_name.to_string())
} }
None => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Path is not valid UTF-8",
))),
}
}
///normalizes timestamp and ignores files that will bust the docker cach
async fn create_deterministic_tar(
&self,
project_root: &std::path::Path,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
debug!("building tar file from project root {:#?}", project_root);
let mut tar_data = Vec::new();
{
let mut builder = Builder::new(&mut tar_data);
let ignore_prefixes = [
"target",
".git",
".github",
".harmony_generated",
"node_modules",
];
let mut entries: Vec<_> = WalkDir::new(project_root)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.filter(|e| {
let rel_path = e.path().strip_prefix(project_root).unwrap();
!ignore_prefixes
.iter()
.any(|prefix| rel_path.starts_with(prefix))
})
.collect();
entries.sort_by_key(|e| e.path().to_owned());
for entry in entries {
let path = entry.path();
let rel_path = path.strip_prefix(project_root).unwrap();
let mut file = fs::File::open(path)?;
let mut header = Header::new_gnu();
header.set_size(entry.metadata()?.len());
header.set_mode(0o644);
header.set_mtime(0);
header.set_uid(0);
header.set_gid(0);
builder.append_data(&mut header, rel_path, &mut file)?;
}
builder.finish()?;
}
Ok(tar_data)
}
/// Tags and pushes a Docker image to the configured remote registry. /// Tags and pushes a Docker image to the configured remote registry.
async fn push_docker_image( async fn push_docker_image(
&self, &self,
@ -272,8 +329,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
@ -394,7 +454,7 @@ image:
service: service:
type: ClusterIP type: ClusterIP
port: 3000 port: {}
ingress: ingress:
enabled: true enabled: true
@ -414,112 +474,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)
@ -571,7 +642,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(),
@ -590,4 +660,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()
}
} }