Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/stackable-operator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@ All notable changes to this project will be documented in this file.
- BREAKING: `ClusterResources` now warns about `objectOverrides` entries that did not match any of the objects it created.
To enable this, the signatures of `apply_deep_merge` and `ObjectOverrides::apply_to` needed to be adjusted ([#1264]).

### Removed

- BREAKING: Removed `timeout_duration` parameter from `signal::crd_established`. It now waits indefinitely and
the timeout should be handled with a startup probe instead. As a consequence `DEFAULT_CRD_ESTABLISHED_TIMEOUT`
also got removed ([#1272]).

[#1264]: https://github.com/stackabletech/operator-rs/pull/1264
[#1269]: https://github.com/stackabletech/operator-rs/pull/1269
[#1272]: https://github.com/stackabletech/operator-rs/pull/1272

## [0.116.0] - 2026-08-14

Expand Down
53 changes: 24 additions & 29 deletions crates/stackable-operator/src/utils/signal.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition;
use kube::runtime::wait;
use snafu::{ResultExt, Snafu};
use stackable_shared::time::Duration;
use tokio::{
signal::unix::{SignalKind, signal},
sync::watch,
Expand Down Expand Up @@ -77,45 +76,41 @@ impl SignalWatcher<()> {
}
}

pub const DEFAULT_CRD_ESTABLISHED_TIMEOUT: Duration = Duration::from_secs(5);

#[derive(Debug, Snafu)]
pub enum CrdEstablishedError {
#[snafu(display("failed to meet CRD established condition before the timeout elapsed"))]
TimeoutElapsed { source: tokio::time::error::Elapsed },

#[snafu(display("failed to await CRD established condition due to api error"))]
Api { source: kube::runtime::wait::Error },
#[snafu(display(
"failed to await CRD established condition for {crd_name:?} due to api error"
))]
Api {
source: kube::runtime::wait::Error,
crd_name: String,
},
}

/// Waits for a CRD named `crd_name` to be established before `timeout_duration` (or by default
/// [`DEFAULT_CRD_ESTABLISHED_TIMEOUT`]) is elapsed.
/// Waits for a CRD named `crd_name` to be established.
///
/// The same caveats from [`conditions::is_crd_established`](wait::conditions::is_crd_established)
/// apply here as well.
///
/// ### Errors
///
/// This function returns errors either if the timeout elapsed without the condition being met or
/// when the underlying API returned errors (CRD is unknown to the Kubernetes API server or due to
/// missing permissions).
pub async fn crd_established(
client: &Client,
crd_name: &str,
timeout_duration: impl Into<Option<Duration>>,
) -> Result<(), CrdEstablishedError> {
/// This function returns errors when the underlying API returned errors
/// (CRD is unknown to the Kubernetes API server or due to missing permissions).
pub async fn crd_established(client: &Client, crd_name: &str) -> Result<(), CrdEstablishedError> {
tracing::info!(
k8s.crd.name = crd_name,
"Waiting for the custom resource definition to be established"
);

let api: kube::Api<CustomResourceDefinition> = client.get_api(&());
let crd_established =
wait::await_condition(api, crd_name, wait::conditions::is_crd_established());
let _ = tokio::time::timeout(
*timeout_duration
.into()
.unwrap_or(DEFAULT_CRD_ESTABLISHED_TIMEOUT),
crd_established,
)
.await
.context(TimeoutElapsedSnafu)?
.context(ApiSnafu)?;
wait::await_condition(api, crd_name, wait::conditions::is_crd_established())
.await
.context(ApiSnafu { crd_name })?;

tracing::info!(
k8s.crd.name = crd_name,
"The custom resource definition is established"
);

Ok(())
}
6 changes: 6 additions & 0 deletions crates/stackable-shared/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- Add `health` module containing `HealthCheck` and `HealthCheckRegistry` for health endpoints ([#1272]).

[#1272]: https://github.com/stackabletech/operator-rs/pull/1272

## [0.1.2] - 2026-07-06

Note: There are only dependency bumps in this release.
Expand Down
114 changes: 114 additions & 0 deletions crates/stackable-shared/src/health.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
use std::{
fmt::Display,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};

/// A single named check contributing to one health endpoint.
///
/// A check only passes once [`HealthCheck::mark_passed`] has been called.
#[derive(Clone)]
pub struct HealthCheck {
name: String,
passed: Arc<AtomicBool>,
}
Comment on lines +13 to +16

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole machinery could be simplified a little. I will add my suggestions to each piece of code individually.

Here, we only need access to the underlying AtomicBool. I also renamed the struct to better reflect what it is: a handle to mark a readiness check as ready.

Suggested change
pub struct HealthCheck {
name: String,
passed: Arc<AtomicBool>,
}
pub struct ReadinessHandle(Arc<AtomicBool>)


impl HealthCheck {
fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
passed: Arc::new(AtomicBool::new(false)),
}
}

pub fn mark_passed(&self) {
self.passed.store(true, Ordering::Release);
}

fn passed(&self) -> bool {
self.passed.load(Ordering::Acquire)
}
}
Comment on lines +18 to +33

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The impl block can also be simplified a bunch:

Suggested change
impl HealthCheck {
fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
passed: Arc::new(AtomicBool::new(false)),
}
}
pub fn mark_passed(&self) {
self.passed.store(true, Ordering::Release);
}
fn passed(&self) -> bool {
self.passed.load(Ordering::Acquire)
}
}
impl ReadinessHandle {
pub fn ready(self) {
self.0.store(true, Ordering::Release);
}
}


/// A set of checks to be used for a health endpoint a probe can call.
///
/// # Example
///
/// ```
/// use stackable_shared::health::HealthCheckRegistry;
///
/// let mut startup_checks = HealthCheckRegistry::new();
/// let crds_established = startup_checks.register("crds-established");
///
/// assert!(!startup_checks.all_passed());
/// crds_established.mark_passed();
/// assert!(startup_checks.all_passed());
/// ```
#[derive(Default)]
pub struct HealthCheckRegistry {
checks: Vec<HealthCheck>,
}
Comment on lines +50 to +52

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now use a Vec over a tuple instead:

Suggested change
pub struct HealthCheckRegistry {
checks: Vec<HealthCheck>,
}
// This has to be an AtomicBool as we could otherwise not share references to it.
pub struct ReadinessChecks(Vec<(String, Arc<AtomicBool>)>);


impl HealthCheckRegistry {
pub fn new() -> Self {
Self::default()
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If both a new method and a Default impl exist, the Default impl should delegate to new, not the other way around (see CLippy lint). In this case, I would argue we can even remove the new method.

/// Registers a new [`HealthCheck`] with the provided name and returns it.
pub fn register(&mut self, name: impl Into<String>) -> HealthCheck {
let check = HealthCheck::new(name);
self.checks.push(check.clone());
check
}

/// Returns `true` if all the registered health checks have passed or no health checks are
/// registered.
pub fn all_passed(&self) -> bool {
self.checks.iter().all(HealthCheck::passed)
}
}
Comment on lines +54 to +71

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we split the health check into its name and AtomicBool, we only have to clone the Arc (increase its reference count). I also renamed the all_passed method to all_ready.

Suggested change
impl HealthCheckRegistry {
pub fn new() -> Self {
Self::default()
}
/// Registers a new [`HealthCheck`] with the provided name and returns it.
pub fn register(&mut self, name: impl Into<String>) -> HealthCheck {
let check = HealthCheck::new(name);
self.checks.push(check.clone());
check
}
/// Returns `true` if all the registered health checks have passed or no health checks are
/// registered.
pub fn all_passed(&self) -> bool {
self.checks.iter().all(HealthCheck::passed)
}
}
impl ReadinessChecks {
/// Registers a new readiness check with the provided name.
///
/// The returned handle can be used to mark the check as ready.
pub fn register(&mut self, name: impl Into<String>) -> ReadinessHandle {
let ready = Arc::new(AtomicBool::default());
// Store an reference counted clone of the same underlying AtomicBool in the list of checks.
// Both the handle and the item in the list refer to the same AtomicBool.
self.0.push((name.into(), ready.clone()));
ReadinessHandle(ready)
}
/// Returns `true` if all the registered checks are ready or no checks are registered.
pub fn all_ready(&self) -> bool {
self.0
.iter()
.all(|(_, ready)| ready.load(Ordering::Acquire))
}
}


impl Display for HealthCheckRegistry {
/// Renders one line per check, with the check's name and status only. Anything else, error
/// causes in particular, must not end up in a response to an unauthenticated endpoint.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.checks.is_empty() {
return writeln!(f, "[ok] no checks registered");
}

for check in &self.checks {
let status = if check.passed() { "ok" } else { "pending" };
writeln!(f, "[{status}] {name}", name = check.name)?;
}
Ok(())
}
}
Comment on lines +73 to +87

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now have to adjust the handling and loading of the statuses:

Suggested change
impl Display for HealthCheckRegistry {
/// Renders one line per check, with the check's name and status only. Anything else, error
/// causes in particular, must not end up in a response to an unauthenticated endpoint.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.checks.is_empty() {
return writeln!(f, "[ok] no checks registered");
}
for check in &self.checks {
let status = if check.passed() { "ok" } else { "pending" };
writeln!(f, "[{status}] {name}", name = check.name)?;
}
Ok(())
}
}
impl Display for ReadinessChecks {
/// Renders one line per check, with the check's name and status only. Anything else, error
/// causes in particular, must not end up in a response to an unauthenticated endpoint.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.0.is_empty() {
return writeln!(f, "[ok] no readiness checks registered");
}
for (name, ready) in &self.0 {
let status = if ready.load(Ordering::Acquire) {
"ready"
} else {
"pending"
};
writeln!(f, "[{status}] {name}")?;
}
Ok(())
}
}


