From dc97fa73b97543f86933f2c0f5e9e326614d03b5 Mon Sep 17 00:00:00 2001 From: Marc Schreiber Date: Wed, 17 Jun 2026 08:51:11 +0200 Subject: [PATCH 1/2] Add Option to Generate Secrets Idempotently In order to avoid that secrets will be re-generated whenever a bootstrapping container on the Kubernetes backend runs, PREvant mounts the previously generated secrets into the next run of the bootstrapping. That means bootstrapping container may lookup if files exists under /run/secrets// and then reuse this value in order to avoid too much randomness when generating passwords. For example, if a bootstrapping container generates a OpenID client secret, the second time the bootstrapping runs, it can re-use the already existing secret. --- api/src/config/mod.rs | 2 +- .../kubernetes/deployment_unit.rs | 213 +++- .../kubernetes/infrastructure.rs | 1000 +++++++++-------- docs/companions.md | 30 + 4 files changed, 754 insertions(+), 491 deletions(-) diff --git a/api/src/config/mod.rs b/api/src/config/mod.rs index 33eeac45..94264e52 100644 --- a/api/src/config/mod.rs +++ b/api/src/config/mod.rs @@ -27,7 +27,7 @@ pub use self::applications::{ ApplicationCleanUpPolicy, Applications, ReplicateApplicationCondition, RouterMetricsProvider, }; -pub use self::companion::BootstrappingContainer; +pub use self::companion::{BootstrappingContainer, ImagePullPolicy}; pub use self::companion::Companions; pub use self::container::ContainerConfig; pub use self::runtime::Runtime; diff --git a/api/src/infrastructure/kubernetes/deployment_unit.rs b/api/src/infrastructure/kubernetes/deployment_unit.rs index 653ada47..13325c41 100644 --- a/api/src/infrastructure/kubernetes/deployment_unit.rs +++ b/api/src/infrastructure/kubernetes/deployment_unit.rs @@ -16,9 +16,7 @@ use crate::{ use anyhow::Result; use domain::{ AppName, Image, RawInfrastructureElement, - app_deployment::{ - ApplicationCompanion, BootstrappedCompanions, MergeRawElementsContext, - }, + app_deployment::{ApplicationCompanion, BootstrappedCompanions, MergeRawElementsContext}, app_instance::ContainerType, }; use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, TryStreamExt}; @@ -30,7 +28,7 @@ use k8s_openapi::{ batch::v1::Job, core::v1::{ ConfigMap, Container, LocalObjectReference, PersistentVolumeClaim, Pod, PodSpec, - Secret, Service, ServiceAccount, + Secret, SecretVolumeSource, Service, ServiceAccount, Volume, VolumeMount, }, networking::v1::{Ingress, NetworkPolicy}, rbac::v1::{Role, RoleBinding}, @@ -39,7 +37,7 @@ use k8s_openapi::{ }; use kube::{ Api, Client, ResourceExt, - api::{LogParams, Patch, PatchParams, PostParams, WatchParams}, + api::{ListParams, LogParams, Patch, PatchParams, PostParams, WatchParams}, core::{DynamicObject, ObjectMeta, WatchEvent}, }; use log::{debug, error, trace, warn}; @@ -49,6 +47,8 @@ use std::{ str::FromStr, }; +static BOOTSTRAPPED_SECRET: &str = "com.aixigo.preview.servant.bootstrapped-secret"; + #[derive(Default)] pub(super) struct K8sDeploymentUnit { roles: Vec, @@ -156,7 +156,10 @@ macro_rules! parse_from_dynamic_object { } match $dyn_obj.clone().try_parse::() { - Ok(secret) => { + Ok(mut secret) => { + if let Some(labels) = secret.metadata.labels.as_mut() { + labels.insert(BOOTSTRAPPED_SECRET.to_string(), String::new()); + } $secrets.push(secret); } Err(e) => { @@ -428,6 +431,11 @@ impl K8sDeploymentUnit { None => None, }; + let api = Api::::namespaced(client, &app_name.to_rfc1123_namespace_id()); + let lp = ListParams::default().labels(BOOTSTRAPPED_SECRET); + let existing_secrets = api.list(&lp).await?; + let client = api.into_client(); + if log::log_enabled!(log::Level::Debug) { log::debug!( "Bootstrapping {app_name} with {}", @@ -448,6 +456,24 @@ impl K8sDeploymentUnit { image: Some(bc.image.to_string()), image_pull_policy: Some(bc.image_pull_policy.to_string()), args: Some(bc.args.clone()), + volume_mounts: if existing_secrets.iter().next().is_none() { + None + } else { + Some( + existing_secrets + .iter() + .map(|secret| { + let name = secret.metadata.name.as_deref().unwrap_or_default(); + VolumeMount { + name: name.to_string(), + mount_path: format!("/run/secrets/{name}/",), + read_only: Some(true), + ..Default::default() + } + }) + .collect::>(), + ) + }, ..Default::default() }) }) @@ -473,6 +499,31 @@ impl K8sDeploymentUnit { containers, image_pull_secrets, restart_policy: Some(String::from("Never")), + volumes: if existing_secrets.iter().next().is_none() { + None + } else { + Some( + existing_secrets + .iter() + .map(|secret| { + let name = secret + .metadata + .name + .as_deref() + .unwrap_or_default() + .to_string(); + Volume { + name: name.clone(), + secret: Some(SecretVolumeSource { + secret_name: Some(name), + ..Default::default() + }), + ..Default::default() + } + }) + .collect::>(), + ) + }, ..Default::default() }), ..Default::default() @@ -539,8 +590,8 @@ impl K8sDeploymentUnit { _ = interval_timer.tick() => { let pod = api.get_status(pod_name).await?; - if let Some(phase) = pod.status.and_then(|status| status.phase) { - match phase.as_str() { + if let Some(phase) = pod.status.as_ref().and_then(|status| status.phase.as_deref()) { + match phase { "Running" | "Succeeded" => { return Ok(()); } @@ -1373,7 +1424,8 @@ impl K8sDeploymentUnit { /// [`assert_json_diff::assert_json_include!`]. #[cfg(test)] pub(super) fn without_date_annotations(mut self) -> Self { - for metadata in self.deployments + for metadata in self + .deployments .iter_mut() .flat_map(|d| d.spec.as_mut()) .map(|d| &mut d.template) @@ -1863,7 +1915,8 @@ mod tests { "name": "secret-tls", "namespace": "master", "labels": { - APP_NAME_LABEL: "master" + APP_NAME_LABEL: "master", + BOOTSTRAPPED_SECRET: "" } }, "type": "kubernetes.io/tls", @@ -3445,7 +3498,7 @@ spec: port: number: 2001 --- -r#"apiVersion: v1 +apiVersion: v1 kind: Service metadata: name: whoami @@ -3481,34 +3534,126 @@ spec: actual: payload, expected: serde_json::json!([ { - "apiVersion": "traefik.containo.us/v1alpha1", - "kind": "IngressRoute", - "metadata": { - "annotations": { - "com.aixigo.preview.servant.app-name": "master", - "traefik.ingress.kubernetes.io/router.entrypoints": "web" + "apiVersion": "v1", + "kind": "Service", + "spec": { + "selector": { + "app": "whoami" + }, + "ports": [{ + "port": 2001, + "targetPort": 2001 + }] + } + }, + { + "apiVersion": "traefik.containo.us/v1alpha1", + "kind": "IngressRoute", + "metadata": { + "annotations": { + "com.aixigo.preview.servant.app-name": "master", + "traefik.ingress.kubernetes.io/router.entrypoints": "web" + }, + "name": "whoami", + "namespace": "master" }, - "name": "whoami", - "namespace": "master" - }, - "spec": { - "routes": [ - { - "kind": "Rule", - "match": "Host(`example.com`) && PathPrefix(`/some-route/master/my-route/`)", - "services": [ - { - "kind": "Service", - "name": "whoami", - "port": 2001 - } + "spec": { + "routes": [ + { + "kind": "Rule", + "match": "Host(`example.com`) && PathPrefix(`/some-route/master/my-route/`)", + "services": [ + { + "kind": "Service", + "name": "whoami", + "port": 2001 + } + ] + } ] - } - ] - } + } } ]) ); } } + + mod bootstrapping { + use super::*; + use crate::{ + config::ImagePullPolicy, + infrastructure::kubernetes::{infrastructure::K3sRuntime, payloads::namespace_payload}, + }; + use assert_json_diff::assert_json_eq; + use k8s_openapi::api::core::v1::Namespace; + use kube::{Config, config::Kubeconfig}; + use uuid::Uuid; + + #[test] + fn with_idempontent_secret_generation() -> Result<()> { + let script = r#"echo " +apiVersion: v1 +kind: Secret +metadata: + name: dotfile-secret +data: + .secret-file: $(if [ -f /run/secrets/dotfile-secret/.secret-file ]; then cat /run/secrets/dotfile-secret/.secret-file | base64 ; else ::all(client); + api.create( + &Default::default(), + &namespace_payload(&app_name, &Default::default(), None, &HashSet::new()), + ) + .await?; + + let client = api.into_client(); + let unit_without_existing_secret = K8sDeploymentUnit::bootstrap( + client.clone(), + &app_name, + &[BootstrappingContainer { + image: Image::from_str("busybox")?, + image_pull_policy: ImagePullPolicy::IfNotPresent, + args: vec![String::from("sh"), String::from("-c"), String::from(script)], + }], + None, + ) + .await?; + + let payload_without_existing_secret = unit_without_existing_secret.to_json_vec(); + unit_without_existing_secret + .deploy(client.clone(), &app_name) + .await?; + + let unit_with_existing_secret = K8sDeploymentUnit::bootstrap( + client.clone(), + &app_name, + &[BootstrappingContainer { + image: Image::from_str("busybox")?, + image_pull_policy: ImagePullPolicy::IfNotPresent, + args: vec![String::from("sh"), String::from("-c"), String::from(script)], + }], + None, + ) + .await?; + + assert_json_eq!( + payload_without_existing_secret, + unit_with_existing_secret.to_json_vec() + ); + + Ok(()) + }) + } + } } diff --git a/api/src/infrastructure/kubernetes/infrastructure.rs b/api/src/infrastructure/kubernetes/infrastructure.rs index 076c7553..4e4f4191 100644 --- a/api/src/infrastructure/kubernetes/infrastructure.rs +++ b/api/src/infrastructure/kubernetes/infrastructure.rs @@ -130,8 +130,9 @@ impl KubernetesInfrastructure { async fn client(&self) -> Result { let configuration = match &self.config.runtime { - Runtime::Kubernetes(k8s_config) if k8s_config.kube_config.is_some() => { - let config_file = k8s_config.kube_config.as_ref().unwrap(); + Runtime::Kubernetes(k8s_config) + if let Some(config_file) = k8s_config.kube_config.as_ref() => + { let config = tokio::fs::read_to_string(&config_file) .await .map_err( @@ -1154,181 +1155,244 @@ impl From for KubernetesInfrastructureError { } #[cfg(test)] -mod tests { - use super::*; - use crate::{apps::AppsError, config::runtime::KubernetesRuntimeConfig}; - use domain::{ - RawInfrastructureElement, - app_deployment::{AppDeploymentBuilder, MergeRawElementsContext}, - app_instance::ContainerType, - blueprint_service, - }; - use std::convert::Infallible; - use tempfile::TempDir; - use testcontainers::{ - ContainerAsync, ImageExt, - core::{WaitFor, logs::consumer::logging_consumer::LoggingConsumer}, - runners::AsyncRunner, - }; - use testcontainers_modules::k3s::{K3s, KUBE_SECURE_PORT}; +pub struct K3sRuntime { + runtime: tokio::runtime::Runtime, + k3s_instance: testcontainers::ContainerAsync, + config_path: std::path::PathBuf, + tempdir: tempfile::TempDir, + count: std::sync::atomic::AtomicU8, +} - async fn create_cluster_and_infra() -> (ContainerAsync, KubernetesInfrastructure, TempDir) - { +#[cfg(test)] +static K3S: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[cfg(test)] +impl K3sRuntime { + fn new() -> Result { let _ = env_logger::builder().is_test(true).try_init(); + // TODO: double check if that is conflicting with other threads because each test is + // started on a thread + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; - let tempdir = tempfile::tempdir().unwrap(); + let tempdir = tempfile::tempdir()?; let config_mount = tempdir.path().to_path_buf(); - let k3s_instance = K3s::default() + let (k3s_instance, config_path) = runtime.block_on(async { + use testcontainers::{ImageExt as _, runners::AsyncRunner as _}; + + let k3s_instance = testcontainers_modules::k3s::K3s::default() .with_conf_mount(&config_mount) .with_privileged(true) - .with_ready_conditions(vec![WaitFor::message_on_stderr( + .with_ready_conditions(vec![testcontainers::core::WaitFor::message_on_stderr( r#""QuotaMonitor created object count evaluator" resource="ingressroutetcps.traefik.io""#, )]) .with_startup_timeout(std::time::Duration::from_mins(2)) .with_log_consumer( - LoggingConsumer::new() + testcontainers::core::logs::consumer::logging_consumer::LoggingConsumer::new() .with_stdout_level(log::Level::Trace) .with_stderr_level(log::Level::Trace), ) .start() - .await - .unwrap(); + .await?; - let mapped_port = k3s_instance - .get_host_port_ipv4(KUBE_SECURE_PORT.as_u16()) - .await - .unwrap(); + let mapped_port = k3s_instance + .get_host_port_ipv4(testcontainers_modules::k3s::KUBE_SECURE_PORT.as_u16()) + .await?; - let config = k3s_instance.image().read_kube_config().unwrap(); + let config = k3s_instance.image().read_kube_config()?; - let config_file = tempdir.path().join("k3s-mapped.yaml"); - let config = config.replace( - "server: https://127.0.0.1:6443", - &format!("server: https://127.0.0.1:{mapped_port}"), - ); - std::fs::write(&config_file, config).unwrap(); + let config_path = tempdir.path().join("k3s-mapped.yaml"); + let config = config.replace( + "server: https://127.0.0.1:6443", + &format!("server: https://127.0.0.1:{mapped_port}"), + ); + std::fs::write(&config_path, config)?; - let infra = KubernetesInfrastructure::new(PREvantConfig { - runtime: Runtime::Kubernetes(KubernetesRuntimeConfig { - kube_config: Some(config_file), - ..Default::default() - }), - ..Default::default() - }); + Ok::<_, anyhow::Error>((k3s_instance, config_path)) + })?; - (k3s_instance, infra, tempdir) + Ok(Self { + count: std::sync::atomic::AtomicU8::new(0), + k3s_instance, + config_path, + tempdir, + runtime, + }) } - #[tokio::test] - async fn fetch_backed_up_app() { - let _ = env_logger::builder().is_test(true).try_init(); + pub fn run(test_case: H) -> Result<()> + where + H: AsyncFnOnce(std::path::PathBuf) -> Result<()>, + { + let k3s = K3S.get_or_init(|| K3sRuntime::new().unwrap()); - let (_k3s, infra, _tempdir) = create_cluster_and_infra().await; + k3s.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let app_name = AppName::master(); - let unit = AppDeploymentBuilder::init( - app_name.clone(), - vec![blueprint_service!("http1", "nginx")], - None, - ) - .finish() - .unwrap(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + k3s.runtime + .block_on(async { test_case(k3s.config_path.clone()).await }) + })); - let deploy_result = infra - .deploy_services(&unit, &Default::default()) - .await - .map_err(AppsError::from); - assert_eq!( - deploy_result.map(|app| app - .services - .into_iter() - .map(|s| s.blueprint_config) - .collect()), - Ok(vec![blueprint_service!("http1", "nginx")]) - ); + let previous_count = k3s.count.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + if previous_count == 1 { + log::info!("Shutting down cluster"); + if let Err(err) = k3s.runtime.block_on(async { + k3s.k3s_instance + .stop_with_timeout(None) + .await + .map_err(|e| anyhow::anyhow!("Cannot stop cluster: {e}"))?; + testcontainers::core::client::docker_client_instance() + .await + .map_err(|e| anyhow::anyhow!("Get docker client: {e}"))? + .remove_container(k3s.k3s_instance.id(), None) + .await + .map_err(|e| anyhow::anyhow!("Cannot remove cluster container: {e}")) + }) { + log::error!("Cannot delete cluster: {err}"); + } - let backup_payload = infra - .fetch_app_as_backup_based_infrastructure_payload(&app_name) - .await - .unwrap() - .unwrap(); - infra - .delete_infrastructure_objects_partially(&app_name, &backup_payload) - .await - .unwrap(); + if let Err(err) = std::fs::remove_dir_all(k3s.tempdir.path()) { + log::error!("Cannot delete tempdir: {err}"); + } + } - let fetch_result = infra.fetch_apps().await.map_err(AppsError::from); - assert_eq!( - fetch_result - .as_ref() - .map(|apps| { apps.values().filter_map(|app| app.created_at).count() }), - Ok(1) - ); - assert_eq!( - fetch_result - .and_then(move |mut apps| apps.remove(&app_name).ok_or_else(|| { - AppsError::AppNotFound { - app_name: app_name.clone(), - } - })) - .map(|app| app.services), - Ok(vec![]), - ); + match result { + Ok(ok) => ok, + Err(err) => std::panic::resume_unwind(err), + } } +} - #[tokio::test] - async fn fetch_regular_apps() { - let _ = env_logger::builder().is_test(true).try_init(); +#[cfg(test)] +mod tests { + use super::*; + use crate::{apps::AppsError, config::runtime::KubernetesRuntimeConfig}; + use domain::{ + RawInfrastructureElement, + app_deployment::{AppDeploymentBuilder, MergeRawElementsContext}, + app_instance::ContainerType, + blueprint_service, + }; + use std::convert::Infallible; + use uuid::Uuid; + + #[test] + fn fetch_backed_up_app() -> Result<()> { + K3sRuntime::run(async |config_path| { + let infra = KubernetesInfrastructure::new(PREvantConfig { + runtime: Runtime::Kubernetes(KubernetesRuntimeConfig { + kube_config: Some(config_path), + ..Default::default() + }), + ..Default::default() + }); - let (_k3s, infra, _tempdir) = create_cluster_and_infra().await; + let app_name = AppName::from_str(&Uuid::new_v4().to_string())?; + let unit = AppDeploymentBuilder::init( + app_name.clone(), + vec![blueprint_service!("http1", "nginx")], + None, + ) + .finish() + .unwrap(); - let app_name = AppName::master(); - let unit = AppDeploymentBuilder::init( - app_name.clone(), - vec![blueprint_service!("http1", "nginx")], - None, - ) - .finish() - .unwrap(); + let deploy_result = infra + .deploy_services(&unit, &Default::default()) + .await + .map_err(AppsError::from); + assert_eq!( + deploy_result.map(|app| app + .services + .into_iter() + .map(|s| s.blueprint_config) + .collect()), + Ok(vec![blueprint_service!("http1", "nginx")]) + ); - let deploy_result = infra - .deploy_services(&unit, &Default::default()) - .await - .map_err(AppsError::from); - assert_eq!( - deploy_result.map(|app| app - .services - .into_iter() - .map(|s| s.blueprint_config) - .collect()), - Ok(vec![blueprint_service!("http1", "nginx")]) - ); + let backup_payload = infra + .fetch_app_as_backup_based_infrastructure_payload(&app_name) + .await? + .unwrap(); + infra + .delete_infrastructure_objects_partially(&app_name, &backup_payload) + .await + .unwrap(); - let fetch_result = infra.fetch_apps().await.map_err(AppsError::from); - assert_eq!( - fetch_result - .as_ref() - .map(|apps| { apps.values().filter_map(|app| app.created_at).count() }), - Ok(1) - ); - assert_eq!( - fetch_result - .and_then(move |mut apps| apps.remove(&app_name).ok_or_else(|| { - AppsError::AppNotFound { - app_name: app_name.clone(), - } - })) - .map(|app| app + let mut apps = infra.fetch_apps().await.map_err(AppsError::from)?; + let app = apps + .remove(&app_name) + .ok_or_else(|| AppsError::AppNotFound { + app_name: app_name.clone(), + })?; + + assert!(app.created_at.is_some()); + assert_eq!( + app.services + .into_iter() + .map(|s| s.blueprint_config) + .collect::>(), + Vec::new() + ); + + Ok(()) + }) + } + + #[test] + fn fetch_regular_apps() -> Result<()> { + K3sRuntime::run(async |config_path| { + let infra = KubernetesInfrastructure::new(PREvantConfig { + runtime: Runtime::Kubernetes(KubernetesRuntimeConfig { + kube_config: Some(config_path), + ..Default::default() + }), + ..Default::default() + }); + + let app_name = AppName::from_str(&Uuid::new_v4().to_string())?; + let unit = AppDeploymentBuilder::init( + app_name.clone(), + vec![blueprint_service!("http1", "nginx")], + None, + ) + .finish() + .unwrap(); + + let deploy_result = infra + .deploy_services(&unit, &Default::default()) + .await + .map_err(AppsError::from); + assert_eq!( + deploy_result.map(|app| app .services .into_iter() .map(|s| s.blueprint_config) .collect()), - Ok(vec![blueprint_service!("http1", "nginx")]) - ); + Ok(vec![blueprint_service!("http1", "nginx")]) + ); + + let mut apps = infra.fetch_apps().await.map_err(AppsError::from)?; + let app = apps + .remove(&app_name) + .ok_or_else(|| AppsError::AppNotFound { + app_name: app_name.clone(), + })?; + + assert!(app.created_at.is_some()); + assert_eq!( + app.services + .into_iter() + .map(|s| s.blueprint_config) + .collect::>(), + vec![blueprint_service!("http1", "nginx")] + ); + + Ok(()) + }) } - #[tokio::test] #[rstest::rstest] #[case::only_bootstrapping( vec![], @@ -1338,370 +1402,394 @@ mod tests { vec![blueprint_service!("whoami", "traefik/whoami:v1.11.0")], (ContainerType::Instance, Image::from_str("traefik/whoami:v1.11.0").unwrap()) )] - async fn bootstrap_application( + fn bootstrap_application( #[case] service_configs: Vec, #[case] (expected_container_type, expected_image): (ContainerType, Image), ) -> Result<()> { - let _ = env_logger::builder().is_test(true).try_init(); - - let (_k3s, infra, _tempdir) = create_cluster_and_infra().await; + K3sRuntime::run(async |config_path| { + let infra = KubernetesInfrastructure::new(PREvantConfig { + runtime: Runtime::Kubernetes(KubernetesRuntimeConfig { + kube_config: Some(config_path), + ..Default::default() + }), + ..Default::default() + }); - let app_name = AppName::master(); - let (unit, _) = StaticBootstrapCompanion { - app_name: app_name.clone(), - service_configs, - ..Default::default() - } - .bootstrap(&infra) - .await?; + let app_name = AppName::from_str(&Uuid::new_v4().to_string())?; + let (unit, _) = StaticBootstrapCompanion { + app_name: app_name.clone(), + service_configs, + ..Default::default() + } + .bootstrap(&infra) + .await?; - let app = infra.fetch_app(&app_name).await?; + let app = infra.fetch_app(&app_name).await?; - assert_eq!( - Some(vec![("whoami", &expected_container_type, &expected_image)]), - app.as_ref().map(|app| { - app.services - .iter() - .map(|service| { - ( - service.blueprint_config.service_name.as_str(), - &service.service_type, - &service.blueprint_config.image, - ) - }) - .collect::>() - }), - ); + assert_eq!( + Some(vec![("whoami", &expected_container_type, &expected_image)]), + app.as_ref().map(|app| { + app.services + .iter() + .map(|service| { + ( + service.blueprint_config.service_name.as_str(), + &service.service_type, + &service.blueprint_config.image, + ) + }) + .collect::>() + }), + ); - let payload = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) - .await? - .without_managed_data() - .without_date_annotations() - .to_json_vec(); - - assert_json_diff::assert_json_include!( - actual: payload, - expected: serde_json::json!([ - {}, - {}, - {}, - {}, - { - "apiVersion": "traefik.containo.us/v1alpha1", - "kind": "IngressRoute", - "metadata": { - "annotations": { - "com.aixigo.preview.servant.app-name": "master", - "com.aixigo.preview.servant.container-type": expected_container_type, - "com.aixigo.preview.servant.service-name": "whoami", - "traefik.ingress.kubernetes.io/router.entrypoints": "web" - }, - "name": "whoami", - "namespace": "master" - }, - "spec": { - "routes": [ - { - "kind": "Rule", - "match": "PathPrefix(`/master/my-route/`)", - "middlewares": [{ - "name": "whoami-middleware", - }], - "services": [ + let payload = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) + .await? + .without_managed_data() + .without_date_annotations() + .to_json_vec(); + + assert_json_diff::assert_json_include!( + actual: payload, + expected: serde_json::json!([ + {}, + {}, + {}, + {}, + { + "apiVersion": "traefik.containo.us/v1alpha1", + "kind": "IngressRoute", + "metadata": { + "annotations": { + "com.aixigo.preview.servant.app-name": app_name, + "com.aixigo.preview.servant.container-type": expected_container_type, + "com.aixigo.preview.servant.service-name": "whoami", + "traefik.ingress.kubernetes.io/router.entrypoints": "web" + }, + "name": "whoami", + "namespace": app_name + }, + "spec": { + "routes": [ { - "kind": "Service", - "name": "whoami", - "port": 2001 + "kind": "Rule", + "match": format!("PathPrefix(`/{app_name}/my-route/`)"), + "middlewares": [{ + "name": "whoami-middleware", + }], + "services": [ + { + "kind": "Service", + "name": "whoami", + "port": 2001 + } + ] } ] } - ] - } - }, - { - "apiVersion": "traefik.containo.us/v1alpha1", - "kind": "Middleware", - "metadata": { - "name": "whoami-middleware", - "namespace": "master" - }, - "spec": { - "stripPrefix": { - "prefixes": [ - "/master/my-route/" - ] + }, + { + "apiVersion": "traefik.containo.us/v1alpha1", + "kind": "Middleware", + "metadata": { + "name": "whoami-middleware", + "namespace": app_name + }, + "spec": { + "stripPrefix": { + "prefixes": [ + format!("/{app_name}/my-route/") + ] + } + } } - } - } - ]) - ); + ]) + ); - // Redeploy again to check if the operation is idempotent - infra.deploy_services(&unit, &Default::default()).await?; + // Redeploy again to check if the operation is idempotent + infra.deploy_services(&unit, &Default::default()).await?; - let payload_2 = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) - .await? - .without_managed_data() - .without_date_annotations() - .to_json_vec(); - assert_json_diff::assert_json_eq!(payload, payload_2); + let payload_2 = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) + .await? + .without_managed_data() + .without_date_annotations() + .to_json_vec(); + assert_json_diff::assert_json_eq!(payload, payload_2); - Ok(()) + Ok(()) + }) } - #[tokio::test] - async fn bootstrap_application_without_deployment() -> Result<()> { - let _ = env_logger::builder().is_test(true).try_init(); - - let (_k3s, infra, _tempdir) = create_cluster_and_infra().await; + #[test] + fn bootstrap_application_without_deployment() -> Result<()> { + K3sRuntime::run(async |config_path| { + let infra = KubernetesInfrastructure::new(PREvantConfig { + runtime: Runtime::Kubernetes(KubernetesRuntimeConfig { + kube_config: Some(config_path), + ..Default::default() + }), + ..Default::default() + }); - let app_name = AppName::master(); - let (unit, _) = StaticBootstrapCompanion { - generate_whoami_deployment: false, - app_name: app_name.clone(), - ..Default::default() - } - .bootstrap(&infra) - .await?; + let app_name = AppName::from_str(&Uuid::new_v4().to_string())?; + let (unit, _) = StaticBootstrapCompanion { + generate_whoami_deployment: false, + app_name: app_name.clone(), + ..Default::default() + } + .bootstrap(&infra) + .await?; - let payload = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) - .await? - .without_managed_data() - .to_json_vec(); - - assert_json_diff::assert_json_include!( - actual: payload, - expected: serde_json::json!([ - {}, - {}, - { - "apiVersion": "traefik.containo.us/v1alpha1", - "kind": "IngressRoute", - "metadata": { - "annotations": { - "com.aixigo.preview.servant.app-name": "master", - "traefik.ingress.kubernetes.io/router.entrypoints": "web" - }, - "name": "whoami", - "namespace": "master" - }, - "spec": { - "routes": [ - { - "kind": "Rule", - "match": "PathPrefix(`/master/my-route/`)", - "middlewares": [{ - "name": "whoami-middleware", - }], - "services": [ + let payload = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) + .await? + .without_managed_data() + .to_json_vec(); + + assert_json_diff::assert_json_include!( + actual: payload, + expected: serde_json::json!([ + {}, + {}, + { + "apiVersion": "traefik.containo.us/v1alpha1", + "kind": "IngressRoute", + "metadata": { + "annotations": { + "com.aixigo.preview.servant.app-name": app_name, + "traefik.ingress.kubernetes.io/router.entrypoints": "web" + }, + "name": "whoami", + "namespace": app_name + }, + "spec": { + "routes": [ { - "kind": "Service", - "name": "whoami", - "port": 2001 + "kind": "Rule", + "match": format!("PathPrefix(`/{app_name}/my-route/`)"), + "middlewares": [{ + "name": "whoami-middleware", + }], + "services": [ + { + "kind": "Service", + "name": "whoami", + "port": 2001 + } + ] } ] } - ] - } - }, - { - "apiVersion": "traefik.containo.us/v1alpha1", - "kind": "Middleware", - "metadata": { - "name": "whoami-middleware", - "namespace": "master" - }, - "spec": { - "stripPrefix": { - "prefixes": [ - "/master/my-route/" - ] + }, + { + "apiVersion": "traefik.containo.us/v1alpha1", + "kind": "Middleware", + "metadata": { + "name": "whoami-middleware", + "namespace": app_name + }, + "spec": { + "stripPrefix": { + "prefixes": [ + format!("/{app_name}/my-route/") + ] + } + } } - } - } - ]) - ); + ]) + ); - // Redeploy again to check if the operation is idempotent - infra.deploy_services(&unit, &Default::default()).await?; + // Redeploy again to check if the operation is idempotent + infra.deploy_services(&unit, &Default::default()).await?; - let payload_2 = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) - .await? - .without_managed_data() - .to_json_vec(); - assert_json_diff::assert_json_eq!(payload, payload_2); + let payload_2 = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) + .await? + .without_managed_data() + .to_json_vec(); + assert_json_diff::assert_json_eq!(payload, payload_2); - Ok(()) + Ok(()) + }) } - #[tokio::test] - async fn bootstrap_application_and_update() -> Result<()> { - let _ = env_logger::builder().is_test(true).try_init(); + #[test] + fn bootstrap_application_and_update() -> Result<()> { + K3sRuntime::run(async |config_path| { + let infra = KubernetesInfrastructure::new(PREvantConfig { + runtime: Runtime::Kubernetes(KubernetesRuntimeConfig { + kube_config: Some(config_path), + ..Default::default() + }), + ..Default::default() + }); - let (_k3s, infra, _tempdir) = create_cluster_and_infra().await; + let app_name = AppName::from_str(&Uuid::new_v4().to_string())?; + StaticBootstrapCompanion { + app_name: app_name.clone(), + ..Default::default() + } + .bootstrap(&infra) + .await?; - let app_name = AppName::master(); - StaticBootstrapCompanion { - app_name: app_name.clone(), - ..Default::default() - } - .bootstrap(&infra) - .await?; - - let app = infra.fetch_app(&app_name).await?; - - assert_eq!( - Some(vec![( - "whoami", - &ContainerType::ApplicationCompanion, - &Image::from_str("traefik/whoami").unwrap() - )]), - app.as_ref().map(|app| { - app.services - .iter() - .map(|service| { - ( - service.blueprint_config.service_name.as_str(), - &service.service_type, - &service.blueprint_config.image, - ) - }) - .collect::>() - }), - ); + let app = infra.fetch_app(&app_name).await?; - let (unit, _) = StaticBootstrapCompanion { - app_name: app_name.clone(), - service_configs: vec![blueprint_service!("whoami", "traefik/whoami:v1.11.0")], - ..Default::default() - } - .bootstrap(&infra) - .await?; - - let app = infra.fetch_app(&app_name).await?; - - assert_eq!( - Some(vec![( - "whoami", - &ContainerType::Instance, - &Image::from_str("traefik/whoami:v1.11.0").unwrap() - )]), - app.as_ref().map(|app| { - app.services - .iter() - .map(|service| { - ( - service.blueprint_config.service_name.as_str(), - &service.service_type, - &service.blueprint_config.image, - ) - }) - .collect::>() - }), - ); + assert_eq!( + Some(vec![( + "whoami", + &ContainerType::ApplicationCompanion, + &Image::from_str("traefik/whoami").unwrap() + )]), + app.as_ref().map(|app| { + app.services + .iter() + .map(|service| { + ( + service.blueprint_config.service_name.as_str(), + &service.service_type, + &service.blueprint_config.image, + ) + }) + .collect::>() + }), + ); - let payload = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) - .await? - .without_managed_data() - .without_date_annotations() - .to_json_vec(); + let (unit, _) = StaticBootstrapCompanion { + app_name: app_name.clone(), + service_configs: vec![blueprint_service!("whoami", "traefik/whoami:v1.11.0")], + ..Default::default() + } + .bootstrap(&infra) + .await?; - // Redeploy again to check if the operation is idempotent - infra.deploy_services(&unit, &Default::default()).await?; + let app = infra.fetch_app(&app_name).await?; - let payload_2 = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) - .await? - .without_managed_data() - .without_date_annotations() - .to_json_vec(); - assert_json_diff::assert_json_eq!(payload, payload_2); + assert_eq!( + Some(vec![( + "whoami", + &ContainerType::Instance, + &Image::from_str("traefik/whoami:v1.11.0").unwrap() + )]), + app.as_ref().map(|app| { + app.services + .iter() + .map(|service| { + ( + service.blueprint_config.service_name.as_str(), + &service.service_type, + &service.blueprint_config.image, + ) + }) + .collect::>() + }), + ); - Ok(()) - } + let payload = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) + .await? + .without_managed_data() + .without_date_annotations() + .to_json_vec(); - #[tokio::test] - async fn deploy_application_twice() -> Result<()> { - let _ = env_logger::builder().is_test(true).try_init(); + // Redeploy again to check if the operation is idempotent + infra.deploy_services(&unit, &Default::default()).await?; - let (_k3s, infra, _tempdir) = create_cluster_and_infra().await; - - let app_name = AppName::master(); - let unit = AppDeploymentBuilder::init( - app_name.clone(), - vec![ - blueprint_service!( - "nextcloud", - "nextcloud", - env = ( - "MYSQL_DATABASE" => "example", - "MYSQL_USER" => "example-user", - "MYSQL_PASSWORD" => "my_cool_secret", - "MYSQL_HOST" => "db" - ) - ), - blueprint_service!( - "db", - "mariadb", - env = ( - "MARIADB_ROOT_PASSWORD" => "example", - "MARIADB_USER" => "example-user", - "MARIADB_PASSWORD" => "my_cool_secret", - "MARIADB_DATABASE" => "example-database" - ) - ), - ], - None, - ) - .finish()?; + let payload_2 = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) + .await? + .without_managed_data() + .without_date_annotations() + .to_json_vec(); + assert_json_diff::assert_json_eq!(payload, payload_2); - infra.deploy_services(&unit, &Default::default()).await?; + Ok(()) + }) + } - let app = infra.fetch_app(&app_name).await?; + #[test] + fn deploy_application_twice() -> Result<()> { + K3sRuntime::run(async |config_path| { + let infra = KubernetesInfrastructure::new(PREvantConfig { + runtime: Runtime::Kubernetes(KubernetesRuntimeConfig { + kube_config: Some(config_path), + ..Default::default() + }), + ..Default::default() + }); - assert_eq!( - Some(vec![ - ( - "db", - &ContainerType::Instance, - &Image::from_str("mariadb").unwrap() - ), - ( - "nextcloud", - &ContainerType::Instance, - &Image::from_str("nextcloud").unwrap() - ), - ]), - app.as_ref().map(|app| { - app.services - .iter() - .map(|service| { - ( - service.blueprint_config.service_name.as_str(), - &service.service_type, - &service.blueprint_config.image, + let app_name = AppName::from_str(&Uuid::new_v4().to_string())?; + let unit = AppDeploymentBuilder::init( + app_name.clone(), + vec![ + blueprint_service!( + "nextcloud", + "nextcloud", + env = ( + "MYSQL_DATABASE" => "example", + "MYSQL_USER" => "example-user", + "MYSQL_PASSWORD" => "my_cool_secret", + "MYSQL_HOST" => "db" ) - }) - .collect::>() - }), - ); + ), + blueprint_service!( + "db", + "mariadb", + env = ( + "MARIADB_ROOT_PASSWORD" => "example", + "MARIADB_USER" => "example-user", + "MARIADB_PASSWORD" => "my_cool_secret", + "MARIADB_DATABASE" => "example-database" + ) + ), + ], + None, + ) + .finish()?; - let payload = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) - .await? - .without_managed_data() - .without_date_annotations() - .to_json_vec(); + infra.deploy_services(&unit, &Default::default()).await?; + + let app = infra.fetch_app(&app_name).await?; + + assert_eq!( + Some(vec![ + ( + "db", + &ContainerType::Instance, + &Image::from_str("mariadb").unwrap() + ), + ( + "nextcloud", + &ContainerType::Instance, + &Image::from_str("nextcloud").unwrap() + ), + ]), + app.as_ref().map(|app| { + app.services + .iter() + .map(|service| { + ( + service.blueprint_config.service_name.as_str(), + &service.service_type, + &service.blueprint_config.image, + ) + }) + .collect::>() + }), + ); - // Redeploy again to check if the operation is idempotent - infra.deploy_services(&unit, &Default::default()).await?; + let payload = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) + .await? + .without_managed_data() + .without_date_annotations() + .to_json_vec(); - let payload_2 = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) - .await? - .without_managed_data() - .without_date_annotations() - .to_json_vec(); - assert_json_diff::assert_json_eq!(payload, payload_2); + // Redeploy again to check if the operation is idempotent + infra.deploy_services(&unit, &Default::default()).await?; - Ok(()) + let payload_2 = K8sDeploymentUnit::fetch(infra.client().await?, &app_name) + .await? + .without_managed_data() + .without_date_annotations() + .to_json_vec(); + assert_json_diff::assert_json_eq!(payload, payload_2); + + Ok(()) + }) } /// A fake bootstrapping implementation for testing purposes diff --git a/docs/companions.md b/docs/companions.md index 19463eee..712be606 100644 --- a/docs/companions.md +++ b/docs/companions.md @@ -251,6 +251,36 @@ and the container image: Additionally, check out the [builtin extra helpers][builtin-extra-helpers] PREvant offers via the [Handlebars Rust library][handlebars-rust]. +### Random Secret Generation (Kubernetes) + +In some cases, the bootstrapping infrastructure may generate random secrets. +For example, if an OpenID provider and API is provided by the bootstrapping +container, they may share an OpenID client secret so that each of them trust +each other. The generation of the secret may be random, e.g. in Helm template +functions such as +[`randNumeric`](https://helm.sh/docs/chart_template_guide/function_list/#randalphanum-randalpha-randnumeric-and-randascii). +However, updating the application with a second bootstrapping run, will +generate a new random secret that may be cause issues in the deployments. + +In order to prevent issues, PREvant will provide the secrets from previous +bootstrapping runs, to the second bootstrapping run. For example, if a +bootstrapping container generates following secret, in which `$(/`). + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: dotfile-secret +data: + .secret-file: $( Date: Mon, 17 Aug 2026 09:54:49 +0200 Subject: [PATCH 2/2] WIP: commit --- .../kubernetes/deployment_unit.rs | 118 +++++++++----- .../kubernetes/infrastructure.rs | 151 ++++++++++-------- domain/src/image.rs | 1 - 3 files changed, 163 insertions(+), 107 deletions(-) diff --git a/api/src/infrastructure/kubernetes/deployment_unit.rs b/api/src/infrastructure/kubernetes/deployment_unit.rs index 13325c41..7d4153ea 100644 --- a/api/src/infrastructure/kubernetes/deployment_unit.rs +++ b/api/src/infrastructure/kubernetes/deployment_unit.rs @@ -19,7 +19,7 @@ use domain::{ app_deployment::{ApplicationCompanion, BootstrappedCompanions, MergeRawElementsContext}, app_instance::ContainerType, }; -use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, TryStreamExt}; +use futures::{AsyncReadExt, StreamExt, TryStreamExt}; use handlebars::RenderError; use k8s_openapi::{ DeepMerge, Metadata, Resource, @@ -87,6 +87,7 @@ macro_rules! parse_from_dynamic_object { $api_version:ident, $kind:ident, $app_name:ident, + $bootstrapping_image_name:expr, $dyn_obj:ident ) => { match ($api_version, $kind) { @@ -157,8 +158,11 @@ macro_rules! parse_from_dynamic_object { match $dyn_obj.clone().try_parse::() { Ok(mut secret) => { - if let Some(labels) = secret.metadata.labels.as_mut() { - labels.insert(BOOTSTRAPPED_SECRET.to_string(), String::new()); + match ($bootstrapping_image_name, secret.metadata.labels.as_mut()) { + (Some(bootstrapping_image_name), Some(labels)) => { + labels.insert(BOOTSTRAPPED_SECRET.to_string(), bootstrapping_image_name.name().unwrap_or_default().replace("/", "_")); + } + _ => {} } $secrets.push(secret); } @@ -355,6 +359,11 @@ macro_rules! empty_read_only_fields { } } +pub(super) struct BootstrappingLogStream<'a> { + pub(super) container_image: &'a Image, + pub(super) log_stream: Box, +} + impl K8sDeploymentUnit { fn sorted(self) -> Self { let Self { @@ -414,12 +423,36 @@ impl K8sDeploymentUnit { } } - async fn start_bootstrapping_pods( - app_name: &AppName, + async fn fetch_bootstrapped_secrets( client: Client, + app_name: &AppName, bootstrapping_containers: &[BootstrappingContainer], + ) -> Result>> { + let mut secrets = HashMap::new(); + + let api = Api::::namespaced(client, &app_name.to_rfc1123_namespace_id()); + + for bc in bootstrapping_containers { + let image_name = bc + .image + .name() + .ok_or_else(|| anyhow::anyhow!("{} does not provide a name", bc.image))? + .replace("/", "_"); + let lp = ListParams::default().labels(&format!("{BOOTSTRAPPED_SECRET}={image_name}")); + + let existing_secrets = api.list(&lp).await?; + secrets.insert(image_name, existing_secrets.into_iter().collect::>()); + } + + Ok(secrets) + } + + async fn start_bootstrapping_pods<'a>( + app_name: &AppName, + client: Client, + bootstrapping_containers: &'a [BootstrappingContainer], image_pull_secret: Option, - ) -> Result<(String, Vec>)> { + ) -> Result<(String, Vec>)> { let image_pull_secrets = match image_pull_secret { Some(image_pull_secret) => { let image_pull_secrets = vec![LocalObjectReference { @@ -431,10 +464,9 @@ impl K8sDeploymentUnit { None => None, }; - let api = Api::::namespaced(client, &app_name.to_rfc1123_namespace_id()); - let lp = ListParams::default().labels(BOOTSTRAPPED_SECRET); - let existing_secrets = api.list(&lp).await?; - let client = api.into_client(); + let existing_secrets = + Self::fetch_bootstrapped_secrets(client.clone(), app_name, bootstrapping_containers) + .await?; if log::log_enabled!(log::Level::Debug) { log::debug!( @@ -456,10 +488,9 @@ impl K8sDeploymentUnit { image: Some(bc.image.to_string()), image_pull_policy: Some(bc.image_pull_policy.to_string()), args: Some(bc.args.clone()), - volume_mounts: if existing_secrets.iter().next().is_none() { - None - } else { - Some( + volume_mounts: existing_secrets + .get(bc.image.name().as_deref().unwrap_or_default()) + .map(|existing_secrets| { existing_secrets .iter() .map(|secret| { @@ -471,9 +502,8 @@ impl K8sDeploymentUnit { ..Default::default() } }) - .collect::>(), - ) - }, + .collect::>() + }), ..Default::default() }) }) @@ -499,12 +529,13 @@ impl K8sDeploymentUnit { containers, image_pull_secrets, restart_policy: Some(String::from("Never")), - volumes: if existing_secrets.iter().next().is_none() { + volumes: if existing_secrets.is_empty() { None } else { Some( existing_secrets - .iter() + .values() + .flat_map(|secrets| secrets.iter()) .map(|secret| { let name = secret .metadata @@ -558,18 +589,21 @@ impl K8sDeploymentUnit { let mut log_streams = Vec::with_capacity(bootstrapping_containers.len()); - for i in 0..bootstrapping_containers.len() { - log_streams.push( - api.log_stream( - &pod_name, - &LogParams { - container: Some(format!("bootstrap-{i}")), - follow: true, - ..Default::default() - }, - ) - .await?, - ); + for (i, bc) in bootstrapping_containers.iter().enumerate() { + log_streams.push(BootstrappingLogStream { + log_stream: Box::new( + api.log_stream( + &pod_name, + &LogParams { + container: Some(format!("bootstrap-{i}")), + follow: true, + ..Default::default() + }, + ) + .await?, + ), + container_image: &bc.image, + }); } Ok((pod_name, log_streams)) @@ -632,7 +666,7 @@ impl K8sDeploymentUnit { return Ok(Default::default()); } - let (bootstrapping_pod_name, mut log_streams) = Self::start_bootstrapping_pods( + let (bootstrapping_pod_name, log_streams) = Self::start_bootstrapping_pods( app_name, client.clone(), bootstrapping_container, @@ -640,7 +674,7 @@ impl K8sDeploymentUnit { ) .await?; - let result = Self::parse_from_log_streams(app_name, &mut log_streams).await; + let result = Self::parse_from_log_streams(app_name, log_streams).await; let pod_api: Api = Api::namespaced(client, &app_name.to_rfc1123_namespace_id()); pod_api @@ -650,14 +684,12 @@ impl K8sDeploymentUnit { result } - pub(super) async fn parse_from_log_streams( + pub(super) async fn parse_from_log_streams<'a, L>( app_name: &AppName, log_streams: L, ) -> Result where - L: IntoIterator, - ::Item: AsyncBufReadExt, - ::Item: Unpin, + L: IntoIterator>, { let mut roles = Vec::new(); let mut role_bindings = Vec::new(); @@ -677,7 +709,7 @@ impl K8sDeploymentUnit { for mut log_stream in log_streams.into_iter() { let mut stdout = String::new(); - log_stream.read_to_string(&mut stdout).await?; + log_stream.log_stream.read_to_string(&mut stdout).await?; if log::log_enabled!(log::Level::Trace) { trace!( @@ -730,6 +762,7 @@ impl K8sDeploymentUnit { api_version, kind, app_name, + Some(log_stream.container_image), dy ); } @@ -1513,6 +1546,7 @@ impl K8sDeploymentUnit { api_version, kind, app_name, + None::<&Image>, dyn_obj ); } @@ -1863,7 +1897,11 @@ mod tests { }; async fn parse_unit_from_log_stream(stdout: &'static str) -> K8sDeploymentUnit { - let log_streams = vec![stdout.as_bytes()]; + let image = Image::from_str("busybox").unwrap(); + let log_streams = vec![BootstrappingLogStream { + log_stream: Box::new(stdout.as_bytes()), + container_image: &image, + }]; K8sDeploymentUnit::parse_from_log_streams(&AppName::master(), log_streams) .await @@ -1916,7 +1954,7 @@ mod tests { "namespace": "master", "labels": { APP_NAME_LABEL: "master", - BOOTSTRAPPED_SECRET: "" + BOOTSTRAPPED_SECRET: "library_busybox" } }, "type": "kubernetes.io/tls", diff --git a/api/src/infrastructure/kubernetes/infrastructure.rs b/api/src/infrastructure/kubernetes/infrastructure.rs index 4e4f4191..18ebadf1 100644 --- a/api/src/infrastructure/kubernetes/infrastructure.rs +++ b/api/src/infrastructure/kubernetes/infrastructure.rs @@ -1268,7 +1268,10 @@ impl K3sRuntime { #[cfg(test)] mod tests { use super::*; - use crate::{apps::AppsError, config::runtime::KubernetesRuntimeConfig}; + use crate::{ + apps::AppsError, config::runtime::KubernetesRuntimeConfig, + infrastructure::kubernetes::deployment_unit::BootstrappingLogStream, + }; use domain::{ RawInfrastructureElement, app_deployment::{AppDeploymentBuilder, MergeRawElementsContext}, @@ -1860,74 +1863,90 @@ mod tests { context: BootstrapCompanionsWithRawElementsContext<'_>, _template_data: &TemplateData, ) -> Result { + let image = Image::from_str("busybox").unwrap(); let output = [ - if self.generate_whoami_deployment { - r#" - apiVersion: apps/v1 - kind: Deployment - metadata: - name: whoami - spec: - selector: - matchLabels: - app: whoami - template: + BootstrappingLogStream { + container_image: &image, + log_stream: Box::new( + if self.generate_whoami_deployment { + r#" + apiVersion: apps/v1 + kind: Deployment + metadata: + name: whoami + spec: + selector: + matchLabels: + app: whoami + template: + metadata: + labels: + app: whoami + spec: + containers: + - name: whoami + image: traefik/whoami + args: + - --port=2001 + - --name=iamfoo + ports: + - containerPort: 2001 + "# + } else { + "" + } + .as_bytes(), + ), + }, + BootstrappingLogStream { + container_image: &image, + log_stream: Box::new( + if self.generate_whoami_deployment { + r#" + apiVersion: v1 + kind: Service + metadata: + name: whoami + spec: + selector: + app: whoami + ports: + - port: 2001 + targetPort: 2001 + "# + } else { + "" + } + .as_bytes(), + ), + }, + BootstrappingLogStream { + container_image: &image, + log_stream: Box::new( + r#" + apiVersion: networking.k8s.io/v1 + kind: Ingress metadata: - labels: - app: whoami + name: whoami + annotations: + nginx.ingress.kubernetes.io/use-regex: true + nginx.ingress.kubernetes.io/rewrite-target: /$2 spec: - containers: - - name: whoami - image: traefik/whoami - args: - - --port=2001 - - --name=iamfoo - ports: - - containerPort: 2001 - "# - } else { - "" - } - .as_bytes(), - if self.generate_whoami_deployment { - r#" - apiVersion: v1 - kind: Service - metadata: - name: whoami - spec: - selector: - app: whoami - ports: - - port: 2001 - targetPort: 2001 - "# - } else { - "" - } - .as_bytes(), - r#" - apiVersion: networking.k8s.io/v1 - kind: Ingress - metadata: - name: whoami - annotations: - nginx.ingress.kubernetes.io/use-regex: true - nginx.ingress.kubernetes.io/rewrite-target: /$2 - spec: - ingressClassName: nginx - rules: - - http: - paths: - - path: /my-route - pathType: Prefix - backend: - service: - name: whoami - port: - number: 2001 - "# - .as_bytes(), + ingressClassName: nginx + rules: + - http: + paths: + - path: /my-route + pathType: Prefix + backend: + service: + name: whoami + port: + number: 2001 + "# + .as_bytes(), + ), + }, ]; let k8s_deployment_unit = diff --git a/domain/src/image.rs b/domain/src/image.rs index 225e82c8..89377050 100644 --- a/domain/src/image.rs +++ b/domain/src/image.rs @@ -141,7 +141,6 @@ impl Image { } } - #[cfg(test)] pub fn name(&self) -> Option { match &self { Image::Digest { .. } => None,