From 03f824e7789ecfaca6857e719ede7e8baed534c4 Mon Sep 17 00:00:00 2001 From: James Petersen Date: Tue, 10 Dec 2024 11:04:11 -0700 Subject: [PATCH] feat: refactor server code Signed-off-by: James Petersen --- Cargo.lock | 33 ++-- Cargo.toml | 3 +- Dockerfile | 4 +- charts/protect-webhook/Chart.yaml | 4 +- .../protect-webhook/templates/deployment.yaml | 2 +- charts/protect-webhook/templates/webhook.yaml | 1 + charts/protect-webhook/values.yaml | 20 +-- src/main.rs | 106 +---------- src/server/healthz.rs | 9 + src/server/livez.rs | 9 + src/server/mod.rs | 34 ++++ src/server/mutate.rs | 166 ++++++++++++++++++ 12 files changed, 251 insertions(+), 140 deletions(-) create mode 100644 src/server/healthz.rs create mode 100644 src/server/livez.rs create mode 100644 src/server/mod.rs create mode 100644 src/server/mutate.rs diff --git a/Cargo.lock b/Cargo.lock index 471477b..08582c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,9 +137,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" @@ -804,6 +804,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "protect-webhook" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bytes", + "env_logger", + "log", + "serde", + "serde_json", + "tokio", + "warp", +] + [[package]] name = "quote" version = "1.0.37" @@ -1352,20 +1367,6 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" -[[package]] -name = "webhook" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64 0.22.1", - "env_logger", - "log", - "serde", - "serde_json", - "tokio", - "warp", -] - [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index f7a1a55..db8e640 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,12 @@ [package] -name = "webhook" +name = "protect-webhook" version = "0.1.0" edition = "2021" [dependencies] anyhow = "1.0.94" base64 = "0.22.1" +bytes = "1.9.0" env_logger = "0.11.5" log = "0.4.22" serde = { version = "1.0.215", features = ["derive"] } diff --git a/Dockerfile b/Dockerfile index 9438435..37ec125 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,8 @@ ENV TARGET_LIBC=musl TARGET_VENDOR=unknown WORKDIR /usr/src/app COPY . . -RUN cargo build --release --bin webhook -RUN mv ./target/release/webhook /usr/sbin/protect-webhook +RUN cargo build --release --bin protect-webhook +RUN mv ./target/release/protect-webhook /usr/sbin/protect-webhook FROM scratch ENTRYPOINT ["/usr/sbin/protect-webhook"] diff --git a/charts/protect-webhook/Chart.yaml b/charts/protect-webhook/Chart.yaml index 19997ed..ee66aec 100644 --- a/charts/protect-webhook/Chart.yaml +++ b/charts/protect-webhook/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: protect-webhook -description: A Helm chart for Kubernetes +description: A Helm chart for the Edera Protect Mutating Webhook # A chart can be either an 'application' or a 'library' chart. # @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "1.16.0" +appVersion: "0.1.0" diff --git a/charts/protect-webhook/templates/deployment.yaml b/charts/protect-webhook/templates/deployment.yaml index db1cb12..57fac1e 100644 --- a/charts/protect-webhook/templates/deployment.yaml +++ b/charts/protect-webhook/templates/deployment.yaml @@ -39,7 +39,7 @@ spec: protocol: TCP env: - name: RUST_LOG - value: debug + value: {{ .Values.logLevel | default "info" }} livenessProbe: {{- toYaml .Values.livenessProbe | nindent 12 }} readinessProbe: diff --git a/charts/protect-webhook/templates/webhook.yaml b/charts/protect-webhook/templates/webhook.yaml index 7aa1132..763b75b 100644 --- a/charts/protect-webhook/templates/webhook.yaml +++ b/charts/protect-webhook/templates/webhook.yaml @@ -23,6 +23,7 @@ webhooks: {{- toYaml . | nindent 8 }} {{- end }} {{- end }} + failurePolicy: {{ .Values.webhook.failurePolicy | default "Ignore" }} {{- end }} rules: - operations: ["CREATE"] diff --git a/charts/protect-webhook/values.yaml b/charts/protect-webhook/values.yaml index 5fcbb99..3dfa6dd 100644 --- a/charts/protect-webhook/values.yaml +++ b/charts/protect-webhook/values.yaml @@ -3,11 +3,11 @@ replicaCount: 1 image: - repository: ttl.sh/beet/protect-webhook + repository: ghcr.io/edera-dev/protect-webhook # This sets the pull policy for images. pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. - tag: "latest@sha256:c7c690744c66213d0792c4c6bcea99eb4c49da31f81ba18c9b774f7c0987a85e" + tag: "latest" # This is for the secretes for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ imagePullSecrets: [] @@ -15,8 +15,7 @@ imagePullSecrets: [] nameOverride: "" fullnameOverride: "" -podAnnotations: - cert-manager.io/inject-ca-from: edera-system/webhook-ca +podAnnotations: {} podLabels: {} @@ -42,16 +41,10 @@ readinessProbe: port: 8443 # Additional volumes on the output Deployment definition. -volumes: - - name: webhook-tls - secret: - secretName: webhook-server-tls +volumes: [] # Additional volumeMounts on the output Deployment definition. -volumeMounts: - - name: webhook-tls - mountPath: /certs - readOnly: true +volumeMounts: [] nodeSelector: {} @@ -61,6 +54,3 @@ affinity: {} webhook: serviceNamespace: edera-system - objectSelector: - matchLabels: - actions-ephemeral-runner: "true" diff --git a/src/main.rs b/src/main.rs index ed25b06..6596fb3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,109 +1,9 @@ use anyhow::Result; -use base64::prelude::*; -use log::{debug, info}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::env; -use warp::Filter; -const RUNTIME_CLASS: &str = "edera"; - -#[derive(Deserialize, Debug, Clone)] -struct AdmissionReview { - request: Option, -} - -#[derive(Deserialize, Debug, Clone)] -struct AdmissionRequest { - uid: String, -} - -#[derive(Serialize, Debug)] -#[serde(rename_all = "camelCase")] -struct AdmissionReviewResponse { - api_version: String, - kind: String, - response: Option, -} - -#[derive(Serialize, Debug)] -#[serde(rename_all = "camelCase")] -struct Response { - uid: String, - allowed: bool, - patch_type: Option, - patch: Option, -} +mod server; #[tokio::main] async fn main() -> Result<()> { - env_logger::init(); - - let certs_dir = env::var("WEBHOOK_CERTS_DIR").unwrap_or("/certs".to_string()); - let crt_file = env::var("WEBHOOK_CRT_FILE").unwrap_or("tls.crt".to_string()); - let key_file = env::var("WEBHOOK_KEY_FILE").unwrap_or("tls.key".to_string()); - info!("configured certs directory to: {}", certs_dir); - - // TODO: Make healthz and livez listen on http rather than https if they need to do more - let routes = routes(); - - info!("listening on 8443"); - warp::serve(routes) - .tls() - .cert_path(format!("{}/{}", certs_dir, crt_file)) - .key_path(format!("{}/{}", certs_dir, key_file)) - .run(([0, 0, 0, 0], 8443)) - .await; - - Ok(()) -} - -fn routes() -> impl Filter + Clone { - mutate().or(livez()).or(healthz()) -} - -fn healthz() -> impl Filter + Clone { - warp::get().and(warp::path("healthz")).map(|| { - debug!("GET /healthz"); - warp::reply() - }) -} - -fn livez() -> impl Filter + Clone { - warp::get().and(warp::path("livez")).map(|| { - debug!("GET /livez"); - warp::reply() - }) -} - -fn mutate() -> impl Filter + Clone { - warp::post() - .and(warp::path("mutate")) - .and(warp::body::json()) - .map(|admission_review: AdmissionReview| { - let response = mutate_internal(admission_review).unwrap(); - info!("mutating {:?}", response); - warp::reply::json(&response) - }) -} - -fn mutate_internal(review: AdmissionReview) -> Result { - let request = review.request.clone().unwrap(); - let patch = json!([{ - "op": "add", - "path": "/spec/runtimeClassName", - "value": RUNTIME_CLASS - }]); - let patch_base64 = BASE64_STANDARD.encode(serde_json::to_string(&patch).unwrap()); - - Ok(AdmissionReviewResponse { - api_version: "admission.k8s.io/v1".to_string(), - kind: "AdmissionReview".to_string(), - response: Some(Response { - uid: request.uid, - allowed: true, - patch_type: Some("JSONPatch".to_string()), - patch: Some(patch_base64), - }), - }) + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + server::start().await } diff --git a/src/server/healthz.rs b/src/server/healthz.rs new file mode 100644 index 0000000..9ee87f7 --- /dev/null +++ b/src/server/healthz.rs @@ -0,0 +1,9 @@ +use log::debug; +use warp::Filter; + +pub fn handler() -> impl Filter + Clone { + warp::get().and(warp::path("healthz")).map(|| { + debug!("GET /healthz"); + warp::reply() + }) +} diff --git a/src/server/livez.rs b/src/server/livez.rs new file mode 100644 index 0000000..eca9c8b --- /dev/null +++ b/src/server/livez.rs @@ -0,0 +1,9 @@ +use log::debug; +use warp::Filter; + +pub fn handler() -> impl Filter + Clone { + warp::get().and(warp::path("livez")).map(|| { + debug!("GET /livez"); + warp::reply() + }) +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..594acef --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,34 @@ +use anyhow::Result; +use log::info; +use std::env; +use warp::Filter; + +mod healthz; +mod livez; +mod mutate; + +fn routes() -> impl Filter + Clone { + mutate::handler() + .or(livez::handler()) + .or(healthz::handler()) +} + +pub async fn start() -> Result<()> { + let certs_dir = env::var("WEBHOOK_CERTS_DIR").unwrap_or("/certs".to_string()); + let crt_file = env::var("WEBHOOK_CRT_FILE").unwrap_or("tls.crt".to_string()); + let key_file = env::var("WEBHOOK_KEY_FILE").unwrap_or("tls.key".to_string()); + info!("configured certs directory to: {}", certs_dir); + + // TODO: Make healthz and livez listen on http rather than https if they need to do more + let routes = routes(); + + info!("listening on 8443"); + warp::serve(routes) + .tls() + .cert_path(format!("{}/{}", certs_dir, crt_file)) + .key_path(format!("{}/{}", certs_dir, key_file)) + .run(([0, 0, 0, 0], 8443)) + .await; + + Ok(()) +} diff --git a/src/server/mutate.rs b/src/server/mutate.rs new file mode 100644 index 0000000..b298e80 --- /dev/null +++ b/src/server/mutate.rs @@ -0,0 +1,166 @@ +use anyhow::Result; +use base64::prelude::*; +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use warp::Filter; + +#[derive(Deserialize, Debug, Clone)] +struct AdmissionReview { + request: Option, +} + +#[derive(Deserialize, Debug, Clone)] +struct AdmissionRequest { + uid: String, + name: String, + namespace: String, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +struct AdmissionReviewResponse { + api_version: String, + kind: String, + response: Option, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Response { + uid: String, + allowed: bool, + patch_type: Option, + patch: Option, +} + +pub fn handler() -> impl Filter + Clone { + let base_path = warp::path!("mutate"); + + base_path + .and(warp::post()) + .and(warp::body::json()) + .and_then(mutate_internal) +} + +async fn mutate_internal(review: AdmissionReview) -> Result { + let Some(request) = review.request.clone() else { + error!("failed to decode request"); + let error_response = json!({ + "error": "Invalid input", + "code": 400 + }); + return Ok(warp::reply::with_status( + warp::reply::json(&error_response), + warp::http::StatusCode::BAD_REQUEST, + )); + }; + + let patch = r#"[{ + "op": "add", + "path": "/spec/runtimeClassName", + "value": "edera" + }]"#; + let patch = BASE64_STANDARD.encode(patch); + + let response = AdmissionReviewResponse { + api_version: "admission.k8s.io/v1".to_string(), + kind: "AdmissionReview".to_string(), + response: Some(Response { + uid: request.uid, + allowed: true, + patch_type: Some("JSONPatch".to_string()), + patch: Some(patch), + }), + }; + + info!("mutating {}/{}", request.namespace, request.name); + debug!("payload {:?}", response); + Ok(warp::reply::with_status( + warp::reply::json(&response), + warp::http::StatusCode::OK, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + use warp::test::request; + use warp::Reply; + + #[tokio::test] + async fn test_mutate_function() { + let admission_review = AdmissionReview { + request: Some(AdmissionRequest { + uid: "test-uid".to_string(), + }), + }; + + let response = mutate_internal(admission_review).await.unwrap(); + let body = warp::hyper::body::to_bytes(response.into_response().into_body()) + .await + .unwrap(); + let result: AdmissionReviewResponse = serde_json::from_slice(&body).unwrap(); + + // Check the response structure + assert_eq!(result.api_version, "admission.k8s.io/v1"); + assert_eq!(result.kind, "AdmissionReview"); + assert!(result.response.is_some()); + + let response = result.response.unwrap(); + assert_eq!(response.uid, "test-uid"); + assert!(response.allowed); + assert_eq!(response.patch_type, Some("JSONPatch".to_string())); + + // Decode and verify the patch + let patch_base64 = response.patch.unwrap(); + let patch_json: Value = serde_json::from_str( + &String::from_utf8(BASE64_STANDARD.decode(patch_base64).unwrap()).unwrap(), + ) + .unwrap(); + + let expected_patch = json!([{ + "op": "add", + "path": "/spec/runtimeClassName", + "value": "edera", + }]); + + assert_eq!(patch_json, expected_patch); + } + + #[tokio::test] + async fn test_mutate_endpoint() { + let filter = warp::post() + .and(warp::path("mutate")) + .and(warp::body::json()) + .and_then(mutate_internal); + + let admission_review = json!({ + "request": { + "uid": "test-uid" + } + }); + + let response = request() + .method("POST") + .path("/mutate") + .json(&admission_review) + .reply(&filter) + .await; + + assert_eq!(response.status(), 200); + + let response_body: AdmissionReviewResponse = + serde_json::from_slice(response.body()).unwrap(); + + assert_eq!(response_body.api_version, "admission.k8s.io/v1"); + assert_eq!(response_body.kind, "AdmissionReview"); + assert!(response_body.response.is_some()); + + let response = response_body.response.unwrap(); + assert_eq!(response.uid, "test-uid"); + assert!(response.allowed); + assert_eq!(response.patch_type, Some("JSONPatch".to_string())); + } +}