diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..30725bd4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Demeng +# +# SPDX-License-Identifier: CC0-1.0 + +name: "Rust Tests" +on: + pull_request: + branches: + - "main" +permissions: + contents: "read" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + tests: + name: "Tests" + runs-on: "ubuntu-24.04" + container: "ghcr.io/confidential-clusters/buildroot:latest" + steps: + - name: "Check out repository" + uses: actions/checkout@v5 + - name: "Install toolchain" + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + - name: "Cache build artifacts" + uses: Swatinem/rust-cache@v2 + - name: "cargo test" + run: cargo test --all-targets diff --git a/Cargo.lock b/Cargo.lock index e3f03300..e453b069 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1053,6 +1053,16 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "byteorder", + "num-traits", +] + [[package]] name = "heck" version = "0.5.0" @@ -1683,7 +1693,7 @@ dependencies = [ "thiserror 2.0.16", "tokio", "tokio-util", - "tower", + "tower 0.5.2", "tower-http", "tracing", ] @@ -2117,6 +2127,8 @@ dependencies = [ "env_logger", "futures-util", "hex", + "http 1.3.1", + "hyper 0.14.32", "json-patch", "jsonptr", "k8s-openapi", @@ -2130,6 +2142,7 @@ dependencies = [ "serde_json", "thiserror 2.0.16", "tokio", + "tower 0.4.13", ] [[package]] @@ -2304,6 +2317,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -2374,6 +2396,36 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + [[package]] name = "redox_syscall" version = "0.5.15" @@ -2502,7 +2554,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-util", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -3223,6 +3275,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "hdrhistogram", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -3255,7 +3328,7 @@ dependencies = [ "iri-string", "mime", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", diff --git a/operator/Cargo.toml b/operator/Cargo.toml index 9c7e605f..726cc0ef 100644 --- a/operator/Cargo.toml +++ b/operator/Cargo.toml @@ -30,3 +30,8 @@ serde.workspace = true serde_json.workspace = true thiserror = "2.0.16" tokio.workspace = true + +[dev-dependencies] +http = "1.1.0" +hyper = { version = "0.14", features = ["full"] } +tower = { version = "0.4.13", features = ["full"] } diff --git a/operator/src/trustee.rs b/operator/src/trustee.rs index 424f4bc2..d2f0eb65 100644 --- a/operator/src/trustee.rs +++ b/operator/src/trustee.rs @@ -421,7 +421,323 @@ pub async fn generate_kbs( let create = kbs_configs .create(&PostParams::default(), &kbs_config) .await; - info_if_exists!(create, "KbsConfig", trustee.kbs_config_name); + info_if_exists!(create, "KbsConfig", &trustee.kbs_config_name); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use compute_pcrs_lib::Pcr; + use http::{Method, Request, Response, StatusCode}; + use kube::client::Body; + use kube::error::ErrorResponse; + use rv_store::{ImagePcr, ImagePcrs}; + use std::collections::BTreeMap; + use std::convert::Infallible; + use std::sync::{Arc, Mutex}; + use tower::service_fn; + + /// A helper struct to ensure temporary files are cleaned up after a test. + /// It deletes the specified files when it goes out of scope. + struct FileCleanup { + paths: Vec, + } + + impl Drop for FileCleanup { + fn drop(&mut self) { + for path in &self.paths { + // .ok() ignores errors if the file doesn't exist, preventing panics in cleanup. + std::fs::remove_file(path).ok(); + } + } + } + + #[derive(Clone)] + struct CapturingMockClient { + requests: Arc>>>, + response_queue: Arc>>>, + } + + impl CapturingMockClient { + fn new(responses: Vec>) -> Self { + Self { + requests: Arc::new(Mutex::new(vec![])), + response_queue: Arc::new(Mutex::new(responses.into_iter().rev().collect())), + } + } + + fn into_client(self, namespace: &str) -> Client { + let svc = service_fn(move |req: Request| { + self.requests.lock().unwrap().push(req); + let response = self + .response_queue + .lock() + .unwrap() + .pop() + .unwrap_or_else(|| { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from( + "Mock client ran out of responses".to_string().into_bytes(), + )) + .unwrap() + }); + async move { Ok::<_, Infallible>(response) } + }); + Client::new(svc, namespace) + } + } + + fn ok_response(body: &T) -> Response { + Response::builder() + .status(StatusCode::OK) + .body(Body::from( + serde_json::to_string(body).unwrap().into_bytes(), + )) + .unwrap() + } + + fn created_response(body: &T) -> Response { + Response::builder() + .status(StatusCode::CREATED) + .body(Body::from( + serde_json::to_string(body).unwrap().into_bytes(), + )) + .unwrap() + } + + fn error_response(status_code: StatusCode, reason: &str, message: &str) -> Response { + let error = ErrorResponse { + status: "Failure".to_string(), + message: message.to_string(), + reason: reason.to_string(), + code: status_code.as_u16(), + }; + Response::builder() + .status(status_code) + .body(Body::from( + serde_json::to_string(&error).unwrap().into_bytes(), + )) + .unwrap() + } + + fn empty_kbs_config(name: &str) -> KbsConfig { + KbsConfig::new( + name, + KbsConfigSpec { + kbs_config_map_name: String::new(), + kbs_auth_secret_name: String::new(), + kbs_deployment_type: String::new(), + kbs_rvps_ref_values_config_map_name: String::new(), + kbs_secret_resources: vec![], + kbs_https_key_secret_name: String::new(), + kbs_https_cert_secret_name: String::new(), + kbs_resource_policy_config_map_name: String::new(), + kbs_attestation_policy_config_map_name: String::new(), + }, + ) + } + + #[test] + fn test_get_image_pcrs_success() { + let mut data = BTreeMap::new(); + let image_pcrs = ImagePcrs(BTreeMap::from([( + "cos".to_string(), + ImagePcr { + first_seen: "2023-01-01T00:00:00Z".parse().unwrap(), + pcrs: vec![Pcr { + id: 0, + value: "pcr0_val".to_string(), + parts: vec![], + }], + }, + )])); + let pcrs_json = serde_json::to_string(&image_pcrs).unwrap(); + data.insert(PCR_CONFIG_FILE.to_string(), pcrs_json); + let config_map = ConfigMap { + data: Some(data), + ..Default::default() + }; + + let result = get_image_pcrs(config_map); + assert!(result.is_ok()); + let pcrs = result.unwrap(); + assert_eq!(pcrs.0.get("cos").unwrap().pcrs[0].value, "pcr0_val"); + } + + #[test] + fn test_get_image_pcrs_no_data() { + let config_map = ConfigMap::default(); + let result = get_image_pcrs(config_map); + assert!(result.is_err()); + match result { + Ok(_) => panic!("Expected error, got Ok"), + Err(e) => assert!( + e.to_string() + .contains("Image PCRs map existed, but had no data") + ), + } + } + + #[test] + fn test_get_image_pcrs_invalid_json() { + let mut data = BTreeMap::new(); + data.insert(PCR_CONFIG_FILE.to_string(), "this is not json".to_string()); + let config_map = ConfigMap { + data: Some(data), + ..Default::default() + }; + let result = get_image_pcrs(config_map); + assert!(result.is_err()); + } + + #[test] + fn test_generate_luks_key_returns_correct_size() { + let result = generate_luks_key().unwrap(); + let jwk: serde_json::Value = serde_json::from_slice(&result).unwrap(); + let key = jwk.get("key").and_then(|v| v.as_str()).unwrap(); + assert_eq!(key.len(), 32); + } + + #[tokio::test] + async fn test_create_resource_idempotency() { + let created_cm = ConfigMap::default(); + let mock_client = CapturingMockClient::new(vec![ + created_response(&created_cm), + error_response( + StatusCode::CONFLICT, + "AlreadyExists", + "configmap already exists", + ), + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "ServerTimeout", + "internal server error", + ), + ]); + let client = mock_client.clone().into_client("test-ns"); + + let result1 = + create_reference_value_config_map(client.clone(), "test-ns", "test-rv-map").await; + assert!(result1.is_ok()); + + let result2 = + create_reference_value_config_map(client.clone(), "test-ns", "test-rv-map").await; + assert!(result2.is_ok()); + + let result3 = + create_reference_value_config_map(client.clone(), "test-ns", "test-rv-map").await; + assert!(result3.is_err()); + } + + #[tokio::test] + async fn test_generate_kbs_auth_public_key_success() { + // Ensure files are cleaned up even if the test panics. + let _cleanup = FileCleanup { + paths: vec!["privateKey".to_string(), "publicKey".to_string()], + }; + + let mock_client = CapturingMockClient::new(vec![created_response(&Secret::default())]); + let client = mock_client.clone().into_client("test-ns"); + + let result = generate_kbs_auth_public_key(client, "test-ns", "test-auth-key-secret").await; + assert!(result.is_ok()); + + let requests = mock_client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + let req = &requests[0]; + assert_eq!(req.method(), Method::POST); + assert_eq!(req.uri().path(), "/api/v1/namespaces/test-ns/secrets"); + } + + #[tokio::test] + async fn test_recompute_reference_values_flow() { + let pcr_cm = ConfigMap { + data: Some(BTreeMap::from([( + PCR_CONFIG_FILE.to_string(), + serde_json::to_string(&ImagePcrs(BTreeMap::from([( + "cos".to_string(), + ImagePcr { + first_seen: Utc::now(), + pcrs: vec![Pcr { + id: 0, + value: "pcr0_val".to_string(), + parts: vec![], + }], + }, + )]))) + .unwrap(), + )])), + ..Default::default() + }; + let rv_cm = ConfigMap::default(); + + let mock_client = CapturingMockClient::new(vec![ + ok_response(&pcr_cm), + ok_response(&rv_cm), + ok_response(&rv_cm), + ]); + let client = mock_client.clone().into_client("op-ns"); + + let ctx = RvContextData { + client, + operator_namespace: "op-ns".to_string(), + trustee_namespace: "trustee-ns".to_string(), + pcrs_compute_image: "".to_string(), + rv_map: "test-rv-map".to_string(), + }; + + let result = recompute_reference_values(ctx).await; + assert!(result.is_ok()); + + let requests = mock_client.requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].method(), Method::GET); + assert!(requests[0].uri().path().contains(PCR_CONFIG_MAP)); + assert_eq!(requests[1].method(), Method::GET); + assert!(requests[1].uri().path().contains("test-rv-map")); + assert_eq!(requests[2].method(), Method::PUT); + assert!(requests[2].uri().path().contains("test-rv-map")); + } + + #[tokio::test] + async fn test_generate_secret_flow_with_patch() { + let kbs_config_before_patch = empty_kbs_config("test-kbs-config"); + let mock_client = CapturingMockClient::new(vec![ + created_response(&Secret::default()), + ok_response(&kbs_config_before_patch), + ok_response(&empty_kbs_config("test-kbs-config")), + ]); + let client = mock_client.clone().into_client("test-ns"); + + let result = generate_secret(client, "test-ns", "test-kbs-config", "new-secret-id").await; + assert!(result.is_ok()); + + let requests = mock_client.requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].method(), Method::POST); + assert_eq!(requests[1].method(), Method::GET); + assert_eq!(requests[2].method(), Method::PATCH); + assert!(requests[2].uri().path().contains("test-kbs-config")); + } + + #[tokio::test] + async fn test_generate_secret_already_present_in_spec() { + let mut kbs_config_with_secret = empty_kbs_config("test-kbs-config"); + kbs_config_with_secret.spec.kbs_secret_resources = vec!["existing-secret".to_string()]; + + let mock_client = CapturingMockClient::new(vec![ + created_response(&Secret::default()), + ok_response(&kbs_config_with_secret), + ]); + let client = mock_client.clone().into_client("test-ns"); + + let result = generate_secret(client, "test-ns", "test-kbs-config", "existing-secret").await; + assert!(result.is_ok()); + + let requests = mock_client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + } +}