diff --git a/Cargo.lock b/Cargo.lock index f0a3a871..308d45b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1067,6 +1067,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 = "headers" version = "0.3.9" @@ -1735,7 +1745,7 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-util", - "tower", + "tower 0.5.2", "tower-http", "tracing", ] @@ -2198,6 +2208,7 @@ dependencies = [ "env_logger", "futures-util", "hex", + "http 1.3.1", "json-patch", "jsonptr", "k8s-openapi", @@ -2211,6 +2222,7 @@ dependencies = [ "serde_json", "thiserror 2.0.17", "tokio", + "tower 0.4.13", ] [[package]] @@ -2642,7 +2654,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-util", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -3407,6 +3419,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" @@ -3439,7 +3472,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 42147b4e..c195c9e1 100644 --- a/operator/Cargo.toml +++ b/operator/Cargo.toml @@ -31,3 +31,7 @@ serde.workspace = true serde_json.workspace = true thiserror = "2.0.17" tokio.workspace = true + +[dev-dependencies] +http = "1.1.0" +tower = { version = "0.4.13", features = ["full"] } diff --git a/operator/src/main.rs b/operator/src/main.rs index 025ffdcd..bdf9874a 100644 --- a/operator/src/main.rs +++ b/operator/src/main.rs @@ -21,6 +21,8 @@ use kube::{ use log::{error, info, warn}; use crds::ConfidentialCluster; +#[cfg(test)] +mod mock_client; mod reference_values; mod register_server; mod trustee; diff --git a/operator/src/mock_client.rs b/operator/src/mock_client.rs new file mode 100644 index 00000000..f1bab2de --- /dev/null +++ b/operator/src/mock_client.rs @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Alice Frosi +// SPDX-FileCopyrightText: Jakob Naucke +// +// SPDX-License-Identifier: MIT + +use http::{Request, Response, StatusCode}; +use kube::{Client, client::Body, error::ErrorResponse}; +use serde::{Deserialize, Serialize}; +use std::convert::Infallible; +use tower::service_fn; + +macro_rules! assert_kube_api_error { + ($err:expr, $code:expr, $reason:expr, $message:expr, $status:expr) => {{ + let kube_error = $err + .downcast_ref::() + .expect(&format!("Expected kube::Error, got: {:?}", $err)); + + if let kube::Error::Api(error_response) = kube_error { + assert_eq!(error_response.code, $code); + assert_eq!(error_response.reason, $reason); + assert_eq!(error_response.message, $message); + assert_eq!(error_response.status, $status); + } else { + assert!(false, "Expected kube::Error::Api, got: {:?}", kube_error); + } + }}; +} + +pub(crate) use assert_kube_api_error; + +pub struct MockClient +where + F: Fn(&Option>) -> Result + Send + 'static, + T: Default + Serialize + for<'de> Deserialize<'de>, +{ + response_closure: F, + namespace: String, +} + +impl MockClient +where + F: Fn(&Option>) -> Result + Send + 'static, + T: Clone + Default + Send + Serialize + for<'de> Deserialize<'de> + 'static, +{ + pub fn new(response_closure: F, namespace: String) -> Self { + Self { + response_closure, + namespace, + } + } + + pub fn into_client(self) -> Client { + let response_data = (self.response_closure)(&None).unwrap_or_default(); + let response_json = serde_json::to_string(&response_data).unwrap(); + let (kind, name) = serde_json::from_str::(&response_json) + .map(|json_value| { + let kind = json_value + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let name = json_value + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("Unknown") + .to_string(); + (kind, name) + }) + .unwrap_or(("Unknown".to_string(), "Unknown".to_string())); + let plural = kind.to_lowercase() + "s"; + let namespace = self.namespace.clone(); + + let mock_svc = service_fn(move |req: Request| { + let mut status_code = StatusCode::OK; + let response = (self.response_closure)(&Some(req)); + let body = if let Ok(response_data) = response { + let response_json = serde_json::to_string(&response_data).unwrap(); + Body::from(response_json.into_bytes()) + } else { + status_code = response.err().unwrap(); + let code = status_code.as_u16(); + let error_response = match status_code { + StatusCode::CONFLICT => ErrorResponse { + status: "Failure".to_string(), + message: format!("{plural} \"{name}\" already exists"), + reason: "AlreadyExists".to_string(), + code, + }, + StatusCode::INTERNAL_SERVER_ERROR => ErrorResponse { + status: "Failure".to_string(), + message: "internal server error".to_string(), + reason: "ServerTimeout".to_string(), + code, + }, + StatusCode::NOT_FOUND => ErrorResponse { + status: "Failure".to_string(), + message: "resource not found".to_string(), + reason: "NotFound".to_string(), + code, + }, + StatusCode::BAD_REQUEST => ErrorResponse { + status: "Failure".to_string(), + message: "bad request".to_string(), + reason: "BadRequest".to_string(), + code, + }, + _ => ErrorResponse { + status: "Failure".to_string(), + message: format!("error with status code {status_code}"), + reason: "Unknown".to_string(), + code, + }, + }; + let error_json = serde_json::to_string(&error_response).unwrap(); + Body::from(error_json.into_bytes()) + }; + + let response = Response::builder().status(status_code).body(body).unwrap(); + async move { Ok::<_, Infallible>(response) } + }); + Client::new(mock_svc, namespace) + } +} diff --git a/operator/src/register_server.rs b/operator/src/register_server.rs index d52671f4..581ebc4b 100644 --- a/operator/src/register_server.rs +++ b/operator/src/register_server.rs @@ -257,8 +257,10 @@ async fn keygen_reconcile( machine: Arc, client: Arc, ) -> Result { + let client = Arc::unwrap_or_clone(client); let id = &machine.spec.id; - trustee::generate_secret(Arc::unwrap_or_clone(client), id).await?; + trustee::generate_secret(client.clone(), id).await?; + trustee::mount_secret(client.clone(), id).await?; Ok(Action::await_change()) } diff --git a/operator/src/trustee.rs b/operator/src/trustee.rs index f582b24f..2b32d115 100644 --- a/operator/src/trustee.rs +++ b/operator/src/trustee.rs @@ -1,5 +1,6 @@ // SPDX-FileCopyrightText: Alice Frosi // SPDX-FileCopyrightText: Jakob Naucke +// SPDX-FileCopyrightText: Dehan Meng // // SPDX-License-Identifier: MIT @@ -87,11 +88,10 @@ fn recompute_reference_values(image_pcrs: ImagePcrs) -> Vec { } pub async fn update_reference_values(ctx: RvContextData) -> Result<()> { - let operator_config_maps: Api = Api::default_namespaced(ctx.client.clone()); - let image_pcrs_map = operator_config_maps.get(PCR_CONFIG_MAP).await?; + let config_maps: Api = Api::default_namespaced(ctx.client); + let image_pcrs_map = config_maps.get(PCR_CONFIG_MAP).await?; let reference_values = recompute_reference_values(get_image_pcrs(image_pcrs_map)?); - let config_maps: Api = Api::default_namespaced(ctx.client); let existing_data = config_maps.get(TRUSTEE_DATA_MAP).await?; let err = format!("ConfigMap {TRUSTEE_DATA_MAP} existed, but had no data"); let existing_data_map = existing_data.data.context(err)?; @@ -144,7 +144,7 @@ fn generate_secret_volume(id: &str) -> (Volume, VolumeMount) { ) } -async fn mount_secret(client: Client, id: &str) -> Result<()> { +pub async fn mount_secret(client: Client, id: &str) -> Result<()> { let deployments: Api = Api::default_namespaced(client); let mut deployment = deployments.get(DEPLOYMENT_NAME).await?; let err = format!("Deployment {DEPLOYMENT_NAME} existed, but had no spec"); @@ -182,7 +182,7 @@ pub async fn generate_secret(client: Client, id: &str) -> Result<()> { let secrets: Api = Api::default_namespaced(client.clone()); let create = secrets.create(&Default::default(), &secret).await; info_if_exists!(create, "Secret", id); - mount_secret(client, id).await + Ok(()) } pub async fn generate_attestation_policy( @@ -391,3 +391,373 @@ pub async fn generate_kbs_deployment( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mock_client::*; + use compute_pcrs_lib::Pcr; + use http::{Method, Request, StatusCode}; + use serde::Deserialize; + + fn dummy_pcrs() -> ImagePcrs { + ImagePcrs(BTreeMap::from([( + "cos".to_string(), + ImagePcr { + first_seen: Utc::now(), + pcrs: vec![ + Pcr { + id: 0, + value: "pcr0_val".to_string(), + parts: vec![], + }, + Pcr { + id: 1, + value: "pcr1_val".to_string(), + parts: vec![], + }, + ], + }, + )])) + } + + fn dummy_pcrs_map() -> ConfigMap { + let data = BTreeMap::from([( + PCR_CONFIG_FILE.to_string(), + serde_json::to_string(&dummy_pcrs()).unwrap(), + )]); + ConfigMap { + data: Some(data), + ..Default::default() + } + } + + #[test] + fn test_get_image_pcrs_success() { + let config_map = dummy_pcrs_map(); + let image_pcrs = get_image_pcrs(config_map).unwrap(); + assert_eq!(image_pcrs.0["cos"].pcrs.len(), 2); + assert_eq!(image_pcrs.0["cos"].pcrs[0].value, "pcr0_val"); + } + + #[test] + fn test_get_image_pcrs_no_data() { + let config_map = ConfigMap::default(); + let err = get_image_pcrs(config_map).err().unwrap(); + assert!(err.to_string().contains("but had no data")); + } + + #[test] + fn test_get_image_pcrs_no_file() { + let config_map = ConfigMap { + data: Some(BTreeMap::new()), + ..Default::default() + }; + let err = get_image_pcrs(config_map).err().unwrap(); + assert!(err.to_string().contains("but had no file")); + } + + #[test] + fn test_get_image_pcrs_invalid_json() { + let data = BTreeMap::from([(PCR_CONFIG_FILE.to_string(), "not json".to_string())]); + let config_map = ConfigMap { + data: Some(data), + ..Default::default() + }; + assert!(get_image_pcrs(config_map).is_err()); + } + + #[test] + fn test_recompute_reference_values() { + let result = recompute_reference_values(dummy_pcrs()); + assert_eq!(result.len(), 3); + let rv = result.iter().find(|rv| rv.name == "tpm_pcr0").unwrap(); + let val_arr = rv.value.as_array().unwrap(); + let vals: Vec<_> = val_arr.iter().map(|v| v.as_str().unwrap()).collect(); + assert_eq!(vals, vec!["pcr0_val".to_string()]); + } + + fn generate_rv_ctx(client: Client) -> RvContextData { + RvContextData { + client, + owner_reference: Default::default(), + pcrs_compute_image: String::new(), + } + } + + #[tokio::test] + async fn test_update_rvs_success() { + let clos = |req: &Option>| match req { + Some(r) if r.uri().path().contains(PCR_CONFIG_MAP) => Ok(dummy_pcrs_map()), + _ => Ok(ConfigMap { + data: Some(BTreeMap::from([( + REFERENCE_VALUES_FILE.to_string(), + "[]".to_string(), + )])), + ..Default::default() + }), + }; + let ctx = generate_rv_ctx(MockClient::new(clos, "test".to_string()).into_client()); + assert!(update_reference_values(ctx).await.is_ok()); + } + + #[tokio::test] + async fn test_update_rvs_no_pcr_map() { + let clos = |req: &Option>| match req { + Some(r) if r.uri().path().contains(PCR_CONFIG_MAP) && r.method() == Method::GET => { + Err::(StatusCode::NOT_FOUND) + } + None => Ok(ConfigMap::default()), + _ => panic!("unexpected API interaction: {req:?}"), + }; + let ctx = generate_rv_ctx(MockClient::new(clos, "test".to_string()).into_client()); + assert!(update_reference_values(ctx).await.is_err()) + } + + #[tokio::test] + async fn test_update_rvs_no_trustee_map() { + let clos = |req: &Option>| match req { + Some(r) if r.uri().path().contains(PCR_CONFIG_MAP) => Ok(dummy_pcrs_map()), + Some(r) if r.uri().path().contains(TRUSTEE_DATA_MAP) && r.method() == Method::GET => { + Err::(StatusCode::NOT_FOUND) + } + None => Ok(ConfigMap::default()), + _ => panic!("unexpected API interaction: {req:?}"), + }; + let ctx = generate_rv_ctx(MockClient::new(clos, "test".to_string()).into_client()); + assert!(update_reference_values(ctx).await.is_err()) + } + + #[tokio::test] + async fn test_update_rvs_no_trustee_data() { + let clos = |req: &Option>| match req { + Some(r) if r.uri().path().contains(PCR_CONFIG_MAP) => Ok(dummy_pcrs_map()), + _ => Ok(ConfigMap::default()), + }; + let ctx = generate_rv_ctx(MockClient::new(clos, "test".to_string()).into_client()); + let err = update_reference_values(ctx).await.err().unwrap(); + assert!(err.to_string().contains("but had no data")); + } + + #[tokio::test] + async fn test_update_rvs_no_file() { + let clos = |req: &Option>| match req { + Some(r) if r.uri().path().contains(PCR_CONFIG_MAP) => Ok(dummy_pcrs_map()), + _ => Ok(ConfigMap { + data: Some(BTreeMap::new()), + ..Default::default() + }), + }; + let ctx = generate_rv_ctx(MockClient::new(clos, "test".to_string()).into_client()); + let err = update_reference_values(ctx).await.err().unwrap(); + assert!(err.to_string().contains("but had no reference values")); + } + + #[test] + fn test_generate_luks_key_returns_correct_size() { + let jwk: ClevisKey = serde_json::from_slice(&generate_luks_key().unwrap()).unwrap(); + assert_eq!(jwk.key.len(), 32); + } + + fn dummy_deployment() -> Deployment { + Deployment { + spec: Some(DeploymentSpec { + template: PodTemplateSpec { + spec: Some(PodSpec { + containers: vec![Container::default()], + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + } + } + + #[tokio::test] + async fn test_mount_secret_success() { + let clos = |_: &_| Ok(dummy_deployment()); + let client = MockClient::new(clos, "test".to_string()).into_client(); + assert!(mount_secret(client, "id").await.is_ok()); + } + + #[tokio::test] + async fn test_mount_secret_no_depl() { + let clos = |req: &Option>| match req { + Some(r) if r.uri().path().contains(DEPLOYMENT_NAME) && r.method() == Method::GET => { + Err::(StatusCode::NOT_FOUND) + } + None => Ok(Deployment::default()), + _ => panic!("unexpected API interaction: {req:?}"), + }; + let client = MockClient::new(clos, "test".to_string()).into_client(); + assert!(mount_secret(client, "id").await.is_err()); + } + + #[tokio::test] + async fn test_mount_secret_no_spec() { + let mut depl = dummy_deployment(); + depl.spec = None; + let client = MockClient::new(move |_| Ok(depl.clone()), "test".to_string()).into_client(); + let err = mount_secret(client, "id").await.err().unwrap(); + assert!(err.to_string().contains("but had no spec")); + } + + #[tokio::test] + async fn test_mount_secret_no_pod_spec() { + let mut depl = dummy_deployment(); + let spec = depl.spec.as_mut().unwrap(); + spec.template.spec = None; + let client = MockClient::new(move |_| Ok(depl.clone()), "test".to_string()).into_client(); + let err = mount_secret(client, "id").await.err().unwrap(); + assert!(err.to_string().contains("but had no pod spec")); + } + + #[tokio::test] + async fn test_mount_secret_no_containers() { + let mut depl = dummy_deployment(); + let spec = depl.spec.as_mut().unwrap(); + let pod_spec = spec.template.spec.as_mut().unwrap(); + pod_spec.containers = vec![]; + let client = MockClient::new(move |_| Ok(depl.clone()), "test".to_string()).into_client(); + let err = mount_secret(client, "id").await.err().unwrap(); + assert!(err.to_string().contains("but had no containers")); + } + + async fn test_create_success< + F: Fn(Client) -> S, + S: Future>, + T: Clone + Default + Send + Serialize + for<'de> Deserialize<'de> + 'static, + >( + create: F, + ) { + let clos = |_: &_| Ok(T::default()); + let client = MockClient::new(clos, "test".to_string()).into_client(); + assert!(create(client).await.is_ok()); + } + + async fn test_create_already_exists< + F: Fn(Client) -> S, + S: Future>, + T: Clone + Default + Send + Serialize + for<'de> Deserialize<'de> + 'static, + >( + create: F, + ) { + let clos = |req: &Option>| match req { + Some(r) if r.method() == Method::POST => Err::(StatusCode::CONFLICT), + None => Ok(T::default()), + _ => panic!("unexpected API interaction: {req:?}"), + }; + let client = MockClient::new(clos, "test".to_string()).into_client(); + assert!(create(client).await.is_ok()); + } + + async fn test_create_error< + F: Fn(Client) -> S, + S: Future>, + T: Clone + Default + Send + Serialize + for<'de> Deserialize<'de> + 'static, + >( + create: F, + ) { + let clos = |req: &Option>| match req { + Some(r) if r.method() == Method::POST => Err::(StatusCode::INTERNAL_SERVER_ERROR), + None => Ok(T::default()), + _ => panic!("unexpected API interaction: {req:?}"), + }; + let client = MockClient::new(clos, "test".to_string()).into_client(); + let err = create(client).await.unwrap_err(); + let msg = "internal server error"; + assert_kube_api_error!(err, 500, "ServerTimeout", msg, "Failure"); + } + + #[tokio::test] + async fn test_generate_att_policy_success() { + let clos = |client| generate_attestation_policy(client, Default::default()); + test_create_success::<_, _, ConfigMap>(clos).await; + } + + #[tokio::test] + async fn test_generate_att_policy_already_exists() { + let clos = |client| generate_attestation_policy(client, Default::default()); + test_create_already_exists::<_, _, ConfigMap>(clos).await; + } + + #[tokio::test] + async fn test_generate_att_policy_error() { + let clos = |client| generate_attestation_policy(client, Default::default()); + test_create_error::<_, _, ConfigMap>(clos).await; + } + + #[tokio::test] + async fn test_generate_secret_success() { + let clos = |client| generate_secret(client, "id"); + test_create_success::<_, _, Secret>(clos).await; + } + + #[tokio::test] + async fn test_generate_secret_already_exists() { + let clos = |client| generate_secret(client, "id"); + test_create_already_exists::<_, _, Secret>(clos).await; + } + + #[tokio::test] + async fn test_generate_secret_error() { + let clos = |client| generate_secret(client, "id"); + test_create_error::<_, _, Secret>(clos).await; + } + + #[tokio::test] + async fn test_generate_trustee_data_success() { + let clos = |client| generate_trustee_data(client, Default::default()); + test_create_success::<_, _, ConfigMap>(clos).await; + } + + #[tokio::test] + async fn test_generate_trustee_data_already_exists() { + let clos = |client| generate_trustee_data(client, Default::default()); + test_create_already_exists::<_, _, ConfigMap>(clos).await; + } + + #[tokio::test] + async fn test_generate_trustee_data_error() { + let clos = |client| generate_trustee_data(client, Default::default()); + test_create_error::<_, _, ConfigMap>(clos).await; + } + + #[tokio::test] + async fn test_generate_kbs_service_success() { + let clos = |client| generate_kbs_service(client, Default::default(), 80); + test_create_success::<_, _, Service>(clos).await; + } + + #[tokio::test] + async fn test_generate_kbs_service_already_exists() { + let clos = |client| generate_kbs_service(client, Default::default(), 80); + test_create_already_exists::<_, _, Service>(clos).await; + } + + #[tokio::test] + async fn test_generate_kbs_service_error() { + let clos = |client| generate_kbs_service(client, Default::default(), 80); + test_create_error::<_, _, Service>(clos).await; + } + + #[tokio::test] + async fn test_generate_kbs_depl_success() { + let clos = |client| generate_kbs_deployment(client, Default::default(), "image"); + test_create_success::<_, _, Deployment>(clos).await; + } + + #[tokio::test] + async fn test_generate_kbs_depl_already_exists() { + let clos = |client| generate_kbs_deployment(client, Default::default(), "image"); + test_create_already_exists::<_, _, Deployment>(clos).await; + } + + #[tokio::test] + async fn test_generate_kbs_depl_error() { + let clos = |client| generate_kbs_deployment(client, Default::default(), "image"); + test_create_error::<_, _, Deployment>(clos).await; + } +}