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>)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'll look into all the improvement suggestions now regarding the code, but with the naming I would keep it that way unless you strongly disagree. The health checks could possibly also be used for liveness probes etc. later on, it was meant as a general construct for that. It also is more closely to the Kubernetes naming of those checks which are then also used by readiness/liveness/startup probes etc.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Hm not sure what the code suggestion improves here. For me it reads worse, the name and status are basically "separated" then instead of being part of the same concept struct. Also in case we want to extend that later (I don't know, add timestamp field when health check last passed or something) it would be easier with the current approach, but feel free to ignore that point since it's just a "maybe scenario".

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.

In my opinion it is fine to separate the name/identifier from the actual check (which can of course contains more than just a bool indicating if the check passed or not) as long as the check it self does not need it (which is currently the case).


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);
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This also kind of reads worse for me (self.0 instead of self.passed). Also ready implies that it's a check rather than a write operation.

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.

This also kind of reads worse for me (self.0 instead of self.passed)

If it is only about the name of the field, then we could bring that back. But having a single field struct is kind of a smell - that's why I opted to use the tuple form.

Also ready implies that it's a check rather than a write operation.

I'm fine making it more explicit, like mark_ready - I just liked the short name.


/// 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>)>);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is kind of related to #1272 (comment) also, and I think some of other suggestions might be derived from the decision which approach to use here. I first started out with the Vec version, but again, it seemed better readable and extensible to me with the current implemented version and it didn't seem to have any performance downsides etc. Or what would be the benefits I might not see to using the Vec version?

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.

I first started out with the Vec version, but again, it seemed better readable and extensible to me with the current implemented version and it didn't seem to have any performance downsides etc.

Am I missing something here? The current implementation and my suggestion both use a Vec internally - just the inner type differs.

Are you talking about using a named field vs the tuple approach?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes I meant the difference between the type (in the current implementation) and the tuple (your suggestion), sorry.


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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The lint you linked was about not having a default implementation. And the current code also aligns with all the other places in the code already using it the same way. I assume dropping the new method would mean we construct with HealthCheckRegistry::default() then? Which to me also also sounds a bit worse in the readability direction ("what are the defaults here?").

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.

I assume dropping the new method would mean we construct with HealthCheckRegistry::default() then?

Yes, exactly.

Which to me also also sounds a bit worse in the readability direction ("what are the defaults here?").

I mean it is literally the definition of the Default impl to say "I don't care about the exact defaults, I just want a new default instance of this type". If you care about individual values, then the new function is usually the way to go.

Also, what is currently the difference between calling new or default? They are literally running the same piece of code. Users currently already don't know what defaults are being used when calling new.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I guess new to me reads more like an "empty" registry, while default might be already prefilled with something. Which theoretically could change later and the user of that function wouldn't notice it. But maybe that's too philosophical 😄 I can change it, just making sure it's fine to divert from the current code convention.

/// 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, wasn't completely sure where to put it. I decided it based on the possibility of moving the whole health checks into a separate health server unrelated to the webhook server in the future, since there might be other things later on like "migration done" check or "external server reachable" check (just some random examples) which have nothing to do with our webhook here.

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.

This goes back to the thing I mentioned in a DM. We most likely want to widen the scope of this crate to not only be about webhooks, but about all HTTP related stuff.

It already is kind of built to be extensible anyway, just not to fully support all arbitrary HTTP handlers yet.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Oh I see! Yeah, if that is the plan for that crate then it sounds like the right place for it, I will move it.

) -> 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