diff --git a/Cargo.toml b/Cargo.toml index e0df8962..f0f155cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,3 +72,6 @@ k8s-openapi = { version = "0.20.0", default-features = false, features = [ rstest = "0.18" test-context = "0.1" tempfile = "3.8.1" +tower-test = "0.4" +# kube-rs mocking requires tower-test v0.14.x +hyper = { version = "^0.14.28" } diff --git a/Makefile b/Makefile index ec78336e..131a9f4b 100644 --- a/Makefile +++ b/Makefile @@ -21,10 +21,10 @@ test: fmt lint cargo test --workspace .PHONY: unit-tests -unit-test: fmt lint +unit-tests: fmt lint cargo test --workspace --lib -.PHONY: integration-test +.PHONY: integration-tests integration-tests: fmt lint cargo test --test '*' diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 00000000..086418b6 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,45 @@ +use tempfile::TempDir; + +use policy_evaluator::{ + evaluation_context::EvaluationContext, policy_evaluator::PolicyEvaluator, + policy_evaluator::PolicyExecutionMode, policy_evaluator_builder::PolicyEvaluatorBuilder, +}; +use policy_fetcher::{policy::Policy, PullDestination}; + +pub(crate) async fn fetch_policy(policy_uri: &str, tempdir: TempDir) -> Policy { + policy_evaluator::policy_fetcher::fetch_policy( + policy_uri, + PullDestination::LocalFile(tempdir.into_path()), + None, + ) + .await + .expect("cannot fetch policy") +} + +pub(crate) fn build_policy_evaluator( + execution_mode: PolicyExecutionMode, + policy: &Policy, + eval_ctx: &EvaluationContext, +) -> PolicyEvaluator { + let policy_evaluator_builder = PolicyEvaluatorBuilder::new() + .execution_mode(execution_mode) + .policy_file(&policy.local_path) + .expect("cannot read policy file") + .enable_wasmtime_cache() + .enable_epoch_interruptions(1, 2); + + let policy_evaluator_pre = policy_evaluator_builder + .build_pre() + .expect("cannot build policy evaluator pre"); + + policy_evaluator_pre + .rehydrate(eval_ctx) + .expect("cannot rehydrate policy evaluator") +} + +pub(crate) fn load_request_data(request_file_name: &str) -> Vec { + let request_file_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/data") + .join(request_file_name); + std::fs::read(request_file_path).expect("cannot read request file") +} diff --git a/tests/data/app_deployment.json b/tests/data/app_deployment.json new file mode 100644 index 00000000..2f91d728 --- /dev/null +++ b/tests/data/app_deployment.json @@ -0,0 +1,130 @@ +{ + "kind": { + "group": "apps", + "kind": "Deployment", + "version": "v1" + }, + "name": "api", + "namespace": "customer-1", + "object": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "annotations": { + "deployment.kubernetes.io/revision": "1", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"api\",\"app.kubernetes.io/component\":\"api\"},\"name\":\"api\",\"namespace\":\"customer-1\"},\"spec\":{\"replicas\":3,\"selector\":{\"matchLabels\":{\"app\":\"api\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"api\",\"app.kubernetes.io/component\":\"api\"}},\"spec\":{\"containers\":[{\"image\":\"api:1.0.0\",\"name\":\"api\",\"ports\":[{\"containerPort\":8080}]}]}}}}\n" + }, + "creationTimestamp": "2023-12-17T08:52:31Z", + "generation": 1, + "labels": { + "app": "api", + "app.kubernetes.io/component": "api", + "customer-id": "1" + }, + "name": "api", + "namespace": "customer-1", + "resourceVersion": "1167496", + "uid": "8a1e598b-cc4b-49b7-a465-bee6204c18db" + }, + "spec": { + "progressDeadlineSeconds": 600, + "replicas": 3, + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "app": "api" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": "25%", + "maxUnavailable": "25%" + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "api", + "app.kubernetes.io/component": "api" + } + }, + "spec": { + "containers": [ + { + "image": "api:1.0.0", + "imagePullPolicy": "IfNotPresent", + "name": "api", + "ports": [ + { + "containerPort": 8080, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "conditions": [ + { + "lastTransitionTime": "2023-12-17T08:52:32Z", + "lastUpdateTime": "2023-12-17T08:52:32Z", + "message": "Deployment does not have minimum availability.", + "reason": "MinimumReplicasUnavailable", + "status": "False", + "type": "Available" + }, + { + "lastTransitionTime": "2023-12-17T08:52:31Z", + "lastUpdateTime": "2023-12-17T08:52:32Z", + "message": "ReplicaSet \"api-58f88975b6\" is progressing.", + "reason": "ReplicaSetUpdated", + "status": "True", + "type": "Progressing" + } + ], + "observedGeneration": 1, + "replicas": 3, + "unavailableReplicas": 3, + "updatedReplicas": 3 + } + }, + + "operation": "CREATE", + "options": { + "apiVersion": "meta.k8s.io/v1", + "fieldManager": "kubectl-client-side-apply", + "kind": "CreateOptions" + }, + "requestKind": { + "group": "apps", + "kind": "Deployment", + "version": "v1" + }, + "requestResource": { + "group": "apps", + "resource": "deployments", + "version": "v1" + }, + "resource": { + "group": "apps", + "resource": "deployments", + "version": "v1" + }, + "uid": "8560a482-887d-49b2-8781-415d04a0dcb0", + "userInfo": { + "groups": ["system:masters", "system:authenticated"], + "username": "system:admin" + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 9025d4d4..254d12a6 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1,16 +1,27 @@ #![allow(clippy::too_many_arguments)] +mod common; +mod k8s_mock; +use hyper::{Body, Request, Response}; +use kube::Client; use rstest::*; use serde_json::json; +use std::collections::BTreeSet; +use std::future::Future; +use tokio::sync::oneshot; +use tower_test::mock::Handle; use policy_evaluator::{ admission_request::AdmissionRequest, admission_response::AdmissionResponseStatus, evaluation_context::EvaluationContext, + policy_evaluator::PolicySettings, policy_evaluator::{PolicyExecutionMode, ValidateRequest}, - policy_evaluator_builder::PolicyEvaluatorBuilder, + policy_metadata::ContextAwareResource, }; -use policy_fetcher::PullDestination; + +use crate::common::{build_policy_evaluator, fetch_policy, load_request_data}; +use crate::k8s_mock::{rego_scenario, wapc_scenario}; #[rstest] #[case::wapc( @@ -147,38 +158,17 @@ async fn test_policy_evaluator( #[case] mutating: bool, ) { let tempdir = tempfile::TempDir::new().expect("cannot create tempdir"); - let policy = policy_evaluator::policy_fetcher::fetch_policy( - policy_uri, - PullDestination::LocalFile(tempdir.into_path()), - None, - ) - .await - .expect("cannot fetch policy"); + let policy = fetch_policy(policy_uri, tempdir).await; let eval_ctx = EvaluationContext { - policy_id: "test".to_string(), + policy_id: "test".to_owned(), callback_channel: None, ctx_aware_resources_allow_list: Default::default(), }; - let policy_evaluator_builder = PolicyEvaluatorBuilder::new() - .execution_mode(execution_mode) - .policy_file(&policy.local_path) - .expect("cannot read policy file") - .enable_wasmtime_cache() - .enable_epoch_interruptions(1, 2); - - let policy_evaluator_pre = policy_evaluator_builder - .build_pre() - .expect("cannot build policy evaluator pre"); - let mut policy_evaluator = policy_evaluator_pre - .rehydrate(&eval_ctx) - .expect("cannot rehydrate policy evaluator"); - - let request_file_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/data") - .join(request_file_path); - let request_data = std::fs::read(request_file_path).expect("cannot read request file"); + let mut policy_evaluator = build_policy_evaluator(execution_mode, &policy, &eval_ctx); + + let request_data = load_request_data(request_file_path); let request_json = serde_json::from_slice(&request_data).expect("cannot deserialize request"); let validation_request = if raw { @@ -214,3 +204,91 @@ async fn test_policy_evaluator( assert!(admission_response.patch.is_none()); } } + +#[rstest] +#[case::wapc( + PolicyExecutionMode::KubewardenWapc, + "ghcr.io/kubewarden/tests/context-aware-test-policy:v0.1.0", + "app_deployment.json", + wapc_scenario +)] +#[case::opa( + PolicyExecutionMode::Opa, + "ghcr.io/kubewarden/tests/context-aware-test-opa-policy:v0.1.0", + "app_deployment.json", + rego_scenario +)] +#[case::gatekeeper( + PolicyExecutionMode::OpaGatekeeper, + "ghcr.io/kubewarden/tests/context-aware-test-gatekeeper-policy:v0.1.0", + "app_deployment.json", + rego_scenario +)] +#[tokio::test(flavor = "multi_thread")] +async fn test_wapc_runtime_context_aware( + #[case] execution_mode: PolicyExecutionMode, + #[case] policy_uri: &str, + #[case] request_file_path: &str, + #[case] scenario: F, +) where + F: FnOnce(Handle, Response>) -> Fut, + Fut: Future, +{ + let tempdir = tempfile::TempDir::new().expect("cannot create tempdir"); + let policy = fetch_policy(policy_uri, tempdir).await; + + let (mocksvc, handle) = tower_test::mock::pair::, Response>(); + let client = Client::new(mocksvc, "default"); + scenario(handle).await; + + let (callback_handler_shutdown_channel_tx, callback_handler_shutdown_channel_rx) = + oneshot::channel(); + let callback_builder = policy_evaluator::callback_handler::CallbackHandlerBuilder::new( + callback_handler_shutdown_channel_rx, + ); + let mut callback_handler = callback_builder + .kube_client(client) + .build() + .expect("cannot build callback handler"); + let callback_handler_channel = callback_handler.sender_channel(); + + tokio::spawn(async move { + callback_handler.loop_eval().await; + }); + + let eval_ctx = EvaluationContext { + policy_id: "test".to_owned(), + callback_channel: Some(callback_handler_channel), + ctx_aware_resources_allow_list: BTreeSet::from([ + ContextAwareResource { + api_version: "v1".to_owned(), + kind: "Namespace".to_owned(), + }, + ContextAwareResource { + api_version: "apps/v1".to_owned(), + kind: "Deployment".to_owned(), + }, + ContextAwareResource { + api_version: "v1".to_owned(), + kind: "Service".to_owned(), + }, + ]), + }; + + let mut policy_evaluator = build_policy_evaluator(execution_mode, &policy, &eval_ctx); + + let request_data = load_request_data(request_file_path); + let request: AdmissionRequest = + serde_json::from_slice(&request_data).expect("cannot deserialize request"); + + let admission_response = policy_evaluator.validate( + ValidateRequest::AdmissionRequest(request), + &PolicySettings::default(), + ); + + assert!(admission_response.allowed); + + callback_handler_shutdown_channel_tx + .send(()) + .expect("cannot send shutdown signal"); +} diff --git a/tests/k8s_mock/fixtures.rs b/tests/k8s_mock/fixtures.rs new file mode 100644 index 00000000..3e016ebd --- /dev/null +++ b/tests/k8s_mock/fixtures.rs @@ -0,0 +1,112 @@ +use k8s_openapi::{ + api::{ + apps::v1::Deployment, + core::v1::{Namespace, Service}, + }, + apimachinery::pkg::apis::meta::v1::{APIResource, APIResourceList}, +}; +use kube::core::{ObjectList, ObjectMeta}; +use std::collections::BTreeMap; + +pub(crate) fn v1_resource_list() -> APIResourceList { + APIResourceList { + group_version: "v1".to_owned(), + resources: vec![ + APIResource { + name: "namespaces".to_owned(), + singular_name: "namespace".to_owned(), + namespaced: false, + kind: "Namespace".to_owned(), + ..Default::default() + }, + APIResource { + name: "services".to_owned(), + singular_name: "service".to_owned(), + namespaced: true, + kind: "Service".to_owned(), + ..Default::default() + }, + ], + } +} + +pub(crate) fn apps_v1_resource_list() -> APIResourceList { + APIResourceList { + group_version: "apps/v1".to_owned(), + resources: vec![APIResource { + name: "deployments".to_owned(), + singular_name: "deployment".to_owned(), + namespaced: true, + kind: "Deployment".to_owned(), + ..Default::default() + }], + } +} + +pub(crate) fn namespaces() -> ObjectList { + ObjectList { + metadata: Default::default(), + items: vec![Namespace { + metadata: ObjectMeta { + name: Some("customer-1".to_owned()), + labels: Some(BTreeMap::from([("customer-id".to_owned(), "1".to_owned())])), + ..Default::default() + }, + ..Default::default() + }], + } +} + +pub(crate) fn deployments() -> ObjectList { + ObjectList { + metadata: Default::default(), + items: vec![ + Deployment { + metadata: ObjectMeta { + name: Some("postgres".to_owned()), + namespace: Some("customer-1".to_owned()), + labels: Some(BTreeMap::from([( + "app.kubernetes.io/component".to_owned(), + "database".to_owned(), + )])), + ..Default::default() + }, + ..Default::default() + }, + Deployment { + metadata: ObjectMeta { + name: Some("single-page-app".to_owned()), + namespace: Some("customer-1".to_owned()), + labels: Some(BTreeMap::from([( + "app.kubernetes.io/component".to_owned(), + "frontend".to_owned(), + )])), + ..Default::default() + }, + ..Default::default() + }, + ], + } +} + +pub(crate) fn services() -> ObjectList { + ObjectList { + metadata: Default::default(), + items: vec![api_auth_service()], + } +} + +pub(crate) fn api_auth_service() -> Service { + Service { + metadata: ObjectMeta { + name: Some("api-auth-service".to_owned()), + namespace: Some("customer-1".to_owned()), + labels: Some(BTreeMap::from([( + "app.kubernetes.io/part-of".to_owned(), + "api".to_owned(), + )])), + ..Default::default() + }, + ..Default::default() + } +} diff --git a/tests/k8s_mock/mod.rs b/tests/k8s_mock/mod.rs new file mode 100644 index 00000000..59a25d85 --- /dev/null +++ b/tests/k8s_mock/mod.rs @@ -0,0 +1,73 @@ +mod fixtures; + +use hyper::{http, Body, Request, Response}; +use serde::Serialize; +use tower_test::mock::{Handle, SendResponse}; + +pub(crate) async fn wapc_scenario(handle: Handle, Response>) { + tokio::spawn(async move { + let mut handle = handle; + + loop { + let (request, send) = handle.next_request().await.expect("service not called"); + + match (request.method(), request.uri().path()) { + (&http::Method::GET, "/api/v1") => { + send_response(send, fixtures::v1_resource_list()); + } + (&http::Method::GET, "/apis/apps/v1") => { + send_response(send, fixtures::apps_v1_resource_list()); + } + (&http::Method::GET, "/api/v1/namespaces") => { + send_response(send, fixtures::namespaces()); + } + (&http::Method::GET, "/apis/apps/v1/namespaces/customer-1/deployments") => { + send_response(send, fixtures::deployments()); + } + (&http::Method::GET, "/api/v1/namespaces/customer-1/services/api-auth-service") => { + send_response(send, fixtures::api_auth_service()); + } + _ => { + panic!("unexpected request: {:?}", request); + } + } + } + }); +} + +pub(crate) async fn rego_scenario(handle: Handle, Response>) { + tokio::spawn(async move { + let mut handle = handle; + + loop { + let (request, send) = handle.next_request().await.expect("service not called"); + + match (request.method(), request.uri().path()) { + (&http::Method::GET, "/api/v1") => { + send_response(send, fixtures::v1_resource_list()); + } + (&http::Method::GET, "/apis/apps/v1") => { + send_response(send, fixtures::apps_v1_resource_list()); + } + (&http::Method::GET, "/api/v1/namespaces") => { + send_response(send, fixtures::namespaces()); + } + (&http::Method::GET, "/apis/apps/v1/deployments") => { + send_response(send, fixtures::deployments()); + } + (&http::Method::GET, "/api/v1/services") => { + send_response(send, fixtures::services()); + } + + _ => { + panic!("unexpected request: {:?}", request); + } + } + } + }); +} + +fn send_response(send: SendResponse>, response: T) { + let response = serde_json::to_vec(&response).unwrap(); + send.send_response(Response::builder().body(Body::from(response)).unwrap()); +}