feat/app-refs-pg-resources #353
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "8081:80"
|
||||
- "8081:8080"
|
||||
environment:
|
||||
- BACKEND_URL=http://backend:8080
|
||||
depends_on:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen 8080;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
78
harmony/src/modules/k8s/env.rs
Normal file
78
harmony/src/modules/k8s/env.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MYSQL_HOST",
|
||||
"value": secret_name
|
||||
},
|
||||
]),
|
||||
};
|
||||
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(),
|
||||
},
|
||||
)
|
||||
.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"));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,36 +383,13 @@ 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()))
|
||||
.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<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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub mod ceph;
|
||||
pub mod object_bucket;
|
||||
|
||||
pub use object_bucket::ObjectBucketScore;
|
||||
pub use object_bucket::{ObjectBucketRef, ObjectBucketScore};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user