From 71b1d3411d4b2b0a74b707337a777a3aaa6b8ab4 Mon Sep 17 00:00:00 2001 From: Vincent Stuyck Date: Sat, 28 Feb 2026 16:58:43 +0100 Subject: [PATCH] add activation api --- Cargo.lock | 12 +++ Cargo.toml | 1 + src/api/activations.rs | 185 +++++++++++++++++++++++++++++++++++++---- src/api/mod.rs | 29 ++++--- src/client.rs | 7 +- src/main.rs | 40 +++++++++ 6 files changed, 241 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 83ef160..49404b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,6 +152,7 @@ dependencies = [ "simplelog", "thiserror", "tokio", + "uuid", ] [[package]] @@ -1884,6 +1885,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "uuid-simd" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index aac251e..30490a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ serde_json = "1.0.145" simplelog = "0.12.2" thiserror = "2.0.17" tokio = { version = "1.48.0", features = ["full"] } +uuid = { version = "1.21.0", features = ["serde"] } [features] schemars = ["dep:schemars"] diff --git a/src/api/activations.rs b/src/api/activations.rs index 625ba25..3374153 100644 --- a/src/api/activations.rs +++ b/src/api/activations.rs @@ -1,20 +1,72 @@ +use std::fmt::Display; + +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::{ApiClient, Client, Result}; +use crate::api::{DomainCollection, DomainExtension, DomainObject, domain_client, domain_read}; +domain_client!(ActivationStatus, activation_api); #[derive(Debug, Default, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(JsonSchema))] -pub struct ChangeActivation; +pub struct ActivationStatus { + sites: Vec, + is_running: bool, + force_foreign_changes: bool, + time_started: DateTime, + changes: Vec, + #[serde(default)] + status_per_site: Vec +} + +impl DomainExtension for ActivationStatus { + const DOMAIN_TYPE: super::DomainType = super::DomainType::ActivationRun; +} #[derive(Debug, Default, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(JsonSchema))] -pub struct PendingChange; +pub struct PendingChange { + id: Uuid, + user_id: Option, + action_name: String, + text: String, + time: DateTime, +} -impl From<&Client> for ApiClient { - fn from(value: &Client) -> Self { - Self::new(value) - } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(JsonSchema))] +pub struct SiteStatus { + site: String, + phase: ChangePhase, + state: ChangeState, + status_text: String, + status_details: String, + start_time: DateTime, + end_time: DateTime +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum ChangePhase { + Initialized, + Queued, + Started, + Sync, + Activate, + Finishing, + Done +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum ChangeState { + Success, + Error, + Warning } #[derive(Debug, Default, Clone, Serialize, Deserialize)] @@ -25,20 +77,119 @@ pub struct ActivateChangeRequest { force_foreign_changes: bool } -impl ApiClient { - pub async fn activate(&self) -> Result<()> { - todo!() +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(JsonSchema))] +pub struct ActivationOptions { + redirect: bool, + sites: Vec, + force_foreign_changes: bool +} + +impl ActivationOptions { + pub fn set_redirect(&mut self, redirect: bool) { + self.redirect = redirect } - pub async fn await_activation(&self, activation_id: &str) -> Result<()> { - todo!() + pub fn with_redirect(mut self, redirect: bool) -> Self { + self.set_redirect(redirect); + self } - pub async fn read_current_activations(&self) -> Result> { - todo!() + pub fn set_force_foreign_changes(&mut self, force: bool) { + self.force_foreign_changes = force; } - pub async fn bulk_read_pending_changes(&self) -> Result> { - todo!() + pub fn with_force_foreign_changes(mut self, force: bool) -> Self { + self.set_force_foreign_changes(force); + self } - pub async fn read_activation_status(&self, activation_id: &str) -> Result { - todo!() + + pub fn set_sites(&mut self, sites: Vec) { + self.sites = sites; + } + pub fn with_sites(mut self, sites: Vec) -> Self { + self.set_sites(sites); + self + } + pub fn set_site(&mut self, site: String) { + self.sites.push(site); + } + pub fn with_site(mut self, site: String) -> Self { + self.set_site(site); + self + } +} + +domain_read!(ActivationStatus); + +impl ApiClient { + pub async fn activate_pending_changes( + &self, + options: &ActivationOptions + ) -> Result { + let etag = { + let url = format!("{}/pending_changes", self.collection_root()); + self.inner.get_etag(&url).await + }?; + let url = format!( + "{}/domain-types/{}/actions/activate-changes/invoke", + self.inner.url, ActivationStatus::DOMAIN_TYPE + ); + + let request = self.inner.http + .post(url) + .json(&options) + .header(reqwest::header::IF_MATCH, etag) + .build() + .unwrap(); + + let response: DomainObject = + self.inner.query_api(request).await?; + Ok(response.id) + } + pub async fn await_completion(&self, activation_id: impl Display) -> Result<()> { + let url = format!( + "{}/objects/{}/{}/actions/wait-for-completion/invoke", + self.inner.url, ActivationStatus::DOMAIN_TYPE, activation_id + ); + + let request = self.inner.http + .get(url) + .build() + .unwrap(); + + self.inner.invoke_api(request).await + } + + #[inline] + fn collection_root(&self) -> String { + format!( + "{}/domain-types/{}/collections", + self.inner.url, ActivationStatus::DOMAIN_TYPE + ) + } + pub async fn read_running_activations(&self) -> Result> { + let url = format!("{}/running", self.collection_root()); + + let request = self.inner.http + .get(url) + .build() + .unwrap(); + + let collection: DomainCollection<_> = self.inner.query_api(request).await?; + Ok(collection.into()) + } + pub async fn read_pending_changes(&self) -> Result> { + let url = format!("{}/pending_changes", self.collection_root()); + + let request = self.inner.http + .get(url) + .build() + .unwrap(); + + #[derive(Deserialize)] + struct ChangeCollection { + value: Vec + } + + let collection: ChangeCollection = self.inner.query_api(request).await?; + Ok(collection.value) } } diff --git a/src/api/mod.rs b/src/api/mod.rs index 5562f6e..ab9f623 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -21,7 +21,8 @@ pub enum DomainType { ContactGroupConfig, HostGroupConfig, ServiceGroupConfig, - HostTagGroup + HostTagGroup, + ActivationRun, } impl fmt::Display for DomainType { @@ -34,6 +35,7 @@ impl fmt::Display for DomainType { DomainType::HostGroupConfig => write!(f, "host_group_config"), DomainType::ServiceGroupConfig => write!(f, "service_group_config"), DomainType::HostTagGroup => write!(f, "host_tag_group"), + DomainType::ActivationRun => write!(f, "activation_run") } } } @@ -89,6 +91,15 @@ pub(crate) struct DomainCollection { // pub extensions: Option, } +impl From> for Vec { + fn from(value: DomainCollection) -> Self { + value.value + .into_iter() + .map(|obj| obj.extensions) + .collect() + } +} + pub trait DomainExtension: DeserializeOwned + Serialize { const DOMAIN_TYPE: DomainType; } @@ -155,7 +166,7 @@ impl InnerClient { pub(crate) async fn bulk_read_domain_objects( &self, query: &Q, - ) -> Result>> + ) -> Result> where E: DomainExtension, Q: Serialize @@ -293,22 +304,16 @@ macro_rules! domain_bulk_read { ($id:ident) => { impl ApiClient<$id> { pub async fn bulk_read(&self) -> Result> { - let objs = self.inner.bulk_read_domain_objects::<$id, _>(&()).await?; - let objs = objs.into_iter() - .map(|obj| obj.extensions) - .collect(); - Ok(objs) + let collection = self.inner.bulk_read_domain_objects::<$id, _>(&()).await?; + Ok(collection.into()) } } }; ($id:ident, $qry:ident) => { impl ApiClient<$id> { pub async fn bulk_read(&self, query: &$qry) -> Result> { - let objs = self.inner.bulk_read_domain_objects::<$id, _>(&query).await?; - let objs = objs.into_iter() - .map(|obj| obj.extensions) - .collect(); - Ok(objs) + let collection = self.inner.bulk_read_domain_objects::<$id, _>(&query).await?; + Ok(collection.into()) } } }; diff --git a/src/client.rs b/src/client.rs index 9ec907a..b2fcc43 100644 --- a/src/client.rs +++ b/src/client.rs @@ -11,7 +11,7 @@ use serde::de::DeserializeOwned; use tokio::sync::Semaphore; use crate::api::{ - DomainCollection, DomainObject, DomainType, + DomainCollection, DomainType, }; use crate::{Error, Result}; @@ -400,7 +400,7 @@ impl InnerClient { &self, domain_type: DomainType, query: &Q - ) -> Result>> + ) -> Result> where O: DeserializeOwned, Q: Serialize @@ -412,8 +412,7 @@ impl InnerClient { .build() .unwrap(); - let response: DomainCollection = self.query_api(request).await?; - Ok(response.value) + self.query_api(request).await } pub(crate) async fn bulk_update( diff --git a/src/main.rs b/src/main.rs index 06f4fd4..9af8c57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,7 @@ async fn main() -> Result<()> { test_host_groups(client.clone()).await?; test_service_groups(client.clone()).await?; test_contact_groups(client.clone()).await?; + test_activations(client.clone()).await?; info!("tests done; all pass"); Ok(()) @@ -324,6 +325,45 @@ async fn test_service_groups(client: Client) -> Result<()> { Ok(()) } +async fn test_activations(client: Client) -> Result<()> { + use checkmk_api::api::activations::*; + info!("testing activations"); + + let api = client.activation_api(); + + let pending = api + .read_pending_changes() + .await + .inspect_err(|e| error!("failed to read pending changes: {e}"))?; + info!("pending changes: {pending:#?}"); + + let running = api + .read_running_activations() + .await + .inspect_err(|e| error!("failed to read running activations: {e}"))?; + info!("running activations: {running:#?}"); + + let options = ActivationOptions::default() + .with_redirect(false) + .with_force_foreign_changes(false) + .with_site(SITENAME.to_string()); + + info!("activating pending changes"); + let activation_id = api + .activate_pending_changes(&options) + .await + .inspect_err(|e| error!("failed to activate pending changes: {e}"))?; + info!("activation started with id: {activation_id}"); + + info!("waiting for activation {activation_id} to complete"); + api.await_completion(&activation_id) + .await + .inspect_err(|e| error!("failed to await activation completion: {e}"))?; + info!("activation {activation_id} completed"); + + Ok(()) +} + async fn test_contact_groups(client: Client) -> Result<()> { use checkmk_api::api::groups::*; const TESTGROUP: &str = "test-contact-group";