xbp-deploy 10.57.0

Service-centric declarative deploy engine for XBP.
Documentation
//! Minimal Kubernetes Deployment/Service YAML for first-time bootstrap.

use crate::types::{ConfigFileMount, ServicePlan};
use std::collections::BTreeMap;

/// Parse workload strings like `deployment/athena`, `deploy/athena`, or bare `athena`.
pub fn workload_name(workload: &str) -> String {
    let w = workload.trim();
    if let Some((_, name)) = w.split_once('/') {
        name.trim().to_string()
    } else {
        w.to_string()
    }
}

/// Generate Deployment + Service YAML for a service with image + namespace + workload.
pub fn bootstrap_manifest_yaml(svc: &ServicePlan, container_port: u16) -> Option<String> {
    bootstrap_manifest_yaml_with_pull_policy(svc, container_port, "IfNotPresent")
}

/// Like [`bootstrap_manifest_yaml`], but set `imagePullPolicy` (e.g. `Never` for local-only images).
pub fn bootstrap_manifest_yaml_with_pull_policy(
    svc: &ServicePlan,
    container_port: u16,
    image_pull_policy: &str,
) -> Option<String> {
    bootstrap_workload_documents(svc, container_port, image_pull_policy)
        .map(|docs| docs.join("---\n"))
}

/// Full multi-document bootstrap: ConfigMaps (config files) + Deployment + Service.
pub fn bootstrap_workload_documents(
    svc: &ServicePlan,
    container_port: u16,
    image_pull_policy: &str,
) -> Option<Vec<String>> {
    let workload = svc.deploy.workload.as_deref()?;
    let workload_name = workload_name(workload);
    if workload_name.is_empty() {
        return None;
    }
    // Deployment uses workload name; Service may use a distinct name.
    let deploy_name = workload_name.clone();
    let service_name = svc
        .deploy
        .service
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_string)
        .unwrap_or_else(|| deploy_name.clone());
    let ns = svc
        .deploy
        .namespace
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or("default");
    let image = svc
        .image_ref
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())?;
    let replicas = 1u32;
    let port = svc.container_port.unwrap_or(container_port);
    let pull = image_pull_policy.trim();
    let pull = if pull.is_empty() {
        "IfNotPresent"
    } else {
        pull
    };

    let cm_name = format!("{deploy_name}-compose-config");
    let mut docs = Vec::new();

    if !svc.config_mounts.is_empty() {
        if let Some(cm) = configmap_yaml(ns, &cm_name, &svc.config_mounts) {
            docs.push(cm);
        }
    }

    let env_yaml = env_block_yaml(&svc.runtime_env);
    let volume_yaml = volume_mounts_yaml(&svc.config_mounts, &cm_name);
    let volumes_yaml = volumes_yaml(&svc.config_mounts, &cm_name);
    let host_port_yaml = host_port_block(svc);
    let service_spec = service_spec_yaml(svc, port);

    docs.push(format!(
        r#"apiVersion: apps/v1
kind: Deployment
metadata:
  name: {deploy_name}
  namespace: {ns}
  labels:
    app.kubernetes.io/name: {deploy_name}
    app.kubernetes.io/managed-by: xbp
spec:
  replicas: {replicas}
  selector:
    matchLabels:
      app.kubernetes.io/name: {deploy_name}
  template:
    metadata:
      labels:
        app.kubernetes.io/name: {deploy_name}
    spec:
      containers:
        - name: {deploy_name}
          image: {image}
          imagePullPolicy: {pull}
          ports:
            - name: http
              containerPort: {port}
{host_port_yaml}{env_yaml}{volume_yaml}{volumes_yaml}"#
    ));

    docs.push(format!(
        r#"apiVersion: v1
kind: Service
metadata:
  name: {service_name}
  namespace: {ns}
  labels:
    app.kubernetes.io/name: {deploy_name}
    app.kubernetes.io/managed-by: xbp
spec:
  selector:
    app.kubernetes.io/name: {deploy_name}
{service_spec}"#
    ));

    Some(docs)
}

fn host_port_block(svc: &ServicePlan) -> String {
    let Some(hp) = svc
        .expose
        .as_ref()
        .and_then(|e| e.host_port)
        .filter(|p| *p > 0)
    else {
        return String::new();
    };
    format!("              hostPort: {hp}\n")
}