#[cfg(test)]
mod test {
use super::*;

#[test]
fn passed_on_empty_registry() {
let registry = HealthCheckRegistry::new();

assert!(registry.all_passed());
}

#[test]
fn passed_only_once_every_check_is() {
let mut registry = HealthCheckRegistry::new();
let crds = registry.register("crds-established");
let migration = registry.register("database-migrated");

assert!(!registry.all_passed());

crds.mark_passed();
assert!(!registry.all_passed());

migration.mark_passed();
assert!(registry.all_passed());
}
}
Comment on lines +90 to +114

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
mod test {
use super::*;
#[test]
fn passed_on_empty_registry() {
let registry = HealthCheckRegistry::new();
assert!(registry.all_passed());
}
#[test]
fn passed_only_once_every_check_is() {
let mut registry = HealthCheckRegistry::new();
let crds = registry.register("crds-established");
let migration = registry.register("database-migrated");
assert!(!registry.all_passed());
crds.mark_passed();
assert!(!registry.all_passed());
migration.mark_passed();
assert!(registry.all_passed());
}
}
mod test {
use super::*;
#[test]
fn ready_on_empty_registry() {
let checks = ReadinessChecks::default();
assert!(checks.all_ready());
}
#[test]
fn ready_only_once_every_check_is() {
let mut checks = ReadinessChecks::default();
let crds = checks.register("crds-established");
let migration = checks.register("database-migrated");
assert!(!checks.all_ready());
crds.ready();
assert!(!checks.all_ready());
migration.ready();
assert!(checks.all_ready());
}
}

