Skip to content

[Merged by Bors] - Added OpaConfig struct #357

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = std::result::Result<T, Error>;
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
319 changes: 319 additions & 0 deletions src/opa.rs
Original file line number Diff line number Diff line change
@@ -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<OpaConfig>
//! }
//!
//! 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<String>,
}

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 `<package>` 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/<package>`
/// * `v1/data/<package>/<rule>`
///
/// # 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<T>(
&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 `<package>` part if
/// provided as can be seen in the examples below.
///
/// # Example
///
/// * `http://localhost:8081/v1/data/<package>`
/// * `http://localhost:8081/v1/data/<package>/<rule>`
///
/// # 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<T>(
&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 `<package>` 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/<package>`
/// * `http://localhost:8081/v1/data/<package>/<rule>`
///
/// # 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<T>(
&self,
client: &Client,
resource: &T,
rule: Option<&str>,
api_version: OpaApiVersion,
) -> OperatorResult<String>
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<String> {
Ok(client
.get::<ConfigMap>(&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()),
}
}
}