jerrycan 0.1.0

The AI-native Rust backend platform: framework, CLI, and MCP server. https://jerrycan.cc
Documentation
//! Hardened deployment-artifact emitters for `jerrycan package`: a multi-stage
//! Dockerfile, security-hardened k8s manifests, a hardened systemd unit, and a
//! release-binary builder. Text artifacts are deterministic; binary/image builds
//! invoke the toolchain (cargo/docker) gated on availability.

use super::design::Design;
use super::{checkpipe, sbom};
use std::path::Path;

const PORT: u16 = 8000;

/// Run the check gate, emit the requested artifacts (+ always an SBOM), and
/// return (artifacts, sbom_path). Shared by the CLI `package` command and the
/// MCP `jerrycan_package` tool so the two surfaces never drift.
pub fn run_package(
    root: &Path,
    design: &Design,
    docker: bool,
    k8s: bool,
    systemd: bool,
    binary: bool,
) -> Result<(Vec<String>, String), String> {
    // Gate: never package an app that doesn't pass check.
    let report = checkpipe::run_all(root, design, None).map_err(|e| e.to_string())?;
    if !report.ok {
        return Err(format!(
            "check failed ({} diagnostics) — fix before packaging",
            report.diagnostics.len()
        ));
    }

    let mut artifacts = Vec::new();
    let mut text_targets = Vec::new();
    if docker {
        text_targets.push("docker");
    }
    if k8s {
        text_targets.push("k8s");
    }
    if systemd {
        text_targets.push("systemd");
    }
    if !text_targets.is_empty() {
        artifacts.extend(emit_text_artifacts(root, design, &text_targets)?);
    }
    if binary {
        artifacts.push(build_binary(root, design)?);
    }

    // SBOM always (it's cheap and the safety pipeline wants it).
    let deploy = root.join("deploy");
    std::fs::create_dir_all(&deploy).map_err(|e| e.to_string())?;
    let sbom = sbom::generate(root, "app")?;
    std::fs::write(deploy.join("sbom.json"), &sbom).map_err(|e| e.to_string())?;
    artifacts.push("deploy/sbom.json".to_string());

    Ok((artifacts, "deploy/sbom.json".to_string()))
}

/// A hardened multi-stage Dockerfile. Always builds a static musl binary inside
/// the rust build image (no glibc/host fallback — that path is only relevant to
/// the `--binary` target's host build, not an in-image build).
pub fn dockerfile(design: &Design) -> String {
    let name = &design.name;
    format!(
        r#"# GENERATED by jerrycan package — hardened, multi-stage, non-root.
# Requires `jerrycan` published to crates.io (or vendored into this context): the
# in-container `cargo build` below fetches it like any dependency.
FROM rust:1-bookworm AS build
WORKDIR /build
RUN rustup target add x86_64-unknown-linux-musl && \
    (apt-get update && apt-get install -y musl-tools || true)
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl -p app
RUN cp target/x86_64-unknown-linux-musl/release/app /build/{name}

FROM gcr.io/distroless/static:nonroot
COPY --from=build /build/{name} /usr/local/bin/{name}
USER nonroot
EXPOSE {PORT}
ENV JERRYCAN_ADDR=0.0.0.0:{PORT}
ENTRYPOINT ["/usr/local/bin/{name}"]
"#
    )
}

/// Deployment + Service + NetworkPolicy, security-hardened.
pub fn k8s_manifests(design: &Design) -> String {
    let name = &design.name;
    format!(
        r#"# GENERATED by jerrycan package — hardened manifests. Edit the image, then `kubectl apply -f k8s.yaml`.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {name}
  labels:
    app: {name}
spec:
  replicas: 2
  selector:
    matchLabels:
      app: {name}
  template:
    metadata:
      labels:
        app: {name}
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: {name}
          image: {name}:latest
          ports:
            - containerPort: {PORT}
          env:
            - name: JERRYCAN_ADDR
              value: "0.0.0.0:{PORT}"
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop:
                - ALL
          livenessProbe:
            httpGet:
              path: /healthz
              port: {PORT}
            initialDelaySeconds: 2
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /healthz
              port: {PORT}
            initialDelaySeconds: 1
            periodSeconds: 5
          resources:
            requests:
              cpu: 50m
              memory: 32Mi
            limits:
              cpu: 500m
              memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
  name: {name}
spec:
  selector:
    app: {name}
  ports:
    - port: 80
      targetPort: {PORT}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: {name}
spec:
  podSelector:
    matchLabels:
      app: {name}
  policyTypes:
    - Ingress
  ingress:
    - ports:
        - protocol: TCP
          port: {PORT}
"#
    )
}

