diff --git a/ROADMAP.md b/ROADMAP.md index e00e0e1e..1844ee9b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -72,8 +72,9 @@ APIs are not accepted designs. Verified against current code: - Apps implement `HarmonyApp` and compose Scores. `ComposeDeploy` / - `Application` are gone. The Java+React example does not yet create a - Service or Ingress (`K8sDeploymentScore` is image+env only). + `Application` are gone. `K8sDeploymentScore` renders a hardened + Deployment and Service; env takes typed Refs (`app_account_ref()`, + `bucket_ref()`, Zitadel outputs). - The real deploy still duplicates namespace-derived names and some provider conventions. Those are concrete DRY problems. - The application layer does not yet prove its full readiness claim; Helm diff --git a/docs/catalogs/scores.md b/docs/catalogs/scores.md index 179eed44..95edbe20 100644 --- a/docs/catalogs/scores.md +++ b/docs/catalogs/scores.md @@ -45,7 +45,7 @@ Scores for installing and managing services _inside_ a Kubernetes cluster. - **CertManagerHelmScore**: Deploys the `cert-manager` Helm chart. - **ClusterIssuerScore**: Configures a `ClusterIssuer` for `cert-manager`, (e.g., for Let's Encrypt). - **K8sNamespaceScore**: Ensures a Kubernetes namespace exists. -- **K8sDeploymentScore**: Deploys a generic `Deployment` resource to Kubernetes. +- **K8sDeploymentScore**: One hardened container `Deployment`, with an optional ClusterIP `Service`, HTTP probes, and env from typed Refs. - **K8sIngressScore**: Configures an `Ingress` resource for a service. ## Monitoring & Alerting diff --git a/docs/guides/application-capabilities.md b/docs/guides/application-capabilities.md index 3ff6381f..d09f08cb 100644 --- a/docs/guides/application-capabilities.md +++ b/docs/guides/application-capabilities.md @@ -9,10 +9,12 @@ Compose the Scores that already exist: `K8sPostgreSQLScore`, ```rust async fn scores(&self, ctx: &AppContext, images: &ImageRefs) -> Result>>, AppError> { - Ok(vec![ - Box::new(K8sPostgreSQLScore::new(ctx.namespace()).cluster_name("app-db")), - Box::new(K8sDeploymentScore { /* … */ }), - ]) + let db = K8sPostgreSQLScore::new(ctx.namespace()).cluster_name("app-db"); + let api = K8sDeploymentScore::new("app", "api", images.require("api")?) + .namespace(ctx.namespace()) + .health_checked_port(8080, "/healthz") + .env("DATABASE_URL", db.app_account_ref().uri()); + Ok(vec![Box::new(db), Box::new(api)]) } ``` diff --git a/examples/compose_java_react/README.md b/examples/compose_java_react/README.md index 5b284569..b4a26aca 100644 --- a/examples/compose_java_react/README.md +++ b/examples/compose_java_react/README.md @@ -9,6 +9,6 @@ is the original local shape; Harmony does not import it. cargo run --bin compose-deploy -- ship --context local ``` -`--context` is required. `K8sDeploymentScore` does not create a Service or -Ingress, so in-cluster DNS (`BACKEND_URL=http://backend:8080`) and public -expose are holes until those Scores exist. +`--context` is required. Backend and frontend each get a Service on 8080 +(`BACKEND_URL=http://backend:8080`). Both images run as non-root users, as the +restricted pod security context requires. There is no Ingress yet. diff --git a/examples/compose_java_react/app/docker-compose.yml b/examples/compose_java_react/app/docker-compose.yml index 2ee09163..2dc4256c 100644 --- a/examples/compose_java_react/app/docker-compose.yml +++ b/examples/compose_java_react/app/docker-compose.yml @@ -13,7 +13,7 @@ services: frontend: build: ./frontend ports: - - "8081:80" + - "8081:8080" environment: - BACKEND_URL=http://backend:8080 depends_on: diff --git a/examples/compose_java_react/app/frontend/Dockerfile b/examples/compose_java_react/app/frontend/Dockerfile index 945f5782..aa3ee6cd 100644 --- a/examples/compose_java_react/app/frontend/Dockerfile +++ b/examples/compose_java_react/app/frontend/Dockerfile @@ -5,10 +5,9 @@ RUN npm install COPY . . RUN npm run build -FROM nginx:alpine +FROM nginxinc/nginx-unprivileged:alpine COPY --from=build /app/dist /usr/share/nginx/html COPY nginx.conf.template /etc/nginx/templates/default.conf.template -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -EXPOSE 80 +COPY --chmod=755 entrypoint.sh /entrypoint.sh +EXPOSE 8080 ENTRYPOINT ["/entrypoint.sh"] diff --git a/examples/compose_java_react/app/frontend/nginx.conf.template b/examples/compose_java_react/app/frontend/nginx.conf.template index ff5a2f62..0022043b 100644 --- a/examples/compose_java_react/app/frontend/nginx.conf.template +++ b/examples/compose_java_react/app/frontend/nginx.conf.template @@ -1,5 +1,5 @@ server { - listen 80; + listen 8080; root /usr/share/nginx/html; index index.html; diff --git a/examples/compose_java_react/src/main.rs b/examples/compose_java_react/src/main.rs index f5561e30..04d1fade 100644 --- a/examples/compose_java_react/src/main.rs +++ b/examples/compose_java_react/src/main.rs @@ -10,7 +10,6 @@ use harmony_app::{ ImageSpec, LocalContext, }; use harmony_macros::context_name; -use serde_json::json; struct Timesheet; @@ -44,26 +43,15 @@ impl HarmonyApp for Timesheet { ) -> Result>>, AppError> { let namespace = ctx.namespace(); let postgres = K8sPostgreSQLScore::new(namespace).cluster_name("timesheet-db"); - let backend = K8sDeploymentScore { - name: "backend".into(), - image: images.require("backend")?.into(), - namespace: Some(namespace.into()), - env_vars: json!([{ - "name": "DATABASE_URL", - "valueFrom": { - "secretKeyRef": { "name": "timesheet-db-app", "key": "uri" } - } - }]), - }; - let frontend = K8sDeploymentScore { - name: "frontend".into(), - image: images.require("frontend")?.into(), - namespace: Some(namespace.into()), - env_vars: json!([{ - "name": "BACKEND_URL", - "value": "http://backend:8080" - }]), - }; + let backend = K8sDeploymentScore::new("timesheet", "backend", images.require("backend")?) + .namespace(namespace) + .port(8080) + .env("DATABASE_URL", postgres.app_account_ref().uri()); + let frontend = + K8sDeploymentScore::new("timesheet", "frontend", images.require("frontend")?) + .namespace(namespace) + .port(8080) + .env("BACKEND_URL", "http://backend:8080"); Ok(vec![ Box::new(postgres), Box::new(backend), diff --git a/examples/notes/src/cluster.rs b/examples/notes/src/cluster.rs index cc6c382c..e94806f3 100644 --- a/examples/notes/src/cluster.rs +++ b/examples/notes/src/cluster.rs @@ -3,8 +3,7 @@ use harmony::modules::k8s::deployment::K8sDeploymentScore; use harmony::modules::postgresql::K8sPostgreSQLScore; use harmony::score::Score; use harmony::topology::K8sAnywhereTopology; -use harmony_app::dx::{container, AsContainer, Container}; -use serde_json::json; +use harmony_app::dx::{AsContainer, Container, container}; pub fn bind(app: &Notes) -> Bound<'_, container, container, container> { Bound { @@ -18,19 +17,10 @@ pub fn scores(app: &Notes, namespace: &str) -> Vec Image { - Image::from_registry("nginx:alpine").port(80) + Image::from_registry("nginxinc/nginx-unprivileged:alpine").port(8080) } } diff --git a/harmony/src/modules/k8s/deployment.rs b/harmony/src/modules/k8s/deployment.rs index 5ddc22d8..c7e1da62 100644 --- a/harmony/src/modules/k8s/deployment.rs +++ b/harmony/src/modules/k8s/deployment.rs @@ -1,64 +1,396 @@ -use k8s_openapi::api::apps::v1::Deployment; +use std::collections::BTreeMap; +use std::time::Duration; + +use async_trait::async_trait; +use harmony_types::id::Id; +use harmony_types::k8s_name::K8sName; +use k8s_openapi::api::apps::v1::RollingUpdateDeployment; +use k8s_openapi::api::apps::v1::{Deployment, DeploymentSpec, DeploymentStrategy}; +use k8s_openapi::api::core::v1::{ + Capabilities, Container, ContainerPort, HTTPGetAction, LocalObjectReference, + PodSecurityContext, PodSpec, PodTemplateSpec, Probe, ResourceRequirements, SeccompProfile, + SecurityContext, Service, ServicePort, ServiceSpec, +}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, ObjectMeta}; +use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use serde::Serialize; -use serde_json::json; use crate::{ - interpret::Interpret, + data::Version, + interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}, + inventory::Inventory, score::Score, topology::{K8sclient, Topology}, }; -use super::resource::{K8sResourceInterpret, K8sResourceScore}; +use super::env::EnvValue; +use super::resource::K8sResourceScore; + +const PORT_NAME: &str = "http"; +const PROBE_PERIOD_SECONDS: i32 = 10; +const STARTUP_GRACE_SECONDS: i32 = 120; + +#[derive(Debug, Clone, Serialize)] +pub struct HttpPort { + pub number: u16, + pub health_path: Option, +} #[derive(Debug, Clone, Serialize)] pub struct K8sDeploymentScore { + pub app: String, pub name: String, pub image: String, pub namespace: Option, - pub env_vars: serde_json::Value, + pub port: Option, + pub env: Vec<(String, EnvValue)>, + pub image_pull_secret: Option, + pub replicas: u16, + pub recreate: bool, + pub resources: Option, + pub wait: Option, +} + +impl K8sDeploymentScore { + pub fn new(app: impl Into, name: impl Into, image: impl Into) -> Self { + Self { + app: app.into(), + name: name.into(), + image: image.into(), + namespace: None, + port: None, + env: Vec::new(), + image_pull_secret: None, + replicas: 1, + recreate: false, + resources: None, + wait: None, + } + } + + pub fn namespace(mut self, namespace: impl Into) -> Self { + self.namespace = Some(namespace.into()); + self + } + + pub fn port(mut self, number: u16) -> Self { + self.port = Some(HttpPort { + number, + health_path: None, + }); + self + } + + pub fn health_checked_port(mut self, number: u16, path: impl Into) -> Self { + self.port = Some(HttpPort { + number, + health_path: Some(path.into()), + }); + self + } + + pub fn env(mut self, name: impl Into, value: impl Into) -> Self { + self.env.push((name.into(), value.into())); + self + } + + pub fn image_pull_secret(mut self, secret: Option) -> Self { + self.image_pull_secret = secret; + self + } + + pub fn replicas(mut self, replicas: u16) -> Self { + self.replicas = replicas; + self + } + + pub fn recreate(mut self) -> Self { + self.recreate = true; + self + } + + pub fn resources(mut self, resources: ResourceRequirements) -> Self { + self.resources = Some(resources); + self + } + + pub fn wait(mut self, timeout: Duration) -> Self { + self.wait = Some(timeout); + self + } + + fn labels(&self) -> BTreeMap { + BTreeMap::from([ + ("app.kubernetes.io/component".to_string(), self.name.clone()), + ( + "app.kubernetes.io/managed-by".to_string(), + "harmony".to_string(), + ), + ("app.kubernetes.io/part-of".to_string(), self.app.clone()), + ]) + } + + fn probes(&self) -> (Option, Option, Option) { + let Some(path) = self + .port + .as_ref() + .and_then(|port| port.health_path.as_ref()) + else { + return (None, None, None); + }; + let base = Probe { + http_get: Some(HTTPGetAction { + path: Some(path.clone()), + port: IntOrString::String(PORT_NAME.to_string()), + scheme: Some("HTTP".to_string()), + ..Default::default() + }), + initial_delay_seconds: Some(0), + period_seconds: Some(PROBE_PERIOD_SECONDS), + timeout_seconds: Some(2), + ..Default::default() + }; + let startup = Probe { + failure_threshold: Some(STARTUP_GRACE_SECONDS / PROBE_PERIOD_SECONDS), + ..base.clone() + }; + let runtime = Probe { + failure_threshold: Some(3), + ..base + }; + (Some(startup), Some(runtime.clone()), Some(runtime)) + } + + pub fn render_deployment(&self) -> Deployment { + let labels = self.labels(); + let (startup_probe, readiness_probe, liveness_probe) = self.probes(); + let env: Vec<_> = self + .env + .iter() + .map(|(name, value)| value.to_env_var(name)) + .collect(); + let strategy = if self.recreate { + DeploymentStrategy { + type_: Some("Recreate".to_string()), + ..Default::default() + } + } else { + DeploymentStrategy { + type_: Some("RollingUpdate".to_string()), + rolling_update: Some(RollingUpdateDeployment { + max_surge: Some(IntOrString::Int(1)), + max_unavailable: Some(IntOrString::Int(0)), + }), + } + }; + Deployment { + metadata: ObjectMeta { + name: Some(self.name.clone()), + labels: Some(labels.clone()), + ..Default::default() + }, + spec: Some(DeploymentSpec { + replicas: Some(i32::from(self.replicas)), + strategy: Some(strategy), + selector: LabelSelector { + match_labels: Some(labels.clone()), + ..Default::default() + }, + template: PodTemplateSpec { + metadata: Some(ObjectMeta { + labels: Some(labels), + ..Default::default() + }), + spec: Some(PodSpec { + automount_service_account_token: Some(false), + image_pull_secrets: self.image_pull_secret.as_ref().map(|secret| { + vec![LocalObjectReference { + name: secret.to_string(), + }] + }), + security_context: Some(PodSecurityContext { + run_as_non_root: Some(true), + seccomp_profile: Some(SeccompProfile { + type_: "RuntimeDefault".to_string(), + ..Default::default() + }), + ..Default::default() + }), + containers: vec![Container { + name: self.name.clone(), + image: Some(self.image.clone()), + ports: self.port.as_ref().map(|port| { + vec![ContainerPort { + name: Some(PORT_NAME.to_string()), + container_port: i32::from(port.number), + protocol: Some("TCP".to_string()), + ..Default::default() + }] + }), + env: (!env.is_empty()).then_some(env), + startup_probe, + readiness_probe, + liveness_probe, + resources: self.resources.clone(), + security_context: Some(SecurityContext { + allow_privilege_escalation: Some(false), + capabilities: Some(Capabilities { + drop: Some(vec!["ALL".to_string()]), + ..Default::default() + }), + run_as_non_root: Some(true), + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }), + }, + ..Default::default() + }), + ..Default::default() + } + } + + pub fn render_service(&self) -> Option { + let port = self.port.as_ref()?.number; + Some(Service { + metadata: ObjectMeta { + name: Some(self.name.clone()), + ..Default::default() + }, + spec: Some(ServiceSpec { + type_: Some("ClusterIP".to_string()), + selector: Some(BTreeMap::from([( + "app.kubernetes.io/component".to_string(), + self.name.clone(), + )])), + ports: Some(vec![ServicePort { + name: Some(PORT_NAME.to_string()), + port: i32::from(port), + target_port: Some(IntOrString::String(PORT_NAME.to_string())), + protocol: Some("TCP".to_string()), + ..Default::default() + }]), + ..Default::default() + }), + ..Default::default() + }) + } } impl Score for K8sDeploymentScore { fn create_interpret(&self) -> Box> { - let deployment = json!( - { - "metadata": { - "name": self.name - }, - "spec": { - "selector": { - "matchLabels": { - "app": self.name - }, - }, - "template": { - "metadata": { - "labels": { - "app": self.name - }, - }, - "spec": { - "containers": [ - { - "image": self.image, - "name": self.name, - "imagePullPolicy": "Always", - "env": self.env_vars, - } - ] - } - } - } - } - ); - - let deployment: Deployment = serde_json::from_value(deployment).unwrap(); - Box::new(K8sResourceInterpret { - score: K8sResourceScore::single(deployment.clone(), self.namespace.clone()), + Box::new(K8sDeploymentInterpret { + score: self.clone(), }) } fn name(&self) -> String { - "K8sDeploymentScore".to_string() + format!("K8sDeploymentScore({})", self.name) + } +} + +#[derive(Debug)] +struct K8sDeploymentInterpret { + score: K8sDeploymentScore, +} + +#[async_trait] +impl Interpret for K8sDeploymentInterpret { + async fn execute( + &self, + inventory: &Inventory, + topology: &T, + ) -> Result { + let namespace = self.score.namespace.clone(); + if let Some(service) = self.score.render_service() { + K8sResourceScore::single(service, namespace.clone()) + .interpret(inventory, topology) + .await?; + } + K8sResourceScore::single(self.score.render_deployment(), namespace.clone()) + .interpret(inventory, topology) + .await?; + if let Some(timeout) = self.score.wait { + topology + .k8s_client() + .await + .map_err(|e| InterpretError::new(format!("get Kubernetes client: {e}")))? + .wait_until_deployment_ready(&self.score.name, namespace.as_deref(), Some(timeout)) + .await + .map_err(|e| { + InterpretError::new(format!("deployment {} not ready: {e}", self.score.name)) + })?; + } + Ok(Outcome::success(format!( + "deployment {} applied", + self.score.name + ))) + } + + fn get_name(&self) -> InterpretName { + InterpretName::Custom("K8sDeploymentInterpret") + } + + fn get_version(&self) -> Version { + Version::from("0.1.0").expect("static version") + } + + fn get_status(&self) -> InterpretStatus { + InterpretStatus::QUEUED + } + + fn get_children(&self) -> Vec { + Vec::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::k8s::env::SecretKey; + + #[test] + fn web_workload_renders_hardened_deployment_and_service() { + let score = K8sDeploymentScore::new("shop", "api", "registry/api:1") + .health_checked_port(8080, "/healthz") + .env("MODE", "prod") + .env( + "DATABASE_URL", + SecretKey { + name: "db-app".to_string(), + key: "uri".to_string(), + }, + ) + .image_pull_secret(Some(K8sName("registry-auth".to_string()))) + .recreate(); + + let deployment = serde_json::to_value(score.render_deployment()).unwrap(); + let labels = serde_json::json!({ + "app.kubernetes.io/component": "api", + "app.kubernetes.io/managed-by": "harmony", + "app.kubernetes.io/part-of": "shop" + }); + assert_eq!(deployment["spec"]["selector"]["matchLabels"], labels); + assert_eq!(deployment["spec"]["strategy"]["type"], "Recreate"); + let pod = &deployment["spec"]["template"]["spec"]; + assert_eq!(pod["imagePullSecrets"][0]["name"], "registry-auth"); + assert_eq!(pod["securityContext"]["runAsNonRoot"], true); + let container = &pod["containers"][0]; + assert_eq!(container["startupProbe"]["failureThreshold"], 12); + assert_eq!(container["livenessProbe"]["httpGet"]["path"], "/healthz"); + assert_eq!(container["env"][0]["value"], "prod"); + assert_eq!( + container["env"][1]["valueFrom"]["secretKeyRef"], + serde_json::json!({ "name": "db-app", "key": "uri", "optional": false }) + ); + + let service = serde_json::to_value(score.render_service().unwrap()).unwrap(); + assert_eq!(service["spec"]["ports"][0]["port"], 8080); + assert_eq!( + service["spec"]["selector"], + serde_json::json!({ "app.kubernetes.io/component": "api" }) + ); } } diff --git a/harmony/src/modules/k8s/env.rs b/harmony/src/modules/k8s/env.rs new file mode 100644 index 00000000..edc96034 --- /dev/null +++ b/harmony/src/modules/k8s/env.rs @@ -0,0 +1,78 @@ +use k8s_openapi::api::core::v1::{ConfigMapKeySelector, EnvVar, EnvVarSource, SecretKeySelector}; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SecretKey { + pub name: String, + pub key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ConfigMapKey { + pub name: String, + pub key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum EnvValue { + Literal(String), + Secret(SecretKey), + ConfigMap(ConfigMapKey), +} + +impl EnvValue { + pub(crate) fn to_env_var(&self, name: impl Into) -> EnvVar { + let mut var = EnvVar { + name: name.into(), + ..Default::default() + }; + match self { + EnvValue::Literal(value) => var.value = Some(value.clone()), + EnvValue::Secret(source) => { + var.value_from = Some(EnvVarSource { + secret_key_ref: Some(SecretKeySelector { + name: source.name.clone(), + key: source.key.clone(), + optional: Some(false), + }), + ..Default::default() + }) + } + EnvValue::ConfigMap(source) => { + var.value_from = Some(EnvVarSource { + config_map_key_ref: Some(ConfigMapKeySelector { + name: source.name.clone(), + key: source.key.clone(), + optional: Some(false), + }), + ..Default::default() + }) + } + } + var + } +} + +impl From<&str> for EnvValue { + fn from(value: &str) -> Self { + Self::Literal(value.to_string()) + } +} + +impl From for EnvValue { + fn from(value: String) -> Self { + Self::Literal(value) + } +} + +impl From for EnvValue { + fn from(value: SecretKey) -> Self { + Self::Secret(value) + } +} + +impl From for EnvValue { + fn from(value: ConfigMapKey) -> Self { + Self::ConfigMap(value) + } +} diff --git a/harmony/src/modules/k8s/ingress.rs b/harmony/src/modules/k8s/ingress.rs index 7405c1dc..d44e10e1 100644 --- a/harmony/src/modules/k8s/ingress.rs +++ b/harmony/src/modules/k8s/ingress.rs @@ -63,7 +63,7 @@ impl K8sIngressScore { /// Render the `networking.k8s.io/v1` Ingress this score describes. /// Extracted from `create_interpret` so the TLS/annotation shape is /// unit-testable without a topology. - fn render_ingress(&self) -> Ingress { + pub fn render_ingress(&self) -> Ingress { let path = self.path.clone().unwrap_or_else(|| ingress_path!("/")); let path_type = self.path_type.clone().unwrap_or(PathType::Prefix); let host = self.host.to_string(); diff --git a/harmony/src/modules/k8s/mod.rs b/harmony/src/modules/k8s/mod.rs index 03882d37..dd8e5137 100644 --- a/harmony/src/modules/k8s/mod.rs +++ b/harmony/src/modules/k8s/mod.rs @@ -2,6 +2,7 @@ pub mod apps; pub mod bare_topology; pub mod coredns; pub mod deployment; +pub mod env; mod failover; pub mod ingress; pub mod namespace; diff --git a/harmony/src/modules/lamp.rs b/harmony/src/modules/lamp.rs index 470d782b..dbf84ff5 100644 --- a/harmony/src/modules/lamp.rs +++ b/harmony/src/modules/lamp.rs @@ -1,11 +1,10 @@ use convert_case::{Case, Casing}; -use dockerfile_builder::instruction::{CMD, COPY, ENV, EXPOSE, FROM, RUN, WORKDIR}; +use dockerfile_builder::instruction::{CMD, COPY, ENV, EXPOSE, FROM, RUN, USER, WORKDIR}; use dockerfile_builder::{Dockerfile, instruction_builder::EnvBuilder}; use fqdn::fqdn; use harmony_macros::ingress_path; use harmony_types::net::Url; use non_blank_string_rs::NonBlankString; -use serde_json::json; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; @@ -23,6 +22,7 @@ use crate::{ interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}, inventory::Inventory, modules::k8s::deployment::K8sDeploymentScore, + modules::k8s::env::SecretKey, score::Score, topology::{K8sclient, Topology}, }; @@ -107,26 +107,18 @@ impl Interpret for LAMPInterpret { let base_name = self.score.name.to_case(Case::Kebab); let secret_name = format!("{}-database-mariadb", base_name); - let deployment_score = K8sDeploymentScore { - name: >::name(&self.score).to_case(Case::Kebab), - image: remote_name, - namespace: self.get_namespace().map(|nbs| nbs.to_string()), - env_vars: json!([ - { - "name": "MYSQL_PASSWORD", - "valueFrom": { - "secretKeyRef": { - "name": secret_name, - "key": "mariadb-root-password" - } - } - }, - { - "name": "MYSQL_HOST", - "value": secret_name - }, - ]), - }; + let name = >::name(&self.score).to_case(Case::Kebab); + let mut deployment_score = K8sDeploymentScore::new(&name, &name, remote_name) + .port(8080) + .env( + "MYSQL_PASSWORD", + SecretKey { + name: secret_name.clone(), + key: "mariadb-root-password".to_string(), + }, + ) + .env("MYSQL_HOST", secret_name); + deployment_score.namespace = self.get_namespace().map(|nbs| nbs.to_string()); info!("Deploying score {deployment_score:#?}"); @@ -334,6 +326,8 @@ opcache.fast_shutdown=1 // Expose Apache port dockerfile.push(EXPOSE::from("8080/tcp")); + dockerfile.push(USER::from("1000")); + // Set the default command dockerfile.push(CMD::from("apache2-foreground")); diff --git a/harmony/src/modules/postgresql/capability.rs b/harmony/src/modules/postgresql/capability.rs index d7bc2270..35ce92d9 100644 --- a/harmony/src/modules/postgresql/capability.rs +++ b/harmony/src/modules/postgresql/capability.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use harmony_types::storage::StorageSize; +use k8s_openapi::api::core::v1::ResourceRequirements; use serde::Serialize; use std::collections::HashMap; @@ -73,6 +74,7 @@ pub struct PostgreSQLConfig { /// won't fail with "service not found" immediately after deployment. pub wait_for_ready: bool, pub extensions: Vec, + pub resources: Option, } impl PostgreSQLConfig { @@ -94,6 +96,7 @@ impl Default for PostgreSQLConfig { wait_for_ready: true, version: None, extensions: Vec::new(), + resources: None, } } } diff --git a/harmony/src/modules/postgresql/cnpg/crd.rs b/harmony/src/modules/postgresql/cnpg/crd.rs index 7433c543..81ecb47d 100644 --- a/harmony/src/modules/postgresql/cnpg/crd.rs +++ b/harmony/src/modules/postgresql/cnpg/crd.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use std::collections::HashMap; +use k8s_openapi::api::core::v1::ResourceRequirements; use kube::{CustomResource, api::ObjectMeta}; use serde::{Deserialize, Serialize}; @@ -28,6 +29,8 @@ pub struct ClusterSpec { #[serde(skip_serializing_if = "Option::is_none")] pub superuser_secret: Option>, pub enable_superuser_access: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub resources: Option, } #[allow(clippy::derivable_impls)] @@ -51,6 +54,7 @@ impl Default for ClusterSpec { replica: None, superuser_secret: None, enable_superuser_access: false, + resources: None, } } } diff --git a/harmony/src/modules/postgresql/failover.rs b/harmony/src/modules/postgresql/failover.rs index 5cd1dc91..5f23f2de 100644 --- a/harmony/src/modules/postgresql/failover.rs +++ b/harmony/src/modules/postgresql/failover.rs @@ -32,6 +32,7 @@ impl PostgreSQL for FailoverTopology { wait_for_ready: config.wait_for_ready, version: config.version.clone(), extensions: config.extensions.clone(), + resources: config.resources.clone(), }; info!( @@ -150,6 +151,7 @@ impl PostgreSQL for FailoverTopology { wait_for_ready: config.wait_for_ready, version: config.version.clone(), extensions: config.extensions.clone(), + resources: config.resources.clone(), }; info!( diff --git a/harmony/src/modules/postgresql/score_k8s.rs b/harmony/src/modules/postgresql/score_k8s.rs index 18914e75..58aa74e7 100644 --- a/harmony/src/modules/postgresql/score_k8s.rs +++ b/harmony/src/modules/postgresql/score_k8s.rs @@ -1,6 +1,7 @@ use crate::data::Version; use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; use crate::inventory::Inventory; +use crate::modules::k8s::env::SecretKey; use crate::modules::k8s::resource::K8sResourceScore; use crate::modules::postgresql::capability::{PostgreSQLConfig, PostgreSQLRootAccountRef}; use crate::modules::postgresql::cnpg::{ @@ -16,7 +17,7 @@ use async_trait::async_trait; use harmony_k8s::KubernetesDistribution; use harmony_types::id::Id; use k8s_openapi::ByteString; -use k8s_openapi::api::core::v1::{Pod, Secret}; +use k8s_openapi::api::core::v1::{Pod, ResourceRequirements, Secret}; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use kube::api::ListParams; use log::{debug, info, warn}; @@ -29,6 +30,28 @@ fn is_forbidden(e: &E) -> bool { msg.contains("forbidden") || msg.contains("403") } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PostgreSQLAppAccountRef { + secret: String, +} + +impl PostgreSQLAppAccountRef { + pub fn uri(&self) -> SecretKey { + self.key("uri") + } + + pub fn jdbc_uri(&self) -> SecretKey { + self.key("jdbc-uri") + } + + fn key(&self, key: &str) -> SecretKey { + SecretKey { + name: self.secret.clone(), + key: key.to_string(), + } + } +} + /// Deploys an opinionated, highly available PostgreSQL cluster managed by CNPG. /// /// This score automatically ensures the CloudNativePG (CNPG) operator is installed @@ -71,6 +94,17 @@ impl K8sPostgreSQLScore { self } + pub fn resources(mut self, resources: ResourceRequirements) -> Self { + self.config.resources = Some(resources); + self + } + + pub fn app_account_ref(&self) -> PostgreSQLAppAccountRef { + PostgreSQLAppAccountRef { + secret: format!("{}-app", self.config.cluster_name), + } + } + pub fn root_account_ref(&self) -> PostgreSQLRootAccountRef { PostgreSQLRootAccountRef { host: format!( @@ -282,6 +316,34 @@ impl K8sPostgreSQLInterpret { } } + fn primary_cluster(&self) -> Cluster { + Cluster { + metadata: ObjectMeta { + name: Some(self.config.cluster_name.clone()), + namespace: Some(self.config.namespace.clone()), + ..ObjectMeta::default() + }, + spec: ClusterSpec { + instances: self.config.instances, + image_name: cnpg_image_name(self.config.version.as_deref()), + storage: Storage { + size: self.config.storage_size.to_string(), + }, + bootstrap: Bootstrap { + initdb: Some(Initdb { + database: "app".to_string(), + owner: "app".to_string(), + }), + recovery: None, + pg_basebackup: None, + }, + enable_superuser_access: true, + resources: self.config.resources.clone(), + ..ClusterSpec::default() + }, + } + } + fn app_database(&self) -> Database { Database { metadata: ObjectMeta { @@ -321,36 +383,13 @@ impl Interpret for K8sPostgr match &self.config.role { super::capability::PostgreSQLClusterRole::Primary => { - let metadata = ObjectMeta { - name: Some(self.config.cluster_name.clone()), - namespace: Some(self.config.namespace.clone()), - ..ObjectMeta::default() - }; - - let spec = ClusterSpec { - instances: self.config.instances, - image_name: cnpg_image_name(self.config.version.as_deref()), - storage: Storage { - size: self.config.storage_size.to_string(), - }, - bootstrap: Bootstrap { - initdb: Some(Initdb { - database: "app".to_string(), - owner: "app".to_string(), - }), - recovery: None, - pg_basebackup: None, - }, - enable_superuser_access: true, - ..ClusterSpec::default() - }; - let cluster = Cluster { metadata, spec }; - - let outcome = - K8sResourceScore::single(cluster, Some(self.config.namespace.clone())) - .create_interpret() - .execute(inventory, topology) - .await?; + let outcome = K8sResourceScore::single( + self.primary_cluster(), + Some(self.config.namespace.clone()), + ) + .create_interpret() + .execute(inventory, topology) + .await?; if self.config.wait_for_ready { self.wait_for_primary(topology).await?; @@ -464,6 +503,7 @@ impl Interpret for K8sPostgr source: replica_config.primary_cluster_name.clone(), primary: None, }), + resources: self.config.resources.clone(), ..ClusterSpec::default() }; @@ -534,6 +574,21 @@ mod tests { ); } + #[test] + fn app_account_ref_points_at_the_cnpg_app_secret() { + let account = K8sPostgreSQLScore::new("app") + .cluster_name("recordum-db") + .app_account_ref(); + assert_eq!( + account.jdbc_uri(), + SecretKey { + name: "recordum-db-app".to_string(), + key: "jdbc-uri".to_string(), + } + ); + assert_eq!(account.uri().key, "uri"); + } + #[test] fn app_database_requests_extensions_on_the_app_database() { let interpret = K8sPostgreSQLInterpret { @@ -561,6 +616,32 @@ mod tests { ); } + #[test] + fn primary_cluster_requests_resources_only_when_set() { + let resources: ResourceRequirements = serde_json::from_value(serde_json::json!({ + "requests": { "cpu": "1", "memory": "1000Mi" }, + "limits": { "cpu": "3500m", "memory": "1500Mi" } + })) + .unwrap(); + let score = K8sPostgreSQLScore::new("app").resources(resources); + let interpret = K8sPostgreSQLInterpret { + config: score.config, + }; + assert_eq!( + serde_json::to_value(interpret.primary_cluster()).unwrap()["spec"]["resources"], + serde_json::json!({ + "requests": { "cpu": "1", "memory": "1000Mi" }, + "limits": { "cpu": "3500m", "memory": "1500Mi" } + }) + ); + + let interpret = K8sPostgreSQLInterpret { + config: PostgreSQLConfig::default(), + }; + let cluster = serde_json::to_value(interpret.primary_cluster()).unwrap(); + assert!(cluster["spec"].get("resources").is_none()); + } + #[test] fn cnpg_image_name_maps_tags_and_full_refs() { assert_eq!(cnpg_image_name(None), None); diff --git a/harmony/src/modules/storage/mod.rs b/harmony/src/modules/storage/mod.rs index 1fb6b555..f19150d0 100644 --- a/harmony/src/modules/storage/mod.rs +++ b/harmony/src/modules/storage/mod.rs @@ -1,4 +1,4 @@ pub mod ceph; pub mod object_bucket; -pub use object_bucket::ObjectBucketScore; +pub use object_bucket::{ObjectBucketRef, ObjectBucketScore}; diff --git a/harmony/src/modules/storage/object_bucket.rs b/harmony/src/modules/storage/object_bucket.rs index 0c7b5acf..59fe8419 100644 --- a/harmony/src/modules/storage/object_bucket.rs +++ b/harmony/src/modules/storage/object_bucket.rs @@ -14,6 +14,7 @@ use sha2::{Digest, Sha256}; use crate::data::Version; use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; use crate::inventory::Inventory; +use crate::modules::k8s::env::SecretKey; use crate::modules::k8s::resource::K8sResourceScore; use crate::score::Score; use crate::topology::{K8sclient, Topology}; @@ -39,6 +40,46 @@ pub struct ObjectBucketClaimSpec { pub additional_config: BTreeMap, } +const ENDPOINT_KEY: &str = "endpoint"; +const BUCKET_KEY: &str = "bucket"; +const REGION_KEY: &str = "region"; +const ACCESS_KEY_KEY: &str = "access-key"; +const SECRET_KEY_KEY: &str = "secret-key"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ObjectBucketRef { + secret: String, +} + +impl ObjectBucketRef { + pub fn endpoint(&self) -> SecretKey { + self.key(ENDPOINT_KEY) + } + + pub fn bucket(&self) -> SecretKey { + self.key(BUCKET_KEY) + } + + pub fn region(&self) -> SecretKey { + self.key(REGION_KEY) + } + + pub fn access_key(&self) -> SecretKey { + self.key(ACCESS_KEY_KEY) + } + + pub fn secret_key(&self) -> SecretKey { + self.key(SECRET_KEY_KEY) + } + + fn key(&self, key: &str) -> SecretKey { + SecretKey { + name: self.secret.clone(), + key: key.to_string(), + } + } +} + /// Provisions a Rook/lib-bucket-provisioner ObjectBucketClaim and synthesizes an /// app-facing credentials Secret (`{name}-app`) with endpoint/bucket/keys. #[derive(Debug, Clone, Serialize)] @@ -89,6 +130,12 @@ impl ObjectBucketScore { format!("{}-app", self.name) } + pub fn bucket_ref(&self) -> ObjectBucketRef { + ObjectBucketRef { + secret: self.app_secret_name(), + } + } + fn claim(&self) -> ObjectBucketClaim { let mut additional_config = BTreeMap::new(); additional_config.insert("maxSize".into(), self.max_size.clone()); @@ -338,24 +385,24 @@ fn app_secret(namespace: &str, name: &str, credentials: &BucketCredentials) -> S type_: Some("Opaque".into()), data: Some(BTreeMap::from([ ( - "endpoint".into(), + ENDPOINT_KEY.into(), ByteString(credentials.endpoint.as_bytes().to_vec()), ), ( - "bucket".into(), + BUCKET_KEY.into(), ByteString(credentials.bucket.as_bytes().to_vec()), ), ( - "region".into(), + REGION_KEY.into(), ByteString(credentials.region.as_bytes().to_vec()), ), ("path-style".into(), ByteString(b"true".to_vec())), ( - "access-key".into(), + ACCESS_KEY_KEY.into(), ByteString(credentials.access_key.as_bytes().to_vec()), ), ( - "secret-key".into(), + SECRET_KEY_KEY.into(), ByteString(credentials.secret_key.as_bytes().to_vec()), ), ])), @@ -568,6 +615,13 @@ mod tests { Some("10G") ); assert_eq!(score.app_secret_name(), "recipe1-files-app"); + assert_eq!( + score.bucket_ref().access_key(), + SecretKey { + name: "recipe1-files-app".to_string(), + key: "access-key".to_string(), + } + ); } #[test] diff --git a/harmony/src/modules/zitadel/contract.rs b/harmony/src/modules/zitadel/contract.rs index 79a65a58..5062e4f5 100644 --- a/harmony/src/modules/zitadel/contract.rs +++ b/harmony/src/modules/zitadel/contract.rs @@ -13,6 +13,8 @@ use harmony_config::Config; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::modules::k8s::env::ConfigMapKey; + macro_rules! named_ref { ($name:ident) => { #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -691,6 +693,14 @@ impl ZitadelProjectOutputRef { pub fn project(&self) -> &ZitadelProjectRef { &self.project } + + pub fn project_id(&self) -> ConfigMapKey { + ConfigMapKey { + name: self.config_map_name(), + key: self.project_id_key().to_string(), + } + } + #[doc(hidden)] pub fn config_map_name(&self) -> String { format!("zitadel-{}-project", self.project.name()) @@ -720,6 +730,14 @@ impl ZitadelApplicationOutputRef { pub fn application(&self) -> &ZitadelApplicationRef { &self.application } + + pub fn client_id(&self) -> ConfigMapKey { + ConfigMapKey { + name: self.config_map_name(), + key: self.client_id_key().to_string(), + } + } + #[doc(hidden)] pub fn config_map_name(&self) -> String { format!( @@ -926,6 +944,13 @@ mod tests { assert_eq!(machine.machine(), &machine_ref); assert!(!serde_json::to_string(&app).unwrap().contains("config_map")); assert_eq!(app.config_map_name(), "zitadel-fleet-console-oidc"); + assert_eq!( + app.client_id(), + ConfigMapKey { + name: "zitadel-fleet-console-oidc".to_string(), + key: "client_id".to_string(), + } + ); assert_eq!(machine.secret_name(), "zitadel-callout-machine"); assert_eq!(machine.client_secret_key(), "client_secret"); }