From fc56f727f1c151965b545a8ce2c408635071b950 Mon Sep 17 00:00:00 2001 From: Razvan-Daniel Mihai <84674+razvan@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:28:56 +0100 Subject: [PATCH] feat: olm deployment helper (#279) * Added olm-deployer crate. * Update changelog. * pass in deployer name * replace unwrap() calls with context() * regenerate-nix --- CHANGELOG.md | 2 + Cargo.lock | 44 +++++ Cargo.nix | 131 +++++++++++++ Cargo.toml | 5 +- rust/olm-deployer/Cargo.toml | 23 +++ rust/olm-deployer/build.rs | 3 + rust/olm-deployer/src/data.rs | 72 +++++++ rust/olm-deployer/src/env/mod.rs | 159 +++++++++++++++ rust/olm-deployer/src/main.rs | 235 +++++++++++++++++++++++ rust/olm-deployer/src/owner/mod.rs | 160 +++++++++++++++ rust/olm-deployer/src/resources/mod.rs | 160 +++++++++++++++ rust/olm-deployer/src/tolerations/mod.rs | 129 +++++++++++++ 12 files changed, 1122 insertions(+), 1 deletion(-) create mode 100644 rust/olm-deployer/Cargo.toml create mode 100644 rust/olm-deployer/build.rs create mode 100644 rust/olm-deployer/src/data.rs create mode 100644 rust/olm-deployer/src/env/mod.rs create mode 100644 rust/olm-deployer/src/main.rs create mode 100644 rust/olm-deployer/src/owner/mod.rs create mode 100644 rust/olm-deployer/src/resources/mod.rs create mode 100644 rust/olm-deployer/src/tolerations/mod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f293275b..889863eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. ### Added - Aggregate emitted Kubernetes events on the CustomResources ([#267]). +- OLM deployment helper ([#279]). ### Changed @@ -14,6 +15,7 @@ All notable changes to this project will be documented in this file. [#267]: https://github.com/stackabletech/listener-operator/pull/267 [#268]: https://github.com/stackabletech/listener-operator/pull/268 +[#279]: https://github.com/stackabletech/listener-operator/pull/279 ## [24.11.1] - 2025-01-10 diff --git a/Cargo.lock b/Cargo.lock index 854a3de2..0a5b42d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1670,6 +1670,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" @@ -2208,6 +2224,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" @@ -3160,6 +3185,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" @@ -3259,6 +3294,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.nix b/Cargo.nix index 044a7d3f..ca48537f 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -47,6 +47,16 @@ rec { # File a bug if you depend on any for non-debug work! debug = internal.debugCrate { inherit packageId; }; }; + "olm-deployer" = rec { + packageId = "olm-deployer"; + build = internal.buildRustCrateWithFeatures { + packageId = "olm-deployer"; + }; + + # Debug support which might change between releases. + # File a bug if you depend on any for non-debug work! + debug = internal.debugCrate { inherit packageId; }; + }; "stackable-listener-operator" = rec { packageId = "stackable-listener-operator"; build = internal.buildRustCrateWithFeatures { @@ -5279,6 +5289,69 @@ rec { }; resolvedDefaultFeatures = [ "archive" "coff" "elf" "macho" "pe" "read_core" "unaligned" "xcoff" ]; }; + "olm-deployer" = rec { + crateName = "olm-deployer"; + version = "0.0.0-dev"; + edition = "2021"; + crateBin = [ + { + name = "olm-deployer"; + path = "src/main.rs"; + requiredFeatures = [ ]; + } + ]; + src = lib.cleanSourceWith { filter = sourceFilter; src = ./rust/olm-deployer; }; + authors = [ + "Stackable GmbH " + ]; + dependencies = [ + { + name = "anyhow"; + packageId = "anyhow"; + } + { + name = "clap"; + packageId = "clap"; + } + { + name = "serde"; + packageId = "serde"; + } + { + name = "serde_json"; + packageId = "serde_json"; + } + { + name = "serde_yaml"; + packageId = "serde_yaml"; + } + { + name = "stackable-operator"; + packageId = "stackable-operator"; + } + { + name = "tokio"; + packageId = "tokio"; + features = [ "full" ]; + } + { + name = "tracing"; + packageId = "tracing"; + } + { + name = "walkdir"; + packageId = "walkdir"; + } + ]; + buildDependencies = [ + { + name = "built"; + packageId = "built"; + features = [ "chrono" "git2" ]; + } + ]; + + }; "once_cell" = rec { crateName = "once_cell"; version = "1.20.2"; @@ -7046,6 +7119,24 @@ rec { "no-panic" = [ "dep:no-panic" ]; }; }; + "same-file" = rec { + crateName = "same-file"; + version = "1.0.6"; + edition = "2018"; + sha256 = "00h5j1w87dmhnvbv9l8bic3y7xxsnjmssvifw2ayvgx9mb1ivz4k"; + libName = "same_file"; + authors = [ + "Andrew Gallant " + ]; + dependencies = [ + { + name = "winapi-util"; + packageId = "winapi-util"; + target = { target, features }: (target."windows" or false); + } + ]; + + }; "schannel" = rec { crateName = "schannel"; version = "0.1.27"; @@ -10303,6 +10394,27 @@ rec { "Sergio Benitez " ]; + }; + "walkdir" = rec { + crateName = "walkdir"; + version = "2.5.0"; + edition = "2018"; + sha256 = "0jsy7a710qv8gld5957ybrnc07gavppp963gs32xk4ag8130jy99"; + authors = [ + "Andrew Gallant " + ]; + dependencies = [ + { + name = "same-file"; + packageId = "same-file"; + } + { + name = "winapi-util"; + packageId = "winapi-util"; + target = { target, features }: (target."windows" or false); + } + ]; + }; "want" = rec { crateName = "want"; @@ -10561,6 +10673,25 @@ rec { "Peter Atashian " ]; + }; + "winapi-util" = rec { + crateName = "winapi-util"; + version = "0.1.9"; + edition = "2021"; + sha256 = "1fqhkcl9scd230cnfj8apfficpf5c9vhwnk4yy9xfc1sw69iq8ng"; + libName = "winapi_util"; + authors = [ + "Andrew Gallant " + ]; + dependencies = [ + { + name = "windows-sys"; + packageId = "windows-sys 0.59.0"; + target = { target, features }: (target."windows" or false); + features = [ "Win32_Foundation" "Win32_Storage_FileSystem" "Win32_System_Console" "Win32_System_SystemInformation" ]; + } + ]; + }; "winapi-x86_64-pc-windows-gnu" = rec { crateName = "winapi-x86_64-pc-windows-gnu"; diff --git a/Cargo.toml b/Cargo.toml index 279cc7ad..675aaba0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["rust/operator-binary", "rust/csi-grpc"] +members = ["rust/operator-binary", "rust/csi-grpc", "rust/olm-deployer"] resolver = "2" [workspace.package] @@ -21,6 +21,8 @@ pin-project = "1.1" prost = "0.13" prost-types = "0.13" serde = "1.0" +serde_json = "1.0" +serde_yaml = "0.9" snafu = "0.8" stackable-operator = { git = "https://github.com/stackabletech/operator-rs.git", tag = "stackable-operator-0.85.0" } strum = { version = "0.26", features = ["derive"] } @@ -31,6 +33,7 @@ tonic = "0.12" tonic-build = "0.12" tonic-reflection = "0.12" tracing = "0.1.40" +walkdir = "2.5.0" [patch."https://github.com/stackabletech/operator-rs.git"] # stackable-operator = { path = "../operator-rs/crates/stackable-operator" } 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/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..be56d9d5 --- /dev/null +++ b/rust/olm-deployer/src/data.rs @@ -0,0 +1,72 @@ +use anyhow::{anyhow, Context}; +use stackable_operator::kube::{api::DynamicObject, ResourceExt}; + +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") + .context(anyhow!("object [{tname}] has no .spec property"))?, + 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() + .context(anyhow!("expected an empty map for [{key}] but found null"))? + } + 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..fb76d3fb --- /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 "listener-operator-deployer" container in `source` +/// to the container "listener-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, "listener-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 == "listener-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: listener-operator-daemonset +spec: + template: + spec: + containers: + - name: listener-operator + image: "quay.io/stackable/listener-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: listener-operator-deployer + uid: d9287d0a-3069-47c3-8c90-b714dc6d1af5 +spec: + template: + spec: + containers: + - name: listener-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, "listener-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..1e8c8ae5 --- /dev/null +++ b/rust/olm-deployer/src/main.rs @@ -0,0 +1,235 @@ +/// This program acts as a proxy Deployment in OLM environments that installs the listener 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 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-listener-olm-deployer"; +pub const ENV_VAR_LOGGING: &str = "STKBL_LISTENER_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 = "Name of deployment object that owns this Pod.")] + deployer: 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, + deployer, + 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, &deployer, &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, + )?; + 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() + .context(anyhow!("ClusterRole object not found for labels {labels}"))? + .clone()) + } else { + bail!("ClusterRole object not found for labels {labels}") + } +} + +async fn get_deployment( + csv: &str, + deployer: &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}"), + _ => Ok(result + .into_iter() + .find(|d| d.name_any() == deployer) + .context(format!("no deployment named {deployer} found"))?), + } +} diff --git a/rust/olm-deployer/src/owner/mod.rs b/rust/olm-deployer/src/owner/mod.rs new file mode 100644 index 00000000..e26c2121 --- /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: listener-operator-daemonset +spec: + template: + spec: + containers: + - name: listener-operator + image: "quay.io/stackable/listener-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: listener-operator-deployer + uid: d9287d0a-3069-47c3-8c90-b714dc6d1af5 +spec: + template: + spec: + containers: + - name: listener-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: listener-operator-clusterrole + uid: d9287d0a-3069-47c3-8c90-b714dc6dddaa +rules: + - apiGroups: + - "" + resources: + - listeners + - 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: "listener-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: "listener-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..22dc4a26 --- /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 "listener-operator-deployer" from `source` +/// to the container "listener-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, "listener-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 == "listener-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: listener-operator-daemonset +spec: + template: + spec: + containers: + - name: listener-operator + image: "quay.io/stackable/listener-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: listener-operator-deployer + uid: d9287d0a-3069-47c3-8c90-b714dc6d1af5 +spec: + template: + spec: + containers: + - name: listener-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, "listener-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..ede81524 --- /dev/null +++ b/rust/olm-deployer/src/tolerations/mod.rs @@ -0,0 +1,129 @@ +use anyhow::{anyhow, Context}; +use stackable_operator::{ + k8s_openapi::api::{apps::v1::Deployment, core::v1::Toleration}, + kube::{ + api::{DynamicObject, GroupVersionKind}, + ResourceExt, + }, +}; + +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" { + let tname = target.name_any(); + if let Some(tolerations) = deployment_tolerations(source) { + let path = "template/spec/tolerations".split("/"); + *get_or_create( + target + .data + .pointer_mut("/spec") + .context(anyhow!("DaemonSet named [{tname}] has empty .spec"))?, + 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: listener-operator-daemonset +spec: + template: + spec: + containers: + - name: listener-operator + image: "quay.io/stackable/listener-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: listener-operator-deployer + uid: d9287d0a-3069-47c3-8c90-b714dc6d1af5 +spec: + template: + spec: + containers: + - name: listener-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(()) + } +}