diff --git a/CHANGELOG.md b/CHANGELOG.md index 076a5a40..2040bfc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. - Made RSA key length configurable for certificates issued by cert-manager ([#528]). - Kerberos principal backends now also provision principals for IP address, not just DNS hostnames ([#552]). +- OLM deployment helper ([#546]). ### Changed @@ -22,6 +23,7 @@ All notable changes to this project will be documented in this file. [#548]: https://github.com/stackabletech/secret-operator/pull/548 [#552]: https://github.com/stackabletech/secret-operator/pull/552 [#544]: https://github.com/stackabletech/secret-operator/pull/544 +[#546]: https://github.com/stackabletech/secret-operator/pull/546 ## [24.11.1] - 2025-01-10 diff --git a/Cargo.lock b/Cargo.lock index 67c78d90..5de26809 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -980,7 +980,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.7.0", + "indexmap 2.7.1", "slab", "tokio", "tokio-util", @@ -1387,9 +1387,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" dependencies = [ "equivalent", "hashbrown 0.15.2", @@ -1921,6 +1921,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "olm-deployer" +version = "0.0.0-dev" +dependencies = [ + "anyhow", + "built", + "clap", + "serde", + "serde_json", + "serde_yaml", + "stackable-operator", + "tokio", + "tracing", + "walkdir", +] + [[package]] name = "once_cell" version = "1.20.2" @@ -2167,7 +2183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.7.0", + "indexmap 2.7.1", ] [[package]] @@ -2529,6 +2545,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.27" @@ -2616,9 +2641,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" +checksum = "f79dfe2d285b0488816f30e700a7438c5a73d816b5b7d3ac72fbc48b0d185e03" [[package]] name = "serde" @@ -2663,9 +2688,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.135" +version = "1.0.137" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b0d7ba2887406110130a978386c4e1befb98c674b4fba677954e4db976630d9" +checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b" dependencies = [ "itoa", "memchr", @@ -2679,7 +2704,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.7.0", + "indexmap 2.7.1", "itoa", "ryu", "serde", @@ -2855,7 +2880,7 @@ dependencies = [ "educe", "either", "futures 0.3.31", - "indexmap 2.7.0", + "indexmap 2.7.1", "json-patch", "k8s-openapi", "kube", @@ -3566,6 +3591,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3665,6 +3700,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index a0165ab7..2ec6a588 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,8 +20,8 @@ clap = "4.5" futures = { version = "0.3", features = ["compat"] } h2 = "0.4" ldap3 = { version = "0.11", default-features = false, features = [ - "gssapi", - "tls", + "gssapi", + "tls", ] } libc = "0.2" native-tls = "0.2" @@ -49,6 +49,7 @@ tonic-build = "0.12" tonic-reflection = "0.12" tracing = "0.1" tracing-subscriber = "0.3" +walkdir = "2.5.0" uuid = { version = "1.10.0", features = ["v4"] } yasna = "0.5" diff --git a/nginx-deployment.yaml b/nginx-deployment.yaml new file mode 100644 index 00000000..69952481 --- /dev/null +++ b/nginx-deployment.yaml @@ -0,0 +1,27 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: secret-operator-deployer + labels: + app: nginx +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80 + tolerations: + - key: keep-out + value: "yes" + operator: Equal + effect: NoSchedule diff --git a/rust/olm-deployer/Cargo.toml b/rust/olm-deployer/Cargo.toml new file mode 100644 index 00000000..d9e8462c --- /dev/null +++ b/rust/olm-deployer/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "olm-deployer" +description = "OLM deployment helper." +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +clap.workspace = true +tokio.workspace = true +tracing.workspace = true +stackable-operator.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml.workspace = true +walkdir.workspace = true + +[build-dependencies] +built.workspace = true diff --git a/rust/olm-deployer/README.md b/rust/olm-deployer/README.md new file mode 100644 index 00000000..95a27a51 --- /dev/null +++ b/rust/olm-deployer/README.md @@ -0,0 +1,26 @@ +# How to test + +Requirements: + +1. An OpenShift cluster. +2. Checkout the branch `secret-olm-deployer` from the [operators](https://github.com/stackabletech/openshift-certified-operators/tree/secret-olm-deployer) repo. +3. Clone the `stackable-utils` [repo](https://github.com/stackabletech/stackable-utils) + +Install the secret operator using OLM and the `olm-deployer`. From the `stackable-utils` repo, run: + +```bash +$ ./olm/build-bundles.sh -c $HOME/repo/stackable/openshift-certified-operators -r 24.11.0 -o secret -d +... +``` + +[!NOTE] +Bundle images are published to `oci.stackable.tech` so you need to log in there first. + +The secret op and all it's dependencies should be installed and running in the `stackable-operators` namespace. + +Run the integration tests: + +```bash +$ ./scripts/run-tests --skip-operator secret --test-suite openshift +... +``` diff --git a/rust/olm-deployer/build.rs b/rust/olm-deployer/build.rs new file mode 100644 index 00000000..fa809bfd --- /dev/null +++ b/rust/olm-deployer/build.rs @@ -0,0 +1,3 @@ +fn main() { + built::write_built_file().unwrap(); +} diff --git a/rust/olm-deployer/src/data.rs b/rust/olm-deployer/src/data.rs new file mode 100644 index 00000000..6592b296 --- /dev/null +++ b/rust/olm-deployer/src/data.rs @@ -0,0 +1,75 @@ +use anyhow::{bail, Result}; +use stackable_operator::kube::{api::DynamicObject, ResourceExt}; + +pub fn data_field_as_mut<'a>( + value: &'a mut serde_json::Value, + pointer: &str, +) -> Result<&'a mut serde_json::Value> { + match value.pointer_mut(pointer) { + Some(field) => Ok(field), + x => bail!("invalid pointer {pointer} for object {x:?}"), + } +} + +pub fn container<'a>( + target: &'a mut DynamicObject, + container_name: &str, +) -> anyhow::Result<&'a mut serde_json::Value> { + let tname = target.name_any(); + let path = "template/spec/containers".split("/"); + match get_or_create(target.data.pointer_mut("/spec").unwrap(), path)? { + serde_json::Value::Array(containers) => { + for c in containers { + if c.is_object() { + if let Some(serde_json::Value::String(name)) = c.get("name") { + if container_name == name { + return Ok(c); + } + } + } else { + anyhow::bail!("container is not a object: {:?}", c); + } + } + anyhow::bail!("container named {container_name} not found"); + } + _ => anyhow::bail!("no containers found in object {tname}"), + } +} + +/// Returns the object nested in `root` by traversing the `path` of nested keys. +/// Creates any missing objects in path. +/// In case of success, the returned value is either the existing object or +/// serde_json::Value::Null. +/// Returns an error if any of the nested objects has a type other than map. +pub fn get_or_create<'a, 'b, I>( + root: &'a mut serde_json::Value, + path: I, +) -> anyhow::Result<&'a mut serde_json::Value> +where + I: IntoIterator, +{ + let mut iter = path.into_iter(); + match iter.next() { + None => Ok(root), + Some(first) => { + let new_root = get_or_insert_default_object(root, first)?; + get_or_create(new_root, iter) + } + } +} + +/// Given a map object create or return the object corresponding to the given `key`. +fn get_or_insert_default_object<'a>( + value: &'a mut serde_json::Value, + key: &str, +) -> anyhow::Result<&'a mut serde_json::Value> { + let map = match value { + serde_json::Value::Object(map) => map, + x @ serde_json::Value::Null => { + *x = serde_json::json!({}); + x.as_object_mut().unwrap() + } + x => anyhow::bail!("invalid type {x:?}, expected map"), + }; + Ok(map.entry(key).or_insert_with(|| serde_json::Value::Null)) +} diff --git a/rust/olm-deployer/src/env/mod.rs b/rust/olm-deployer/src/env/mod.rs new file mode 100644 index 00000000..4e4d7e32 --- /dev/null +++ b/rust/olm-deployer/src/env/mod.rs @@ -0,0 +1,159 @@ +use stackable_operator::{ + k8s_openapi::api::{apps::v1::Deployment, core::v1::EnvVar}, + kube::{ + api::{DynamicObject, GroupVersionKind}, + ResourceExt, + }, +}; + +use crate::data::container; + +/// Copy the environment from the "secret-operator-deployer" container in `source` +/// to the container "secret-operator" in `target`. +/// The `target` must be a DaemonSet object otherwise this is a no-op. +pub(super) fn maybe_copy_env( + source: &Deployment, + target: &mut DynamicObject, + target_gvk: &GroupVersionKind, +) -> anyhow::Result<()> { + if target_gvk.kind == "DaemonSet" { + if let Some(env) = deployer_env_var(source) { + match container(target, "secret-operator")? { + serde_json::Value::Object(c) => { + let json_env = env + .iter() + .map(|e| serde_json::json!(e)) + .collect::>(); + + match c.get_mut("env") { + Some(env) => match env { + v @ serde_json::Value::Null => { + *v = serde_json::json!(json_env); + } + serde_json::Value::Array(container_env) => { + container_env.extend_from_slice(&json_env) + } + _ => anyhow::bail!("env is not null or an array"), + }, + None => { + c.insert("env".to_string(), serde_json::json!(json_env)); + } + } + } + _ => anyhow::bail!("no containers found in object {}", target.name_any()), + } + } + } + + Ok(()) +} + +fn deployer_env_var(deployment: &Deployment) -> Option<&Vec> { + deployment + .spec + .as_ref() + .and_then(|ds| ds.template.spec.as_ref()) + .map(|ts| ts.containers.iter()) + .into_iter() + .flatten() + .filter(|c| c.name == "secret-operator-deployer") + .last() + .and_then(|c| c.env.as_ref()) +} + +#[cfg(test)] +mod test { + use std::sync::LazyLock; + + use anyhow::Result; + use serde::Deserialize; + + use super::*; + + static DAEMONSET: LazyLock = LazyLock::new(|| { + const STR_DAEMONSET: &str = r#" +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: secret-operator-daemonset +spec: + template: + spec: + containers: + - name: secret-operator + image: "quay.io/stackable/secret-operator@sha256:bb5063aa67336465fd3fa80a7c6fd82ac6e30ebe3ffc6dba6ca84c1f1af95bfe" + env: + - name: NAME1 + value: value1 +"#; + + let data = + serde_yaml::Value::deserialize(serde_yaml::Deserializer::from_str(STR_DAEMONSET)) + .unwrap(); + serde_yaml::from_value(data).unwrap() + }); + + static DEPLOYMENT: LazyLock = LazyLock::new(|| { + const STR_DEPLOYMENT: &str = r#" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: secret-operator-deployer + uid: d9287d0a-3069-47c3-8c90-b714dc6d1af5 +spec: + template: + spec: + containers: + - name: secret-operator-deployer + image: "quay.io/stackable/tools@sha256:bb02df387d8f614089fe053373f766e21b7a9a1ad04cb3408059014cb0f1388e" + env: + - name: NAME2 + value: value2 + tolerations: + - key: keep-out + value: "yes" + operator: Equal + effect: NoSchedule + "#; + + let data = + serde_yaml::Value::deserialize(serde_yaml::Deserializer::from_str(STR_DEPLOYMENT)) + .unwrap(); + serde_yaml::from_value(data).unwrap() + }); + + #[test] + fn test_copy_env_var() -> Result<()> { + let gvk: GroupVersionKind = GroupVersionKind { + kind: "DaemonSet".to_string(), + version: "v1".to_string(), + group: "apps".to_string(), + }; + + let mut daemonset = DAEMONSET.clone(); + + maybe_copy_env(&DEPLOYMENT, &mut daemonset, &gvk)?; + + let expected = serde_json::json!(vec![ + EnvVar { + name: "NAME1".to_string(), + value: Some("value1".to_string()), + ..EnvVar::default() + }, + EnvVar { + name: "NAME2".to_string(), + value: Some("value2".to_string()), + ..EnvVar::default() + }, + ]); + assert_eq!( + container(&mut daemonset, "secret-operator")? + .get("env") + .unwrap(), + &expected + ); + Ok(()) + } +} diff --git a/rust/olm-deployer/src/main.rs b/rust/olm-deployer/src/main.rs new file mode 100644 index 00000000..f4a3882a --- /dev/null +++ b/rust/olm-deployer/src/main.rs @@ -0,0 +1,224 @@ +/// This program acts as a proxy Deployment in OLM environments that installs the secret operator. +/// The operator manifests are read from a directory and patched before being submitted to the +/// control plane. +/// It expects the following objects to exist (they are created by OLM) and uses them as +/// sources for patch data: +/// - A Deployment owned by the CSV in the target namespace. +/// - A ClusterRole owned by the same CSV that deployed this tool. +/// +/// See the documentation of the `maybe_*` functions for patching details. +/// +/// The `keep-alive` cli option prevents the program from finishing and thus for OLM +/// to observe it as a failure. +/// +mod data; +mod env; +mod namespace; +mod owner; +mod resources; +mod tolerations; + +use anyhow::{anyhow, bail, Context, Result}; +use clap::{crate_description, crate_version, Parser}; +use stackable_operator::{ + cli::Command, + client, + k8s_openapi::api::{apps::v1::Deployment, rbac::v1::ClusterRole}, + kube, + kube::{ + api::{Api, DynamicObject, ListParams, Patch, PatchParams, ResourceExt}, + core::GroupVersionKind, + discovery::{ApiResource, Discovery, Scope}, + }, + logging, utils, + utils::cluster_info::KubernetesClusterInfoOpts, +}; + +pub const APP_NAME: &str = "stkbl-secret-olm-deployer"; +pub const ENV_VAR_LOGGING: &str = "STKBL_SECRET_OLM_DEPLOYER_LOG"; + +mod built_info { + include!(concat!(env!("OUT_DIR"), "/built.rs")); +} + +#[derive(clap::Parser)] +#[clap(author, version)] +struct Opts { + #[clap(subcommand)] + cmd: Command, +} + +#[derive(clap::Parser)] +struct OlmDeployerRun { + #[arg( + long, + short, + default_value = "false", + help = "Keep running after manifests have been successfully applied." + )] + keep_alive: bool, + #[arg( + long, + short, + help = "Name of ClusterServiceVersion object that owns this Deployment." + )] + csv: String, + #[arg(long, short, help = "Namespace of the ClusterServiceVersion object.")] + namespace: String, + #[arg(long, short, help = "Directory with manifests to patch and apply.")] + dir: std::path::PathBuf, + /// Tracing log collector system + #[arg(long, env, default_value_t, value_enum)] + pub tracing_target: logging::TracingTarget, + #[command(flatten)] + pub cluster_info_opts: KubernetesClusterInfoOpts, +} + +#[tokio::main] +async fn main() -> Result<()> { + let opts = Opts::parse(); + if let Command::Run(OlmDeployerRun { + keep_alive, + csv, + namespace, + dir, + tracing_target, + cluster_info_opts, + }) = opts.cmd + { + logging::initialize_logging(ENV_VAR_LOGGING, APP_NAME, tracing_target); + utils::print_startup_string( + crate_description!(), + crate_version!(), + built_info::GIT_VERSION, + built_info::TARGET, + built_info::BUILT_TIME_UTC, + built_info::RUSTC_VERSION, + ); + + let client = + client::initialize_operator(Some(APP_NAME.to_string()), &cluster_info_opts).await?; + + let deployment = get_deployment(&csv, &namespace, &client).await?; + let cluster_role = get_cluster_role(&csv, &client).await?; + + let kube_client = client.as_kube_client(); + // discovery (to be able to infer apis from kind/plural only) + let discovery = Discovery::new(kube_client.clone()).run().await?; + + for entry in walkdir::WalkDir::new(&dir) { + match entry { + Ok(manifest_file) => { + if manifest_file.file_type().is_file() { + // ---------- + let path = manifest_file.path(); + tracing::info!("Reading manifest file: {}", path.display()); + let yaml = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + for doc in multidoc_deserialize(&yaml)? { + let mut obj: DynamicObject = serde_yaml::from_value(doc)?; + // ---------- + let gvk = if let Some(tm) = &obj.types { + GroupVersionKind::try_from(tm)? + } else { + bail!("cannot apply object without valid TypeMeta {:?}", obj); + }; + let (ar, caps) = discovery + .resolve_gvk(&gvk) + .context(anyhow!("cannot resolve GVK {:?}", gvk))?; + + let api = dynamic_api(ar, &caps.scope, kube_client.clone(), &namespace); + // ---------- patch object + tolerations::maybe_copy_tolerations(&deployment, &mut obj, &gvk)?; + owner::maybe_update_owner( + &mut obj, + &caps.scope, + &deployment, + &cluster_role, + )?; + namespace::maybe_patch_namespace(&namespace, &mut obj, &gvk)?; + env::maybe_copy_env(&deployment, &mut obj, &gvk)?; + resources::maybe_copy_resources(&deployment, &mut obj, &gvk)?; + // ---------- apply + apply(&api, obj, &gvk.kind).await? + } + } + } + Err(e) => { + bail!("Error reading manifest file: {}", e); + } + } + } + + if keep_alive { + // keep the pod running + tokio::time::sleep(std::time::Duration::from_secs(u64::MAX)).await; + } + } + + Ok(()) +} + +async fn apply(api: &Api, obj: DynamicObject, kind: &str) -> Result<()> { + let name = obj.name_any(); + let ssapply = PatchParams::apply(APP_NAME).force(); + tracing::trace!("Applying {}: \n{}", kind, serde_yaml::to_string(&obj)?); + let data: serde_json::Value = serde_json::to_value(&obj)?; + let _r = api.patch(&name, &ssapply, &Patch::Apply(data)).await?; + tracing::info!("applied {} {}", kind, name); + Ok(()) +} + +fn multidoc_deserialize(data: &str) -> Result> { + use serde::Deserialize; + let mut docs = vec![]; + for de in serde_yaml::Deserializer::from_str(data) { + docs.push(serde_yaml::Value::deserialize(de)?); + } + Ok(docs) +} + +fn dynamic_api( + ar: ApiResource, + scope: &Scope, + client: kube::Client, + ns: &str, +) -> Api { + match scope { + Scope::Cluster => Api::all_with(client, &ar), + _ => Api::namespaced_with(client, ns, &ar), + } +} + +async fn get_cluster_role(csv: &str, client: &client::Client) -> Result { + let labels = format!("olm.owner={csv},olm.owner.kind=ClusterServiceVersion"); + let lp = ListParams { + label_selector: Some(labels.clone()), + ..ListParams::default() + }; + + let cluster_role_api = client.get_all_api::(); + let result = cluster_role_api.list(&lp).await?.items; + if !result.is_empty() { + Ok(result.first().unwrap().clone()) + } else { + bail!("ClusterRole object not found for labels {labels}") + } +} + +async fn get_deployment(csv: &str, namespace: &str, client: &client::Client) -> Result { + let labels = format!("olm.owner={csv},olm.owner.kind=ClusterServiceVersion"); + let lp = ListParams { + label_selector: Some(labels.clone()), + ..ListParams::default() + }; + + let deployment_api = client.get_api::(namespace); + let result = deployment_api.list(&lp).await?.items; + + match result.len() { + 0 => bail!("no deployment owned by the csv {csv} found in namespace {namespace}"), + 1 => Ok(result.first().unwrap().clone()), + _ => bail!("multiple deployments owned by the csv {csv} found but only one was expected"), + } +} diff --git a/rust/olm-deployer/src/namespace/mod.rs b/rust/olm-deployer/src/namespace/mod.rs new file mode 100644 index 00000000..6644b45a --- /dev/null +++ b/rust/olm-deployer/src/namespace/mod.rs @@ -0,0 +1,76 @@ +use anyhow::Result; +use serde_json::{json, Value}; +use stackable_operator::kube::api::{DynamicObject, GroupVersionKind}; + +use crate::data; + +/// Path the namespace of the autoTls secret class. +/// Otherwise do nothing. +pub(super) fn maybe_patch_namespace( + ns: &str, + res: &mut DynamicObject, + gvk: &GroupVersionKind, +) -> Result<()> { + if gvk.kind == "SecretClass" { + *auto_tls_namespace(&mut res.data)? = json!(ns.to_string()); + } + Ok(()) +} + +fn auto_tls_namespace(value: &mut serde_json::Value) -> Result<&mut Value> { + data::data_field_as_mut(value, "/spec/backend/autoTls/ca/secret/namespace") +} + +#[cfg(test)] +mod test { + use std::sync::LazyLock; + + use anyhow::Result; + use serde::Deserialize; + + use super::*; + + static TLS_SECRET_CLASS: LazyLock = LazyLock::new(|| { + const STR_TLS_SECRET_CLASS: &str = r#" +--- +apiVersion: secrets.stackable.tech/v1alpha1 +kind: SecretClass +metadata: + name: tls + labels: + app.kubernetes.io/name: secret-operator + app.kubernetes.io/instance: secret-operator + stackable.tech/vendor: Stackable + app.kubernetes.io/version: "24.11.0" +spec: + backend: + autoTls: + ca: + secret: + name: secret-provisioner-tls-ca + namespace: "${NAMESPACE}" # TODO patch with olm-deployer + autoGenerate: true +"#; + + let data = serde_yaml::Value::deserialize(serde_yaml::Deserializer::from_str( + STR_TLS_SECRET_CLASS, + )) + .unwrap(); + serde_yaml::from_value(data).unwrap() + }); + + #[test] + fn test_patch_namespace() -> Result<()> { + let gvk: GroupVersionKind = GroupVersionKind { + kind: "SecretClass".to_string(), + version: "v1alpha1".to_string(), + group: "secrets.stackable.tech".to_string(), + }; + let mut tls = TLS_SECRET_CLASS.clone(); + maybe_patch_namespace("prod", &mut tls, &gvk)?; + + let expected = json!("prod"); + assert_eq!(auto_tls_namespace(&mut tls.data)?, &expected); + Ok(()) + } +} diff --git a/rust/olm-deployer/src/owner/mod.rs b/rust/olm-deployer/src/owner/mod.rs new file mode 100644 index 00000000..b5833aae --- /dev/null +++ b/rust/olm-deployer/src/owner/mod.rs @@ -0,0 +1,160 @@ +use anyhow::{Context, Result}; +use stackable_operator::{ + k8s_openapi::{ + api::{apps::v1::Deployment, rbac::v1::ClusterRole}, + apimachinery::pkg::apis::meta::v1::OwnerReference, + }, + kube::{ + api::{DynamicObject, ResourceExt}, + discovery::Scope, + Resource, + }, +}; + +/// Updates the owner list of the `target` according to it's scope. +/// For namespaced objects it uses the `ns_owner` whereas for cluster wide +/// objects it uses the `cluster_owner`. +pub(super) fn maybe_update_owner( + target: &mut DynamicObject, + scope: &Scope, + ns_owner: &Deployment, + cluster_owner: &ClusterRole, +) -> Result<()> { + let owner_ref = owner_ref(scope, ns_owner, cluster_owner)?; + match target.metadata.owner_references { + Some(ref mut ors) => ors.push(owner_ref), + None => target.metadata.owner_references = Some(vec![owner_ref]), + } + Ok(()) +} + +fn owner_ref(scope: &Scope, depl: &Deployment, cr: &ClusterRole) -> Result { + match scope { + Scope::Cluster => cr.owner_ref(&()).context(format!( + "Cannot make owner ref from ClusterRole [{}]", + cr.name_any() + )), + Scope::Namespaced => depl.owner_ref(&()).context(format!( + "Cannot make owner ref from Deployment [{}]", + depl.name_any() + )), + } +} + +#[cfg(test)] +mod test { + use std::sync::LazyLock; + + use anyhow::Result; + use serde::Deserialize; + use stackable_operator::k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference; + + use super::*; + + static DAEMONSET: LazyLock = LazyLock::new(|| { + const STR_DAEMONSET: &str = r#" +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: secret-operator-daemonset +spec: + template: + spec: + containers: + - name: secret-operator + image: "quay.io/stackable/secret-operator@sha256:bb5063aa67336465fd3fa80a7c6fd82ac6e30ebe3ffc6dba6ca84c1f1af95bfe" +"#; + + let data = + serde_yaml::Value::deserialize(serde_yaml::Deserializer::from_str(STR_DAEMONSET)) + .unwrap(); + serde_yaml::from_value(data).unwrap() + }); + + static DEPLOYMENT: LazyLock = LazyLock::new(|| { + const STR_DEPLOYMENT: &str = r#" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: secret-operator-deployer + uid: d9287d0a-3069-47c3-8c90-b714dc6d1af5 +spec: + template: + spec: + containers: + - name: secret-operator-deployer + image: "quay.io/stackable/tools@sha256:bb02df387d8f614089fe053373f766e21b7a9a1ad04cb3408059014cb0f1388e" + tolerations: + - key: keep-out + value: "yes" + operator: Equal + effect: NoSchedule + "#; + + let data = + serde_yaml::Value::deserialize(serde_yaml::Deserializer::from_str(STR_DEPLOYMENT)) + .unwrap(); + serde_yaml::from_value(data).unwrap() + }); + + static CLUSTER_ROLE: LazyLock = LazyLock::new(|| { + const STR_CLUSTER_ROLE: &str = r#" +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: secret-operator-clusterrole + uid: d9287d0a-3069-47c3-8c90-b714dc6dddaa +rules: + - apiGroups: + - "" + resources: + - secrets + - events + verbs: + - get + "#; + let data = + serde_yaml::Value::deserialize(serde_yaml::Deserializer::from_str(STR_CLUSTER_ROLE)) + .unwrap(); + serde_yaml::from_value(data).unwrap() + }); + + #[test] + fn test_namespaced_owner() -> Result<()> { + let mut daemonset = DAEMONSET.clone(); + maybe_update_owner( + &mut daemonset, + &Scope::Namespaced, + &DEPLOYMENT, + &CLUSTER_ROLE, + )?; + + let expected = Some(vec![OwnerReference { + uid: "d9287d0a-3069-47c3-8c90-b714dc6d1af5".to_string(), + name: "secret-operator-deployer".to_string(), + kind: "Deployment".to_string(), + api_version: "apps/v1".to_string(), + ..OwnerReference::default() + }]); + assert_eq!(daemonset.metadata.owner_references, expected); + Ok(()) + } + + #[test] + fn test_cluster_owner() -> Result<()> { + let mut daemonset = DAEMONSET.clone(); + maybe_update_owner(&mut daemonset, &Scope::Cluster, &DEPLOYMENT, &CLUSTER_ROLE)?; + + let expected = Some(vec![OwnerReference { + uid: "d9287d0a-3069-47c3-8c90-b714dc6dddaa".to_string(), + name: "secret-operator-clusterrole".to_string(), + kind: "ClusterRole".to_string(), + api_version: "rbac.authorization.k8s.io/v1".to_string(), + ..OwnerReference::default() + }]); + assert_eq!(daemonset.metadata.owner_references, expected); + Ok(()) + } +} diff --git a/rust/olm-deployer/src/resources/mod.rs b/rust/olm-deployer/src/resources/mod.rs new file mode 100644 index 00000000..e039becd --- /dev/null +++ b/rust/olm-deployer/src/resources/mod.rs @@ -0,0 +1,160 @@ +use stackable_operator::{ + k8s_openapi::api::{apps::v1::Deployment, core::v1::ResourceRequirements}, + kube::{ + api::{DynamicObject, GroupVersionKind}, + ResourceExt, + }, +}; + +use crate::data::container; + +/// Copies the resources of the container named "secret-operator-deployer" from `source` +/// to the container "secret-operator" in `target`. +/// Does nothing if there are no resources or if the `target` is not a DaemonSet. +pub(super) fn maybe_copy_resources( + source: &Deployment, + target: &mut DynamicObject, + target_gvk: &GroupVersionKind, +) -> anyhow::Result<()> { + if target_gvk.kind == "DaemonSet" { + if let Some(res) = deployment_resources(source) { + match container(target, "secret-operator")? { + serde_json::Value::Object(c) => { + c.insert("resources".to_string(), serde_json::json!(res)); + } + _ => anyhow::bail!("no containers found in object {}", target.name_any()), + } + } + } + + Ok(()) +} + +fn deployment_resources(deployment: &Deployment) -> Option<&ResourceRequirements> { + deployment + .spec + .as_ref() + .and_then(|ds| ds.template.spec.as_ref()) + .map(|ts| ts.containers.iter()) + .into_iter() + .flatten() + .filter(|c| c.name == "secret-operator-deployer") + .last() + .and_then(|c| c.resources.as_ref()) +} + +#[cfg(test)] +mod test { + use std::sync::LazyLock; + + use anyhow::Result; + use serde::Deserialize; + use stackable_operator::k8s_openapi::apimachinery::pkg::api::resource::Quantity; + + use super::*; + + static DAEMONSET: LazyLock = LazyLock::new(|| { + const STR_DAEMONSET: &str = r#" +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: secret-operator-daemonset +spec: + template: + spec: + containers: + - name: secret-operator + image: "quay.io/stackable/secret-operator@sha256:bb5063aa67336465fd3fa80a7c6fd82ac6e30ebe3ffc6dba6ca84c1f1af95bfe" + env: + - name: NAME1 + value: value1 + resources: + limits: + cpu: 500m + memory: 2Mi + requests: + cpu: 200m + memory: 1Mi +"#; + + let data = + serde_yaml::Value::deserialize(serde_yaml::Deserializer::from_str(STR_DAEMONSET)) + .unwrap(); + serde_yaml::from_value(data).unwrap() + }); + + static DEPLOYMENT: LazyLock = LazyLock::new(|| { + const STR_DEPLOYMENT: &str = r#" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: secret-operator-deployer + uid: d9287d0a-3069-47c3-8c90-b714dc6d1af5 +spec: + template: + spec: + containers: + - name: secret-operator-deployer + image: "quay.io/stackable/tools@sha256:bb02df387d8f614089fe053373f766e21b7a9a1ad04cb3408059014cb0f1388e" + env: + - name: NAME2 + value: value2 + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 100m + memory: 512Mi + tolerations: + - key: keep-out + value: "yes" + operator: Equal + effect: NoSchedule + "#; + + let data = + serde_yaml::Value::deserialize(serde_yaml::Deserializer::from_str(STR_DEPLOYMENT)) + .unwrap(); + serde_yaml::from_value(data).unwrap() + }); + + #[test] + fn test_copy_env_var() -> Result<()> { + let gvk: GroupVersionKind = GroupVersionKind { + kind: "DaemonSet".to_string(), + version: "v1".to_string(), + group: "apps".to_string(), + }; + + let mut daemonset = DAEMONSET.clone(); + maybe_copy_resources(&DEPLOYMENT, &mut daemonset, &gvk)?; + + let expected = serde_json::json!(ResourceRequirements { + limits: Some( + [ + ("cpu".to_string(), Quantity("1000m".to_string())), + ("memory".to_string(), Quantity("1Gi".to_string())) + ] + .into() + ), + requests: Some( + [ + ("cpu".to_string(), Quantity("100m".to_string())), + ("memory".to_string(), Quantity("512Mi".to_string())) + ] + .into() + ), + ..ResourceRequirements::default() + }); + assert_eq!( + container(&mut daemonset, "secret-operator")? + .get("resources") + .unwrap(), + &expected + ); + Ok(()) + } +} diff --git a/rust/olm-deployer/src/tolerations/mod.rs b/rust/olm-deployer/src/tolerations/mod.rs new file mode 100644 index 00000000..d8b33b64 --- /dev/null +++ b/rust/olm-deployer/src/tolerations/mod.rs @@ -0,0 +1,119 @@ +use stackable_operator::{ + k8s_openapi::api::{apps::v1::Deployment, core::v1::Toleration}, + kube::api::{DynamicObject, GroupVersionKind}, +}; + +use crate::data::get_or_create; + +/// Copies the pod tolerations from the `source` to the `target`. +/// Does nothing if there are no tolerations or if the `target` is not +/// a DaemonSet. +pub(super) fn maybe_copy_tolerations( + source: &Deployment, + target: &mut DynamicObject, + target_gvk: &GroupVersionKind, +) -> anyhow::Result<()> { + if target_gvk.kind == "DaemonSet" { + if let Some(tolerations) = deployment_tolerations(source) { + let path = "template/spec/tolerations".split("/"); + *get_or_create(target.data.pointer_mut("/spec").unwrap(), path)? = + serde_json::json!(tolerations + .iter() + .map(|t| serde_json::json!(t)) + .collect::>()); + } + } + + Ok(()) +} + +fn deployment_tolerations(deployment: &Deployment) -> Option<&Vec> { + deployment + .spec + .as_ref() + .and_then(|s| s.template.spec.as_ref()) + .and_then(|ps| ps.tolerations.as_ref()) +} + +#[cfg(test)] +mod test { + use std::sync::LazyLock; + + use anyhow::Result; + use serde::Deserialize; + + use super::*; + use crate::tolerations::{deployment_tolerations, maybe_copy_tolerations}; + + static DAEMONSET: LazyLock = LazyLock::new(|| { + const STR_DAEMONSET: &str = r#" +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: secret-operator-daemonset +spec: + template: + spec: + containers: + - name: secret-operator + image: "quay.io/stackable/secret-operator@sha256:bb5063aa67336465fd3fa80a7c6fd82ac6e30ebe3ffc6dba6ca84c1f1af95bfe" +"#; + + let data = + serde_yaml::Value::deserialize(serde_yaml::Deserializer::from_str(STR_DAEMONSET)) + .unwrap(); + serde_yaml::from_value(data).unwrap() + }); + + static DEPLOYMENT: LazyLock = LazyLock::new(|| { + const STR_DEPLOYMENT: &str = r#" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: secret-operator-deployer + uid: d9287d0a-3069-47c3-8c90-b714dc6d1af5 +spec: + template: + spec: + containers: + - name: secret-operator-deployer + image: "quay.io/stackable/tools@sha256:bb02df387d8f614089fe053373f766e21b7a9a1ad04cb3408059014cb0f1388e" + tolerations: + - key: keep-out + value: "yes" + operator: Equal + effect: NoSchedule + "#; + + let data = + serde_yaml::Value::deserialize(serde_yaml::Deserializer::from_str(STR_DEPLOYMENT)) + .unwrap(); + serde_yaml::from_value(data).unwrap() + }); + + #[test] + fn test_copy_tolerations() -> Result<()> { + let gvk: GroupVersionKind = GroupVersionKind { + kind: "DaemonSet".to_string(), + version: "v1".to_string(), + group: "apps".to_string(), + }; + + let mut daemonset = DAEMONSET.clone(); + maybe_copy_tolerations(&DEPLOYMENT, &mut daemonset, &gvk)?; + + let expected = serde_json::json!(deployment_tolerations(&DEPLOYMENT) + .unwrap() + .iter() + .map(|t| serde_json::json!(t)) + .collect::>()); + + assert_eq!( + daemonset.data.pointer("/spec/template/spec/tolerations"), + Some(&expected) + ); + Ok(()) + } +}