Skip to content

Commit ec98bce

Browse files
feat(webhook): Add graceful shutdown (#1144)
* feat(webhook): Add graceful shutdown * test(webhook): Update doc tests * chore: Add changelog entry * chore(webhhok): Adjust changelog * chore: Adjust dev comment about try_join! Co-authored-by: Malte Sander <malte.sander.it@gmail.com> * chore: Update comment about shutdown signal --------- Co-authored-by: Malte Sander <malte.sander.it@gmail.com>
1 parent 99ceb14 commit ec98bce

5 files changed

Lines changed: 78 additions & 71 deletions

File tree

crates/stackable-webhook/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- BREAKING: Add support to gracefully shutdown `WebhookServer` and `TlsServer`.
10+
Both `WebhookServer::run` and `TlsServer::run` now require passing a shutdown signal, which is any
11+
`Future<Output = ()>` ([#1144]).
12+
13+
[#1144]: https://github.com/stackabletech/operator-rs/pull/1144
14+
715
## [0.8.1] - 2026-01-07
816

917
### Fixed

crates/stackable-webhook/src/lib.rs

Lines changed: 21 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,11 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1515

1616
use ::x509_cert::Certificate;
1717
use axum::{Router, routing::get};
18-
use futures_util::{FutureExt as _, TryFutureExt, select};
18+
use futures_util::TryFutureExt;
1919
use k8s_openapi::ByteString;
2020
use snafu::{ResultExt, Snafu};
2121
use stackable_telemetry::AxumTraceLayer;
22-
use tokio::{
23-
signal::unix::{SignalKind, signal},
24-
sync::mpsc,
25-
try_join,
26-
};
22+
use tokio::{sync::mpsc, try_join};
2723
use tower::ServiceBuilder;
2824
use webhooks::{Webhook, WebhookError};
2925
use x509_cert::der::{EncodePem, pem::LineEnding};
@@ -59,6 +55,7 @@ pub enum WebhookServerError {
5955
///
6056
/// ```
6157
/// use stackable_webhook::{WebhookServer, WebhookServerOptions, webhooks::Webhook};
58+
/// use tokio::time::{Duration, sleep};
6259
///
6360
/// # async fn docs() {
6461
/// let mut webhooks: Vec<Box<dyn Webhook>> = vec![];
@@ -69,8 +66,9 @@ pub enum WebhookServerError {
6966
/// webhook_service_name: "my-operator".to_owned(),
7067
/// };
7168
/// let webhook_server = WebhookServer::new(webhooks, webhook_options).await.unwrap();
69+
/// let shutdown_signal = sleep(Duration::from_millis(100));
7270
///
73-
/// webhook_server.run().await.unwrap();
71+
/// webhook_server.run(shutdown_signal).await.unwrap();
7472
/// # }
7573
/// ```
7674
pub struct WebhookServer {
@@ -154,52 +152,16 @@ impl WebhookServer {
154152
})
155153
}
156154

157-
/// Runs the Webhook server and sets up signal handlers for shutting down.
155+
/// Runs the [`WebhookServer`] and handles underlying certificate rotations of the [`TlsServer`].
158156
///
159-
/// This does not implement graceful shutdown of the underlying server. Additionally, the server
160-
/// is never started in cases where no [`Webhook`] is registered. Callers of this function need
161-
/// to ensure to choose the correct joining mechanism for their use-case to for example avoid
162-
/// unexpected shutdowns of the whole Kubernetes controller.
163-
pub async fn run(self) -> Result<()> {
164-
let future_server = self.run_server();
165-
let future_signal = async {
166-
let mut sigint = signal(SignalKind::interrupt()).expect("create SIGINT listener");
167-
let mut sigterm = signal(SignalKind::terminate()).expect("create SIGTERM listener");
168-
169-
tracing::debug!("created unix signal handlers");
170-
171-
select! {
172-
signal = sigint.recv().fuse() => {
173-
if signal.is_some() {
174-
tracing::debug!( "received SIGINT");
175-
}
176-
},
177-
signal = sigterm.recv().fuse() => {
178-
if signal.is_some() {
179-
tracing::debug!( "received SIGTERM");
180-
}
181-
},
182-
};
183-
};
184-
185-
// select requires Future + Unpin
186-
tokio::pin!(future_server);
187-
tokio::pin!(future_signal);
188-
189-
tokio::select! {
190-
res = &mut future_server => {
191-
// If the server future errors, propagate the error
192-
res?;
193-
}
194-
_ = &mut future_signal => {
195-
tracing::info!("shutdown signal received, stopping webhook server");
196-
}
197-
}
198-
199-
Ok(())
200-
}
201-
202-
async fn run_server(self) -> Result<()> {
157+
/// It should be noted that the server is never started in cases where no [`Webhook`] is
158+
/// registered. Callers of this function need to ensure to choose the correct joining mechanism
159+
/// for their use-case to for example avoid unexpected shutdowns of the whole Kubernetes
160+
/// controller.
161+
pub async fn run<F>(self, shutdown_signal: F) -> Result<()>
162+
where
163+
F: Future<Output = ()>,
164+
{
203165
tracing::debug!("run webhook server");
204166

205167
let Self {
@@ -217,10 +179,14 @@ impl WebhookServer {
217179
}
218180

219181
let tls_server = tls_server
220-
.run()
182+
.run(shutdown_signal)
221183
.map_err(|err| WebhookServerError::RunTlsServer { source: err });
222184

223185
let cert_update_loop = async {
186+
// Once the shutdown signal is triggered, the TlsServer above should be dropped as the
187+
// run associated function consumes self. This in turn means that when the receiver is
188+
// polled, it will return `Ok(Ready(None))`, which will cause this while loop to break
189+
// and the future to complete.
224190
while let Some(cert) = cert_rx.recv().await {
225191
// The caBundle needs to be provided as a base64-encoded PEM envelope.
226192
let ca_bundle = cert
@@ -243,6 +209,8 @@ impl WebhookServer {
243209
Ok(())
244210
};
245211

212+
// This either returns if one of the two futures completes with Err(_) or when both complete
213+
// with Ok(_). Both futures complete with Ok(_) when a shutdown signal is received.
246214
try_join!(cert_update_loop, tls_server).map(|_| ())
247215
}
248216
}

crates/stackable-webhook/src/tls/mod.rs

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ use axum::{
77
extract::{ConnectInfo, Request},
88
middleware::AddExtension,
99
};
10+
use futures_util::FutureExt as _;
1011
use hyper::{body::Incoming, service::service_fn};
1112
use hyper_util::rt::{TokioExecutor, TokioIo};
12-
use opentelemetry::trace::{FutureExt, SpanKind};
13+
use opentelemetry::trace::{FutureExt as _, SpanKind};
1314
use opentelemetry_semantic_conventions as semconv;
1415
use snafu::{OptionExt, ResultExt, Snafu};
1516
use stackable_shared::time::Duration;
@@ -138,17 +139,27 @@ impl TlsServer {
138139
/// router.
139140
///
140141
/// It also starts a background task to rotate the certificate as needed.
141-
pub async fn run(self) -> Result<()> {
142+
///
143+
/// The `shutdown_signal` can be used to notify the [`TlsServer`] to
144+
/// gracefully shutdown.
145+
pub async fn run<F>(self, shutdown_signal: F) -> Result<()>
146+
where
147+
F: Future<Output = ()>,
148+
{
149+
let Self {
150+
cert_resolver,
151+
socket_addr,
152+
config,
153+
router,
154+
} = self;
155+
142156
let start = tokio::time::Instant::now() + *WEBHOOK_CERTIFICATE_ROTATION_INTERVAL;
143157
let mut interval = tokio::time::interval_at(start, *WEBHOOK_CERTIFICATE_ROTATION_INTERVAL);
144158

145-
let tls_acceptor = TlsAcceptor::from(Arc::new(self.config));
146-
let tcp_listener =
147-
TcpListener::bind(self.socket_addr)
148-
.await
149-
.context(BindTcpListenerSnafu {
150-
socket_addr: self.socket_addr,
151-
})?;
159+
let tls_acceptor = TlsAcceptor::from(Arc::new(config));
160+
let tcp_listener = TcpListener::bind(socket_addr)
161+
.await
162+
.context(BindTcpListenerSnafu { socket_addr })?;
152163

153164
// To be able to extract the connect info from incoming requests, it is
154165
// required to turn the router into a Tower service which is capable of
@@ -161,24 +172,36 @@ impl TlsServer {
161172
// - https://github.com/tokio-rs/axum/discussions/2397
162173
// - https://github.com/tokio-rs/axum/blob/b02ce307371a973039018a13fa012af14775948c/examples/serve-with-hyper/src/main.rs#L98
163174

164-
let mut router = self
165-
.router
166-
.into_make_service_with_connect_info::<SocketAddr>();
175+
let mut router = router.into_make_service_with_connect_info::<SocketAddr>();
176+
177+
// Fuse the future so that it only yields `Poll::Ready` once. The future
178+
// additionally needs to be pinned to be able to be used in the select!
179+
// macro below.
180+
let shutdown_signal = shutdown_signal.fuse();
181+
tokio::pin!(shutdown_signal);
167182

168183
loop {
169184
let tls_acceptor = tls_acceptor.clone();
170185

171186
// Wait for either a new TCP connection or the certificate rotation interval tick
172187
tokio::select! {
173-
// We opt for a biased execution of arms to make sure we always check if the
174-
// certificate needs rotation based on the interval. This ensures, we always use
175-
// a valid certificate for the TLS connection.
188+
// We opt for a biased execution of arms to make sure we always check if a
189+
// shutdown signal was received or the certificate needs rotation based on the
190+
// interval. This ensures, we always use a valid certificate for the TLS connection.
176191
biased;
177192

193+
// Once a shutdown signal is received (this future becomes `Poll::Ready`), break out
194+
// of the main loop which cancels the certification rotation interval and stops
195+
// accepting new TCP connections.
196+
_ = &mut shutdown_signal => {
197+
tracing::trace!("received shutdown signal");
198+
break;
199+
}
200+
178201
// This is cancellation-safe. If this branch is cancelled, the tick is NOT consumed.
179202
// As such, we will not miss rotating the certificate.
180203
_ = interval.tick() => {
181-
self.cert_resolver
204+
cert_resolver
182205
.rotate_certificate()
183206
.await
184207
.context(RotateCertificateSnafu)?
@@ -210,6 +233,8 @@ impl TlsServer {
210233
}
211234
};
212235
}
236+
237+
Ok(())
213238
}
214239

215240
async fn handle_request(

crates/stackable-webhook/src/webhooks/conversion_webhook.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ pub enum ConversionWebhookError {
5151
/// WebhookServer,
5252
/// webhooks::{ConversionWebhook, ConversionWebhookOptions},
5353
/// };
54+
/// use tokio::time::{Duration, sleep};
5455
///
5556
/// # async fn docs() {
5657
/// // The Kubernetes client
@@ -75,7 +76,9 @@ pub enum ConversionWebhookError {
7576
/// let webhook_server = WebhookServer::new(vec![Box::new(conversion_webhook)], webhook_options)
7677
/// .await
7778
/// .unwrap();
78-
/// webhook_server.run().await.unwrap();
79+
/// let shutdown_signal = sleep(Duration::from_millis(100));
80+
///
81+
/// webhook_server.run(shutdown_signal).await.unwrap();
7982
/// # }
8083
/// ```
8184
pub struct ConversionWebhook<H> {

crates/stackable-webhook/src/webhooks/mutating_webhook.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ pub enum MutatingWebhookError {
5151
/// WebhookServer,
5252
/// webhooks::{MutatingWebhook, MutatingWebhookOptions},
5353
/// };
54+
/// use tokio::time::{Duration, sleep};
5455
///
5556
/// # async fn docs() {
5657
/// // The Kubernetes client
@@ -76,7 +77,9 @@ pub enum MutatingWebhookError {
7677
/// let webhook_server = WebhookServer::new(vec![mutating_webhook], webhook_options)
7778
/// .await
7879
/// .unwrap();
79-
/// webhook_server.run().await.unwrap();
80+
/// let shutdown_signal = sleep(Duration::from_millis(100));
81+
///
82+
/// webhook_server.run(shutdown_signal).await.unwrap();
8083
/// # }
8184
///
8285
/// fn get_mutating_webhook_configuration() -> MutatingWebhookConfiguration {

0 commit comments

Comments
 (0)