use crate::types::{ConfigFileMount, ServicePlan};
use std::collections::BTreeMap;
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()
}
}
pub fn bootstrap_manifest_yaml(svc: &ServicePlan, container_port: u16) -> Option<String> {
bootstrap_manifest_yaml_with_pull_policy(svc, container_port, "IfNotPresent")
}
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"))
}
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;
}
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()?;
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 {
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"));
}
}