feat/app-refs-pg-resources #353

Open
johnride wants to merge 3 commits from feat/app-refs-pg-resources into master
22 changed files with 709 additions and 155 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -9,10 +9,12 @@ Compose the Scores that already exist: `K8sPostgreSQLScore`,
```rust
async fn scores(&self, ctx: &AppContext, images: &ImageRefs) -> Result<Vec<Box<dyn Score<T>>>, 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)])
}
```

View File

@@ -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.

View File

@@ -13,7 +13,7 @@ services:
frontend:
build: ./frontend
ports:
- "8081:80"
- "8081:8080"
environment:
- BACKEND_URL=http://backend:8080
depends_on:

View File

@@ -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"]

View File

@@ -1,5 +1,5 @@
server {
listen 80;
listen 8080;
root /usr/share/nginx/html;
index index.html;

View File

@@ -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<K8sAnywhereTopology> for Timesheet {
) -> Result<Vec<Box<dyn Score<K8sAnywhereTopology>>>, 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),

View File

@@ -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<Box<dyn Score<K8sAnywhereTopo
let bound = bind(app);
let cluster = bound.postgres.inner.name;
let pg = K8sPostgreSQLScore::new(namespace).cluster_name(cluster);
let frontend = K8sDeploymentScore {
name: "frontend".into(),
image: bound.frontend.inner.image().name,
namespace: Some(namespace.into()),
env_vars: json!([]),
};
let backend = K8sDeploymentScore {
name: "backend".into(),
image: bound.backend.inner.image().name,
namespace: Some(namespace.into()),
env_vars: json!([
{ "name": "CORS_ORIGIN", "value": "http://frontend" }
]),
};
let frontend = K8sDeploymentScore::new("notes", "frontend", bound.frontend.inner.image().name)
.namespace(namespace);
let backend = K8sDeploymentScore::new("notes", "backend", bound.backend.inner.image().name)
.namespace(namespace)
.env("CORS_ORIGIN", "http://frontend");
vec![Box::new(pg), Box::new(frontend), Box::new(backend)]
}

View File

@@ -14,6 +14,6 @@ impl Command for Frontend {
impl Container for Frontend {
fn image(&self) -> Image {
Image::from_registry("nginx:alpine").port(80)
Image::from_registry("nginxinc/nginx-unprivileged:alpine").port(8080)
}
}

View File

@@ -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<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct K8sDeploymentScore {
pub app: String,
pub name: String,
pub image: String,
pub namespace: Option<String>,
pub env_vars: serde_json::Value,
pub port: Option<HttpPort>,
pub env: Vec<(String, EnvValue)>,
pub image_pull_secret: Option<K8sName>,
pub replicas: u16,
pub recreate: bool,
pub resources: Option<ResourceRequirements>,
pub wait: Option<Duration>,
}
impl K8sDeploymentScore {
pub fn new(app: impl Into<String>, name: impl Into<String>, image: impl Into<String>) -> 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<String>) -> 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<String>) -> Self {
self.port = Some(HttpPort {
number,
health_path: Some(path.into()),
});
self
}
pub fn env(mut self, name: impl Into<String>, value: impl Into<EnvValue>) -> Self {
self.env.push((name.into(), value.into()));
self
}
pub fn image_pull_secret(mut self, secret: Option<K8sName>) -> 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<String, String> {
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<Probe>, Option<Probe>, Option<Probe>) {
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<Service> {
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<T: Topology + K8sclient> Score<T> for K8sDeploymentScore {
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
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<T: Topology + K8sclient> Interpret<T> for K8sDeploymentInterpret {
async fn execute(
&self,
inventory: &Inventory,
topology: &T,
) -> Result<Outcome, InterpretError> {
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<Id> {
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" })
);
}
}

View File

@@ -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<String>) -> 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<String> for EnvValue {
fn from(value: String) -> Self {
Self::Literal(value)
}
}
impl From<SecretKey> for EnvValue {
fn from(value: SecretKey) -> Self {
Self::Secret(value)
}
}
impl From<ConfigMapKey> for EnvValue {
fn from(value: ConfigMapKey) -> Self {
Self::ConfigMap(value)
}
}

View File

@@ -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();

View File

@@ -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;

View File

@@ -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<T: Topology + K8sclient + HelmCommand> Interpret<T> 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: <LAMPScore as Score<T>>::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"
}
}
let name = <LAMPScore as Score<T>>::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(),
},
{
"name": "MYSQL_HOST",
"value": secret_name
},
]),
};
)
.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"));

View File

@@ -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<String>,
pub resources: Option<ResourceRequirements>,
}
impl PostgreSQLConfig {
@@ -94,6 +96,7 @@ impl Default for PostgreSQLConfig {
wait_for_ready: true,
version: None,
extensions: Vec::new(),
resources: None,
}
}
}

View File

@@ -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<BTreeMap<String, String>>,
pub enable_superuser_access: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub resources: Option<ResourceRequirements>,
}
#[allow(clippy::derivable_impls)]
@@ -51,6 +54,7 @@ impl Default for ClusterSpec {
replica: None,
superuser_secret: None,
enable_superuser_access: false,
resources: None,
}
}
}

View File

@@ -32,6 +32,7 @@ impl<T: PostgreSQL + TlsRouter> PostgreSQL for FailoverTopology<T> {
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<T: PostgreSQL + TlsRouter> PostgreSQL for FailoverTopology<T> {
wait_for_ready: config.wait_for_ready,
version: config.version.clone(),
extensions: config.extensions.clone(),
resources: config.resources.clone(),
};
info!(

View File

@@ -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: std::fmt::Display>(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,33 +383,10 @@ impl<T: Topology + K8sclient + HelmCommand + 'static> Interpret<T> 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()))
let outcome = K8sResourceScore::single(
self.primary_cluster(),
Some(self.config.namespace.clone()),
)
.create_interpret()
.execute(inventory, topology)
.await?;
@@ -464,6 +503,7 @@ impl<T: Topology + K8sclient + HelmCommand + 'static> Interpret<T> 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);

View File

@@ -1,4 +1,4 @@
pub mod ceph;
pub mod object_bucket;
pub use object_bucket::ObjectBucketScore;
pub use object_bucket::{ObjectBucketRef, ObjectBucketScore};

View File

@@ -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<String, String>,
}
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]

View File

@@ -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");
}