hyperi-rustlib 2.8.5

There's plenty of sage advice out there about how to run Rust services in production at scale — config cascades, structured logging, masking secrets, multi-backend secrets management, Prometheus, OpenTelemetry, Kafka transports, tiered disk-spillover sinks, adaptive worker pools, graceful shutdown — but almost none of it as code you can just install and use. This is that code. Opinionated, drop-in, working out of the box. The patterns from blog posts, watercooler chats and beers with your Google mates as actual library — not a framework you assemble from twenty crates and 8 weeks of munging.
Documentation
// Project:   hyperi-rustlib
// File:      src/deployment/generate/dockerfile.rs
// Purpose:   Dockerfile + runtime-stage + apt-block generation
// Language:  Rust
//
// License:   BUSL-1.1
// Copyright: (c) 2026 HYPERI PTY LIMITED

#![allow(clippy::format_push_string)]

use crate::deployment::contract::{DeploymentContract, ImageProfile};

// ============================================================================
// Dockerfile
// ============================================================================

/// Generate a Dockerfile from the deployment contract.
///
/// When `native_deps` is populated (via [`NativeDepsContract::for_rustlib_features`](crate::deployment::NativeDepsContract::for_rustlib_features)),
/// the generated Dockerfile automatically includes custom APT repo setup and
/// runtime package installation. If `native_deps` is empty, only base utilities
/// are installed.
///
/// `identity`, when provided, stamps three `io.hyperi.contract.*` LABEL
/// lines on the image per the Contract Identity Annotation Scheme v1
/// (see [`ContractIdentity`](crate::deployment::ContractIdentity)). Phase 1
/// rollout: optional; callers SHOULD pass `Some(&identity)`. Phase 2 will
/// flip this to a required parameter.
#[must_use]
pub fn generate_dockerfile(
    contract: &DeploymentContract,
    identity: Option<&crate::deployment::ContractIdentity>,
) -> String {
    let binary = contract.binary();

    // EXPOSE line: metrics_port + extra ports
    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(" ")
    };

    // CMD line
    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(", "))
    };

    // Build the apt-get RUN block dynamically from native_deps + image profile
    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",
    };

    // Contract Identity Annotation Scheme v1 -- three LABEL lines.
    // Phase 1: silent on None for backwards compat (see module doc).
    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,
    )
}

// ============================================================================
// Runtime Stage Fragment (for CI Dockerfile composition)
// ============================================================================

/// Generate only the runtime stage of a Dockerfile as a fragment.
///
/// CI composes the full Dockerfile by prepending its own build stages
/// (cargo-chef pattern) and appending this runtime stage. This keeps
/// the boundary clean: rustlib owns what's *in* the container, CI owns
/// how to *build* the binary.
#[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,
    )
}

/// Diagnostic tools installed in development images.
const DEV_TOOLS: &[&str] = &[
    "bash",
    "strace",
    "tcpdump",
    "procps",
    "dnsutils",
    "net-tools",
    "less",
    "jq",
];

/// Build the apt-get RUN block from native deps contract and image profile.
///
/// When custom APT repos are needed (e.g., Confluent for librdkafka), emits
/// the GPG key download, sources list entry, and repo-specific packages.
/// Development profile adds diagnostic tools (strace, tcpdump, etc.).
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;

    // Base packages always installed (curl needed for healthcheck, ca-certificates for TLS)
    let mut base_pkgs = vec!["ca-certificates", "curl", "netcat-openbsd", "iputils-ping"];

    // If we have custom APT repos, we need gnupg for key import
    if !deps.apt_repos.is_empty() {
        base_pkgs.push("gnupg");
    }

    // Dev profile adds diagnostic tools
    if is_dev {
        base_pkgs.extend_from_slice(DEV_TOOLS);
    }

    if deps.is_empty() {
        // No native deps -- simple install
        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;
    }

    // Collect all runtime packages (repo-specific + default)
    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);
    }

    // Build multi-step RUN: base install → repo setup → update → runtime install → cleanup
    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(" ")));

    // Add each custom APT repo
    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,
            // Derive a stable filename from the keyring path
            std::path::Path::new(&repo.keyring)
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("custom-repo"),
        ));
    }

    // Second apt-get update + install runtime packages
    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
}