diff --git a/CHANGELOG.md b/CHANGELOG.md index b45e8f79c..69d7a281d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,16 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Common `OpaConfig` to specify a config map and package name ([#357]). + ### Changed - Split up the builder module into submodules. This is not breaking yet due to reexports. Deprecation warning has been added for `operator-rs` `0.15.0` ([#348]). [#348]: https://github.com/stackabletech/operator-rs/pull/348 +[#357]: https://github.com/stackabletech/operator-rs/pull/357 ## [0.14.1] - 2022.03.15 diff --git a/src/error.rs b/src/error.rs index 4399c5a1b..f878c43e7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -61,6 +61,9 @@ pub enum Error { #[error("Error converting CRD byte array to UTF-8")] CrdFromUtf8Error(#[source] std::string::FromUtf8Error), + + #[error("Missing OPA connect string in configmap [{configmap_name}]")] + MissingOpaConnectString { configmap_name: String }, } pub type OperatorResult = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs index ac41f5048..1a31a9592 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod label_selector; pub mod labels; pub mod logging; pub mod namespace; +pub mod opa; pub mod pod_utils; pub mod product_config_utils; pub mod role_utils; diff --git a/src/opa.rs b/src/opa.rs new file mode 100644 index 000000000..5a6add274 --- /dev/null +++ b/src/opa.rs @@ -0,0 +1,319 @@ +//! This module offers common access to the [`OpaConfig`] which can be used in operators +//! to specify a name for a [`k8s_openapi::api::core::v1::ConfigMap`] and a package name +//! for OPA rules. +//! +//! Additionally several methods are provided to build an URL to query the OPA data API. +//! +//! # Example +//! ```rust +//! use serde::{Deserialize, Serialize}; +//! use stackable_operator::kube::CustomResource; +//! use stackable_operator::opa::{OpaApiVersion, OpaConfig}; +//! use stackable_operator::schemars::{self, JsonSchema}; +//! +//! #[derive(Clone, CustomResource, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] +//! #[kube( +//! group = "test.stackable.tech", +//! version = "v1alpha1", +//! kind = "TestCluster", +//! plural = "testclusters", +//! shortname = "test", +//! namespaced, +//! )] +//! #[serde(rename_all = "camelCase")] +//! pub struct TestClusterSpec { +//! opa: Option +//! } +//! +//! let cluster: TestCluster = serde_yaml::from_str( +//! " +//! apiVersion: test.stackable.tech/v1alpha1 +//! kind: TestCluster +//! metadata: +//! name: simple-test +//! spec: +//! opa: +//! configMapName: simple-opa +//! package: test +//! ", +//! ).unwrap(); +//! +//! let opa_config: &OpaConfig = cluster.spec.opa.as_ref().unwrap(); +//! +//! assert_eq!(opa_config.document_url(&cluster, Some("allow"), OpaApiVersion::V1), "v1/data/test/allow".to_string()); +//! assert_eq!(opa_config.full_document_url(&cluster, "http://localhost:8081", None, OpaApiVersion::V1), "http://localhost:8081/v1/data/test".to_string()); +//! ``` +use crate::client::Client; +use crate::error; +use crate::error::OperatorResult; +use k8s_openapi::api::core::v1::ConfigMap; +use kube::ResourceExt; +use schemars::{self, JsonSchema}; +use serde::{Deserialize, Serialize}; + +/// Indicates the OPA API version. This is required to choose the correct +/// path when constructing the OPA urls to query. +pub enum OpaApiVersion { + V1, +} + +impl OpaApiVersion { + /// Returns the OPA data API path for the selected version + pub fn get_data_api(&self) -> &'static str { + match self { + Self::V1 => "v1/data", + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpaConfig { + pub config_map_name: String, + pub package: Option, +} + +impl OpaConfig { + /// Returns the OPA data API url. If [`OpaConfig`] has no `package` set, + /// will default to the cluster `resource` name. + /// + /// The rule is optional and will be appended to the `` part if + /// provided as can be seen in the examples below. + /// + /// This may be used if the OPA base url is contained in an ENV variable. + /// + /// # Example + /// + /// * `v1/data/` + /// * `v1/data//` + /// + /// # Arguments + /// * `resource` - The cluster resource. + /// * `rule` - The rule name. Can be omitted. + /// * `api_version` - The [`OpaApiVersion`] to extract the data API path. + pub fn document_url( + &self, + resource: &T, + rule: Option<&str>, + api_version: OpaApiVersion, + ) -> String + where + T: ResourceExt, + { + let package_name = match &self.package { + Some(p) => p.to_string(), + None => resource.name(), + }; + + let mut document_url = format!("{}/{}", api_version.get_data_api(), package_name); + + if let Some(document_rule) = rule { + document_url.push('/'); + document_url.push_str(document_rule); + } + + document_url + } + + /// Returns the full qualified OPA data API url. If [`OpaConfig`] has no `package` set, + /// will default to the cluster `resource` name. + /// + /// The rule is optional and will be appended to the `` part if + /// provided as can be seen in the examples below. + /// + /// # Example + /// + /// * `http://localhost:8081/v1/data/` + /// * `http://localhost:8081/v1/data//` + /// + /// # Arguments + /// * `resource` - The cluster resource + /// * `opa_base_url` - The base url to OPA e.g. http://localhost:8081 + /// * `rule` - The rule name. Can be omitted. + /// * `api_version` - The [`OpaApiVersion`] to extract the data API path. + pub fn full_document_url( + &self, + resource: &T, + opa_base_url: &str, + rule: Option<&str>, + api_version: OpaApiVersion, + ) -> String + where + T: ResourceExt, + { + if opa_base_url.ends_with('/') { + format!( + "{}{}", + opa_base_url, + self.document_url(resource, rule, api_version) + ) + } else { + format!( + "{}/{}", + opa_base_url, + self.document_url(resource, rule, api_version) + ) + } + } + + /// Returns the full qualified OPA data API url up to the package. If [`OpaConfig`] has + /// no `package` set, will default to the cluster `resource` name. + /// + /// The rule is optional and will be appended to the `` part if + /// provided as can be seen in the examples below. + /// + /// In contrast to `full_document_url`, this extracts the OPA base url from the provided + /// `config_map_name` in the [`OpaConfig`]. + /// + /// # Example + /// + /// * `http://localhost:8081/v1/data/` + /// * `http://localhost:8081/v1/data//` + /// + /// # Arguments + /// * `client` - The kubernetes client. + /// * `resource` - The cluster resource. + /// * `rule` - The rule name. Can be omitted. + /// * `api_version` - The [`OpaApiVersion`] to extract the data API path. + pub async fn full_document_url_from_config_map( + &self, + client: &Client, + resource: &T, + rule: Option<&str>, + api_version: OpaApiVersion, + ) -> OperatorResult + where + T: ResourceExt, + { + let opa_base_url = self + .base_url_from_config_map(client, resource.namespace().as_deref()) + .await?; + + Ok(self.full_document_url(resource, &opa_base_url, rule, api_version)) + } + + /// Returns the OPA base url defined in the [`k8s_openapi::api::core::v1::ConfigMap`] + /// from `config_map_name` in the [`OpaConfig`]. + /// + /// # Arguments + /// * `client` - The kubernetes client. + /// * `namespace` - The namespace of the config map. + async fn base_url_from_config_map( + &self, + client: &Client, + namespace: Option<&str>, + ) -> OperatorResult { + Ok(client + .get::(&self.config_map_name, namespace) + .await? + .data + .and_then(|mut data| data.remove("OPA")) + .ok_or(error::Error::MissingOpaConnectString { + configmap_name: self.config_map_name.clone(), + })?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kube::CustomResource; + use schemars::{self, JsonSchema}; + use serde::{Deserialize, Serialize}; + + const CLUSTER_NAME: &str = "simple-cluster"; + const PACKAGE_NAME: &str = "my-package"; + const RULE_NAME: &str = "allow"; + const OPA_BASE_URL_WITH_SLASH: &str = "http://opa:8081/"; + const OPA_BASE_URL_WITHOUT_SLASH: &str = "http://opa:8081"; + + const V1: OpaApiVersion = OpaApiVersion::V1; + + #[test] + fn test_document_url_with_package_name() { + let cluster = build_test_cluster(); + let opa_config = build_opa_config(Some(PACKAGE_NAME)); + + assert_eq!( + opa_config.document_url(&cluster, None, V1), + format!("{}/{}", V1.get_data_api(), PACKAGE_NAME) + ); + + assert_eq!( + opa_config.document_url(&cluster, Some(RULE_NAME), V1), + format!("{}/{}/{}", V1.get_data_api(), PACKAGE_NAME, RULE_NAME) + ); + } + + #[test] + fn test_document_url_without_package_name() { + let cluster = build_test_cluster(); + let opa_config = build_opa_config(None); + + assert_eq!( + opa_config.document_url(&cluster, None, V1), + format!("{}/{}", V1.get_data_api(), CLUSTER_NAME) + ); + + assert_eq!( + opa_config.document_url(&cluster, Some(RULE_NAME), V1), + format!("{}/{}/{}", V1.get_data_api(), CLUSTER_NAME, RULE_NAME) + ); + } + + #[test] + fn test_full_document_url() { + let cluster = build_test_cluster(); + let opa_config = build_opa_config(None); + + assert_eq!( + opa_config.full_document_url(&cluster, OPA_BASE_URL_WITH_SLASH, None, V1), + format!( + "{}/{}/{}", + OPA_BASE_URL_WITHOUT_SLASH, + V1.get_data_api(), + CLUSTER_NAME + ) + ); + + let opa_config = build_opa_config(Some(PACKAGE_NAME)); + + assert_eq!( + opa_config.full_document_url(&cluster, OPA_BASE_URL_WITHOUT_SLASH, None, V1), + format!( + "{}/{}/{}", + OPA_BASE_URL_WITHOUT_SLASH, + V1.get_data_api(), + PACKAGE_NAME + ) + ); + } + + #[derive(Clone, CustomResource, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] + #[kube(group = "test", version = "v1", kind = "TestCluster", namespaced)] + pub struct ClusterSpec { + test: u8, + } + + fn build_test_cluster() -> TestCluster { + serde_yaml::from_str(&format!( + " + apiVersion: test/v1 + kind: TestCluster + metadata: + name: {} + spec: + test: 100 + ", + CLUSTER_NAME + )) + .unwrap() + } + + fn build_opa_config(package: Option<&str>) -> OpaConfig { + OpaConfig { + config_map_name: "opa".to_string(), + package: package.map(|p| p.to_string()), + } + } +}