1 change: 1 addition & 0 deletions crates/stackable-shared/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! workspace.

pub mod crd;
pub mod health;
pub mod secret;
pub mod time;
pub mod yaml;
7 changes: 7 additions & 0 deletions crates/stackable-webhook/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- BREAKING: The `WebhookServer` now serves a `/ready` endpoint for a startup probe.
For that, `WebhookServer::new` takes an additional `HealthCheckRegistry` argument ([#1272]).

[#1272]: https://github.com/stackabletech/operator-rs/pull/1272

## [0.9.2] - 2026-07-06

Note: There are only dependency bumps in this release.
Expand Down
33 changes: 28 additions & 5 deletions crates/stackable-webhook/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@
//!
//! For usage please look at the [`WebhookServer`] docs as well as the specific [`Webhook`] you are
//! using.
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
sync::Arc,
};

use ::x509_cert::Certificate;
use axum::{Router, routing::get};
use axum::{Router, http::StatusCode, routing::get};
use futures_util::TryFutureExt;
use k8s_openapi::ByteString;
use snafu::{ResultExt, Snafu};
use stackable_shared::health::HealthCheckRegistry;
use stackable_telemetry::AxumTraceLayer;
use tokio::{sync::mpsc, try_join};
use tower::ServiceBuilder;
Expand Down Expand Up @@ -54,6 +58,7 @@ pub enum WebhookServerError {
/// ### Example usage
///
/// ```
/// use stackable_shared::health::HealthCheckRegistry;
/// use stackable_webhook::{WebhookServer, WebhookServerOptions, webhooks::Webhook};
/// use tokio::time::{Duration, sleep};
///
Expand All @@ -65,7 +70,10 @@ pub enum WebhookServerError {
/// webhook_namespace: "my-namespace".to_owned(),
/// webhook_service_name: "my-operator".to_owned(),
/// };
/// let webhook_server = WebhookServer::new(webhooks, webhook_options).await.unwrap();
/// let readiness_checks = HealthCheckRegistry::new();
/// let webhook_server = WebhookServer::new(webhooks, webhook_options, readiness_checks)
/// .await
/// .unwrap();
/// let shutdown_signal = sleep(Duration::from_millis(100));
///
/// webhook_server.run(shutdown_signal).await.unwrap();
Expand Down Expand Up @@ -111,6 +119,7 @@ impl WebhookServer {
pub async fn new(
webhooks: Vec<Box<dyn Webhook>>,
options: WebhookServerOptions,
readiness_checks: HealthCheckRegistry,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The readiness checks being part of the public API makes me feel like the whole HealthCheck and HealthCheckRegistry machinery should be part of the stackable-webhook crate.

) -> Result<Self> {
tracing::trace!("create new webhook server");

Expand All @@ -132,12 +141,26 @@ impl WebhookServer {
router = webhook.register_routes(router);
}

// Create the route handler for the startup probe.
let readiness_checks = Arc::new(readiness_checks);
let ready_route = move || async move {
let status = if readiness_checks.all_passed() {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
// The response body carries check names and their status. Error causes etc. go to the
// log, never into a response to not leak internal information to the public endpoint.
(status, readiness_checks.to_string())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could potentially implement axum's IntoResponse trait here which internally calls to_string().

};

let router = router
// Enrich spans for routes added above.
// Routes defined below it will not be instrumented to reduce noise.
.layer(trace_service_builder)
// The health route is below the AxumTraceLayer so as not to be instrumented
.route("/health", get(|| async { "ok" }));
// The health and ready routes are below the AxumTraceLayer so as not to be instrumented
.route("/health", get(|| async { "ok" }))
.route("/ready", get(ready_route));

tracing::debug!("create TLS server");
let (tls_server, cert_rx) = TlsServer::new(router, &options)
Expand Down
12 changes: 9 additions & 3 deletions crates/stackable-webhook/src/webhooks/conversion_webhook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pub enum ConversionWebhookError {
/// Client,
/// core::admission::{AdmissionRequest, AdmissionResponse},
/// },
/// shared::health::HealthCheckRegistry,
/// };
/// use stackable_webhook::{
/// WebhookServer,
Expand Down Expand Up @@ -72,9 +73,14 @@ pub enum ConversionWebhookError {
/// ConversionWebhook::new(crds_and_handlers, client, conversion_webhook_options);
///
/// let webhook_options = todo!();
/// let webhook_server = WebhookServer::new(vec![Box::new(conversion_webhook)], webhook_options)
/// .await
/// .unwrap();
/// let readiness_checks = HealthCheckRegistry::new();
/// let webhook_server = WebhookServer::new(
/// vec![Box::new(conversion_webhook)],
/// webhook_options,
/// readiness_checks,
/// )
/// .await
/// .unwrap();
/// let shutdown_signal = sleep(Duration::from_millis(100));
///
/// webhook_server.run(shutdown_signal).await.unwrap();
Expand Down
17 changes: 11 additions & 6 deletions crates/stackable-webhook/src/webhooks/mutating_webhook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ pub enum MutatingWebhookError {
/// use k8s_openapi::api::{
/// admissionregistration::v1::MutatingWebhookConfiguration, apps::v1::StatefulSet,
/// };
/// use stackable_operator::kube::{
/// Client,
/// core::admission::{AdmissionRequest, AdmissionResponse},
/// use stackable_operator::{
/// kube::{
/// Client,
/// core::admission::{AdmissionRequest, AdmissionResponse},
/// },
/// shared::health::HealthCheckRegistry,
/// };
/// use stackable_webhook::{
/// WebhookServer,
Expand Down Expand Up @@ -73,9 +76,11 @@ pub enum MutatingWebhookError {
/// ));
///
/// let webhook_options = todo!();
/// let webhook_server = WebhookServer::new(vec![mutating_webhook], webhook_options)
/// .await
/// .unwrap();
/// let readiness_checks = HealthCheckRegistry::new();
/// let webhook_server =
/// WebhookServer::new(vec![mutating_webhook], webhook_options, readiness_checks)
/// .await
/// .unwrap();
/// let shutdown_signal = sleep(Duration::from_millis(100));
///
/// webhook_server.run(shutdown_signal).await.unwrap();
Expand Down