use std::collections::BTreeMap; use k8s_openapi::api::apps::v1::{Deployment, DeploymentSpec}; use k8s_openapi::api::core::v1::{ Container, ContainerPort, EnvVar, EnvVarSource, HTTPGetAction, ObjectFieldSelector, PodSpec, PodTemplateSpec, Probe, SecretKeySelector, SecurityContext, }; use k8s_openapi::apimachinery::pkg::api::resource::Quantity; use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use kube::api::ObjectMeta; use kube::Resource; use crate::types::chatservice::ChatService; use crate::types::minioinstance::MinioInstance; use crate::types::webservice::{ResourceRequirements, WebService}; const MINIO_IMAGE: &str = "quay.io/minio/minio:latest"; fn to_k8s_resources( r: &ResourceRequirements, ) -> k8s_openapi::api::core::v1::ResourceRequirements { let mut k8s = k8s_openapi::api::core::v1::ResourceRequirements::default(); if let Some(requests) = &r.requests { let mut map = BTreeMap::new(); if let Some(cpu) = &requests.cpu { map.insert("cpu".to_string(), Quantity(cpu.clone())); } if let Some(memory) = &requests.memory { map.insert("memory".to_string(), Quantity(memory.clone())); } if !map.is_empty() { k8s.requests = Some(map); } } if let Some(limits) = &r.limits { let mut map = BTreeMap::new(); if let Some(cpu) = &limits.cpu { map.insert("cpu".to_string(), Quantity(cpu.clone())); } if let Some(memory) = &limits.memory { map.insert("memory".to_string(), Quantity(memory.clone())); } if !map.is_empty() { k8s.limits = Some(map); } } k8s } fn env_plain(name: &str, value: &str) -> EnvVar { EnvVar { name: name.to_string(), value: Some(value.to_string()), ..Default::default() } } fn env_secret(name: &str, secret_name: &str, key: &str) -> EnvVar { EnvVar { name: name.to_string(), value_from: Some(EnvVarSource { secret_key_ref: Some(SecretKeySelector { name: secret_name.to_string(), key: key.to_string(), optional: Some(false), }), ..Default::default() }), ..Default::default() } } fn env_secret_optional(name: &str, secret_name: &str, key: &str) -> EnvVar { EnvVar { name: name.to_string(), value_from: Some(EnvVarSource { secret_key_ref: Some(SecretKeySelector { name: secret_name.to_string(), key: key.to_string(), optional: Some(true), }), ..Default::default() }), ..Default::default() } } fn env_field_ref(name: &str, field_path: &str) -> EnvVar { EnvVar { name: name.to_string(), value_from: Some(EnvVarSource { field_ref: Some(ObjectFieldSelector { field_path: field_path.to_string(), ..Default::default() }), ..Default::default() }), ..Default::default() } } pub fn build_web_deployment(ws: &WebService) -> Deployment { let name = ws.metadata.name.clone().unwrap(); let ns = ws.metadata.namespace.clone().unwrap(); let oref = ws.controller_owner_ref(&()).unwrap(); let port = ws.spec.port.unwrap_or(8080); let labels: BTreeMap = [ ("app".to_string(), name.clone()), ( "app.kubernetes.io/managed-by".to_string(), "platform-operator".to_string(), ), ] .into(); let selector_labels: BTreeMap = [("app".to_string(), name.clone())].into(); let mut env: Vec = Vec::new(); if let Some(db) = &ws.spec.database { let prefix = db .env_prefix .as_deref() .unwrap_or("DB"); env.push(env_plain(&format!("{prefix}_HOST"), &db.host)); env.push(env_plain(&format!("{prefix}_PORT"), &db.port.to_string())); env.push(env_plain(&format!("{prefix}_NAME"), &db.name)); env.push(env_secret(&format!("{prefix}_USER"), &db.secret_ref, "username")); env.push(env_secret(&format!("{prefix}_PASS"), &db.secret_ref, "password")); } if let Some(oidc) = &ws.spec.oidc { env.push(env_plain("OIDC_ISSUER_URL", &oidc.issuer_url)); env.push(env_secret("OIDC_CLIENT_ID", &oidc.secret_ref, "client-id")); env.push(env_secret( "OIDC_CLIENT_SECRET", &oidc.secret_ref, "client-secret", )); env.push(env_plain("OIDC_REDIRECT_URL", &oidc.redirect_url)); } if let Some(s3) = &ws.spec.s3 { env.push(env_plain("S3_ENDPOINT", &s3.endpoint)); env.push(env_plain("S3_BUCKET", &s3.bucket)); env.push(env_secret( "S3_ACCESS_KEY", &s3.credentials_secret, "root-user", )); env.push(env_secret( "S3_SECRET_KEY", &s3.credentials_secret, "root-password", )); } if let Some(stripe) = &ws.spec.stripe { env.push(env_secret_optional( "STRIPE_SECRET_KEY", &stripe.secret_ref, "secret-key", )); env.push(env_secret_optional( "STRIPE_PRICE_ID", &stripe.secret_ref, "price-id", )); env.push(env_secret_optional( "STRIPE_WEBHOOK_SECRET", &stripe.secret_ref, "webhook-secret", )); } if ws.spec.service_account.is_some() { env.push(env_field_ref("NAMESPACE", "metadata.namespace")); } let resources = ws.spec.resources.as_ref().map(to_k8s_resources); let container = Container { name: name.clone(), image: Some(ws.spec.image.clone()), ports: Some(vec![ContainerPort { container_port: port, name: Some("http".to_string()), ..Default::default() }]), env: if env.is_empty() { None } else { Some(env) }, resources, readiness_probe: Some(Probe { http_get: Some(HTTPGetAction { path: Some("/health".to_string()), port: IntOrString::Int(port), ..Default::default() }), initial_delay_seconds: Some(5), ..Default::default() }), security_context: Some(SecurityContext { run_as_non_root: Some(true), allow_privilege_escalation: Some(false), ..Default::default() }), ..Default::default() }; Deployment { metadata: ObjectMeta { name: Some(name.clone()), namespace: Some(ns), labels: Some(labels), owner_references: Some(vec![oref]), ..Default::default() }, spec: Some(DeploymentSpec { replicas: Some(1), selector: LabelSelector { match_labels: Some(selector_labels), ..Default::default() }, template: PodTemplateSpec { metadata: Some(ObjectMeta { labels: Some([("app".to_string(), name.clone())].into()), ..Default::default() }), spec: Some(PodSpec { service_account_name: ws.spec.service_account.clone(), containers: vec![container], ..Default::default() }), }, ..Default::default() }), ..Default::default() } } pub fn build_chat_deployment(cs: &ChatService) -> Deployment { let name = cs.metadata.name.clone().unwrap(); let ns = cs.metadata.namespace.clone().unwrap(); let oref = cs.controller_owner_ref(&()).unwrap(); let port = cs.spec.port.unwrap_or(8080); let labels: BTreeMap = [ ("app".to_string(), name.clone()), ( "app.kubernetes.io/managed-by".to_string(), "platform-operator".to_string(), ), ] .into(); let selector_labels: BTreeMap = [("app".to_string(), name.clone())].into(); let resources = cs.spec.resources.as_ref().map(to_k8s_resources); let container = Container { name: name.clone(), image: Some(cs.spec.image.clone()), ports: Some(vec![ContainerPort { container_port: port, name: Some("http".to_string()), ..Default::default() }]), resources, security_context: Some(SecurityContext { run_as_non_root: Some(true), allow_privilege_escalation: Some(false), ..Default::default() }), ..Default::default() }; Deployment { metadata: ObjectMeta { name: Some(name.clone()), namespace: Some(ns), labels: Some(labels), owner_references: Some(vec![oref]), ..Default::default() }, spec: Some(DeploymentSpec { replicas: Some(1), selector: LabelSelector { match_labels: Some(selector_labels), ..Default::default() }, template: PodTemplateSpec { metadata: Some(ObjectMeta { labels: Some([("app".to_string(), name.clone())].into()), ..Default::default() }), spec: Some(PodSpec { containers: vec![container], ..Default::default() }), }, ..Default::default() }), ..Default::default() } } pub fn build_minio_deployment(mi: &MinioInstance) -> Deployment { let name = mi.metadata.name.clone().unwrap(); let ns = mi.metadata.namespace.clone().unwrap(); let oref = mi.controller_owner_ref(&()).unwrap(); let labels: BTreeMap = [ ("app".to_string(), name.clone()), ( "app.kubernetes.io/managed-by".to_string(), "platform-operator".to_string(), ), ] .into(); let selector_labels: BTreeMap = [("app".to_string(), name.clone())].into(); let resources = mi.spec.resources.as_ref().map(to_k8s_resources); let env = vec![ env_secret("MINIO_ROOT_USER", &mi.spec.credentials_secret, "root-user"), env_secret( "MINIO_ROOT_PASSWORD", &mi.spec.credentials_secret, "root-password", ), ]; let container = Container { name: "minio".to_string(), image: Some(MINIO_IMAGE.to_string()), command: Some(vec!["minio".to_string()]), args: Some(vec!["server".to_string(), "/data".to_string()]), ports: Some(vec![ ContainerPort { container_port: 9000, name: Some("api".to_string()), ..Default::default() }, ContainerPort { container_port: 9001, name: Some("console".to_string()), ..Default::default() }, ]), env: Some(env), resources, volume_mounts: Some(vec![k8s_openapi::api::core::v1::VolumeMount { name: "data".to_string(), mount_path: "/data".to_string(), ..Default::default() }]), security_context: Some(SecurityContext { run_as_non_root: Some(true), allow_privilege_escalation: Some(false), ..Default::default() }), ..Default::default() }; Deployment { metadata: ObjectMeta { name: Some(name.clone()), namespace: Some(ns), labels: Some(labels), owner_references: Some(vec![oref]), ..Default::default() }, spec: Some(DeploymentSpec { replicas: Some(1), selector: LabelSelector { match_labels: Some(selector_labels), ..Default::default() }, template: PodTemplateSpec { metadata: Some(ObjectMeta { labels: Some([("app".to_string(), name.clone())].into()), ..Default::default() }), spec: Some(PodSpec { containers: vec![container], volumes: Some(vec![k8s_openapi::api::core::v1::Volume { name: "data".to_string(), persistent_volume_claim: Some( k8s_openapi::api::core::v1::PersistentVolumeClaimVolumeSource { claim_name: format!("{name}-data"), ..Default::default() }, ), ..Default::default() }]), ..Default::default() }), }, ..Default::default() }), ..Default::default() } } #[cfg(test)] mod tests { use super::*; use crate::testutil::{ test_chatservice, test_minioinstance, test_webservice, test_webservice_minimal, }; #[test] fn web_deployment_uses_image_from_spec() { let dep = build_web_deployment(&test_webservice()); let containers = dep.spec.unwrap().template.spec.unwrap().containers; assert!(containers[0].image.as_ref().unwrap().contains("paste")); } #[test] fn web_deployment_has_db_env_vars() { let dep = build_web_deployment(&test_webservice()); let containers = dep.spec.unwrap().template.spec.unwrap().containers; let env = containers[0].env.as_ref().unwrap(); let names: Vec<&str> = env.iter().map(|e| e.name.as_str()).collect(); assert!(names.contains(&"PASTE_DB_HOST")); assert!(names.contains(&"PASTE_DB_PORT")); assert!(names.contains(&"PASTE_DB_NAME")); assert!(names.contains(&"PASTE_DB_USER")); assert!(names.contains(&"PASTE_DB_PASS")); } #[test] fn web_deployment_has_oidc_env_vars() { let dep = build_web_deployment(&test_webservice()); let containers = dep.spec.unwrap().template.spec.unwrap().containers; let env = containers[0].env.as_ref().unwrap(); let names: Vec<&str> = env.iter().map(|e| e.name.as_str()).collect(); assert!(names.contains(&"OIDC_ISSUER_URL")); assert!(names.contains(&"OIDC_CLIENT_ID")); assert!(names.contains(&"OIDC_CLIENT_SECRET")); assert!(names.contains(&"OIDC_REDIRECT_URL")); } #[test] fn web_deployment_db_user_from_secret() { let dep = build_web_deployment(&test_webservice()); let containers = dep.spec.unwrap().template.spec.unwrap().containers; let env = containers[0].env.as_ref().unwrap(); let db_user = env.iter().find(|e| e.name == "PASTE_DB_USER").unwrap(); let secret_ref = db_user .value_from .as_ref() .unwrap() .secret_key_ref .as_ref() .unwrap(); assert_eq!(secret_ref.name, "paste-db-app"); assert_eq!(secret_ref.key, "username"); } #[test] fn web_deployment_minimal_has_no_env() { let dep = build_web_deployment(&test_webservice_minimal()); let containers = dep.spec.unwrap().template.spec.unwrap().containers; assert!(containers[0].env.is_none()); } #[test] fn web_deployment_selector_uses_app_label() { let dep = build_web_deployment(&test_webservice()); let selector = dep.spec.unwrap().selector.match_labels.unwrap(); assert_eq!(selector["app"], "txt"); assert!(!selector.contains_key("app.kubernetes.io/managed-by")); } #[test] fn web_deployment_has_readiness_probe() { let dep = build_web_deployment(&test_webservice()); let containers = dep.spec.unwrap().template.spec.unwrap().containers; let probe = containers[0].readiness_probe.as_ref().unwrap(); let http = probe.http_get.as_ref().unwrap(); assert_eq!(http.path, Some("/health".to_string())); } #[test] fn web_deployment_has_owner_reference() { let dep = build_web_deployment(&test_webservice()); let orefs = dep.metadata.owner_references.unwrap(); assert_eq!(orefs[0].name, "txt"); } #[test] fn web_deployment_has_resources() { let dep = build_web_deployment(&test_webservice()); let containers = dep.spec.unwrap().template.spec.unwrap().containers; let res = containers[0].resources.as_ref().unwrap(); let requests = res.requests.as_ref().unwrap(); assert_eq!(requests["memory"], Quantity("64Mi".to_string())); } #[test] fn chat_deployment_has_no_env() { let dep = build_chat_deployment(&test_chatservice()); let containers = dep.spec.unwrap().template.spec.unwrap().containers; assert!(containers[0].env.is_none()); } #[test] fn chat_deployment_has_no_readiness_probe() { let dep = build_chat_deployment(&test_chatservice()); let containers = dep.spec.unwrap().template.spec.unwrap().containers; assert!(containers[0].readiness_probe.is_none()); } #[test] fn minio_deployment_uses_minio_image() { let dep = build_minio_deployment(&test_minioinstance()); let containers = dep.spec.unwrap().template.spec.unwrap().containers; assert_eq!(containers[0].image.as_ref().unwrap(), MINIO_IMAGE); } #[test] fn minio_deployment_mounts_pvc() { let dep = build_minio_deployment(&test_minioinstance()); let pod_spec = dep.spec.unwrap().template.spec.unwrap(); let volumes = pod_spec.volumes.unwrap(); let data_vol = volumes.iter().find(|v| v.name == "data").unwrap(); assert_eq!( data_vol .persistent_volume_claim .as_ref() .unwrap() .claim_name, "minio-data" ); } #[test] fn minio_deployment_has_credential_env() { let dep = build_minio_deployment(&test_minioinstance()); let containers = dep.spec.unwrap().template.spec.unwrap().containers; let env = containers[0].env.as_ref().unwrap(); let names: Vec<&str> = env.iter().map(|e| e.name.as_str()).collect(); assert!(names.contains(&"MINIO_ROOT_USER")); assert!(names.contains(&"MINIO_ROOT_PASSWORD")); } #[test] fn minio_deployment_has_two_ports() { let dep = build_minio_deployment(&test_minioinstance()); let containers = dep.spec.unwrap().template.spec.unwrap().containers; let ports = containers[0].ports.as_ref().unwrap(); assert_eq!(ports.len(), 2); assert_eq!(ports[0].container_port, 9000); assert_eq!(ports[1].container_port, 9001); } #[test] fn minio_deployment_has_owner_reference() { let dep = build_minio_deployment(&test_minioinstance()); let orefs = dep.metadata.owner_references.unwrap(); assert_eq!(orefs[0].name, "minio"); } }