#![allow(clippy::format_push_string)]
use crate::deployment::contract::{DeploymentContract, ImageProfile};
#[must_use]
pub fn generate_dockerfile(
contract: &DeploymentContract,
identity: Option<&crate::deployment::ContractIdentity>,
) -> String {
let binary = contract.binary();
let expose_ports = {
let mut ports = vec![contract.metrics_port.to_string()];
for p in &contract.extra_ports {
ports.push(p.port.to_string());
}
ports.join(" ")
};
let cmd = if contract.entrypoint_args.is_empty() {
String::new()
} else {
let args: Vec<String> = contract
.entrypoint_args
.iter()
.map(|a| format!("\"{a}\""))
.collect();
format!("\nCMD [{}]", args.join(", "))
};
let apt_block = build_apt_block(&contract.native_deps, contract.image_profile);
let profile_label = match contract.image_profile {
ImageProfile::Production => "production",
ImageProfile::Development => "development",
};
let identity_block = identity
.map(|id| format!("\n{labels}", labels = id.as_dockerfile_labels()))
.unwrap_or_default();
format!(
r#"# Project: {app_name}
# File: Dockerfile
# Purpose: {profile_label} container image
#
# License: BUSL-1.1
# Copyright: (c) 2026 HYPERI PTY LIMITED
#
# AUTOGENERATED -- do not edit by hand.
# Generated by hyperi-rustlib::deployment::generate_dockerfile()
# Schema version: {schema_version}
# Source contract: {app_name}::deployment::contract()
# Regenerate with: `{binary} emit-dockerfile > Dockerfile`
FROM {base_image}
LABEL io.hyperi.profile="{profile_label}"{identity_block}
{apt_block}
COPY {binary} /usr/local/bin/{binary}
RUN chmod +x /usr/local/bin/{binary}
# Ubuntu 24.04 ships with ubuntu user at UID 1000 -- remove before creating appuser
RUN userdel -r ubuntu && useradd --create-home --uid 1000 appuser
USER appuser
EXPOSE {expose_ports}
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -sf http://localhost:{metrics_port}{liveness_path} > /dev/null || exit 1
ENTRYPOINT ["{binary}"]{cmd}
"#,
app_name = contract.app_name,
base_image = contract.base_image,
binary = binary,
profile_label = profile_label,
apt_block = apt_block,
expose_ports = expose_ports,
metrics_port = contract.metrics_port,
liveness_path = contract.health.liveness_path,
cmd = cmd,
schema_version = contract.schema_version,
identity_block = identity_block,
)
}
#[must_use]
pub fn generate_runtime_stage(contract: &DeploymentContract) -> String {
let binary = contract.binary();
let apt_block = build_apt_block(&contract.native_deps, contract.image_profile);
let profile_label = match contract.image_profile {
ImageProfile::Production => "production",
ImageProfile::Development => "development",
};
let title = if contract.oci_labels.title.is_empty() {
&contract.app_name
} else {
&contract.oci_labels.title
};
let expose_ports = {
let mut ports = vec![contract.metrics_port.to_string()];
for p in &contract.extra_ports {
ports.push(p.port.to_string());
}
ports.join(" ")
};
let cmd = if contract.entrypoint_args.is_empty() {
String::new()
} else {
let args: Vec<String> = contract
.entrypoint_args
.iter()
.map(|a| format!("\"{a}\""))
.collect();
format!("\nCMD [{}]", args.join(", "))
};
format!(
r#"# --- Runtime stage (generated by hyperi-rustlib deployment contract) ---
FROM {base_image} AS runtime
# Static OCI labels (from contract)
LABEL org.opencontainers.image.title="{title}"
LABEL org.opencontainers.image.description="{description}"
LABEL org.opencontainers.image.vendor="{vendor}"
LABEL org.opencontainers.image.licenses="{licenses}"
LABEL io.hyperi.profile="{profile_label}"
{apt_block}
# Dynamic OCI labels (injected by CI at build time)
ARG OCI_SOURCE=""
ARG OCI_REVISION=""
ARG OCI_VERSION=""
ARG OCI_CREATED=""
LABEL org.opencontainers.image.source="${{OCI_SOURCE}}"
LABEL org.opencontainers.image.revision="${{OCI_REVISION}}"
LABEL org.opencontainers.image.version="${{OCI_VERSION}}"
LABEL org.opencontainers.image.created="${{OCI_CREATED}}"
COPY --from=builder /app/target/release/{binary} /usr/local/bin/{binary}
RUN chmod +x /usr/local/bin/{binary}
RUN userdel -r ubuntu && useradd --create-home --uid 1000 appuser
USER appuser
EXPOSE {expose_ports}
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -sf http://localhost:{metrics_port}{liveness_path} > /dev/null || exit 1
ENTRYPOINT ["{binary}"]{cmd}
"#,
base_image = contract.base_image,
title = title,
description = contract.oci_labels.description,
vendor = contract.oci_labels.vendor,
licenses = contract.oci_labels.licenses,
profile_label = profile_label,
apt_block = apt_block,
binary = binary,
expose_ports = expose_ports,
metrics_port = contract.metrics_port,
liveness_path = contract.health.liveness_path,
cmd = cmd,
)
}
const DEV_TOOLS: &[&str] = &[
"bash",
"strace",
"tcpdump",
"procps",
"dnsutils",
"net-tools",
"less",
"jq",
];
fn build_apt_block(
deps: &crate::deployment::native_deps::NativeDepsContract,
profile: ImageProfile,
) -> String {
let mut out = String::with_capacity(512);
let is_dev = profile == ImageProfile::Development;
let mut base_pkgs = vec!["ca-certificates", "curl", "netcat-openbsd", "iputils-ping"];
if !deps.apt_repos.is_empty() {
base_pkgs.push("gnupg");
}
if is_dev {
base_pkgs.extend_from_slice(DEV_TOOLS);
}
if deps.is_empty() {
out.push_str("RUN apt-get update && apt-get install -y --no-install-recommends \\\n");
out.push_str(&format!(" {} \\\n", base_pkgs.join(" ")));
out.push_str(" && rm -rf /var/lib/apt/lists/*\n");
return out;
}
let mut runtime_pkgs: Vec<&str> = Vec::new();
for repo in &deps.apt_repos {
for pkg in &repo.packages {
runtime_pkgs.push(pkg);
}
}
for pkg in &deps.apt_packages {
runtime_pkgs.push(pkg);
}
out.push_str("# Runtime shared libraries for dynamically-linked Rust crates.\n");
out.push_str("RUN apt-get update && apt-get install -y --no-install-recommends \\\n");
out.push_str(&format!(" {} \\\n", base_pkgs.join(" ")));
for repo in &deps.apt_repos {
out.push_str(&format!(
" && curl -fsSL {} \\\n\
\x20 | gpg --dearmor -o {} \\\n\
\x20 && echo \"deb [signed-by={}] \\\n\
\x20 {} {} main\" \\\n\
\x20 > /etc/apt/sources.list.d/{}.list \\\n",
repo.key_url,
repo.keyring,
repo.keyring,
repo.url,
repo.codename,
std::path::Path::new(&repo.keyring)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("custom-repo"),
));
}
out.push_str(" && apt-get update && apt-get install -y --no-install-recommends \\\n");
out.push_str(&format!(" {} \\\n", runtime_pkgs.join(" ")));
out.push_str(" && rm -rf /var/lib/apt/lists/*\n");
out
}