/// A hardened systemd unit (binary at `/usr/local/bin/<name>`).
pub fn systemd_unit(design: &Design) -> String {
    let name = &design.name;
    format!(
        r#"# GENERATED by jerrycan package. Install: copy the binary to /usr/local/bin/{name},
# this file to /etc/systemd/system/{name}.service, then `systemctl enable --now {name}`.
[Unit]
Description={name} (jerrycan)
After=network.target

[Service]
ExecStart=/usr/local/bin/{name}
Environment=JERRYCAN_ADDR=0.0.0.0:{PORT}
Environment=JERRYCAN_ENV=prod
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target
"#
    )
}

/// Write the text artifacts for the requested targets into `<app>/deploy/`.
/// Returns the relative paths written. (Binary/image builds are separate.)
pub fn emit_text_artifacts(
    app_root: &Path,
    design: &Design,
    targets: &[&str],
) -> Result<Vec<String>, String> {
    let deploy = app_root.join("deploy");
    std::fs::create_dir_all(&deploy).map_err(|e| e.to_string())?;
    let mut written = Vec::new();
    let mut write = |rel: &str, content: &str| -> Result<(), String> {
        let path = deploy.join(rel);
        std::fs::write(&path, content).map_err(|e| format!("write deploy/{rel}: {e}"))?;
        written.push(format!("deploy/{rel}"));
        Ok(())
    };
    if targets.contains(&"docker") {
        write("Dockerfile", &dockerfile(design))?;
    }
    if targets.contains(&"k8s") {
        write("k8s.yaml", &k8s_manifests(design))?;
    }
    if targets.contains(&"systemd") {
        write(&format!("{}.service", design.name), &systemd_unit(design))?;
    }
    Ok(written)
}

/// Build a release binary, preferring static musl; falls back to the host
/// target with a note. Returns the relative artifact path.
pub fn build_binary(app_root: &Path, design: &Design) -> Result<String, String> {
    let musl = "x86_64-unknown-linux-musl";
    let musl_ok = std::process::Command::new("rustc")
        .args(["--print", "target-list"])
        .output()
        .map(|o| {
            String::from_utf8_lossy(&o.stdout)
                .lines()
                .any(|t| t == musl)
        })
        .unwrap_or(false)
        && target_installed(musl);
    let (target_args, built_path) = if musl_ok {
        (vec!["--target", musl], format!("target/{musl}/release/app"))
    } else {
        eprintln!(
            "jerrycan package: musl target unavailable — building a host-target binary (not fully static). Install with: rustup target add {musl}"
        );
        (vec![], "target/release/app".to_string())
    };
    let mut args = vec!["build", "--release", "-p", "app"];
    args.extend(target_args);
    let status = std::process::Command::new("cargo")
        .current_dir(app_root)
        .args(&args)
        .status()
        .map_err(|e| format!("cargo build failed to run: {e}"))?;
    if !status.success() {
        return Err("release build failed".to_string());
    }
    let deploy = app_root.join("deploy");
    std::fs::create_dir_all(&deploy).map_err(|e| e.to_string())?;
    let dest = deploy.join(&design.name);
    std::fs::copy(app_root.join(&built_path), &dest).map_err(|e| format!("copy binary: {e}"))?;
    Ok(format!("deploy/{}", design.name))
}

fn target_installed(target: &str) -> bool {
    std::process::Command::new("rustup")
        .args(["target", "list", "--installed"])
        .output()
        .map(|o| {
            String::from_utf8_lossy(&o.stdout)
                .lines()
                .any(|t| t == target)
        })
        .unwrap_or(false)
}