diff --git a/crates/catalog-driver-sql/src/endpoint_group.rs b/crates/catalog-driver-sql/src/endpoint_group.rs new file mode 100644 index 000000000..3638333fc --- /dev/null +++ b/crates/catalog-driver-sql/src/endpoint_group.rs @@ -0,0 +1,108 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; + +use sea_orm::entity::*; +use serde_json::Value; +use tracing::error; +use uuid::Uuid; + +use openstack_keystone_core::catalog::CatalogProviderError; +use openstack_keystone_core_types::catalog::*; + +use crate::entity::endpoint_group as db_endpoint_group; + +mod create; +mod delete; +mod get; +mod list; +mod update; + +pub use create::create; +pub use delete::delete; +pub use get::get; +pub use list::list; +pub use update::update; + +impl TryFrom for EndpointGroup { + type Error = CatalogProviderError; + + /// Tries to convert a database endpoint group model into a domain endpoint + /// group. + /// + /// # Parameters + /// - `value`: The database endpoint group model. + /// + /// # Returns + /// A `Result` containing the `EndpointGroup`, or a `CatalogProviderError`. + fn try_from(value: db_endpoint_group::Model) -> Result { + let mut builder = EndpointGroupBuilder::default(); + builder.id(value.id.clone()); + builder.name(value.name.clone()); + if let Some(description) = &value.description { + builder.description(description.clone()); + } + + if value.filters != "{}" { + match serde_json::from_str::>(&value.filters) { + Ok(val) => { + builder.filters(val); + } + Err(e) => { + error!("failed to deserialize endpoint group filters: {e}"); + } + } + } + + Ok(builder.build()?) + } +} + +impl TryFrom for db_endpoint_group::ActiveModel { + type Error = CatalogProviderError; + + /// Tries to convert endpoint group creation parameters into a database + /// active model. + /// + /// # Parameters + /// - `value`: The endpoint group creation parameters. + /// + /// # Returns + /// A `Result` containing the `ActiveModel`, or a `CatalogProviderError`. + fn try_from(value: EndpointGroupCreate) -> Result { + Ok(Self { + id: Set(value + .id + .unwrap_or_else(|| Uuid::new_v4().simple().to_string())), + name: Set(value.name), + description: Set(value.description), + filters: Set(serde_json::to_string(&value.filters)?), + }) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use crate::entity::endpoint_group; + + pub fn get_endpoint_group_mock>(id: I) -> endpoint_group::Model { + endpoint_group::Model { + id: id.into(), + name: "group".into(), + description: Some("description".into()), + filters: r#"{"interface":"public"}"#.into(), + } + } +} diff --git a/crates/catalog-driver-sql/src/endpoint_group/create.rs b/crates/catalog-driver-sql/src/endpoint_group/create.rs new file mode 100644 index 000000000..fd004ed68 --- /dev/null +++ b/crates/catalog-driver-sql/src/endpoint_group/create.rs @@ -0,0 +1,75 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Create endpoint group + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; + +use openstack_keystone_core::catalog::CatalogProviderError; +use openstack_keystone_core::error::DbContextExt; +use openstack_keystone_core_types::catalog::{EndpointGroup, EndpointGroupCreate}; + +use crate::entity::endpoint_group as db_endpoint_group; + +/// Creates a new endpoint group. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `endpoint_group`: The endpoint group creation parameters. +/// +/// # Returns +/// A `Result` containing the created `EndpointGroup`, or an `Error`. +pub async fn create( + db: &DatabaseConnection, + endpoint_group: EndpointGroupCreate, +) -> Result { + TryInto::::try_into(endpoint_group)? + .insert(db) + .await + .context("creating endpoint group")? + .try_into() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use sea_orm::{DatabaseBackend, MockDatabase}; + use serde_json::json; + + use super::super::tests::get_endpoint_group_mock; + use super::*; + + #[tokio::test] + async fn test_create() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_endpoint_group_mock("eg-1")]]) + .into_connection(); + + let req = EndpointGroupCreate { + id: Some("eg-1".into()), + name: "group".into(), + description: Some("description".into()), + filters: HashMap::from([("interface".into(), json!("public"))]), + }; + + let created = create(&db, req).await.unwrap(); + assert_eq!(created.id, "eg-1"); + assert_eq!(created.name, "group"); + assert_eq!( + created.filters, + HashMap::from([("interface".to_string(), json!("public"))]) + ); + } +} diff --git a/crates/catalog-driver-sql/src/endpoint_group/delete.rs b/crates/catalog-driver-sql/src/endpoint_group/delete.rs new file mode 100644 index 000000000..2822facee --- /dev/null +++ b/crates/catalog-driver-sql/src/endpoint_group/delete.rs @@ -0,0 +1,60 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Delete Endpoint Group + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; + +use openstack_keystone_core::catalog::CatalogProviderError; +use openstack_keystone_core::error::DbContextExt; + +use crate::entity::prelude::EndpointGroup as DbEndpointGroup; + +/// Deletes an existing endpoint group. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `id`: The endpoint group ID. +/// +/// # Returns +/// A `Result` indicating success or an `Error`. +pub async fn delete>( + db: &DatabaseConnection, + id: S, +) -> Result<(), CatalogProviderError> { + DbEndpointGroup::delete_by_id(id.as_ref()) + .exec(db) + .await + .context("deleting endpoint group")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; + + use super::*; + + #[tokio::test] + async fn test_delete() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_exec_results([MockExecResult { + rows_affected: 1, + ..Default::default() + }]) + .into_connection(); + + delete(&db, "eg-1").await.unwrap(); + } +} diff --git a/crates/catalog-driver-sql/src/endpoint_group/get.rs b/crates/catalog-driver-sql/src/endpoint_group/get.rs new file mode 100644 index 000000000..1a8b2d2a6 --- /dev/null +++ b/crates/catalog-driver-sql/src/endpoint_group/get.rs @@ -0,0 +1,73 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Get endpoint group + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; + +use openstack_keystone_core::catalog::CatalogProviderError; +use openstack_keystone_core::error::DbContextExt; +use openstack_keystone_core_types::catalog::EndpointGroup; + +use crate::entity::prelude::EndpointGroup as DbEndpointGroup; + +/// Gets an endpoint group by ID. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `id`: The ID of the endpoint group to retrieve. +/// +/// # Returns +/// A `Result` containing an `Option` with the `EndpointGroup` if found, or an +/// `Error`. +pub async fn get>( + db: &DatabaseConnection, + id: I, +) -> Result, CatalogProviderError> { + DbEndpointGroup::find_by_id(id.as_ref()) + .one(db) + .await + .context("fetching endpoint group by ID")? + .map(TryInto::try_into) + .transpose() +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase}; + + use super::super::tests::get_endpoint_group_mock; + use super::*; + use crate::entity::endpoint_group; + + #[tokio::test] + async fn test_get() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_endpoint_group_mock("eg-1")]]) + .into_connection(); + + let eg = get(&db, "eg-1").await.unwrap().expect("group found"); + assert_eq!(eg.id, "eg-1"); + assert_eq!(eg.name, "group"); + } + + #[tokio::test] + async fn test_get_not_found() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([Vec::::new()]) + .into_connection(); + + assert!(get(&db, "missing").await.unwrap().is_none()); + } +} diff --git a/crates/catalog-driver-sql/src/endpoint_group/list.rs b/crates/catalog-driver-sql/src/endpoint_group/list.rs new file mode 100644 index 000000000..8183b0d8d --- /dev/null +++ b/crates/catalog-driver-sql/src/endpoint_group/list.rs @@ -0,0 +1,77 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # List endpoint groups + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; + +use openstack_keystone_core::catalog::CatalogProviderError; +use openstack_keystone_core::error::DbContextExt; +use openstack_keystone_core_types::catalog::*; + +use crate::entity::{ + endpoint_group as db_endpoint_group, prelude::EndpointGroup as DbEndpointGroup, +}; + +/// Lists endpoint groups. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `params`: The parameters for listing endpoint groups. +/// +/// # Returns +/// A `Result` containing a vector of `EndpointGroup`s, or a +/// `CatalogProviderError`. +pub async fn list( + db: &DatabaseConnection, + params: &EndpointGroupListParameters, +) -> Result, CatalogProviderError> { + let mut select = DbEndpointGroup::find(); + + if let Some(name) = ¶ms.name { + select = select.filter(db_endpoint_group::Column::Name.eq(name)); + } + + select + .all(db) + .await + .context("fetching endpoint groups")? + .into_iter() + .map(TryInto::::try_into) + .collect() +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase}; + + use super::super::tests::get_endpoint_group_mock; + use super::*; + + #[tokio::test] + async fn test_list() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![ + get_endpoint_group_mock("eg-1"), + get_endpoint_group_mock("eg-2"), + ]]) + .into_connection(); + + let groups = list(&db, &EndpointGroupListParameters::default()) + .await + .unwrap(); + assert_eq!(groups.len(), 2); + } +} diff --git a/crates/catalog-driver-sql/src/endpoint_group/update.rs b/crates/catalog-driver-sql/src/endpoint_group/update.rs new file mode 100644 index 000000000..fef95e3e5 --- /dev/null +++ b/crates/catalog-driver-sql/src/endpoint_group/update.rs @@ -0,0 +1,108 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Update Endpoint Group + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; + +use openstack_keystone_core::catalog::CatalogProviderError; +use openstack_keystone_core::error::DbContextExt; +use openstack_keystone_core_types::catalog::{EndpointGroup, EndpointGroupUpdate}; + +use crate::entity::{ + endpoint_group as db_endpoint_group, prelude::EndpointGroup as DbEndpointGroup, +}; + +/// Updates an existing endpoint group. +/// +/// Only the fields set in `endpoint_group` are changed; the rest are left +/// as-is. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `id`: The ID of the endpoint group to update. +/// - `endpoint_group`: The fields to change. +/// +/// # Returns +/// A `Result` containing the updated `EndpointGroup`, or an `Error` (including +/// `EndpointGroupNotFound` if no endpoint group with that ID exists). +pub async fn update>( + db: &DatabaseConnection, + id: I, + endpoint_group: EndpointGroupUpdate, +) -> Result { + let existing = DbEndpointGroup::find_by_id(id.as_ref()) + .one(db) + .await + .context("fetching endpoint group for update")? + .ok_or_else(|| CatalogProviderError::EndpointGroupNotFound(id.as_ref().to_string()))?; + + let mut update_model: db_endpoint_group::ActiveModel = existing.into(); + + if let Some(name) = endpoint_group.name { + update_model.name = Set(name); + } + if let Some(description) = endpoint_group.description { + update_model.description = Set(Some(description)); + } + if let Some(filters) = endpoint_group.filters { + update_model.filters = Set(serde_json::to_string(&filters)?); + } + + update_model + .update(db) + .await + .context("updating endpoint group")? + .try_into() +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase}; + + use super::super::tests::get_endpoint_group_mock; + use super::*; + use crate::entity::endpoint_group; + + #[tokio::test] + async fn test_update() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + // 1. fetch the existing group + .append_query_results([vec![get_endpoint_group_mock("eg-1")]]) + // 2. the UPDATE returns the updated row + .append_query_results([vec![get_endpoint_group_mock("eg-1")]]) + .into_connection(); + + let req = EndpointGroupUpdate { + name: Some("renamed".into()), + ..Default::default() + }; + + let result = update(&db, "eg-1", req).await; + assert!(result.is_ok(), "update failed: {:?}", result.err()); + } + + #[tokio::test] + async fn test_update_not_found() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([Vec::::new()]) + .into_connection(); + + let result = update(&db, "missing", EndpointGroupUpdate::default()).await; + assert!(matches!( + result, + Err(CatalogProviderError::EndpointGroupNotFound(_)) + )); + } +} diff --git a/crates/catalog-driver-sql/src/lib.rs b/crates/catalog-driver-sql/src/lib.rs index 96dc3f4ff..712c83a3e 100644 --- a/crates/catalog-driver-sql/src/lib.rs +++ b/crates/catalog-driver-sql/src/lib.rs @@ -37,7 +37,10 @@ use crate::entity::{ }; mod endpoint; +mod endpoint_group; pub mod entity; +mod project_endpoint; +mod project_endpoint_group; mod region; mod service; @@ -67,6 +70,50 @@ inventory::submit! { #[async_trait] impl CatalogBackend for SqlBackend { + /// Associate an endpoint with a project. + #[tracing::instrument(level = "debug", skip(self, state))] + async fn add_endpoint_to_project<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result<(), CatalogProviderError> { + Ok(project_endpoint::add(&state.db, project_id, endpoint_id).await?) + } + + /// Associate an endpoint group with a project. + #[tracing::instrument(level = "debug", skip(self, state))] + async fn add_endpoint_group_to_project<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result<(), CatalogProviderError> { + Ok(project_endpoint_group::add(&state.db, project_id, endpoint_group_id).await?) + } + + /// Check whether an endpoint is associated with a project. + #[tracing::instrument(level = "debug", skip(self, state))] + async fn check_endpoint_in_project<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result { + Ok(project_endpoint::check(&state.db, project_id, endpoint_id).await?) + } + + /// Check whether an endpoint group is associated with a project. + #[tracing::instrument(level = "debug", skip(self, state))] + async fn check_endpoint_group_in_project<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result { + Ok(project_endpoint_group::check(&state.db, project_id, endpoint_group_id).await?) + } + /// Create a new endpoint. /// /// # Parameters @@ -85,6 +132,16 @@ impl CatalogBackend for SqlBackend { Ok(endpoint::create(&state.db, endpoint_data).await?) } + /// Create a new endpoint group. + #[tracing::instrument(level = "debug", skip(self, state))] + async fn create_endpoint_group( + &self, + state: &ServiceState, + endpoint_group: EndpointGroupCreate, + ) -> Result { + Ok(endpoint_group::create(&state.db, endpoint_group).await?) + } + /// Create a new region. /// /// # Parameters @@ -137,6 +194,16 @@ impl CatalogBackend for SqlBackend { Ok(endpoint::delete(&state.db, id).await?) } + /// Delete an endpoint group by ID. + #[tracing::instrument(level = "debug", skip(self, state))] + async fn delete_endpoint_group<'a>( + &self, + state: &ServiceState, + id: &'a str, + ) -> Result<(), CatalogProviderError> { + Ok(endpoint_group::delete(&state.db, id).await?) + } + /// Delete a region by ID. /// /// # Parameters @@ -207,6 +274,16 @@ impl CatalogBackend for SqlBackend { Ok(endpoint::get(&state.db, id).await?) } + /// Get a single endpoint group by ID. + #[tracing::instrument(level = "debug", skip(self, state))] + async fn get_endpoint_group<'a>( + &self, + state: &ServiceState, + id: &'a str, + ) -> Result, CatalogProviderError> { + Ok(endpoint_group::get(&state.db, id).await?) + } + /// Get a single region by ID. /// /// # Parameters @@ -261,6 +338,36 @@ impl CatalogBackend for SqlBackend { Ok(endpoint::list(&state.db, params).await?) } + /// List endpoint groups. + #[tracing::instrument(level = "debug", skip(self, state))] + async fn list_endpoint_groups( + &self, + state: &ServiceState, + params: &EndpointGroupListParameters, + ) -> Result, CatalogProviderError> { + Ok(endpoint_group::list(&state.db, params).await?) + } + + /// List the endpoints associated with a project. + #[tracing::instrument(level = "debug", skip(self, state))] + async fn list_project_endpoints<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + ) -> Result, CatalogProviderError> { + Ok(project_endpoint::list_endpoints(&state.db, project_id).await?) + } + + /// List the endpoint groups associated with a project. + #[tracing::instrument(level = "debug", skip(self, state))] + async fn list_project_endpoint_groups<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + ) -> Result, CatalogProviderError> { + Ok(project_endpoint_group::list_endpoint_groups(&state.db, project_id).await?) + } + /// List regions. /// /// # Parameters @@ -308,6 +415,30 @@ impl CatalogBackend for SqlBackend { /// A `Result` containing the updated `Endpoint`, or a /// `CatalogProviderError`. #[tracing::instrument(level = "debug", skip(self, state))] + /// Remove the association between an endpoint and a project. + #[tracing::instrument(level = "debug", skip(self, state))] + async fn remove_endpoint_from_project<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result<(), CatalogProviderError> { + Ok(project_endpoint::remove(&state.db, project_id, endpoint_id).await?) + } + + /// Remove the association between an endpoint group and a project. + #[tracing::instrument(level = "debug", skip(self, state))] + async fn remove_endpoint_group_from_project<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result<(), CatalogProviderError> { + Ok(project_endpoint_group::remove(&state.db, project_id, endpoint_group_id).await?) + } + + /// Update an existing endpoint. + #[tracing::instrument(level = "debug", skip(self, state))] async fn update_endpoint<'a>( &self, state: &ServiceState, @@ -317,6 +448,17 @@ impl CatalogBackend for SqlBackend { Ok(endpoint::update(&state.db, id, endpoint_data).await?) } + /// Update an existing endpoint group. + #[tracing::instrument(level = "debug", skip(self, state))] + async fn update_endpoint_group<'a>( + &self, + state: &ServiceState, + id: &'a str, + endpoint_group: EndpointGroupUpdate, + ) -> Result { + Ok(endpoint_group::update(&state.db, id, endpoint_group).await?) + } + /// Update an existing region. /// /// # Parameters diff --git a/crates/catalog-driver-sql/src/project_endpoint.rs b/crates/catalog-driver-sql/src/project_endpoint.rs new file mode 100644 index 000000000..a9c2688a1 --- /dev/null +++ b/crates/catalog-driver-sql/src/project_endpoint.rs @@ -0,0 +1,186 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Project ↔ endpoint associations (OS-EP-FILTER) + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; + +use openstack_keystone_core::catalog::CatalogProviderError; +use openstack_keystone_core::error::DbContextExt; +use openstack_keystone_core_types::catalog::Endpoint; + +use crate::entity::{ + endpoint as db_endpoint, + prelude::{Endpoint as DbEndpoint, ProjectEndpoint as DbProjectEndpoint}, + project_endpoint as db_project_endpoint, +}; + +/// Associates an endpoint with a project. +/// +/// The operation is idempotent: associating an already-associated endpoint is a +/// no-op. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `project_id`: The ID of the project. +/// - `endpoint_id`: The ID of the endpoint. +/// +/// # Returns +/// A `Result` indicating success or an `Error`. +pub async fn add, E: AsRef>( + db: &DatabaseConnection, + project_id: P, + endpoint_id: E, +) -> Result<(), CatalogProviderError> { + if check(db, project_id.as_ref(), endpoint_id.as_ref()).await? { + return Ok(()); + } + db_project_endpoint::ActiveModel { + endpoint_id: Set(endpoint_id.as_ref().to_string()), + project_id: Set(project_id.as_ref().to_string()), + } + .insert(db) + .await + .context("associating endpoint with project")?; + Ok(()) +} + +/// Checks whether an endpoint is associated with a project. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `project_id`: The ID of the project. +/// - `endpoint_id`: The ID of the endpoint. +/// +/// # Returns +/// A `Result` containing `true` when the association exists. +pub async fn check, E: AsRef>( + db: &DatabaseConnection, + project_id: P, + endpoint_id: E, +) -> Result { + Ok(DbProjectEndpoint::find_by_id(( + endpoint_id.as_ref().to_string(), + project_id.as_ref().to_string(), + )) + .one(db) + .await + .context("checking project-endpoint association")? + .is_some()) +} + +/// Removes the association between an endpoint and a project. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `project_id`: The ID of the project. +/// - `endpoint_id`: The ID of the endpoint. +/// +/// # Returns +/// A `Result` indicating success or an `Error`. +pub async fn remove, E: AsRef>( + db: &DatabaseConnection, + project_id: P, + endpoint_id: E, +) -> Result<(), CatalogProviderError> { + DbProjectEndpoint::delete_by_id(( + endpoint_id.as_ref().to_string(), + project_id.as_ref().to_string(), + )) + .exec(db) + .await + .context("removing project-endpoint association")?; + Ok(()) +} + +/// Lists the endpoints associated with a project. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `project_id`: The ID of the project. +/// +/// # Returns +/// A `Result` containing a vector of `Endpoint`s associated with the project. +pub async fn list_endpoints>( + db: &DatabaseConnection, + project_id: P, +) -> Result, CatalogProviderError> { + let endpoint_ids: Vec = DbProjectEndpoint::find() + .filter(db_project_endpoint::Column::ProjectId.eq(project_id.as_ref())) + .all(db) + .await + .context("listing project-endpoint associations")? + .into_iter() + .map(|pe| pe.endpoint_id) + .collect(); + + if endpoint_ids.is_empty() { + return Ok(Vec::new()); + } + + DbEndpoint::find() + .filter(db_endpoint::Column::Id.is_in(endpoint_ids)) + .all(db) + .await + .context("fetching endpoints for project")? + .into_iter() + .map(TryInto::::try_into) + .collect() +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; + + use super::*; + + #[tokio::test] + async fn test_check_true() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![db_project_endpoint::Model { + endpoint_id: "e1".into(), + project_id: "p1".into(), + }]]) + .into_connection(); + assert!(check(&db, "p1", "e1").await.unwrap()); + } + + #[tokio::test] + async fn test_check_false() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([Vec::::new()]) + .into_connection(); + assert!(!check(&db, "p1", "e1").await.unwrap()); + } + + #[tokio::test] + async fn test_remove() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_exec_results([MockExecResult { + rows_affected: 1, + ..Default::default() + }]) + .into_connection(); + remove(&db, "p1", "e1").await.unwrap(); + } + + #[tokio::test] + async fn test_list_endpoints_empty() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([Vec::::new()]) + .into_connection(); + assert!(list_endpoints(&db, "p1").await.unwrap().is_empty()); + } +} diff --git a/crates/catalog-driver-sql/src/project_endpoint_group.rs b/crates/catalog-driver-sql/src/project_endpoint_group.rs new file mode 100644 index 000000000..d3026c80b --- /dev/null +++ b/crates/catalog-driver-sql/src/project_endpoint_group.rs @@ -0,0 +1,187 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Project ↔ endpoint group associations (OS-EP-FILTER) + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; + +use openstack_keystone_core::catalog::CatalogProviderError; +use openstack_keystone_core::error::DbContextExt; +use openstack_keystone_core_types::catalog::EndpointGroup; + +use crate::entity::{ + endpoint_group as db_endpoint_group, + prelude::{EndpointGroup as DbEndpointGroup, ProjectEndpointGroup as DbProjectEndpointGroup}, + project_endpoint_group as db_project_endpoint_group, +}; + +/// Associates an endpoint group with a project. +/// +/// The operation is idempotent: associating an already-associated endpoint +/// group is a no-op. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `project_id`: The ID of the project. +/// - `endpoint_group_id`: The ID of the endpoint group. +/// +/// # Returns +/// A `Result` indicating success or an `Error`. +pub async fn add, G: AsRef>( + db: &DatabaseConnection, + project_id: P, + endpoint_group_id: G, +) -> Result<(), CatalogProviderError> { + if check(db, project_id.as_ref(), endpoint_group_id.as_ref()).await? { + return Ok(()); + } + db_project_endpoint_group::ActiveModel { + endpoint_group_id: Set(endpoint_group_id.as_ref().to_string()), + project_id: Set(project_id.as_ref().to_string()), + } + .insert(db) + .await + .context("associating endpoint group with project")?; + Ok(()) +} + +/// Checks whether an endpoint group is associated with a project. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `project_id`: The ID of the project. +/// - `endpoint_group_id`: The ID of the endpoint group. +/// +/// # Returns +/// A `Result` containing `true` when the association exists. +pub async fn check, G: AsRef>( + db: &DatabaseConnection, + project_id: P, + endpoint_group_id: G, +) -> Result { + Ok(DbProjectEndpointGroup::find_by_id(( + endpoint_group_id.as_ref().to_string(), + project_id.as_ref().to_string(), + )) + .one(db) + .await + .context("checking project-endpoint-group association")? + .is_some()) +} + +/// Removes the association between an endpoint group and a project. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `project_id`: The ID of the project. +/// - `endpoint_group_id`: The ID of the endpoint group. +/// +/// # Returns +/// A `Result` indicating success or an `Error`. +pub async fn remove, G: AsRef>( + db: &DatabaseConnection, + project_id: P, + endpoint_group_id: G, +) -> Result<(), CatalogProviderError> { + DbProjectEndpointGroup::delete_by_id(( + endpoint_group_id.as_ref().to_string(), + project_id.as_ref().to_string(), + )) + .exec(db) + .await + .context("removing project-endpoint-group association")?; + Ok(()) +} + +/// Lists the endpoint groups associated with a project. +/// +/// # Parameters +/// - `db`: The database connection. +/// - `project_id`: The ID of the project. +/// +/// # Returns +/// A `Result` containing a vector of `EndpointGroup`s associated with the +/// project. +pub async fn list_endpoint_groups>( + db: &DatabaseConnection, + project_id: P, +) -> Result, CatalogProviderError> { + let group_ids: Vec = DbProjectEndpointGroup::find() + .filter(db_project_endpoint_group::Column::ProjectId.eq(project_id.as_ref())) + .all(db) + .await + .context("listing project-endpoint-group associations")? + .into_iter() + .map(|peg| peg.endpoint_group_id) + .collect(); + + if group_ids.is_empty() { + return Ok(Vec::new()); + } + + DbEndpointGroup::find() + .filter(db_endpoint_group::Column::Id.is_in(group_ids)) + .all(db) + .await + .context("fetching endpoint groups for project")? + .into_iter() + .map(TryInto::::try_into) + .collect() +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; + + use super::*; + + #[tokio::test] + async fn test_check_true() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![db_project_endpoint_group::Model { + endpoint_group_id: "g1".into(), + project_id: "p1".into(), + }]]) + .into_connection(); + assert!(check(&db, "p1", "g1").await.unwrap()); + } + + #[tokio::test] + async fn test_check_false() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([Vec::::new()]) + .into_connection(); + assert!(!check(&db, "p1", "g1").await.unwrap()); + } + + #[tokio::test] + async fn test_remove() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_exec_results([MockExecResult { + rows_affected: 1, + ..Default::default() + }]) + .into_connection(); + remove(&db, "p1", "g1").await.unwrap(); + } + + #[tokio::test] + async fn test_list_endpoint_groups_empty() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([Vec::::new()]) + .into_connection(); + assert!(list_endpoint_groups(&db, "p1").await.unwrap().is_empty()); + } +} diff --git a/crates/core-types/src/catalog.rs b/crates/core-types/src/catalog.rs index 6155b23b7..2ff97c19c 100644 --- a/crates/core-types/src/catalog.rs +++ b/crates/core-types/src/catalog.rs @@ -13,11 +13,13 @@ // SPDX-License-Identifier: Apache-2.0 //! # Catalog provider mod endpoint; +mod endpoint_group; mod error; mod region; mod service; pub use endpoint::*; +pub use endpoint_group::*; pub use error::*; pub use region::*; pub use service::*; diff --git a/crates/core-types/src/catalog/endpoint_group.rs b/crates/core-types/src/catalog/endpoint_group.rs new file mode 100644 index 000000000..349cd7ad8 --- /dev/null +++ b/crates/core-types/src/catalog/endpoint_group.rs @@ -0,0 +1,90 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; + +use derive_builder::Builder; +use serde_json::Value; +use validator::Validate; + +use crate::error::BuilderError; + +/// An endpoint group: a named set of endpoints selected by `filters`. +#[derive(Builder, Clone, Debug, Default, PartialEq, Validate)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(strip_option, into))] +pub struct EndpointGroup { + /// The endpoint group description. + #[builder(default)] + pub description: Option, + + /// The filters used to associate endpoints with the group (e.g. by + /// `interface`, `service_id`, or `region_id`). + #[builder(default)] + pub filters: HashMap, + + /// The ID of the endpoint group. + #[validate(length(min = 1, max = 64))] + pub id: String, + + /// The name of the endpoint group. + #[validate(length(min = 1, max = 255))] + pub name: String, +} + +/// Parameters for creating a new endpoint group. +#[derive(Clone, Debug, Default, PartialEq, Validate)] +pub struct EndpointGroupCreate { + /// The endpoint group description. + pub description: Option, + + /// The filters used to associate endpoints with the group. + pub filters: HashMap, + + /// The ID of the endpoint group. A UUID is generated when omitted. + #[validate(length(min = 1, max = 64))] + pub id: Option, + + /// The name of the endpoint group. + #[validate(length(min = 1, max = 255))] + pub name: String, +} + +/// Parameters for filtering the list of endpoint groups. +#[derive(Builder, Clone, Debug, Default, PartialEq, Validate)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(strip_option, into))] +pub struct EndpointGroupListParameters { + /// Filters the response by an endpoint group name. + #[builder(default)] + #[validate(length(max = 255))] + pub name: Option, +} + +/// Fields that can be changed when updating an endpoint group. +/// +/// Each field is `None` when the caller did not provide it (leave unchanged) +/// and `Some(..)` to set a new value. +#[derive(Clone, Debug, Default, PartialEq, Validate)] +pub struct EndpointGroupUpdate { + /// New endpoint group description. + pub description: Option, + + /// New filters (replaces the existing filters when provided). + pub filters: Option>, + + /// New endpoint group name. + #[validate(length(min = 1, max = 255))] + pub name: Option, +} diff --git a/crates/core-types/src/catalog/error.rs b/crates/core-types/src/catalog/error.rs index e9967c6ac..e2504e4ad 100644 --- a/crates/core-types/src/catalog/error.rs +++ b/crates/core-types/src/catalog/error.rs @@ -35,6 +35,10 @@ pub enum CatalogProviderError { #[error("endpoint {0} not found")] EndpointNotFound(String), + /// The endpoint group has not been found. + #[error("endpoint group {0} not found")] + EndpointGroupNotFound(String), + /// The service has not been found. #[error("service {0} not found")] ServiceNotFound(String), diff --git a/crates/core-types/src/events.rs b/crates/core-types/src/events.rs index cda7d410e..b56dc0d40 100644 --- a/crates/core-types/src/events.rs +++ b/crates/core-types/src/events.rs @@ -120,6 +120,17 @@ pub enum EventPayload { Endpoint { id: String, }, + EndpointGroup { + id: String, + }, + ProjectEndpoint { + project_id: String, + endpoint_id: String, + }, + ProjectEndpointGroup { + project_id: String, + endpoint_group_id: String, + }, Region { id: String, }, diff --git a/crates/core/src/cadf_hook.rs b/crates/core/src/cadf_hook.rs index a2a4e0207..76b38764a 100644 --- a/crates/core/src/cadf_hook.rs +++ b/crates/core/src/cadf_hook.rs @@ -86,6 +86,14 @@ fn build_target_from_event(event: &Event) -> Target { EventPayload::AccessRule { id, .. } => (id, "data/security/identity/access-rule"), EventPayload::Credential { id, .. } => (id, "data/security/identity/credential"), EventPayload::Endpoint { id } => (id, "data/compute/catalog/endpoint"), + EventPayload::EndpointGroup { id } => (id, "data/compute/catalog/endpoint-group"), + EventPayload::ProjectEndpoint { + endpoint_id: id, .. + } => (id, "data/compute/catalog/project-endpoint"), + EventPayload::ProjectEndpointGroup { + endpoint_group_id: id, + .. + } => (id, "data/compute/catalog/project-endpoint-group"), EventPayload::Region { id } => (id, "data/compute/catalog/region"), EventPayload::Service { id } => (id, "data/compute/catalog/service"), EventPayload::Trust { id } => (id, "data/security/identity/trust"), diff --git a/crates/core/src/catalog/backend.rs b/crates/core/src/catalog/backend.rs index 33512e725..ccb620096 100644 --- a/crates/core/src/catalog/backend.rs +++ b/crates/core/src/catalog/backend.rs @@ -22,6 +22,72 @@ use crate::keystone::ServiceState; #[cfg_attr(test, mockall::automock)] #[async_trait] pub trait CatalogBackend: Send + Sync { + /// Associate an endpoint with a project. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `project_id`: The ID of the project. + /// - `endpoint_id`: The ID of the endpoint. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn add_endpoint_to_project<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result<(), CatalogProviderError>; + + /// Associate an endpoint group with a project. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `project_id`: The ID of the project. + /// - `endpoint_group_id`: The ID of the endpoint group. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn add_endpoint_group_to_project<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result<(), CatalogProviderError>; + + /// Check whether an endpoint is associated with a project. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `project_id`: The ID of the project. + /// - `endpoint_id`: The ID of the endpoint. + /// + /// # Returns + /// A `Result` containing `true` when the association exists, or a + /// `CatalogProviderError`. + async fn check_endpoint_in_project<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result; + + /// Check whether an endpoint group is associated with a project. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `project_id`: The ID of the project. + /// - `endpoint_group_id`: The ID of the endpoint group. + /// + /// # Returns + /// A `Result` containing `true` when the association exists, or a + /// `CatalogProviderError`. + async fn check_endpoint_group_in_project<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result; + /// Create a new endpoint. /// /// # Parameters @@ -37,6 +103,21 @@ pub trait CatalogBackend: Send + Sync { endpoint: EndpointCreate, ) -> Result; + /// Create a new endpoint group. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `endpoint_group`: The endpoint group creation parameters. + /// + /// # Returns + /// A `Result` containing the created `EndpointGroup`, or a + /// `CatalogProviderError`. + async fn create_endpoint_group( + &self, + state: &ServiceState, + endpoint_group: EndpointGroupCreate, + ) -> Result; + /// Create a new region. /// /// # Parameters @@ -80,6 +161,20 @@ pub trait CatalogBackend: Send + Sync { id: &'a str, ) -> Result<(), CatalogProviderError>; + /// Delete an endpoint group by ID. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `id`: The unique identifier of the endpoint group. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn delete_endpoint_group<'a>( + &self, + state: &ServiceState, + id: &'a str, + ) -> Result<(), CatalogProviderError>; + /// Delete a region by ID. /// /// # Parameters @@ -138,6 +233,21 @@ pub trait CatalogBackend: Send + Sync { id: &'a str, ) -> Result, CatalogProviderError>; + /// Get single endpoint group by ID. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `id`: The unique identifier of the endpoint group. + /// + /// # Returns + /// A `Result` containing an `Option` with the `EndpointGroup` if found, or a + /// `CatalogProviderError`. + async fn get_endpoint_group<'a>( + &self, + state: &ServiceState, + id: &'a str, + ) -> Result, CatalogProviderError>; + /// Get single region by ID. /// /// # Parameters @@ -183,6 +293,51 @@ pub trait CatalogBackend: Send + Sync { params: &EndpointListParameters, ) -> Result, CatalogProviderError>; + /// List endpoint groups. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `params`: Parameters for filtering the endpoint group list. + /// + /// # Returns + /// A `Result` containing a vector of `EndpointGroup` objects or a + /// `CatalogProviderError`. + async fn list_endpoint_groups( + &self, + state: &ServiceState, + params: &EndpointGroupListParameters, + ) -> Result, CatalogProviderError>; + + /// List the endpoints associated with a project. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `project_id`: The ID of the project. + /// + /// # Returns + /// A `Result` containing a vector of `Endpoint` objects or a + /// `CatalogProviderError`. + async fn list_project_endpoints<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + ) -> Result, CatalogProviderError>; + + /// List the endpoint groups associated with a project. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `project_id`: The ID of the project. + /// + /// # Returns + /// A `Result` containing a vector of `EndpointGroup` objects or a + /// `CatalogProviderError`. + async fn list_project_endpoint_groups<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + ) -> Result, CatalogProviderError>; + /// List regions. /// /// # Parameters @@ -213,6 +368,48 @@ pub trait CatalogBackend: Send + Sync { params: &ServiceListParameters, ) -> Result, CatalogProviderError>; + /// Update an existing endpoint. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `id`: The unique identifier of the endpoint. + /// - `endpoint`: The fields to change. + /// + /// # Returns + /// A `Result` containing the updated `Endpoint`, or a + /// `CatalogProviderError`. + /// Remove the association between an endpoint and a project. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `project_id`: The ID of the project. + /// - `endpoint_id`: The ID of the endpoint. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn remove_endpoint_from_project<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result<(), CatalogProviderError>; + + /// Remove the association between an endpoint group and a project. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `project_id`: The ID of the project. + /// - `endpoint_group_id`: The ID of the endpoint group. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn remove_endpoint_group_from_project<'a>( + &self, + state: &ServiceState, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result<(), CatalogProviderError>; + /// Update an existing endpoint. /// /// # Parameters @@ -230,6 +427,23 @@ pub trait CatalogBackend: Send + Sync { endpoint: EndpointUpdate, ) -> Result; + /// Update an existing endpoint group. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `id`: The unique identifier of the endpoint group. + /// - `endpoint_group`: The fields to change. + /// + /// # Returns + /// A `Result` containing the updated `EndpointGroup`, or a + /// `CatalogProviderError`. + async fn update_endpoint_group<'a>( + &self, + state: &ServiceState, + id: &'a str, + endpoint_group: EndpointGroupUpdate, + ) -> Result; + /// Update an existing region. /// /// # Parameters diff --git a/crates/core/src/catalog/provider_api.rs b/crates/core/src/catalog/provider_api.rs index b1a45f2e4..ab0411e34 100644 --- a/crates/core/src/catalog/provider_api.rs +++ b/crates/core/src/catalog/provider_api.rs @@ -21,6 +21,72 @@ use crate::catalog::CatalogProviderError; #[async_trait] pub trait CatalogApi: Send + Sync { + /// Associate an endpoint with a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// - `endpoint_id`: The ID of the endpoint. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn add_endpoint_to_project<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result<(), CatalogProviderError>; + + /// Associate an endpoint group with a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// - `endpoint_group_id`: The ID of the endpoint group. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn add_endpoint_group_to_project<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result<(), CatalogProviderError>; + + /// Check whether an endpoint is associated with a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// - `endpoint_id`: The ID of the endpoint. + /// + /// # Returns + /// A `Result` containing `true` when the association exists, or a + /// `CatalogProviderError`. + async fn check_endpoint_in_project<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result; + + /// Check whether an endpoint group is associated with a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// - `endpoint_group_id`: The ID of the endpoint group. + /// + /// # Returns + /// A `Result` containing `true` when the association exists, or a + /// `CatalogProviderError`. + async fn check_endpoint_group_in_project<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result; + /// Create a new endpoint. /// /// # Parameters @@ -36,6 +102,21 @@ pub trait CatalogApi: Send + Sync { endpoint: EndpointCreate, ) -> Result; + /// Create a new endpoint group. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `endpoint_group`: The endpoint group creation parameters. + /// + /// # Returns + /// A `Result` containing the created `EndpointGroup`, or a + /// `CatalogProviderError`. + async fn create_endpoint_group<'a>( + &self, + exec: &ExecutionContext<'a>, + endpoint_group: EndpointGroupCreate, + ) -> Result; + /// Create a new region. /// /// # Parameters @@ -79,6 +160,20 @@ pub trait CatalogApi: Send + Sync { id: &'a str, ) -> Result<(), CatalogProviderError>; + /// Delete an endpoint group by ID. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `id`: The unique identifier of the endpoint group. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn delete_endpoint_group<'a>( + &self, + exec: &ExecutionContext<'a>, + id: &'a str, + ) -> Result<(), CatalogProviderError>; + /// Delete a region by ID. /// /// # Parameters @@ -137,6 +232,21 @@ pub trait CatalogApi: Send + Sync { id: &'a str, ) -> Result, CatalogProviderError>; + /// Get single endpoint group by ID. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `id`: The unique identifier of the endpoint group. + /// + /// # Returns + /// A `Result` containing an `Option` with the `EndpointGroup` if found, or a + /// `CatalogProviderError`. + async fn get_endpoint_group<'a>( + &self, + exec: &ExecutionContext<'a>, + id: &'a str, + ) -> Result, CatalogProviderError>; + /// Get single region by ID. /// /// # Parameters @@ -182,6 +292,51 @@ pub trait CatalogApi: Send + Sync { params: &EndpointListParameters, ) -> Result, CatalogProviderError>; + /// List endpoint groups. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `params`: Parameters for filtering the endpoint group list. + /// + /// # Returns + /// A `Result` containing a vector of `EndpointGroup` objects or a + /// `CatalogProviderError`. + async fn list_endpoint_groups<'a>( + &self, + exec: &ExecutionContext<'a>, + params: &EndpointGroupListParameters, + ) -> Result, CatalogProviderError>; + + /// List the endpoints associated with a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// + /// # Returns + /// A `Result` containing a vector of `Endpoint` objects or a + /// `CatalogProviderError`. + async fn list_project_endpoints<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + ) -> Result, CatalogProviderError>; + + /// List the endpoint groups associated with a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// + /// # Returns + /// A `Result` containing a vector of `EndpointGroup` objects or a + /// `CatalogProviderError`. + async fn list_project_endpoint_groups<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + ) -> Result, CatalogProviderError>; + /// List regions. /// /// # Parameters @@ -222,6 +377,48 @@ pub trait CatalogApi: Send + Sync { /// # Returns /// A `Result` containing the updated `Endpoint`, or a /// `CatalogProviderError`. + /// Remove the association between an endpoint and a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// - `endpoint_id`: The ID of the endpoint. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn remove_endpoint_from_project<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result<(), CatalogProviderError>; + + /// Remove the association between an endpoint group and a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// - `endpoint_group_id`: The ID of the endpoint group. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn remove_endpoint_group_from_project<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result<(), CatalogProviderError>; + + /// Update an existing endpoint. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `id`: The unique identifier of the endpoint. + /// - `endpoint`: The fields to change. + /// + /// # Returns + /// A `Result` containing the updated `Endpoint`, or a + /// `CatalogProviderError`. async fn update_endpoint<'a>( &self, exec: &ExecutionContext<'a>, @@ -229,6 +426,23 @@ pub trait CatalogApi: Send + Sync { endpoint: EndpointUpdate, ) -> Result; + /// Update an existing endpoint group. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `id`: The unique identifier of the endpoint group. + /// - `endpoint_group`: The fields to change. + /// + /// # Returns + /// A `Result` containing the updated `EndpointGroup`, or a + /// `CatalogProviderError`. + async fn update_endpoint_group<'a>( + &self, + exec: &ExecutionContext<'a>, + id: &'a str, + endpoint_group: EndpointGroupUpdate, + ) -> Result; + /// Update an existing region. /// /// # Parameters diff --git a/crates/core/src/catalog/service.rs b/crates/core/src/catalog/service.rs index c10154a8d..388056d0b 100644 --- a/crates/core/src/catalog/service.rs +++ b/crates/core/src/catalog/service.rs @@ -52,6 +52,150 @@ impl CatalogService { #[async_trait] impl CatalogApi for CatalogService { + /// Associate an endpoint with a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// - `endpoint_id`: The ID of the endpoint. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn add_endpoint_to_project<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result<(), CatalogProviderError> { + if let Some(vsc) = exec.ctx() { + let backend_driver = &self.backend_driver; + crate::audited_op! { + dispatcher: &exec.state().event_dispatcher, + ctx: vsc, + event: Event::new( + Operation::Create, + EventPayload::ProjectEndpoint { + project_id: project_id.to_string(), + endpoint_id: endpoint_id.to_string(), + }, + ), + operation: async { + backend_driver.add_endpoint_to_project(exec.state(), project_id, endpoint_id).await + }, + on_audit_error: |_: AuditDispatchError| CatalogProviderError::Driver("audit dispatch failed".into()), + }? + } else { + self.backend_driver + .add_endpoint_to_project(exec.state(), project_id, endpoint_id) + .await?; + + exec.state() + .event_dispatcher + .emit(Event::new( + Operation::Create, + EventPayload::ProjectEndpoint { + project_id: project_id.to_string(), + endpoint_id: endpoint_id.to_string(), + }, + )) + .await; + } + Ok(()) + } + + /// Associate an endpoint group with a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// - `endpoint_group_id`: The ID of the endpoint group. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn add_endpoint_group_to_project<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result<(), CatalogProviderError> { + if let Some(vsc) = exec.ctx() { + let backend_driver = &self.backend_driver; + crate::audited_op! { + dispatcher: &exec.state().event_dispatcher, + ctx: vsc, + event: Event::new( + Operation::Create, + EventPayload::ProjectEndpointGroup { + project_id: project_id.to_string(), + endpoint_group_id: endpoint_group_id.to_string(), + }, + ), + operation: async { + backend_driver.add_endpoint_group_to_project(exec.state(), project_id, endpoint_group_id).await + }, + on_audit_error: |_: AuditDispatchError| CatalogProviderError::Driver("audit dispatch failed".into()), + }? + } else { + self.backend_driver + .add_endpoint_group_to_project(exec.state(), project_id, endpoint_group_id) + .await?; + + exec.state() + .event_dispatcher + .emit(Event::new( + Operation::Create, + EventPayload::ProjectEndpointGroup { + project_id: project_id.to_string(), + endpoint_group_id: endpoint_group_id.to_string(), + }, + )) + .await; + } + Ok(()) + } + + /// Check whether an endpoint is associated with a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// - `endpoint_id`: The ID of the endpoint. + /// + /// # Returns + /// A `Result` containing `true` when the association exists, or a + /// `CatalogProviderError`. + async fn check_endpoint_in_project<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result { + self.backend_driver + .check_endpoint_in_project(exec.state(), project_id, endpoint_id) + .await + } + + /// Check whether an endpoint group is associated with a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// - `endpoint_group_id`: The ID of the endpoint group. + /// + /// # Returns + /// A `Result` containing `true` when the association exists, or a + /// `CatalogProviderError`. + async fn check_endpoint_group_in_project<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result { + self.backend_driver + .check_endpoint_group_in_project(exec.state(), project_id, endpoint_group_id) + .await + } + /// Create a new endpoint. /// /// # Parameters @@ -104,6 +248,56 @@ impl CatalogApi for CatalogService { Ok(endpoint) } + /// Create a new endpoint group. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `endpoint_group`: The endpoint group creation parameters. + /// + /// # Returns + /// A `Result` containing the created `EndpointGroup`, or a + /// `CatalogProviderError`. + async fn create_endpoint_group<'a>( + &self, + exec: &ExecutionContext<'a>, + endpoint_group: EndpointGroupCreate, + ) -> Result { + endpoint_group.validate()?; + let endpoint_group = if let Some(vsc) = exec.ctx() { + let backend_driver = &self.backend_driver; + let eg_clone = endpoint_group.clone(); + crate::audited_op! { + dispatcher: &exec.state().event_dispatcher, + ctx: vsc, + event: Event::new( + Operation::Create, + EventPayload::EndpointGroup { id: eg_clone.id.clone().unwrap_or_default() }, + ), + operation: async { + backend_driver.create_endpoint_group(exec.state(), eg_clone).await + }, + on_audit_error: |_: AuditDispatchError| CatalogProviderError::Driver("audit dispatch failed".into()), + }? + } else { + let eg = self + .backend_driver + .create_endpoint_group(exec.state(), endpoint_group) + .await?; + + exec.state() + .event_dispatcher + .emit(Event::new( + Operation::Create, + EventPayload::EndpointGroup { id: eg.id.clone() }, + )) + .await; + + eg + }; + + Ok(endpoint_group) + } + /// Create a new region. /// /// # Parameters @@ -252,6 +446,50 @@ impl CatalogApi for CatalogService { Ok(()) } + /// Delete an endpoint group by ID. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `id`: The unique identifier of the endpoint group. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn delete_endpoint_group<'a>( + &self, + exec: &ExecutionContext<'a>, + id: &'a str, + ) -> Result<(), CatalogProviderError> { + if let Some(vsc) = exec.ctx() { + crate::audited_op! { + dispatcher: &exec.state().event_dispatcher, + ctx: vsc, + event: Event::new( + Operation::Delete, + EventPayload::EndpointGroup { id: id.to_string() }, + ), + operation: async { + self.backend_driver.delete_endpoint_group(exec.state(), id).await?; + Ok::<(), CatalogProviderError>(()) + }, + on_audit_error: |_: AuditDispatchError| CatalogProviderError::Driver("audit dispatch failed".into()), + }?; + } else { + self.backend_driver + .delete_endpoint_group(exec.state(), id) + .await?; + + exec.state() + .event_dispatcher + .emit(Event::new( + Operation::Delete, + EventPayload::EndpointGroup { id: id.to_string() }, + )) + .await; + } + + Ok(()) + } + /// Delete a region by ID. /// /// # Parameters @@ -370,6 +608,25 @@ impl CatalogApi for CatalogService { self.backend_driver.get_endpoint(exec.state(), id).await } + /// Get single endpoint group by ID. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `id`: The unique identifier of the endpoint group. + /// + /// # Returns + /// A `Result` containing an `Option` with the endpoint group if found, or an + /// `Error`. + async fn get_endpoint_group<'a>( + &self, + exec: &ExecutionContext<'a>, + id: &'a str, + ) -> Result, CatalogProviderError> { + self.backend_driver + .get_endpoint_group(exec.state(), id) + .await + } + /// Get single region by ID. /// /// # Parameters @@ -424,6 +681,64 @@ impl CatalogApi for CatalogService { .await } + /// List endpoint groups. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `params`: Parameters for filtering the endpoint group list. + /// + /// # Returns + /// A `Result` containing a vector of `EndpointGroup` objects or a + /// `CatalogProviderError`. + async fn list_endpoint_groups<'a>( + &self, + exec: &ExecutionContext<'a>, + params: &EndpointGroupListParameters, + ) -> Result, CatalogProviderError> { + params.validate()?; + self.backend_driver + .list_endpoint_groups(exec.state(), params) + .await + } + + /// List the endpoints associated with a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// + /// # Returns + /// A `Result` containing a vector of `Endpoint` objects or a + /// `CatalogProviderError`. + async fn list_project_endpoints<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + ) -> Result, CatalogProviderError> { + self.backend_driver + .list_project_endpoints(exec.state(), project_id) + .await + } + + /// List the endpoint groups associated with a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// + /// # Returns + /// A `Result` containing a vector of `EndpointGroup` objects or a + /// `CatalogProviderError`. + async fn list_project_endpoint_groups<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + ) -> Result, CatalogProviderError> { + self.backend_driver + .list_project_endpoint_groups(exec.state(), project_id) + .await + } + /// List regions. /// /// # Parameters @@ -472,6 +787,118 @@ impl CatalogApi for CatalogService { /// # Returns /// A `Result` containing the updated `Endpoint`, or a /// `CatalogProviderError`. + /// Remove the association between an endpoint and a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// - `endpoint_id`: The ID of the endpoint. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn remove_endpoint_from_project<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result<(), CatalogProviderError> { + if let Some(vsc) = exec.ctx() { + let backend_driver = &self.backend_driver; + crate::audited_op! { + dispatcher: &exec.state().event_dispatcher, + ctx: vsc, + event: Event::new( + Operation::Delete, + EventPayload::ProjectEndpoint { + project_id: project_id.to_string(), + endpoint_id: endpoint_id.to_string(), + }, + ), + operation: async { + backend_driver.remove_endpoint_from_project(exec.state(), project_id, endpoint_id).await + }, + on_audit_error: |_: AuditDispatchError| CatalogProviderError::Driver("audit dispatch failed".into()), + }? + } else { + self.backend_driver + .remove_endpoint_from_project(exec.state(), project_id, endpoint_id) + .await?; + + exec.state() + .event_dispatcher + .emit(Event::new( + Operation::Delete, + EventPayload::ProjectEndpoint { + project_id: project_id.to_string(), + endpoint_id: endpoint_id.to_string(), + }, + )) + .await; + } + Ok(()) + } + + /// Remove the association between an endpoint group and a project. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `project_id`: The ID of the project. + /// - `endpoint_group_id`: The ID of the endpoint group. + /// + /// # Returns + /// A `Result` indicating success or a `CatalogProviderError`. + async fn remove_endpoint_group_from_project<'a>( + &self, + exec: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result<(), CatalogProviderError> { + if let Some(vsc) = exec.ctx() { + let backend_driver = &self.backend_driver; + crate::audited_op! { + dispatcher: &exec.state().event_dispatcher, + ctx: vsc, + event: Event::new( + Operation::Delete, + EventPayload::ProjectEndpointGroup { + project_id: project_id.to_string(), + endpoint_group_id: endpoint_group_id.to_string(), + }, + ), + operation: async { + backend_driver.remove_endpoint_group_from_project(exec.state(), project_id, endpoint_group_id).await + }, + on_audit_error: |_: AuditDispatchError| CatalogProviderError::Driver("audit dispatch failed".into()), + }? + } else { + self.backend_driver + .remove_endpoint_group_from_project(exec.state(), project_id, endpoint_group_id) + .await?; + + exec.state() + .event_dispatcher + .emit(Event::new( + Operation::Delete, + EventPayload::ProjectEndpointGroup { + project_id: project_id.to_string(), + endpoint_group_id: endpoint_group_id.to_string(), + }, + )) + .await; + } + Ok(()) + } + + /// Update an existing endpoint. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `id`: The unique identifier of the endpoint. + /// - `endpoint`: The fields to change. + /// + /// # Returns + /// A `Result` containing the updated `Endpoint`, or a + /// `CatalogProviderError`. async fn update_endpoint<'a>( &self, exec: &ExecutionContext<'a>, @@ -511,6 +938,55 @@ impl CatalogApi for CatalogService { Ok(updated) } + /// Update an existing endpoint group. + /// + /// # Parameters + /// - `exec`: The execution context. + /// - `id`: The unique identifier of the endpoint group. + /// - `endpoint_group`: The fields to change. + /// + /// # Returns + /// A `Result` containing the updated `EndpointGroup`, or a + /// `CatalogProviderError`. + async fn update_endpoint_group<'a>( + &self, + exec: &ExecutionContext<'a>, + id: &'a str, + endpoint_group: EndpointGroupUpdate, + ) -> Result { + endpoint_group.validate()?; + let updated = if let Some(vsc) = exec.ctx() { + let backend_driver = &self.backend_driver; + let eg_clone = endpoint_group.clone(); + crate::audited_op! { + dispatcher: &exec.state().event_dispatcher, + ctx: vsc, + event: Event::new( + Operation::Update, + EventPayload::EndpointGroup { id: id.to_string() }, + ), + operation: async { + backend_driver.update_endpoint_group(exec.state(), id, eg_clone).await + }, + on_audit_error: |_: AuditDispatchError| CatalogProviderError::Driver("audit dispatch failed".into()), + }? + } else { + let updated = self + .backend_driver + .update_endpoint_group(exec.state(), id, endpoint_group) + .await?; + exec.state() + .event_dispatcher + .emit(Event::new( + Operation::Update, + EventPayload::EndpointGroup { id: id.to_string() }, + )) + .await; + updated + }; + Ok(updated) + } + /// Update an existing region. /// /// # Parameters diff --git a/crates/core/src/mocks.rs b/crates/core/src/mocks.rs index e82103728..d405f037f 100644 --- a/crates/core/src/mocks.rs +++ b/crates/core/src/mocks.rs @@ -633,12 +633,46 @@ mod catalog { #[async_trait] impl CatalogApi for CatalogProvider { + async fn add_endpoint_to_project<'a>( + &self, + ctx: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result<(), CatalogProviderError>; + + async fn add_endpoint_group_to_project<'a>( + &self, + ctx: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result<(), CatalogProviderError>; + + async fn check_endpoint_in_project<'a>( + &self, + ctx: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result; + + async fn check_endpoint_group_in_project<'a>( + &self, + ctx: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result; + async fn create_endpoint<'a>( &self, ctx: &ExecutionContext<'a>, endpoint: EndpointCreate, ) -> Result; + async fn create_endpoint_group<'a>( + &self, + ctx: &ExecutionContext<'a>, + endpoint_group: EndpointGroupCreate, + ) -> Result; + async fn create_region<'a>( &self, ctx: &ExecutionContext<'a>, @@ -657,6 +691,12 @@ mod catalog { id: &'a str, ) -> Result<(), CatalogProviderError>; + async fn delete_endpoint_group<'a>( + &self, + ctx: &ExecutionContext<'a>, + id: &'a str, + ) -> Result<(), CatalogProviderError>; + async fn delete_region<'a>( &self, ctx: &ExecutionContext<'a>, @@ -681,6 +721,12 @@ mod catalog { id: &'a str, ) -> Result, CatalogProviderError>; + async fn get_endpoint_group<'a>( + &self, + ctx: &ExecutionContext<'a>, + id: &'a str, + ) -> Result, CatalogProviderError>; + async fn get_region<'a>( &self, ctx: &ExecutionContext<'a>, @@ -699,6 +745,24 @@ mod catalog { params: &EndpointListParameters, ) -> Result, CatalogProviderError>; + async fn list_endpoint_groups<'a>( + &self, + ctx: &ExecutionContext<'a>, + params: &EndpointGroupListParameters, + ) -> Result, CatalogProviderError>; + + async fn list_project_endpoints<'a>( + &self, + ctx: &ExecutionContext<'a>, + project_id: &'a str, + ) -> Result, CatalogProviderError>; + + async fn list_project_endpoint_groups<'a>( + &self, + ctx: &ExecutionContext<'a>, + project_id: &'a str, + ) -> Result, CatalogProviderError>; + async fn list_regions<'a>( &self, ctx: &ExecutionContext<'a>, @@ -711,6 +775,20 @@ mod catalog { params: &ServiceListParameters, ) -> Result, CatalogProviderError>; + async fn remove_endpoint_from_project<'a>( + &self, + ctx: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_id: &'a str, + ) -> Result<(), CatalogProviderError>; + + async fn remove_endpoint_group_from_project<'a>( + &self, + ctx: &ExecutionContext<'a>, + project_id: &'a str, + endpoint_group_id: &'a str, + ) -> Result<(), CatalogProviderError>; + async fn update_endpoint<'a>( &self, ctx: &ExecutionContext<'a>, @@ -718,6 +796,13 @@ mod catalog { endpoint: EndpointUpdate, ) -> Result; + async fn update_endpoint_group<'a>( + &self, + ctx: &ExecutionContext<'a>, + id: &'a str, + endpoint_group: EndpointGroupUpdate, + ) -> Result; + async fn update_region<'a>( &self, ctx: &ExecutionContext<'a>, diff --git a/tests/integration/src/catalog.rs b/tests/integration/src/catalog.rs index 0ba0fee1b..01bc8e403 100644 --- a/tests/integration/src/catalog.rs +++ b/tests/integration/src/catalog.rs @@ -12,7 +12,9 @@ // // SPDX-License-Identifier: Apache-2.0 +mod associations; mod endpoint; +mod endpoint_group; mod region; mod service; @@ -33,6 +35,12 @@ use crate::common::*; use crate::impl_deleter; impl_deleter!(Service, Endpoint, get_catalog_provider, delete_endpoint); +impl_deleter!( + Service, + EndpointGroup, + get_catalog_provider, + delete_endpoint_group +); impl_deleter!(Service, Region, get_catalog_provider, delete_region); impl_deleter!( Service, @@ -56,6 +64,21 @@ pub async fn create_endpoint( Ok(AsyncResourceGuard::new(res, state.clone())) } +/// Create an endpoint group through the catalog provider, returning a guard +/// that deletes it again when dropped. +pub async fn create_endpoint_group( + state: &ServiceState, + data: EndpointGroupCreate, +) -> Result> { + let res = state + .provider + .get_catalog_provider() + .create_endpoint_group(&ExecutionContext::internal(state), data) + .await + .unwrap(); + Ok(AsyncResourceGuard::new(res, state.clone())) +} + /// Create a region through the catalog provider, returning a guard that deletes /// it again when dropped. pub async fn create_region( diff --git a/tests/integration/src/catalog/associations.rs b/tests/integration/src/catalog/associations.rs new file mode 100644 index 000000000..285100a35 --- /dev/null +++ b/tests/integration/src/catalog/associations.rs @@ -0,0 +1,149 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! Test the OS-EP-FILTER project association operations. + +use std::collections::HashMap; + +use eyre::Result; +use tracing_test::traced_test; +use uuid::Uuid; + +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::catalog::{EndpointCreate, EndpointGroupCreate, ServiceCreate}; + +use crate::catalog::{create_endpoint, create_endpoint_group, create_service}; +use crate::common::get_state; +use crate::{create_domain, create_project}; + +#[traced_test] +#[tokio::test] +async fn test_project_endpoint_association() -> Result<()> { + let (state, _tmp) = get_state().await?; + let domain = create_domain!(state)?; + let project = create_project!(state, domain.id.clone())?; + let service = create_service( + &state, + ServiceCreate { + enabled: true, + extra: HashMap::new(), + id: None, + r#type: Some("compute".to_string()), + }, + ) + .await?; + let endpoint = create_endpoint( + &state, + EndpointCreate { + enabled: true, + extra: HashMap::new(), + id: None, + interface: "public".to_string(), + region_id: None, + service_id: service.id.clone(), + url: "http://localhost:8774".to_string(), + }, + ) + .await?; + + let catalog = state.provider.get_catalog_provider(); + let ctx = ExecutionContext::internal(&state); + + // Not associated initially. + assert!( + !catalog + .check_endpoint_in_project(&ctx, &project.id, &endpoint.id) + .await? + ); + + // Associate, then check + list reflect it. + catalog + .add_endpoint_to_project(&ctx, &project.id, &endpoint.id) + .await?; + assert!( + catalog + .check_endpoint_in_project(&ctx, &project.id, &endpoint.id) + .await? + ); + let endpoints = catalog.list_project_endpoints(&ctx, &project.id).await?; + assert!(endpoints.iter().any(|e| e.id == endpoint.id)); + + // Adding again is idempotent. + catalog + .add_endpoint_to_project(&ctx, &project.id, &endpoint.id) + .await?; + + // Remove, then it is gone. + catalog + .remove_endpoint_from_project(&ctx, &project.id, &endpoint.id) + .await?; + assert!( + !catalog + .check_endpoint_in_project(&ctx, &project.id, &endpoint.id) + .await? + ); + Ok(()) +} + +#[traced_test] +#[tokio::test] +async fn test_project_endpoint_group_association() -> Result<()> { + let (state, _tmp) = get_state().await?; + let domain = create_domain!(state)?; + let project = create_project!(state, domain.id.clone())?; + let group = create_endpoint_group( + &state, + EndpointGroupCreate { + id: None, + name: Uuid::new_v4().to_string(), + description: None, + filters: HashMap::new(), + }, + ) + .await?; + + let catalog = state.provider.get_catalog_provider(); + let ctx = ExecutionContext::internal(&state); + + // Not associated initially. + assert!( + !catalog + .check_endpoint_group_in_project(&ctx, &project.id, &group.id) + .await? + ); + + // Associate, then check + list reflect it. + catalog + .add_endpoint_group_to_project(&ctx, &project.id, &group.id) + .await?; + assert!( + catalog + .check_endpoint_group_in_project(&ctx, &project.id, &group.id) + .await? + ); + let groups = catalog + .list_project_endpoint_groups(&ctx, &project.id) + .await?; + assert!(groups.iter().any(|g| g.id == group.id)); + + // Remove, then it is gone. + catalog + .remove_endpoint_group_from_project(&ctx, &project.id, &group.id) + .await?; + assert!( + !catalog + .check_endpoint_group_in_project(&ctx, &project.id, &group.id) + .await? + ); + Ok(()) +} diff --git a/tests/integration/src/catalog/endpoint_group.rs b/tests/integration/src/catalog/endpoint_group.rs new file mode 100644 index 000000000..6f1e65d5f --- /dev/null +++ b/tests/integration/src/catalog/endpoint_group.rs @@ -0,0 +1,19 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +mod create; +mod delete; +mod get; +mod list; +mod update; diff --git a/tests/integration/src/catalog/endpoint_group/create.rs b/tests/integration/src/catalog/endpoint_group/create.rs new file mode 100644 index 000000000..cd7fd6775 --- /dev/null +++ b/tests/integration/src/catalog/endpoint_group/create.rs @@ -0,0 +1,79 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! Test endpoint group creation. + +use std::collections::HashMap; + +use eyre::Result; +use tracing_test::traced_test; +use uuid::Uuid; + +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::catalog::EndpointGroupCreate; + +use crate::catalog::create_endpoint_group; +use crate::common::get_state; + +#[traced_test] +#[tokio::test] +async fn test_create() -> Result<()> { + let (state, _tmp) = get_state().await?; + let group = create_endpoint_group( + &state, + EndpointGroupCreate { + id: None, + name: Uuid::new_v4().to_string(), + description: Some("a group".to_string()), + filters: HashMap::from([( + "interface".to_string(), + serde_json::Value::String("public".to_string()), + )]), + }, + ) + .await?; + + // An ID is generated when none is provided. + assert!(!group.id.is_empty()); + assert_eq!(group.description.as_deref(), Some("a group")); + assert_eq!( + group.filters.get("interface").and_then(|v| v.as_str()), + Some("public") + ); + Ok(()) +} + +#[traced_test] +#[tokio::test] +async fn test_create_invalid_empty_name() -> Result<()> { + let (state, _tmp) = get_state().await?; + let result = state + .provider + .get_catalog_provider() + .create_endpoint_group( + &ExecutionContext::internal(&state), + EndpointGroupCreate { + id: None, + name: String::new(), + description: None, + filters: HashMap::new(), + }, + ) + .await; + + assert!( + result.is_err(), + "creating an endpoint group with an empty name is rejected" + ); + Ok(()) +} diff --git a/tests/integration/src/catalog/endpoint_group/delete.rs b/tests/integration/src/catalog/endpoint_group/delete.rs new file mode 100644 index 000000000..bf4e4beff --- /dev/null +++ b/tests/integration/src/catalog/endpoint_group/delete.rs @@ -0,0 +1,59 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! Test endpoint group deletion. + +use std::collections::HashMap; + +use eyre::Result; +use tracing_test::traced_test; +use uuid::Uuid; + +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::catalog::EndpointGroupCreate; + +use crate::common::get_state; + +#[traced_test] +#[tokio::test] +async fn test_delete() -> Result<()> { + let (state, _tmp) = get_state().await?; + // Create directly (not via the guard) so we own the deletion. + let group = state + .provider + .get_catalog_provider() + .create_endpoint_group( + &ExecutionContext::internal(&state), + EndpointGroupCreate { + id: None, + name: Uuid::new_v4().to_string(), + description: None, + filters: HashMap::new(), + }, + ) + .await?; + + state + .provider + .get_catalog_provider() + .delete_endpoint_group(&ExecutionContext::internal(&state), &group.id) + .await?; + + let fetched = state + .provider + .get_catalog_provider() + .get_endpoint_group(&ExecutionContext::internal(&state), &group.id) + .await?; + assert!(fetched.is_none(), "endpoint group is gone after delete"); + Ok(()) +} diff --git a/tests/integration/src/catalog/endpoint_group/get.rs b/tests/integration/src/catalog/endpoint_group/get.rs new file mode 100644 index 000000000..08f1d679c --- /dev/null +++ b/tests/integration/src/catalog/endpoint_group/get.rs @@ -0,0 +1,68 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! Test endpoint group get. + +use std::collections::HashMap; + +use eyre::Result; +use tracing_test::traced_test; +use uuid::Uuid; + +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::catalog::EndpointGroupCreate; + +use crate::catalog::create_endpoint_group; +use crate::common::get_state; + +#[traced_test] +#[tokio::test] +async fn test_get() -> Result<()> { + let (state, _tmp) = get_state().await?; + let group = create_endpoint_group( + &state, + EndpointGroupCreate { + id: None, + name: Uuid::new_v4().to_string(), + description: None, + filters: HashMap::new(), + }, + ) + .await?; + + let fetched = state + .provider + .get_catalog_provider() + .get_endpoint_group(&ExecutionContext::internal(&state), &group.id) + .await? + .expect("endpoint group found"); + assert_eq!(fetched.id, group.id); + assert_eq!(fetched.name, group.name); + Ok(()) +} + +#[traced_test] +#[tokio::test] +async fn test_get_missing() -> Result<()> { + let (state, _tmp) = get_state().await?; + let result = state + .provider + .get_catalog_provider() + .get_endpoint_group( + &ExecutionContext::internal(&state), + &Uuid::new_v4().to_string(), + ) + .await?; + assert!(result.is_none(), "a missing endpoint group returns None"); + Ok(()) +} diff --git a/tests/integration/src/catalog/endpoint_group/list.rs b/tests/integration/src/catalog/endpoint_group/list.rs new file mode 100644 index 000000000..500eaf445 --- /dev/null +++ b/tests/integration/src/catalog/endpoint_group/list.rs @@ -0,0 +1,90 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! Test endpoint group listing. + +use std::collections::HashMap; + +use eyre::Result; +use tracing_test::traced_test; +use uuid::Uuid; + +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::catalog::{EndpointGroupCreate, EndpointGroupListParameters}; + +use crate::catalog::create_endpoint_group; +use crate::common::get_state; + +#[traced_test] +#[tokio::test] +async fn test_list() -> Result<()> { + let (state, _tmp) = get_state().await?; + let group = create_endpoint_group( + &state, + EndpointGroupCreate { + id: None, + name: Uuid::new_v4().to_string(), + description: None, + filters: HashMap::new(), + }, + ) + .await?; + + let groups = state + .provider + .get_catalog_provider() + .list_endpoint_groups( + &ExecutionContext::internal(&state), + &EndpointGroupListParameters::default(), + ) + .await?; + assert!( + groups.iter().any(|g| g.id == group.id), + "created group is listed" + ); + Ok(()) +} + +#[traced_test] +#[tokio::test] +async fn test_list_by_name() -> Result<()> { + let (state, _tmp) = get_state().await?; + let name = Uuid::new_v4().to_string(); + let group = create_endpoint_group( + &state, + EndpointGroupCreate { + id: None, + name: name.clone(), + description: None, + filters: HashMap::new(), + }, + ) + .await?; + + let groups = state + .provider + .get_catalog_provider() + .list_endpoint_groups( + &ExecutionContext::internal(&state), + &EndpointGroupListParameters { + name: Some(name.clone()), + }, + ) + .await?; + assert!( + groups.iter().all(|g| g.name == name), + "only groups with the requested name are returned" + ); + assert!(groups.iter().any(|g| g.id == group.id)); + Ok(()) +} diff --git a/tests/integration/src/catalog/endpoint_group/update.rs b/tests/integration/src/catalog/endpoint_group/update.rs new file mode 100644 index 000000000..7cd993212 --- /dev/null +++ b/tests/integration/src/catalog/endpoint_group/update.rs @@ -0,0 +1,89 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! Test endpoint group update. + +use std::collections::HashMap; + +use eyre::Result; +use tracing_test::traced_test; +use uuid::Uuid; + +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::catalog::{EndpointGroupCreate, EndpointGroupUpdate}; + +use crate::catalog::create_endpoint_group; +use crate::common::get_state; + +#[traced_test] +#[tokio::test] +async fn test_update() -> Result<()> { + let (state, _tmp) = get_state().await?; + let group = create_endpoint_group( + &state, + EndpointGroupCreate { + id: None, + name: Uuid::new_v4().to_string(), + description: Some("old".to_string()), + filters: HashMap::new(), + }, + ) + .await?; + + let new_name = Uuid::new_v4().to_string(); + let updated = state + .provider + .get_catalog_provider() + .update_endpoint_group( + &ExecutionContext::internal(&state), + &group.id, + EndpointGroupUpdate { + name: Some(new_name.clone()), + description: Some("new".to_string()), + ..Default::default() + }, + ) + .await?; + assert_eq!(updated.name, new_name); + assert_eq!(updated.description.as_deref(), Some("new")); + + // Confirm the change persisted. + let fetched = state + .provider + .get_catalog_provider() + .get_endpoint_group(&ExecutionContext::internal(&state), &group.id) + .await? + .expect("endpoint group found"); + assert_eq!(fetched.name, new_name); + Ok(()) +} + +#[traced_test] +#[tokio::test] +async fn test_update_not_found() -> Result<()> { + let (state, _tmp) = get_state().await?; + let result = state + .provider + .get_catalog_provider() + .update_endpoint_group( + &ExecutionContext::internal(&state), + &Uuid::new_v4().to_string(), + EndpointGroupUpdate { + name: Some("x".to_string()), + ..Default::default() + }, + ) + .await; + assert!(result.is_err(), "updating a missing endpoint group errors"); + Ok(()) +}