scalo 2.10.11

Self-regulating runtime for Rust data-plane services. Backpressure, load shedding and adaptive scaling are on by default.
// Project:   scalo
// File:      src/deployment/generate/dockerfile.rs
// Purpose:   Dockerfile + runtime-stage + apt-block generation
// Language:  Rust
//
// License:   Apache-2.0
// 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_scalo_features`](crate::deployment::NativeDepsContract::for_scalo_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:   {license}
# Copyright: {copyright}
#
# AUTOGENERATED -- do not edit by hand.
# Generated by scalo::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 if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu; fi && 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,
        license = contract.oci_labels.licenses,
        copyright = contract.oci_labels.copyright,
        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: scalo 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 scalo 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 if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu; fi && 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");
    // The release these package names are for. Both warnings below name it.
    let release = deps
        .distro
        .unwrap_or(crate::deployment::DEFAULT_BASE_DISTRO);
    if let Some(base_image) = &deps.unresolved_base_image {
        // Package names below are release-specific. We could not tell which
        // release this base image is (a digest pin carries no codename), so an
        // assumption was made -- say so in the artefact rather than let a wrong
        // guess reach a build log as "Unable to locate package".
        out.push_str(&format!(
            "# WARNING: could not derive the distro release from base image '{base_image}'.\n\
             # Assumed {release}. State it explicitly with either\n\
             # `deployment.base_distro` in the config cascade, or the env var\n\
             # DEPLOYMENT__BASE_DISTRO=trixie|bookworm|noble|jammy|focal.\n",
        ));
    }
    if let Some(base_image) = &deps.contradicted_base_image {
        // Explicit config won, but it disagrees with the image it will run on,
        // so the FROM line and these package names are for different releases.
        out.push_str(&format!(
            "# WARNING: `deployment.base_distro` says {release}, but base image\n\
             # '{base_image}' names a different release. The packages below are\n\
             # for {release}. Fix one of the two -- this combination is almost\n\
             # certainly not what was intended.\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 {
        // Download to a file rather than piping into gpg: `sh` has no pipefail,
        // so `curl ... | gpg` discards curl's exit status and a failed fetch
        // leaves an empty keyring instead of failing the build (hadolint DL4006).
        out.push_str(&format!(
            "    && curl -fsSL {key_url} -o /tmp/repo-key.asc \\\n",
            key_url = repo.key_url,
        ));
        // Assert the key is the one we pinned BEFORE it is trusted. Written to
        // a file and grepped rather than piped, so a gpg failure cannot be
        // swallowed by the pipeline's exit status.
        if !repo.key_fingerprint.is_empty() {
            out.push_str(&format!(
                "    && gpg --show-keys --with-colons --with-fingerprint /tmp/repo-key.asc \\\n\
                 \x20      > /tmp/repo-key.info \\\n\
                 \x20   && grep -q \"^fpr:::::::::{fpr}:\" /tmp/repo-key.info \\\n\
                 \x20   && rm -f /tmp/repo-key.info \\\n",
                fpr = repo.key_fingerprint,
            ));
        }
        out.push_str(&format!(
            "    && gpg --dearmor -o {keyring} /tmp/repo-key.asc \\\n\
             \x20   && rm -f /tmp/repo-key.asc \\\n\
             \x20   && echo \"deb [signed-by={keyring}] \\\n\
             \x20      {url} {codename} main\" \\\n\
             \x20      > /etc/apt/sources.list.d/{list}.list \\\n",
            keyring = repo.keyring,
            url = repo.url,
            codename = repo.codename,
            // Derive a stable filename from the keyring path
            list = 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
}