Skip to main content

hyperi_rustlib/deployment/generate/
dockerfile.rs

1// Project:   hyperi-rustlib
2// File:      src/deployment/generate/dockerfile.rs
3// Purpose:   Dockerfile + runtime-stage + apt-block generation
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9#![allow(clippy::format_push_string)]
10
11use crate::deployment::contract::{DeploymentContract, ImageProfile};
12
13// ============================================================================
14// Dockerfile
15// ============================================================================
16
17/// Generate a Dockerfile from the deployment contract.
18///
19/// When `native_deps` is populated (via [`NativeDepsContract::for_rustlib_features`](crate::deployment::NativeDepsContract::for_rustlib_features)),
20/// the generated Dockerfile automatically includes custom APT repo setup and
21/// runtime package installation. If `native_deps` is empty, only base utilities
22/// are installed.
23///
24/// `identity`, when provided, stamps three `io.hyperi.contract.*` LABEL
25/// lines on the image per the Contract Identity Annotation Scheme v1
26/// (see [`ContractIdentity`](crate::deployment::ContractIdentity)). Phase 1
27/// rollout: optional; callers SHOULD pass `Some(&identity)`. Phase 2 will
28/// flip this to a required parameter.
29#[must_use]
30pub fn generate_dockerfile(
31    contract: &DeploymentContract,
32    identity: Option<&crate::deployment::ContractIdentity>,
33) -> String {
34    let binary = contract.binary();
35
36    // EXPOSE line: metrics_port + extra ports
37    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    // CMD line
46    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    // Build the apt-get RUN block dynamically from native_deps + image profile
58    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    // Contract Identity Annotation Scheme v1 -- three LABEL lines.
66    // Phase 1: silent on None for backwards compat (see module doc).
67    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// ============================================================================
119// Runtime Stage Fragment (for CI Dockerfile composition)
120// ============================================================================
121
122/// Generate only the runtime stage of a Dockerfile as a fragment.
123///
124/// CI composes the full Dockerfile by prepending its own build stages
125/// (cargo-chef pattern) and appending this runtime stage. This keeps
126/// the boundary clean: rustlib owns what's *in* the container, CI owns
127/// how to *build* the binary.
128#[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
213/// Diagnostic tools installed in development images.
214const DEV_TOOLS: &[&str] = &[
215    "bash",
216    "strace",
217    "tcpdump",
218    "procps",
219    "dnsutils",
220    "net-tools",
221    "less",
222    "jq",
223];
224
225/// Build the apt-get RUN block from native deps contract and image profile.
226///
227/// When custom APT repos are needed (e.g., Confluent for librdkafka), emits
228/// the GPG key download, sources list entry, and repo-specific packages.
229/// Development profile adds diagnostic tools (strace, tcpdump, etc.).
230fn 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    // Base packages always installed (curl needed for healthcheck, ca-certificates for TLS)
238    let mut base_pkgs = vec!["ca-certificates", "curl", "netcat-openbsd", "iputils-ping"];
239
240    // If we have custom APT repos, we need gnupg for key import
241    if !deps.apt_repos.is_empty() {
242        base_pkgs.push("gnupg");
243    }
244
245    // Dev profile adds diagnostic tools
246    if is_dev {
247        base_pkgs.extend_from_slice(DEV_TOOLS);
248    }
249
250    if deps.is_empty() {
251        // No native deps -- simple install
252        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    // Collect all runtime packages (repo-specific + default)
259    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    // Build multi-step RUN: base install → repo setup → update → runtime install → cleanup
270    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    // Add each custom APT repo
276    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            // Derive a stable filename from the keyring path
289            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    // Second apt-get update + install runtime packages
297    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}