fn service_spec_yaml(svc: &ServicePlan, port: u16) -> String {
    let expose = svc.expose.as_ref();
    let service_type = expose
        .and_then(|e| e.service_type.as_deref())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or("ClusterIP");
    let node_port = expose.and_then(|e| e.node_port).filter(|p| *p > 0);

    let mut out = format!("  type: {service_type}\n  ports:\n    - name: http\n      port: {port}\n      targetPort: http\n");
    if service_type.eq_ignore_ascii_case("NodePort") {
        if let Some(np) = node_port {
            out.push_str(&format!("      nodePort: {np}\n"));
        }
    }
    out
}

fn configmap_yaml(ns: &str, name: &str, mounts: &[ConfigFileMount]) -> Option<String> {
    if mounts.is_empty() {
        return None;
    }
    let mut data = String::new();
    for m in mounts {
        let raw = std::fs::read_to_string(&m.source).ok()?;
        // YAML literal block for multiline config
        let indented = raw
            .lines()
            .map(|l| format!("    {l}"))
            .collect::<Vec<_>>()
            .join("\n");
        data.push_str(&format!("  {}: |\n{}\n", m.key, indented));
    }
    Some(format!(
        r#"apiVersion: v1
kind: ConfigMap
metadata:
  name: {name}
  namespace: {ns}
  labels:
    app.kubernetes.io/managed-by: xbp
data:
{data}"#
    ))
}

fn env_block_yaml(env: &BTreeMap<String, String>) -> String {
    if env.is_empty() {
        return String::new();
    }
    let mut s = String::from("          env:\n");
    for (k, v) in env {
        // Escape for YAML double-quoted string
        let escaped = v
            .replace('\\', "\\\\")
            .replace('"', "\\\"")
            .replace('\n', "\\n");
        s.push_str(&format!("            - name: {k}\n              value: \"{escaped}\"\n"));
    }
    s
}

fn volume_mounts_yaml(mounts: &[ConfigFileMount], _cm_name: &str) -> String {
    if mounts.is_empty() {
        return String::new();
    }
    let mut s = String::from("          volumeMounts:\n");
    for m in mounts {
        s.push_str(&format!(
            "            - name: compose-config\n              mountPath: {}\n              subPath: {}\n              readOnly: true\n",
            m.mount_path, m.key
        ));
    }
    s
}

fn volumes_yaml(mounts: &[ConfigFileMount], cm_name: &str) -> String {
    if mounts.is_empty() {
        return String::new();
    }
    format!(
        r#"      volumes:
        - name: compose-config
          configMap:
            name: {cm_name}
"#
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::ServiceDeployPlan;

    #[test]
    fn workload_name_parses_shorthand() {
        assert_eq!(workload_name("deployment/athena"), "athena");
        assert_eq!(workload_name("deploy/api"), "api");
        assert_eq!(workload_name("athena"), "athena");
    }

    #[test]
    fn generates_yaml_with_image_and_ns() {
        let mut env = BTreeMap::new();
        env.insert("FOO".into(), "bar".into());
        let svc = ServicePlan {
            name: "athena".into(),
            provider: "kubernetes".into(),
            destination: Some("kubernetes".into()),
            root_directory: None,
            version: "1".into(),
            image: None,
            image_ref: Some("ghcr.io/x/athena:1.0.0".into()),
            digest: None,
            dockerfile: None,
            build_context: None,
            platforms: vec![],
            worker_app: None,
            rollout: None,
            runtime_env: env,
            container_port: Some(5052),
            config_mounts: vec![],
            expose: Some(crate::types::ServiceExposeView {
                service_type: Some("NodePort".into()),
                node_port: Some(30052),
                host_port: Some(5052),
                local_port: Some(4052),
                port_forward: Some(true),
                dns_hosts: vec!["athena.local".into()],
                dns_mode: Some("hosts".into()),
            }),
            deploy: ServiceDeployPlan {
                namespace: Some("athena".into()),
                workload: Some("deployment/athena".into()),
                service: None,
                health: vec![],
                manifest_paths: vec![],
                crds_path: None,
                install_path: None,
                selector: None,
                actions: vec![],
            },
        };
        let yaml = bootstrap_manifest_yaml(&svc, 8080).unwrap();
        assert!(yaml.contains("name: athena"));
        assert!(yaml.contains("namespace: athena"));
        assert!(yaml.contains("ghcr.io/x/athena:1.0.0"));
        assert!(yaml.contains("containerPort: 5052"));
        assert!(yaml.contains("hostPort: 5052"));
        assert!(yaml.contains("type: NodePort"));
        assert!(yaml.contains("nodePort: 30052"));
        assert!(yaml.contains("name: FOO"));
        assert!(yaml.contains("kind: Deployment"));
        assert!(yaml.contains("kind: Service"));
    }
}