Skip to content

Commit 91fd3cb

Browse files
committed
feat!: Add support for mutating webhooks
1 parent d74c0ce commit 91fd3cb

11 files changed

Lines changed: 487 additions & 862 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ repository = "https://github.com/stackabletech/operator-rs"
1111
[workspace.dependencies]
1212
product-config = { git = "https://github.com/stackabletech/product-config.git", tag = "0.8.0" }
1313

14-
arc-swap = "1.7"
14+
arc-swap = "1.7.0"
15+
async-trait = "0.1.89"
1516
axum = { version = "0.8.1", features = ["http2"] }
1617
chrono = { version = "0.4.38", default-features = false }
1718
clap = { version = "4.5.17", features = ["derive", "cargo", "env"] }
@@ -38,7 +39,7 @@ k8s-openapi = { version = "0.26.0", default-features = false, features = ["schem
3839
# We use rustls instead of openssl for easier portability, e.g. so that we can build stackablectl without the need to vendor (build from source) openssl
3940
# We use ring instead of aws-lc-rs, as this currently fails to build in "make run-dev"
4041
# We pin the kube version, as we use a patch for 2.0.1 below
41-
kube = { version = "=2.0.1", default-features = false, features = ["client", "jsonpatch", "runtime", "derive", "rustls-tls", "ring"] }
42+
kube = { version = "=2.0.1", default-features = false, features = ["client", "jsonpatch", "runtime", "derive", "admission", "rustls-tls", "ring"] }
4243
opentelemetry = "0.31.0"
4344
opentelemetry_sdk = { version = "0.31.0", features = ["rt-tokio"] }
4445
opentelemetry-appender-tracing = "0.31.0"

crates/stackable-webhook/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ stackable-shared = { path = "../stackable-shared" }
1212
stackable-telemetry = { path = "../stackable-telemetry" }
1313

1414
arc-swap.workspace = true
15+
async-trait.workspace = true
1516
axum.workspace = true
1617
futures-util.workspace = true
1718
hyper-util.workspace = true
@@ -21,6 +22,7 @@ kube.workspace = true
2122
opentelemetry.workspace = true
2223
opentelemetry-semantic-conventions.workspace = true
2324
rand.workspace = true
25+
serde.workspace = true
2426
serde_json.workspace = true
2527
snafu.workspace = true
2628
tokio-rustls.workspace = true
Lines changed: 91 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,25 @@
1-
//! Utility types and functions to easily create ready-to-use webhook servers
2-
//! which can handle different tasks, for example CRD conversions. All webhook
3-
//! servers use HTTPS by default. This library is fully compatible with the
4-
//! [`tracing`] crate and emits debug level tracing data.
5-
//!
6-
//! Most users will only use the top-level exported generic [`WebhookServer`]
7-
//! which enables complete control over the [Router] which handles registering
8-
//! routes and their handler functions.
9-
//!
10-
//! ```
11-
//! use stackable_webhook::{WebhookServer, WebhookOptions};
12-
//! use axum::Router;
13-
//!
14-
//! # async fn test() {
15-
//! let router = Router::new();
16-
//! let (server, cert_rx) = WebhookServer::new(router, WebhookOptions::default())
17-
//! .await
18-
//! .expect("failed to create WebhookServer");
19-
//! # }
20-
//! ```
21-
//!
22-
//! For some usages, complete end-to-end [`WebhookServer`] implementations
23-
//! exist. One such implementation is the [`ConversionWebhookServer`][1].
24-
//!
25-
//! This library additionally also exposes lower-level structs and functions to
26-
//! enable complete control over these details if needed.
27-
//!
28-
//! [1]: crate::servers::ConversionWebhookServer
291
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
302

313
use ::x509_cert::Certificate;
324
use axum::{Router, routing::get};
33-
use futures_util::{FutureExt as _, pin_mut, select};
5+
use futures_util::{FutureExt as _, TryFutureExt, select};
6+
use k8s_openapi::ByteString;
7+
use servers::{WebhookServerImplementation, WebhookServerImplementationError};
348
use snafu::{ResultExt, Snafu};
359
use stackable_telemetry::AxumTraceLayer;
3610
use tokio::{
3711
signal::unix::{SignalKind, signal},
3812
sync::mpsc,
13+
try_join,
3914
};
4015
use tower::ServiceBuilder;
16+
use x509_cert::der::{EncodePem, pem::LineEnding};
4117

42-
// Selected re-exports
43-
pub use crate::options::WebhookOptions;
4418
use crate::tls::TlsServer;
4519

46-
pub mod maintainer;
47-
pub mod options;
4820
pub mod servers;
4921
pub mod tls;
5022

51-
/// A generic webhook handler receiving a request and sending back a response.
52-
///
53-
/// This trait is not intended to be implemented by external crates and this
54-
/// library provides various ready-to-use implementations for it. One such an
55-
/// implementation is part of the [`ConversionWebhookServer`][1].
56-
///
57-
/// [1]: crate::servers::ConversionWebhookServer
58-
pub trait WebhookHandler<Req, Res> {
59-
fn call(self, req: Req) -> Res;
60-
}
61-
6223
/// A result type alias with the [`WebhookError`] type as the default error type.
6324
pub type Result<T, E = WebhookError> = std::result::Result<T, E>;
6425

@@ -69,24 +30,38 @@ pub enum WebhookError {
6930

7031
#[snafu(display("failed to run TLS server"))]
7132
RunTlsServer { source: tls::TlsServerError },
33+
34+
#[snafu(display("failed to update certificate"))]
35+
UpdateCertificate {
36+
source: WebhookServerImplementationError,
37+
},
38+
39+
#[snafu(display("failed to encode CA certificate as PEM format"))]
40+
EncodeCertificateAuthorityAsPem { source: x509_cert::der::Error },
7241
}
7342

74-
/// A ready-to-use webhook server.
75-
///
76-
/// This server abstracts away lower-level details like TLS termination
77-
/// and other various configurations, validations or middlewares. The routes
78-
/// and their handlers are completely customizable by bringing your own
79-
/// Axum [`Router`].
80-
///
81-
/// For complete end-to-end implementations, see [`ConversionWebhookServer`][1].
82-
///
83-
/// [1]: crate::servers::ConversionWebhookServer
8443
pub struct WebhookServer {
44+
options: WebhookOptions,
45+
webhooks: Vec<Box<dyn WebhookServerImplementation>>,
8546
tls_server: TlsServer,
47+
cert_rx: mpsc::Receiver<Certificate>,
48+
}
49+
50+
#[derive(Clone, Debug)]
51+
pub struct WebhookOptions {
52+
/// The default HTTPS socket address the [`TcpListener`][tokio::net::TcpListener]
53+
/// binds to.
54+
pub socket_addr: SocketAddr,
55+
56+
/// The namespace the operator/webhook is running in.
57+
pub operator_namespace: String,
58+
59+
/// The name of the Kubernetes service which points to the operator/webhook.
60+
pub operator_service_name: String,
8661
}
8762

8863
impl WebhookServer {
89-
/// The default HTTPS port `8443`
64+
/// The default HTTPS port
9065
pub const DEFAULT_HTTPS_PORT: u16 = 8443;
9166
/// The default IP address [`Ipv4Addr::UNSPECIFIED`] (`0.0.0.0`) the webhook server binds to,
9267
/// which represents binding on all network addresses.
@@ -99,52 +74,10 @@ impl WebhookServer {
9974
pub const DEFAULT_SOCKET_ADDRESS: SocketAddr =
10075
SocketAddr::new(Self::DEFAULT_LISTEN_ADDRESS, Self::DEFAULT_HTTPS_PORT);
10176

102-
/// Creates a new ready-to-use webhook server.
103-
///
104-
/// The server listens on `socket_addr` which is provided via the [`WebhookOptions`] and handles
105-
/// routing based on the provided Axum `router`. Most of the time it is sufficient to use
106-
/// [`WebhookOptions::default()`]. See the documentation for [`WebhookOptions`] for more details
107-
/// on the default values.
108-
///
109-
/// To start the server, use the [`WebhookServer::run()`] function. This will
110-
/// run the server using the Tokio runtime until it is terminated.
111-
///
112-
/// ### Basic Example
113-
///
114-
/// ```
115-
/// use stackable_webhook::{WebhookServer, WebhookOptions};
116-
/// use axum::Router;
117-
///
118-
/// # async fn test() {
119-
/// let router = Router::new();
120-
/// let (server, cert_rx) = WebhookServer::new(router, WebhookOptions::default())
121-
/// .await
122-
/// .expect("failed to create WebhookServer");
123-
/// # }
124-
/// ```
125-
///
126-
/// ### Example with Custom Options
127-
///
128-
/// ```
129-
/// use stackable_webhook::{WebhookServer, WebhookOptions};
130-
/// use axum::Router;
131-
///
132-
/// # async fn test() {
133-
/// let options = WebhookOptions::builder()
134-
/// .bind_address([127, 0, 0, 1], 8080)
135-
/// .add_subject_alterative_dns_name("my-san-entry")
136-
/// .build();
137-
///
138-
/// let router = Router::new();
139-
/// let (server, cert_rx) = WebhookServer::new(router, options)
140-
/// .await
141-
/// .expect("failed to create WebhookServer");
142-
/// # }
143-
/// ```
14477
pub async fn new(
145-
router: Router,
14678
options: WebhookOptions,
147-
) -> Result<(Self, mpsc::Receiver<Certificate>)> {
79+
webhooks: Vec<Box<dyn WebhookServerImplementation>>,
80+
) -> Result<Self> {
14881
tracing::trace!("create new webhook server");
14982

15083
// TODO (@Techassi): Make opt-in configurable from the outside
@@ -161,17 +94,26 @@ impl WebhookServer {
16194

16295
// Create the root router and merge the provided router into it.
16396
tracing::debug!("create core router and merge provided router");
164-
let router = router
97+
let mut router = Router::new()
16598
.layer(service_builder)
16699
// The health route is below the AxumTraceLayer so as not to be instrumented
167100
.route("/health", get(|| async { "ok" }));
168101

102+
for webhook in webhooks.iter() {
103+
router = webhook.register_routes(router);
104+
}
105+
169106
tracing::debug!("create TLS server");
170-
let (tls_server, cert_rx) = TlsServer::new(router, options)
107+
let (tls_server, cert_rx) = TlsServer::new(router, options.clone())
171108
.await
172109
.context(CreateTlsServerSnafu)?;
173110

174-
Ok((Self { tls_server }, cert_rx))
111+
Ok(Self {
112+
options,
113+
webhooks,
114+
tls_server,
115+
cert_rx,
116+
})
175117
}
176118

177119
/// Runs the Webhook server and sets up signal handlers for shutting down.
@@ -200,19 +142,59 @@ impl WebhookServer {
200142
};
201143

202144
// select requires Future + Unpin
203-
pin_mut!(future_server);
204-
pin_mut!(future_signal);
205-
206-
futures_util::future::select(future_server, future_signal).await;
145+
tokio::pin!(future_server);
146+
tokio::pin!(future_signal);
147+
148+
tokio::select! {
149+
res = &mut future_server => {
150+
// If the server future errors, propagate the error
151+
res?;
152+
}
153+
_ = &mut future_signal => {
154+
tracing::info!("shutdown signal received, stopping server");
155+
}
156+
}
207157

208158
Ok(())
209159
}
210160

211-
/// Runs the webhook server by creating a TCP listener and binding it to
212-
/// the specified socket address.
213161
async fn run_server(self) -> Result<()> {
214162
tracing::debug!("run webhook server");
215163

216-
self.tls_server.run().await.context(RunTlsServerSnafu)
164+
let Self {
165+
options,
166+
mut webhooks,
167+
tls_server,
168+
mut cert_rx,
169+
// initial_reconcile_tx,
170+
} = self;
171+
let tls_server = tls_server
172+
.run()
173+
.map_err(|err| WebhookError::RunTlsServer { source: err });
174+
175+
let cert_update_loop = async {
176+
loop {
177+
while let Some(cert) = cert_rx.recv().await {
178+
// The caBundle needs to be provided as a base64-encoded PEM envelope.
179+
let ca_bundle = cert
180+
.to_pem(LineEnding::LF)
181+
.context(EncodeCertificateAuthorityAsPemSnafu)?;
182+
let ca_bundle = ByteString(ca_bundle.as_bytes().to_vec());
183+
184+
for webhook in webhooks.iter_mut() {
185+
webhook
186+
.handle_certificate_rotation(&cert, &ca_bundle, &options)
187+
.await
188+
.context(UpdateCertificateSnafu)?;
189+
}
190+
}
191+
}
192+
193+
// We need to hint the return type to the compiler
194+
#[allow(unreachable_code)]
195+
Ok(())
196+
};
197+
198+
try_join!(cert_update_loop, tls_server).map(|_| ())
217199
}
218200
}

0 commit comments

Comments
 (0)