hyperi_rustlib/deployment/generate/
dockerfile.rs1#![allow(clippy::format_push_string)]
10
11use crate::deployment::contract::{DeploymentContract, ImageProfile};
12
13#[must_use]
30pub fn generate_dockerfile(
31 contract: &DeploymentContract,
32 identity: Option<&crate::deployment::ContractIdentity>,
33) -> String {
34 let binary = contract.binary();
35
36 let expose_ports = {
38 let mut ports = vec![contract.metrics_port.to_string()];
39 for p in &contract.extra_ports {
40 ports.push(p.port.to_string());
41 }
42 ports.join(" ")
43 };
44
45 let cmd = if contract.entrypoint_args.is_empty() {
47 String::new()
48 } else {
49 let args: Vec<String> = contract
50 .entrypoint_args
51 .iter()
52 .map(|a| format!("\"{a}\""))
53 .collect();
54 format!("\nCMD [{}]", args.join(", "))
55 };
56
57 let apt_block = build_apt_block(&contract.native_deps, contract.image_profile);
59
60 let profile_label = match contract.image_profile {
61 ImageProfile::Production => "production",
62 ImageProfile::Development => "development",
63 };
64
65 let identity_block = identity
68 .map(|id| format!("\n{labels}", labels = id.as_dockerfile_labels()))
69 .unwrap_or_default();
70
71 format!(
72 r#"# Project: {app_name}
73# File: Dockerfile
74# Purpose: {profile_label} container image
75#
76# License: BUSL-1.1
77# Copyright: (c) 2026 HYPERI PTY LIMITED
78#
79# AUTOGENERATED -- do not edit by hand.
80# Generated by hyperi-rustlib::deployment::generate_dockerfile()
81# Schema version: {schema_version}
82# Source contract: {app_name}::deployment::contract()
83# Regenerate with: `{binary} emit-dockerfile > Dockerfile`
84
85FROM {base_image}
86
87LABEL io.hyperi.profile="{profile_label}"{identity_block}
88
89{apt_block}
90COPY {binary} /usr/local/bin/{binary}
91RUN chmod +x /usr/local/bin/{binary}
92
93# Ubuntu 24.04 ships with ubuntu user at UID 1000 -- remove before creating appuser
94RUN userdel -r ubuntu && useradd --create-home --uid 1000 appuser
95USER appuser
96
97EXPOSE {expose_ports}
98
99HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
100 CMD curl -sf http://localhost:{metrics_port}{liveness_path} > /dev/null || exit 1
101
102ENTRYPOINT ["{binary}"]{cmd}
103"#,
104 app_name = contract.app_name,
105 base_image = contract.base_image,
106 binary = binary,
107 profile_label = profile_label,
108 apt_block = apt_block,
109 expose_ports = expose_ports,
110 metrics_port = contract.metrics_port,
111 liveness_path = contract.health.liveness_path,
112 cmd = cmd,
113 schema_version = contract.schema_version,
114 identity_block = identity_block,
115 )
116}
117
118#[must_use]
129pub fn generate_runtime_stage(contract: &DeploymentContract) -> String {
130 let binary = contract.binary();
131 let apt_block = build_apt_block(&contract.native_deps, contract.image_profile);
132
133 let profile_label = match contract.image_profile {
134 ImageProfile::Production => "production",
135 ImageProfile::Development => "development",
136 };
137
138 let title = if contract.oci_labels.title.is_empty() {
139 &contract.app_name
140 } else {
141 &contract.oci_labels.title
142 };
143
144 let expose_ports = {
145 let mut ports = vec![contract.metrics_port.to_string()];
146 for p in &contract.extra_ports {
147 ports.push(p.port.to_string());
148 }
149 ports.join(" ")
150 };
151
152 let cmd = if contract.entrypoint_args.is_empty() {
153 String::new()
154 } else {
155 let args: Vec<String> = contract
156 .entrypoint_args
157 .iter()
158 .map(|a| format!("\"{a}\""))
159 .collect();
160 format!("\nCMD [{}]", args.join(", "))
161 };
162
163 format!(
164 r#"# --- Runtime stage (generated by hyperi-rustlib deployment contract) ---
165FROM {base_image} AS runtime
166
167# Static OCI labels (from contract)
168LABEL org.opencontainers.image.title="{title}"
169LABEL org.opencontainers.image.description="{description}"
170LABEL org.opencontainers.image.vendor="{vendor}"
171LABEL org.opencontainers.image.licenses="{licenses}"
172LABEL io.hyperi.profile="{profile_label}"
173
174{apt_block}
175# Dynamic OCI labels (injected by CI at build time)
176ARG OCI_SOURCE=""
177ARG OCI_REVISION=""
178ARG OCI_VERSION=""
179ARG OCI_CREATED=""
180LABEL org.opencontainers.image.source="${{OCI_SOURCE}}"
181LABEL org.opencontainers.image.revision="${{OCI_REVISION}}"
182LABEL org.opencontainers.image.version="${{OCI_VERSION}}"
183LABEL org.opencontainers.image.created="${{OCI_CREATED}}"
184
185COPY --from=builder /app/target/release/{binary} /usr/local/bin/{binary}
186RUN chmod +x /usr/local/bin/{binary}
187
188RUN userdel -r ubuntu && useradd --create-home --uid 1000 appuser
189USER appuser
190
191EXPOSE {expose_ports}
192
193HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
194 CMD curl -sf http://localhost:{metrics_port}{liveness_path} > /dev/null || exit 1
195
196ENTRYPOINT ["{binary}"]{cmd}
197"#,
198 base_image = contract.base_image,
199 title = title,
200 description = contract.oci_labels.description,
201 vendor = contract.oci_labels.vendor,
202 licenses = contract.oci_labels.licenses,
203 profile_label = profile_label,
204 apt_block = apt_block,
205 binary = binary,
206 expose_ports = expose_ports,
207 metrics_port = contract.metrics_port,
208 liveness_path = contract.health.liveness_path,
209 cmd = cmd,
210 )
211}
212
213const DEV_TOOLS: &[&str] = &[
215 "bash",
216 "strace",
217 "tcpdump",
218 "procps",
219 "dnsutils",
220 "net-tools",
221 "less",
222 "jq",
223];
224
225fn build_apt_block(
231 deps: &crate::deployment::native_deps::NativeDepsContract,
232 profile: ImageProfile,
233) -> String {
234 let mut out = String::with_capacity(512);
235 let is_dev = profile == ImageProfile::Development;
236
237 let mut base_pkgs = vec!["ca-certificates", "curl", "netcat-openbsd", "iputils-ping"];
239
240 if !deps.apt_repos.is_empty() {
242 base_pkgs.push("gnupg");
243 }
244
245 if is_dev {
247 base_pkgs.extend_from_slice(DEV_TOOLS);
248 }
249
250 if deps.is_empty() {
251 out.push_str("RUN apt-get update && apt-get install -y --no-install-recommends \\\n");
253 out.push_str(&format!(" {} \\\n", base_pkgs.join(" ")));
254 out.push_str(" && rm -rf /var/lib/apt/lists/*\n");
255 return out;
256 }
257
258 let mut runtime_pkgs: Vec<&str> = Vec::new();
260 for repo in &deps.apt_repos {
261 for pkg in &repo.packages {
262 runtime_pkgs.push(pkg);
263 }
264 }
265 for pkg in &deps.apt_packages {
266 runtime_pkgs.push(pkg);
267 }
268
269 out.push_str("# Runtime shared libraries for dynamically-linked Rust crates.\n");
271
272 out.push_str("RUN apt-get update && apt-get install -y --no-install-recommends \\\n");
273 out.push_str(&format!(" {} \\\n", base_pkgs.join(" ")));
274
275 for repo in &deps.apt_repos {
277 out.push_str(&format!(
278 " && curl -fsSL {} \\\n\
279 \x20 | gpg --dearmor -o {} \\\n\
280 \x20 && echo \"deb [signed-by={}] \\\n\
281 \x20 {} {} main\" \\\n\
282 \x20 > /etc/apt/sources.list.d/{}.list \\\n",
283 repo.key_url,
284 repo.keyring,
285 repo.keyring,
286 repo.url,
287 repo.codename,
288 std::path::Path::new(&repo.keyring)
290 .file_stem()
291 .and_then(|s| s.to_str())
292 .unwrap_or("custom-repo"),
293 ));
294 }
295
296 out.push_str(" && apt-get update && apt-get install -y --no-install-recommends \\\n");
298 out.push_str(&format!(" {} \\\n", runtime_pkgs.join(" ")));
299 out.push_str(" && rm -rf /var/lib/apt/lists/*\n");
300
301 out
302}