From 32461f9fc85921bcc0c06bbeaecb946e75d68013 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Tue, 15 Sep 2026 17:33:42 +0200 Subject: [PATCH] feat(sdk): Add project-cleanup subsystem core engine Adds a rust-native project-cleanup engine to openstack_sdk, improving on python openstacksdk's project_cleanup: declarative RelationRule-based dependency edges (Blocks/CascadeGroup) between resource kinds, a discover/apply engine that toposorts and layers the resulting graph, concurrent per-layer discovery and deletion, built-in created_at/updated_at filters, and a CleanupProvider extension point proven out by a network provider (ports/routers/networks) with router-interface and DHCP-port handling. See docs/superpowers/specs/2026-09-15-project-cleanup-design.md and docs/superpowers/plans/2026-09-15-project-cleanup-core-engine.md for the design and implementation plan. Signed-off-by: Artem Goncharov --- Cargo.lock | 1 + .../2026-09-15-project-cleanup-core-engine.md | 1742 +++++++++++++++++ .../2026-09-15-project-cleanup-design.md | 227 +++ openstack_sdk/Cargo.toml | 3 +- openstack_sdk/src/cleanup/engine.rs | 1425 ++++++++++++++ openstack_sdk/src/cleanup/filters.rs | 146 ++ openstack_sdk/src/cleanup/mod.rs | 34 + openstack_sdk/src/cleanup/provider.rs | 336 ++++ openstack_sdk/src/cleanup/providers/mod.rs | 19 + .../src/cleanup/providers/network.rs | 623 ++++++ openstack_sdk/src/cleanup/relations.rs | 153 ++ openstack_sdk/src/cleanup/types.rs | 195 ++ openstack_sdk/src/lib.rs | 2 + 13 files changed, 4905 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-09-15-project-cleanup-core-engine.md create mode 100644 docs/superpowers/specs/2026-09-15-project-cleanup-design.md create mode 100644 openstack_sdk/src/cleanup/engine.rs create mode 100644 openstack_sdk/src/cleanup/filters.rs create mode 100644 openstack_sdk/src/cleanup/mod.rs create mode 100644 openstack_sdk/src/cleanup/provider.rs create mode 100644 openstack_sdk/src/cleanup/providers/mod.rs create mode 100644 openstack_sdk/src/cleanup/providers/network.rs create mode 100644 openstack_sdk/src/cleanup/relations.rs create mode 100644 openstack_sdk/src/cleanup/types.rs diff --git a/Cargo.lock b/Cargo.lock index 45dd285aa..8b31d6d9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4628,6 +4628,7 @@ dependencies = [ "openstack-sdk-plugin-wasm", "openstack_sdk_core", "parking_lot", + "petgraph", "reqwest", "secrecy", "serde", diff --git a/docs/superpowers/plans/2026-09-15-project-cleanup-core-engine.md b/docs/superpowers/plans/2026-09-15-project-cleanup-core-engine.md new file mode 100644 index 000000000..1828457eb --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-project-cleanup-core-engine.md @@ -0,0 +1,1742 @@ +# Project Cleanup Core Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the resource-level dependency graph engine and provider extension point for project cleanup in `openstack_sdk`, proven against one real provider (network) that exercises the two hard cases python hand-codes today: "don't delete a parent while an unselected child still points at it" and "deleting one member of a connected group must delete the whole group." + +**Architecture:** A `CleanupProvider` trait (one impl per service) discovers resources as untyped `PlannedResource` envelopes and declares `RelationRule`s between resource kinds. A pure `relations::evaluate_edges` function turns discovered nodes + rules into a graph. `discover()` merges all providers' nodes, computes edges, propagates cascade-group selection, and returns a serializable `CleanupPlan` — this is the plan/approve artifact. `apply()` takes a (possibly caller-edited) plan and walks it as a DAG, deleting children before parents, skipping any parent still blocked by a kept child. + +**Tech Stack:** Rust, `openstack_sdk` crate (async feature), `petgraph` (new dependency) for graph/topological walk, `tokio` for concurrent provider discovery, `async-trait`, `serde`/`serde_json`, `httpmock` (existing dev-dependency) for the network provider's integration test. + +## Global Constraints + +- Async-only (`#[cfg(feature = "async")]`); no sync-feature support in this plan. +- New code lives entirely under `openstack_sdk/src/cleanup/`. +- No live-cloud calls in unit tests for the engine (Tasks 1-5); only the network provider task (Task 6) talks to a mocked HTTP server via `httpmock`, following the existing pattern in `openstack_sdk/src/test.rs`. +- `RelationEffect` has exactly two variants, `Blocks` and `CascadeGroup` — no third "Detach" primitive. A resource that needs to be *detached* rather than deleted (e.g. a router interface) is modeled as its own `ResourceKind` whose `CleanupProvider::delete` performs the detach call. This is a deliberate simplification versus the design doc's three-variant sketch, made because an async detach action can't be carried as a plain `fn` pointer without boxed-future ceremony, and the two-variant + dedicated-kind approach covers the same cases with less machinery. Record this as the implemented behavior; the design doc's "Detach" bullet is superseded by this note. +- Every provider's `delete` must treat `OpenStackError` whose underlying `ApiError::is_not_found()` is `true` as success (plan may be stale). + +--- + +### Task 1: Core types (`ResourceKind`, `PlannedResource`) and crate wiring + +**Files:** +- Create: `openstack_sdk/src/cleanup/mod.rs` +- Create: `openstack_sdk/src/cleanup/types.rs` +- Modify: `openstack_sdk/src/lib.rs` (add `#[cfg(feature = "async")] pub mod cleanup;` after the existing `#[cfg(feature = "async")] mod openstack_async;` block) +- Modify: `openstack_sdk/Cargo.toml` (add `petgraph` dependency) +- Test: `openstack_sdk/src/cleanup/types.rs` (inline `#[cfg(test)] mod tests`) + +**Interfaces:** +- Produces: `pub struct ResourceKind { pub service_type: &'static str, pub resource_type: &'static str }` with `pub const fn new(service_type: &'static str, resource_type: &'static str) -> Self`, deriving `Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize`. +- Produces: `pub struct PlannedResource { pub kind: ResourceKind, pub id: String, pub name: Option, pub raw: serde_json::Value, pub selected: bool, pub reason: Option }` deriving `Debug, Clone, serde::Serialize, serde::Deserialize`. + +- [ ] **Step 1: Add the `petgraph` dependency** + +Add to the workspace's shared dependency table if one exists, otherwise directly under `[dependencies]` in `openstack_sdk/Cargo.toml`, alphabetically next to `parking_lot`: + +```toml +petgraph = "0.6" +``` + +- [ ] **Step 2: Write the failing test for `ResourceKind` and `PlannedResource`** + +Create `openstack_sdk/src/cleanup/types.rs`: + +```rust +// 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 + +//! Core envelope types shared by every cleanup provider and by the +//! discover/apply engine. + +use serde::{Deserialize, Serialize}; + +/// Identifies a resource type across services without requiring the engine +/// to be generic over every SDK resource struct. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ResourceKind { + pub service_type: &'static str, + pub resource_type: &'static str, +} + +impl ResourceKind { + pub const fn new(service_type: &'static str, resource_type: &'static str) -> Self { + Self { + service_type, + resource_type, + } + } +} + +/// A single resource discovered by a [`crate::cleanup::provider::CleanupProvider`], +/// carried through discovery, plan inspection/editing, and apply. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlannedResource { + pub kind: ResourceKind, + pub id: String, + pub name: Option, + /// Full resource body as returned by the API, used for relation + /// matching and caller-supplied filters. + pub raw: serde_json::Value, + /// Whether this resource is currently slated for deletion. Discovery + /// sets this from filters/cascade rules; a caller may flip it before + /// calling `apply()`. + pub selected: bool, + /// Human-readable reason `selected` has its current value, for plan + /// display (e.g. "matched filter", "cascade: network net-123"). + pub reason: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_kind_equality_and_hash() { + let a = ResourceKind::new("network", "network"); + let b = ResourceKind::new("network", "network"); + let c = ResourceKind::new("network", "port"); + assert_eq!(a, b); + assert_ne!(a, c); + + use std::collections::HashSet; + let mut set = HashSet::new(); + set.insert(a); + set.insert(b); + set.insert(c); + assert_eq!(set.len(), 2); + } + + #[test] + fn planned_resource_serde_roundtrip() { + let node = PlannedResource { + kind: ResourceKind::new("network", "network"), + id: "net-1".into(), + name: Some("private".into()), + raw: serde_json::json!({"id": "net-1", "name": "private"}), + selected: true, + reason: Some("matched filter".into()), + }; + let json = serde_json::to_string(&node).unwrap(); + let back: PlannedResource = serde_json::from_str(&json).unwrap(); + assert_eq!(back.id, "net-1"); + assert_eq!(back.kind, node.kind); + assert!(back.selected); + } +} +``` + +- [ ] **Step 3: Wire the module and run to verify it fails to build (module not yet registered)** + +Create `openstack_sdk/src/cleanup/mod.rs`: + +```rust +// 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 cleanup: resource-level dependency graph, discover/apply engine, +//! and the [`provider::CleanupProvider`] extension point. + +pub mod types; + +pub use types::{PlannedResource, ResourceKind}; +``` + +In `openstack_sdk/src/lib.rs`, find: + +```rust +#[cfg(feature = "async")] +mod openstack_async; +#[cfg(feature = "async")] +pub use openstack_async::{AsyncOpenStack, AsyncOpenStackBuilder, RenewHandle}; +``` + +and add immediately after it: + +```rust +#[cfg(feature = "async")] +pub mod cleanup; +``` + +Run: `cargo test -p openstack_sdk --lib cleanup:: 2>&1 | tail -20` +Expected: compiles and runs the two tests in `cleanup::types::tests`, both PASS. (There is no "fails first" step here since this task only adds new, self-contained types — there's no existing behavior to regress. Confirm PASS, not a pre-existing FAIL.) + +- [ ] **Step 4: Commit** + +```bash +git add openstack_sdk/Cargo.toml openstack_sdk/src/lib.rs openstack_sdk/src/cleanup/mod.rs openstack_sdk/src/cleanup/types.rs +git commit -m "feat(sdk): add cleanup module with PlannedResource/ResourceKind types" +``` + +--- + +### Task 2: `RelationRule` and pure edge evaluation + +**Files:** +- Create: `openstack_sdk/src/cleanup/relations.rs` +- Modify: `openstack_sdk/src/cleanup/mod.rs` (add `pub mod relations;` and re-exports) + +**Interfaces:** +- Consumes: `PlannedResource`, `ResourceKind` from Task 1 (`crate::cleanup::types`). +- Produces: + - `pub enum RelationEffect { Blocks, CascadeGroup }` deriving `Debug, Clone, Copy, PartialEq, Eq`. + - `pub struct RelationRule { pub parent_kind: ResourceKind, pub child_kind: ResourceKind, pub matches: fn(child: &PlannedResource, parent: &PlannedResource) -> bool, pub effect: RelationEffect }`. + - `pub struct Edge { pub child: usize, pub parent: usize, pub effect: RelationEffect }` (indices into the node slice passed to `evaluate_edges`). + - `pub fn evaluate_edges(nodes: &[PlannedResource], rules: &[RelationRule]) -> Vec`. + +- [ ] **Step 1: Write the failing test** + +Create `openstack_sdk/src/cleanup/relations.rs`: + +```rust +// 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 + +//! Declarative, resource-level dependency rules between resource kinds, +//! and their evaluation against a concrete set of discovered resources. +//! +//! This replaces the imperative, per-service ordering logic (e.g. the +//! python network proxy's inline "does this network still have ports" +//! check) with a rule every provider can reuse. + +use crate::cleanup::types::{PlannedResource, ResourceKind}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelationEffect { + /// The parent cannot be deleted while a matching child still exists + /// and is not itself selected for deletion. + Blocks, + /// If either side of a matching pair is selected for deletion, both + /// become selected (and, transitively, every node reachable through + /// other `CascadeGroup` edges). + CascadeGroup, +} + +pub struct RelationRule { + pub parent_kind: ResourceKind, + pub child_kind: ResourceKind, + pub matches: fn(child: &PlannedResource, parent: &PlannedResource) -> bool, + pub effect: RelationEffect, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Edge { + pub child: usize, + pub parent: usize, + pub effect: RelationEffect, +} + +/// Evaluate every rule against every (child, parent) pair of matching kind +/// in `nodes`, returning the resulting edges as node indices. +pub fn evaluate_edges(nodes: &[PlannedResource], rules: &[RelationRule]) -> Vec { + let mut edges = Vec::new(); + for rule in rules { + for (child_idx, child) in nodes.iter().enumerate() { + if child.kind != rule.child_kind { + continue; + } + for (parent_idx, parent) in nodes.iter().enumerate() { + if parent.kind != rule.parent_kind { + continue; + } + if (rule.matches)(child, parent) { + edges.push(Edge { + child: child_idx, + parent: parent_idx, + effect: rule.effect, + }); + } + } + } + } + edges +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn node(kind: ResourceKind, id: &str, extra: serde_json::Value) -> PlannedResource { + PlannedResource { + kind, + id: id.into(), + name: None, + raw: extra, + selected: false, + reason: None, + } + } + + const NETWORK: ResourceKind = ResourceKind::new("network", "network"); + const PORT: ResourceKind = ResourceKind::new("network", "port"); + + fn port_blocks_network_rule() -> RelationRule { + RelationRule { + parent_kind: NETWORK, + child_kind: PORT, + matches: |child, parent| { + child.raw.get("network_id").and_then(|v| v.as_str()) == parent.raw.get("id").and_then(|v| v.as_str()) + }, + effect: RelationEffect::Blocks, + } + } + + #[test] + fn blocks_edge_created_only_for_matching_pair() { + let nodes = vec![ + node(NETWORK, "net-1", json!({"id": "net-1"})), + node(NETWORK, "net-2", json!({"id": "net-2"})), + node(PORT, "port-1", json!({"id": "port-1", "network_id": "net-1"})), + ]; + let edges = evaluate_edges(&nodes, &[port_blocks_network_rule()]); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].child, 2); // port-1 + assert_eq!(edges[0].parent, 0); // net-1 + assert_eq!(edges[0].effect, RelationEffect::Blocks); + } + + #[test] + fn no_edges_when_nothing_matches() { + let nodes = vec![ + node(NETWORK, "net-1", json!({"id": "net-1"})), + node(PORT, "port-1", json!({"id": "port-1", "network_id": "net-2"})), + ]; + let edges = evaluate_edges(&nodes, &[port_blocks_network_rule()]); + assert!(edges.is_empty()); + } +} +``` + +- [ ] **Step 2: Run to verify tests fail (module not yet registered)** + +Run: `cargo test -p openstack_sdk --lib cleanup::relations 2>&1 | tail -20` +Expected: FAIL — `cleanup::relations` module not found (not yet added to `mod.rs`). + +- [ ] **Step 3: Register the module** + +In `openstack_sdk/src/cleanup/mod.rs`, replace the file contents with: + +```rust +// 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 cleanup: resource-level dependency graph, discover/apply engine, +//! and the [`provider::CleanupProvider`] extension point. + +pub mod relations; +pub mod types; + +pub use relations::{Edge, RelationEffect, RelationRule, evaluate_edges}; +pub use types::{PlannedResource, ResourceKind}; +``` + +- [ ] **Step 4: Run to verify tests pass** + +Run: `cargo test -p openstack_sdk --lib cleanup:: 2>&1 | tail -20` +Expected: PASS — 4 tests total (2 from Task 1, 2 from this task). + +- [ ] **Step 5: Commit** + +```bash +git add openstack_sdk/src/cleanup/mod.rs openstack_sdk/src/cleanup/relations.rs +git commit -m "feat(sdk): add RelationRule and pure edge evaluation for cleanup" +``` + +--- + +### Task 3: `CleanupProvider` trait, `CleanupContext`, `CleanupError`, and service-level ordering + +**Files:** +- Create: `openstack_sdk/src/cleanup/provider.rs` +- Modify: `openstack_sdk/src/cleanup/mod.rs` + +**Interfaces:** +- Consumes: `PlannedResource`, `RelationRule` from Tasks 1-2. +- Produces: + - `pub struct CleanupDependency { pub before: Vec<&'static str>, pub after: Vec<&'static str> }` (`Default` derived, both fields default to empty). + - `pub struct CleanupContext<'a> { pub client: &'a crate::AsyncOpenStack, pub filters: std::collections::HashMap, pub evaluation_fn: Option bool + Send + Sync>> }`. + - `#[derive(Debug, thiserror::Error)] pub enum CleanupError { #[error("cleanup error for {kind:?} {id}: {source}")] Provider { kind: ResourceKind, id: String, #[source] source: crate::OpenStackError }, #[error("cleanup engine error: {0}")] Engine(String) }` with `pub fn is_not_found(&self) -> bool`. + - `#[async_trait::async_trait] pub trait CleanupProvider: Send + Sync { fn service_type(&self) -> &'static str; fn dependencies(&self) -> CleanupDependency { CleanupDependency::default() } fn relations(&self) -> Vec { Vec::new() } async fn discover(&self, ctx: &CleanupContext<'_>) -> Result, CleanupError>; async fn delete(&self, ctx: &CleanupContext<'_>, resource: &PlannedResource) -> Result<(), CleanupError>; }`. + - `pub fn service_order(providers: &[&dyn CleanupProvider]) -> Result, CleanupError>` — returns indices into `providers` in an order satisfying every `CleanupDependency` (topological sort over `before`/`after`), erroring with `CleanupError::Engine` on a cycle. + +- [ ] **Step 1: Write the failing test** + +Create `openstack_sdk/src/cleanup/provider.rs`: + +```rust +// 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 + +//! The [`CleanupProvider`] extension point: the same trait built-in +//! service providers and caller-injected providers both implement, so the +//! engine treats them identically. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use petgraph::algo::toposort; +use petgraph::graph::DiGraph; + +use crate::AsyncOpenStack; +use crate::OpenStackError; +use crate::cleanup::relations::RelationRule; +use crate::cleanup::types::{PlannedResource, ResourceKind}; + +/// Coarse, service-level ordering hint. Used only for orderings that are +/// not derivable from a resource-level [`RelationRule`] — e.g. a service +/// that must run after everything else because it drops the project +/// itself. +#[derive(Debug, Clone, Default)] +pub struct CleanupDependency { + pub before: Vec<&'static str>, + pub after: Vec<&'static str>, +} + +/// Per-run context handed to every provider call. +pub struct CleanupContext<'a> { + pub client: &'a AsyncOpenStack, + pub filters: HashMap, + pub evaluation_fn: Option bool + Send + Sync>>, +} + +#[derive(Debug, thiserror::Error)] +pub enum CleanupError { + #[error("cleanup error for {kind:?} {id}: {source}")] + Provider { + kind: ResourceKind, + id: String, + #[source] + source: OpenStackError, + }, + #[error("cleanup engine error: {0}")] + Engine(String), +} + +impl CleanupError { + pub fn is_not_found(&self) -> bool { + match self { + CleanupError::Provider { source, .. } => match source { + OpenStackError::Api { source } => source.is_not_found(), + _ => false, + }, + CleanupError::Engine(_) => false, + } + } +} + +#[async_trait] +pub trait CleanupProvider: Send + Sync { + fn service_type(&self) -> &'static str; + + fn dependencies(&self) -> CleanupDependency { + CleanupDependency::default() + } + + fn relations(&self) -> Vec { + Vec::new() + } + + async fn discover(&self, ctx: &CleanupContext<'_>) -> Result, CleanupError>; + + async fn delete( + &self, + ctx: &CleanupContext<'_>, + resource: &PlannedResource, + ) -> Result<(), CleanupError>; +} + +/// Order providers so that every `before`/`after` hint is satisfied. +/// Returns indices into `providers`. +pub fn service_order(providers: &[&dyn CleanupProvider]) -> Result, CleanupError> { + let mut graph = DiGraph::::new(); + let node_ids: Vec<_> = (0..providers.len()).map(|i| graph.add_node(i)).collect(); + let index_of = |service_type: &str| providers.iter().position(|p| p.service_type() == service_type); + + for (idx, provider) in providers.iter().enumerate() { + let deps = provider.dependencies(); + for before in &deps.before { + if let Some(other) = index_of(before) { + // `idx` must run before `other`: edge idx -> other + graph.add_edge(node_ids[idx], node_ids[other], ()); + } + } + for after in &deps.after { + if let Some(other) = index_of(after) { + // `idx` must run after `other`: edge other -> idx + graph.add_edge(node_ids[other], node_ids[idx], ()); + } + } + } + + toposort(&graph, None) + .map(|order| order.into_iter().map(|n| graph[n]).collect()) + .map_err(|_| CleanupError::Engine("cycle in service-level cleanup dependencies".into())) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FakeProvider { + service_type: &'static str, + deps: CleanupDependency, + } + + #[async_trait] + impl CleanupProvider for FakeProvider { + fn service_type(&self) -> &'static str { + self.service_type + } + fn dependencies(&self) -> CleanupDependency { + self.deps.clone() + } + async fn discover(&self, _ctx: &CleanupContext<'_>) -> Result, CleanupError> { + Ok(Vec::new()) + } + async fn delete(&self, _ctx: &CleanupContext<'_>, _r: &PlannedResource) -> Result<(), CleanupError> { + Ok(()) + } + } + + #[test] + fn network_before_identity_is_respected() { + let network = FakeProvider { + service_type: "network", + deps: CleanupDependency { + before: vec!["identity"], + after: vec![], + }, + }; + let identity = FakeProvider { + service_type: "identity", + deps: CleanupDependency::default(), + }; + // Registered in the "wrong" order on purpose. + let providers: Vec<&dyn CleanupProvider> = vec![&identity, &network]; + let order = service_order(&providers).unwrap(); + let network_pos = order.iter().position(|&i| i == 1).unwrap(); + let identity_pos = order.iter().position(|&i| i == 0).unwrap(); + assert!(network_pos < identity_pos, "network must be ordered before identity"); + } + + #[test] + fn cycle_is_reported_as_engine_error() { + let a = FakeProvider { + service_type: "a", + deps: CleanupDependency { + before: vec!["b"], + after: vec![], + }, + }; + let b = FakeProvider { + service_type: "b", + deps: CleanupDependency { + before: vec!["a"], + after: vec![], + }, + }; + let providers: Vec<&dyn CleanupProvider> = vec![&a, &b]; + let err = service_order(&providers).unwrap_err(); + assert!(matches!(err, CleanupError::Engine(_))); + } +} +``` + +- [ ] **Step 2: Run to verify tests fail (module not registered)** + +Run: `cargo test -p openstack_sdk --lib cleanup::provider 2>&1 | tail -20` +Expected: FAIL — module not found. + +- [ ] **Step 3: Register the module and add `thiserror` (already a workspace dependency, confirm it's listed under `[dependencies]` in `openstack_sdk/Cargo.toml` — it is, per the existing `thiserror.workspace = true` line, so no Cargo.toml change is needed here)** + +Update `openstack_sdk/src/cleanup/mod.rs`: + +```rust +pub mod provider; +pub mod relations; +pub mod types; + +pub use provider::{CleanupContext, CleanupDependency, CleanupError, CleanupProvider, service_order}; +pub use relations::{Edge, RelationEffect, RelationRule, evaluate_edges}; +pub use types::{PlannedResource, ResourceKind}; +``` + +(keep the file's existing module doc comment and license header at the top.) + +- [ ] **Step 4: Run to verify tests pass** + +Run: `cargo test -p openstack_sdk --lib cleanup:: 2>&1 | tail -30` +Expected: PASS — 6 tests total. + +- [ ] **Step 5: Commit** + +```bash +git add openstack_sdk/src/cleanup/mod.rs openstack_sdk/src/cleanup/provider.rs +git commit -m "feat(sdk): add CleanupProvider trait and service-level ordering" +``` + +--- + +### Task 4: Discover engine — `CleanupPlan` and cascade-group selection + +**Files:** +- Create: `openstack_sdk/src/cleanup/engine.rs` +- Modify: `openstack_sdk/src/cleanup/mod.rs` + +**Interfaces:** +- Consumes: `CleanupProvider`, `CleanupContext`, `CleanupDependency`, `CleanupError`, `service_order` from Task 3; `PlannedResource`, `ResourceKind` from Task 1; `RelationRule`, `RelationEffect`, `Edge`, `evaluate_edges` from Task 2. +- Produces: + - `#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct CleanupPlan { pub nodes: Vec, pub edges: Vec }` where `pub struct PlanEdge { pub child: usize, pub parent: usize, pub effect: RelationEffect }` (mirrors `relations::Edge`, but `RelationEffect` must derive `Serialize`/`Deserialize` too — add those derives to `RelationEffect` in Task 2's file as part of this task, see Step 1 note). + - `pub struct ProjectCleanupBuilder<'a> { /* private */ }` with `pub fn new(client: &'a crate::AsyncOpenStack) -> Self` and `pub fn with_provider(self, provider: impl CleanupProvider + 'static) -> Self`. + - `pub struct ProjectCleanup<'a> { /* private */ }` with `pub fn discover(&self, filters: std::collections::HashMap, evaluation_fn: Option bool + Send + Sync>>) -> impl std::future::Future> + '_` (an `async fn` on the struct). + - `ProjectCleanupBuilder::build(self) -> ProjectCleanup<'a>`. + +- [ ] **Step 1: Add `Serialize`/`Deserialize` to `RelationEffect`** + +In `openstack_sdk/src/cleanup/relations.rs`, change: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelationEffect { +``` + +to: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum RelationEffect { +``` + +- [ ] **Step 2: Write the failing test** + +Create `openstack_sdk/src/cleanup/engine.rs`: + +```rust +// 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 + +//! Discover/apply engine: turns registered [`CleanupProvider`]s into a +//! materialized, inspectable [`CleanupPlan`] (discover), then executes a +//! (possibly caller-edited) plan (apply, added in a later task). + +use std::collections::HashMap; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::AsyncOpenStack; +use crate::cleanup::provider::{CleanupContext, CleanupError, CleanupProvider, service_order}; +use crate::cleanup::relations::{Edge, RelationEffect, evaluate_edges}; +use crate::cleanup::types::PlannedResource; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct PlanEdge { + pub child: usize, + pub parent: usize, + pub effect: RelationEffect, +} + +impl From for PlanEdge { + fn from(e: Edge) -> Self { + PlanEdge { + child: e.child, + parent: e.parent, + effect: e.effect, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CleanupPlan { + pub nodes: Vec, + pub edges: Vec, +} + +/// Propagate `CascadeGroup` selection: if any node in a group formed by +/// `CascadeGroup` edges is selected, every node in that group becomes +/// selected. Pure function over plan data, used by both `discover()` and +/// tested directly here. +pub(crate) fn propagate_cascade_groups(nodes: &mut [PlannedResource], edges: &[Edge]) { + // Union-find over cascade-group edges only. + let mut parent: Vec = (0..nodes.len()).collect(); + fn find(parent: &mut [usize], x: usize) -> usize { + if parent[x] != x { + parent[x] = find(parent, parent[x]); + } + parent[x] + } + fn union(parent: &mut [usize], a: usize, b: usize) { + let ra = find(parent, a); + let rb = find(parent, b); + if ra != rb { + parent[ra] = rb; + } + } + for edge in edges.iter().filter(|e| e.effect == RelationEffect::CascadeGroup) { + union(&mut parent, edge.child, edge.parent); + } + + let mut group_selected: HashMap = HashMap::new(); // root -> index of a selected member + for i in 0..nodes.len() { + if nodes[i].selected { + let root = find(&mut parent, i); + group_selected.entry(root).or_insert(i); + } + } + for i in 0..nodes.len() { + let root = find(&mut parent, i); + if let Some(&selected_idx) = group_selected.get(&root) { + if !nodes[i].selected { + let cause_id = nodes[selected_idx].id.clone(); + nodes[i].selected = true; + nodes[i].reason = Some(format!("cascade: {cause_id}")); + } + } + } +} + +pub struct ProjectCleanupBuilder<'a> { + client: &'a AsyncOpenStack, + providers: Vec>, +} + +impl<'a> ProjectCleanupBuilder<'a> { + pub fn new(client: &'a AsyncOpenStack) -> Self { + Self { + client, + providers: Vec::new(), + } + } + + pub fn with_provider(mut self, provider: impl CleanupProvider + 'static) -> Self { + self.providers.push(Box::new(provider)); + self + } + + pub fn build(self) -> ProjectCleanup<'a> { + ProjectCleanup { + client: self.client, + providers: self.providers, + } + } +} + +pub struct ProjectCleanup<'a> { + client: &'a AsyncOpenStack, + providers: Vec>, +} + +impl<'a> ProjectCleanup<'a> { + pub async fn discover( + &self, + filters: HashMap, + evaluation_fn: Option bool + Send + Sync>>, + ) -> Result { + let provider_refs: Vec<&dyn CleanupProvider> = self.providers.iter().map(|p| p.as_ref()).collect(); + let order = service_order(&provider_refs)?; + + let ctx = CleanupContext { + client: self.client, + filters, + evaluation_fn, + }; + + // Service-level ordering only gates *listing*, not selection: run + // providers in dependency order, but nothing stops a later + // provider's rules from referencing an earlier provider's nodes. + let mut nodes: Vec = Vec::new(); + for idx in order { + let provider = &self.providers[idx]; + let mut discovered = provider.discover(&ctx).await?; + if let Some(eval) = &ctx.evaluation_fn { + for r in &mut discovered { + r.selected = eval(r); + if r.selected && r.reason.is_none() { + r.reason = Some("matched evaluation_fn".into()); + } + } + } + nodes.append(&mut discovered); + } + + let all_rules: Vec<_> = self.providers.iter().flat_map(|p| p.relations()).collect(); + let edges = evaluate_edges(&nodes, &all_rules); + + propagate_cascade_groups(&mut nodes, &edges); + + Ok(CleanupPlan { + nodes, + edges: edges.into_iter().map(PlanEdge::from).collect(), + }) + } +} +``` + +- [ ] **Step 3: Register the module** + +Update `openstack_sdk/src/cleanup/mod.rs`: + +```rust +pub mod engine; +pub mod provider; +pub mod relations; +pub mod types; + +pub use engine::{CleanupPlan, PlanEdge, ProjectCleanup, ProjectCleanupBuilder}; +pub use provider::{CleanupContext, CleanupDependency, CleanupError, CleanupProvider, service_order}; +pub use relations::{Edge, RelationEffect, RelationRule, evaluate_edges}; +pub use types::{PlannedResource, ResourceKind}; +``` + +- [ ] **Step 4: Add the cascade-propagation test and run it (add to `engine.rs`, at the bottom)** + +Append to `openstack_sdk/src/cleanup/engine.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::cleanup::relations::RelationEffect; + use crate::cleanup::types::ResourceKind; + use serde_json::json; + + fn node(kind: ResourceKind, id: &str, selected: bool) -> PlannedResource { + PlannedResource { + kind, + id: id.into(), + name: None, + raw: json!({"id": id}), + selected, + reason: None, + } + } + + const NETWORK: ResourceKind = ResourceKind::new("network", "network"); + const ROUTER: ResourceKind = ResourceKind::new("network", "router"); + const SUBNET: ResourceKind = ResourceKind::new("network", "subnet"); + + #[test] + fn cascade_selection_spreads_across_group() { + // net-1 (selected) -- cascade -- router-1 + // router-1 -- cascade -- subnet-1 + // subnet-1 is not directly linked to net-1, only transitively. + let mut nodes = vec![ + node(NETWORK, "net-1", true), + node(ROUTER, "router-1", false), + node(SUBNET, "subnet-1", false), + node(NETWORK, "net-2", false), // unrelated, must stay false + ]; + let edges = vec![ + Edge { child: 1, parent: 0, effect: RelationEffect::CascadeGroup }, + Edge { child: 2, parent: 1, effect: RelationEffect::CascadeGroup }, + ]; + propagate_cascade_groups(&mut nodes, &edges); + assert!(nodes[0].selected); + assert!(nodes[1].selected, "router-1 must be pulled in transitively"); + assert!(nodes[2].selected, "subnet-1 must be pulled in transitively"); + assert!(!nodes[3].selected, "unrelated network must not be selected"); + assert_eq!(nodes[1].reason.as_deref(), Some("cascade: net-1")); + } + + #[tokio::test] + async fn discover_merges_providers_and_applies_evaluation_fn() { + use crate::cleanup::provider::{CleanupContext, CleanupDependency}; + use async_trait::async_trait; + + struct OnlyEvenIdsProvider; + #[async_trait] + impl CleanupProvider for OnlyEvenIdsProvider { + fn service_type(&self) -> &'static str { + "fake" + } + fn dependencies(&self) -> CleanupDependency { + CleanupDependency::default() + } + async fn discover(&self, _ctx: &CleanupContext<'_>) -> Result, CleanupError> { + Ok(vec![ + node(ResourceKind::new("fake", "thing"), "1", false), + node(ResourceKind::new("fake", "thing"), "2", false), + ]) + } + async fn delete(&self, _ctx: &CleanupContext<'_>, _r: &PlannedResource) -> Result<(), CleanupError> { + Ok(()) + } + } + + // discover() needs a real AsyncOpenStack only to populate + // CleanupContext::client; no HTTP call happens because the fake + // provider never touches it. Build a minimally-configured client + // via the existing test helpers pattern used across the crate + // (see openstack_sdk/src/test.rs) — a mock server is started but + // no request is expected against it in this test. + // NOTE: this requires the httpmock dev-dependency already present + // in openstack_sdk/Cargo.toml. + let server = httpmock::MockServer::start_async().await; + let config = openstack_sdk_core::config::CloudConfig { + auth: Some(openstack_sdk_core::config::Auth { + auth_url: Some(format!("{}/v3", server.base_url())), + username: Some("test-user".into()), + user_domain_name: Some("Default".into()), + password: Some("test-password".into()), + project_id: Some("test-project".into()), + ..Default::default() + }), + region_name: Some("RegionOne".into()), + interface: Some("public".into()), + auth_cache: Some(false), + ..Default::default() + }; + let base_url = server.base_url(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/"); + then.status(200).json_body(serde_json::json!({ + "versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}] + })); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v3/"); + then.status(200).json_body(serde_json::json!({ + "versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}] + })); + }); + let expires = (chrono::Utc::now() + chrono::TimeDelta::hours(1)).to_rfc3339(); + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/v3/auth/tokens"); + then.status(201) + .header("x-subject-token", "test-token") + .json_body(serde_json::json!({"token": { + "id": "token-id", "expires_at": expires, + "project": {"id": "test-project", "name": "TestProject"}, + "user": {"id": "test-user", "name": "test-user"}, + "methods": ["password"], "audit_ids": ["audit-1"], + "catalog": [] + }})); + }); + + let client = AsyncOpenStack::new_with_authentication_helper( + &config, + crate::auth::auth_helper::Noop::default(), + false, + ) + .await + .expect("client creation failed"); + + let cleanup = ProjectCleanupBuilder::new(&client) + .with_provider(OnlyEvenIdsProvider) + .build(); + + let eval: Arc bool + Send + Sync> = + Arc::new(|r: &PlannedResource| r.id == "2"); + let plan = cleanup + .discover(HashMap::new(), Some(eval)) + .await + .expect("discover failed"); + + assert_eq!(plan.nodes.len(), 2); + let selected: Vec<_> = plan.nodes.iter().filter(|n| n.selected).map(|n| n.id.clone()).collect(); + assert_eq!(selected, vec!["2".to_string()]); + } +} +``` + +- [ ] **Step 5: Run to verify tests pass** + +Run: `cargo test -p openstack_sdk --lib cleanup:: 2>&1 | tail -40` +Expected: PASS — 8 tests total. If `auth::auth_helper::Noop` is not `pub` from the crate root in this way, check its actual path with `grep -n "pub use.*Noop\|pub mod auth_helper" openstack_sdk/src/auth/mod.rs openstack_sdk/src/openstack_async.rs` and adjust the `use` path in the test to match — the test in `openstack_sdk/src/test.rs` (`crate::auth::auth_helper::Noop::default()`) is the reference for the correct path. + +- [ ] **Step 6: Commit** + +```bash +git add openstack_sdk/src/cleanup/mod.rs openstack_sdk/src/cleanup/engine.rs openstack_sdk/src/cleanup/relations.rs +git commit -m "feat(sdk): add discover engine with cascade-group selection propagation" +``` + +--- + +### Task 5: Apply engine — DAG delete walk + +**Files:** +- Modify: `openstack_sdk/src/cleanup/engine.rs` +- Modify: `openstack_sdk/src/cleanup/mod.rs` + +**Interfaces:** +- Consumes: `CleanupPlan`, `PlanEdge`, `ProjectCleanup` from Task 4; `RelationEffect`, `PlannedResource` from earlier tasks. +- Produces: + - `#[derive(Debug, Clone, Default, serde::Serialize)] pub struct CleanupResult { pub deleted: Vec, pub deleted_ids: Vec, pub skipped: Vec<(String, String)>, pub errors: Vec<(String, String)> }` — `skipped`/`errors` are `(resource_id, reason)` pairs. + - `impl<'a> ProjectCleanup<'a> { pub async fn apply(&self, plan: CleanupPlan) -> Result }`. + +- [ ] **Step 1: Write the failing test** + +Append to `openstack_sdk/src/cleanup/engine.rs`, inside the existing `mod tests` block (add before the closing brace, alongside the other tests): + +```rust + #[tokio::test] + async fn apply_deletes_children_before_parents_and_skips_blocked_parent() { + use crate::cleanup::provider::CleanupDependency; + use async_trait::async_trait; + use std::sync::Mutex; + + struct RecordingProvider { + log: Arc>>, + } + + #[async_trait] + impl CleanupProvider for RecordingProvider { + fn service_type(&self) -> &'static str { + "fake" + } + fn dependencies(&self) -> CleanupDependency { + CleanupDependency::default() + } + async fn discover(&self, _ctx: &CleanupContext<'_>) -> Result, CleanupError> { + Ok(Vec::new()) + } + async fn delete( + &self, + _ctx: &CleanupContext<'_>, + resource: &PlannedResource, + ) -> Result<(), CleanupError> { + self.log.lock().unwrap().push(resource.id.clone()); + Ok(()) + } + } + + let log = Arc::new(Mutex::new(Vec::new())); + + // net-1 has one selected child (port-selected) and one kept child + // (port-kept). net-2 has only a selected child. Expect: net-1 is + // NOT deleted (blocked by port-kept), net-2 IS deleted, and + // port-selected/port-kept... wait, port-kept is not selected so it + // is never passed to delete() at all; only selected nodes are + // touched by apply(). + let mut net1 = node(ResourceKind::new("network", "network"), "net-1", true); + net1.reason = Some("matched filter".into()); + let net2 = node(ResourceKind::new("network", "network"), "net-2", true); + let port_selected = node(ResourceKind::new("network", "port"), "port-on-net2", true); + let mut port_kept = node(ResourceKind::new("network", "port"), "port-on-net1", false); + port_kept.selected = false; + + let nodes = vec![net1, net2, port_selected, port_kept]; + // index: 0 net-1, 1 net-2, 2 port-on-net2, 3 port-on-net1 + let edges = vec![ + PlanEdge { child: 2, parent: 1, effect: RelationEffect::Blocks }, // port-on-net2 blocks net-2, but port-on-net2 IS selected -> not blocking + PlanEdge { child: 3, parent: 0, effect: RelationEffect::Blocks }, // port-on-net1 blocks net-1, and port-on-net1 is NOT selected -> blocking + ]; + let plan = CleanupPlan { nodes, edges }; + + // Build a client the same way as the discover test (no HTTP calls + // are made because RecordingProvider never touches ctx.client). + let server = httpmock::MockServer::start_async().await; + let base_url = server.base_url(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/"); + then.status(200).json_body(serde_json::json!({"versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}]})); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v3/"); + then.status(200).json_body(serde_json::json!({"versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}]})); + }); + let expires = (chrono::Utc::now() + chrono::TimeDelta::hours(1)).to_rfc3339(); + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/v3/auth/tokens"); + then.status(201).header("x-subject-token", "test-token").json_body(serde_json::json!({"token": { + "id": "token-id", "expires_at": expires, + "project": {"id": "test-project", "name": "TestProject"}, + "user": {"id": "test-user", "name": "test-user"}, + "methods": ["password"], "audit_ids": ["audit-1"], "catalog": [] + }})); + }); + let config = openstack_sdk_core::config::CloudConfig { + auth: Some(openstack_sdk_core::config::Auth { + auth_url: Some(format!("{}/v3", server.base_url())), + username: Some("test-user".into()), + user_domain_name: Some("Default".into()), + password: Some("test-password".into()), + project_id: Some("test-project".into()), + ..Default::default() + }), + region_name: Some("RegionOne".into()), + interface: Some("public".into()), + auth_cache: Some(false), + ..Default::default() + }; + let client = AsyncOpenStack::new_with_authentication_helper( + &config, + crate::auth::auth_helper::Noop::default(), + false, + ) + .await + .expect("client creation failed"); + + let cleanup = ProjectCleanupBuilder::new(&client) + .with_provider(RecordingProvider { log: log.clone() }) + .build(); + + let result = cleanup.apply(plan).await.expect("apply failed"); + + let deleted = log.lock().unwrap().clone(); + assert!(deleted.contains(&"port-on-net2".to_string())); + assert!(deleted.contains(&"net-2".to_string())); + assert!( + !deleted.contains(&"net-1".to_string()), + "net-1 must not be deleted while port-on-net1 is kept" + ); + assert!(!deleted.contains(&"port-on-net1".to_string()), "unselected node must never be passed to delete()"); + let port2_pos = deleted.iter().position(|id| id == "port-on-net2").unwrap(); + let net2_pos = deleted.iter().position(|id| id == "net-2").unwrap(); + assert!(port2_pos < net2_pos, "child must delete before parent"); + + assert!(result.skipped.iter().any(|(id, _)| id == "net-1")); + } +``` + +- [ ] **Step 2: Run to verify it fails to compile (no `apply` method yet)** + +Run: `cargo test -p openstack_sdk --lib cleanup::engine 2>&1 | tail -30` +Expected: FAIL — `no method named 'apply' found`. + +- [ ] **Step 3: Implement `apply`** + +Add to `openstack_sdk/src/cleanup/engine.rs`, right after the `discover` method (still inside `impl<'a> ProjectCleanup<'a>`): + +```rust + pub async fn apply(&self, plan: CleanupPlan) -> Result { + let ctx = CleanupContext { + client: self.client, + filters: HashMap::new(), + evaluation_fn: None, + }; + + // A parent is blocked while any `Blocks` child of it is not + // selected (i.e. is being kept). Compute this once, up front, + // against the plan as handed in (apply does not re-run discovery). + let mut blocked: Vec = vec![false; plan.nodes.len()]; + for edge in plan.edges.iter().filter(|e| e.effect == RelationEffect::Blocks) { + if !plan.nodes[edge.child].selected { + blocked[edge.parent] = true; + } + } + + // Deletion order: children (via Blocks edges) before parents. + // Build a DAG over selected, unblocked nodes only and topo-sort it. + let mut graph = petgraph::graph::DiGraph::::new(); + let node_ids: Vec<_> = (0..plan.nodes.len()).map(|i| graph.add_node(i)).collect(); + for edge in plan.edges.iter().filter(|e| e.effect == RelationEffect::Blocks) { + // child must run before parent: edge child -> parent + graph.add_edge(node_ids[edge.child], node_ids[edge.parent], ()); + } + let order = petgraph::algo::toposort(&graph, None) + .map_err(|_| CleanupError::Engine("cycle in resource-level cleanup dependencies".into()))?; + + let mut result = CleanupResult::default(); + for node_idx in order { + let idx = graph[node_idx]; + if !plan.nodes[idx].selected { + continue; + } + if blocked[idx] { + let id = plan.nodes[idx].id.clone(); + result.skipped.push((id, "blocked by a kept child resource".into())); + continue; + } + let resource = &plan.nodes[idx]; + let provider = self + .provider_for_kind(resource.kind) + .ok_or_else(|| CleanupError::Engine(format!("no provider registered for {:?}", resource.kind)))?; + match provider.delete(&ctx, resource).await { + Ok(()) => { + result.deleted.push(resource.kind); + result.deleted_ids.push(resource.id.clone()); + } + Err(e) if e.is_not_found() => { + result.deleted.push(resource.kind); + result.deleted_ids.push(resource.id.clone()); + } + Err(e) => { + result.errors.push((resource.id.clone(), e.to_string())); + } + } + } + + Ok(result) + } + + fn provider_for_kind(&self, kind: crate::cleanup::types::ResourceKind) -> Option<&dyn CleanupProvider> { + self.providers + .iter() + .find(|p| p.service_type() == kind.service_type) + .map(|p| p.as_ref()) + } +``` + +`apply` takes `plan` by value and never reads it back — its (possibly caller-edited) `selected`/`reason` fields exist for `CleanupPlan` to serve as an inspectable artifact between discover and apply, not as apply's output; `CleanupResult` is the output. + +Add `CleanupResult` above `ProjectCleanupBuilder` in the same file: + +```rust +#[derive(Debug, Clone, Default, Serialize)] +pub struct CleanupResult { + pub deleted: Vec, + pub deleted_ids: Vec, + pub skipped: Vec<(String, String)>, + pub errors: Vec<(String, String)>, +} +``` + +(`ResourceKind` already derives `Serialize` from Task 1.) + +- [ ] **Step 4: Update the module's public exports** + +In `openstack_sdk/src/cleanup/mod.rs`, change the `engine` re-export line to: + +```rust +pub use engine::{CleanupPlan, CleanupResult, PlanEdge, ProjectCleanup, ProjectCleanupBuilder}; +``` + +- [ ] **Step 5: Run to verify tests pass** + +Run: `cargo test -p openstack_sdk --lib cleanup:: 2>&1 | tail -40` +Expected: PASS — 9 tests total. + +- [ ] **Step 6: Commit** + +```bash +git add openstack_sdk/src/cleanup/mod.rs openstack_sdk/src/cleanup/engine.rs +git commit -m "feat(sdk): add apply() DAG delete walk honoring Blocks edges" +``` + +--- + +### Task 6: `NetworkCleanupProvider` — real provider proving `Blocks` + `CascadeGroup` + +**Files:** +- Create: `openstack_sdk/src/cleanup/providers/mod.rs` +- Create: `openstack_sdk/src/cleanup/providers/network.rs` +- Modify: `openstack_sdk/src/cleanup/mod.rs` + +**Interfaces:** +- Consumes: `CleanupProvider`, `CleanupContext`, `CleanupError`, `CleanupDependency` from Task 3; `RelationRule`, `RelationEffect` from Task 2; `PlannedResource`, `ResourceKind` from Task 1. +- Produces: `pub struct NetworkCleanupProvider;` (unit struct, `Default` derived) implementing `CleanupProvider`, gated `#[cfg(feature = "network")]`. + +This provider covers: `network`, `subnet`, `router`, and `router_interface` (a router's attached interface ports, modeled as their own kind since deleting one means detaching, not calling the port-delete endpoint). It intentionally does not yet cover floating IPs, security groups, or VPN resources — those are separate, additive follow-up tasks once this one is merged, per the design doc's v1 scope note. + +Resource kinds used: +- `ResourceKind::new("network", "network")` +- `ResourceKind::new("network", "subnet")` +- `ResourceKind::new("network", "router")` +- `ResourceKind::new("network", "router_interface")` + +Relations: +- `subnet` `Blocks` `network` (subnet.network_id == network.id) — a network cannot be deleted while a subnet still exists. +- `router_interface` `Blocks` `network` (router_interface.network_id == network.id) — a network cannot be deleted while still attached to a router. +- `router_interface` `Blocks` `router` (router_interface.device_id == router.id) — a router cannot be deleted while an interface is still attached (mirrors real Neutron behavior: `router_interface`'s `delete` detaches first). +- `subnet` `CascadeGroup` `network`, `router_interface` `CascadeGroup` `network` — selecting a network pulls in its subnets and router interfaces (and, transitively via the interface's `device_id`, its router) so the whole "networks are crazy" group is deleted together, matching python's behavior but generically. + +- [ ] **Step 1: Write the failing test** + +Create `openstack_sdk/src/cleanup/providers/network.rs`: + +```rust +// 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 + +//! Network (Neutron) cleanup provider. +//! +//! Proves the resource-level `Blocks`/`CascadeGroup` primitives against +//! the case the python SDK hand-codes imperatively: a network cannot be +//! deleted while it still has subnets or router interfaces, and deleting +//! a network should pull its subnets/router-interfaces/router along with +//! it as one group. + +use async_trait::async_trait; +use serde_json::Value; + +use crate::api::{Pagination, QueryAsync, paged, raw}; +use crate::api::network::v2::network; +use crate::api::network::v2::router; +use crate::api::network::v2::router::remove_router_interface; +use crate::api::network::v2::subnet; + +use crate::cleanup::provider::{CleanupContext, CleanupDependency, CleanupError, CleanupProvider}; +use crate::cleanup::relations::{RelationEffect, RelationRule}; +use crate::cleanup::types::{PlannedResource, ResourceKind}; + +pub const NETWORK: ResourceKind = ResourceKind::new("network", "network"); +pub const SUBNET: ResourceKind = ResourceKind::new("network", "subnet"); +pub const ROUTER: ResourceKind = ResourceKind::new("network", "router"); +pub const ROUTER_INTERFACE: ResourceKind = ResourceKind::new("network", "router_interface"); + +#[derive(Debug, Default)] +pub struct NetworkCleanupProvider; + +fn value_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> { + v.get(key).and_then(|x| x.as_str()) +} + +fn to_planned(kind: ResourceKind, v: Value) -> PlannedResource { + let id = value_str(&v, "id").unwrap_or_default().to_string(); + let name = value_str(&v, "name").map(str::to_string); + PlannedResource { + kind, + id, + name, + raw: v, + selected: false, + reason: None, + } +} + +#[async_trait] +impl CleanupProvider for NetworkCleanupProvider { + fn service_type(&self) -> &'static str { + "network" + } + + fn dependencies(&self) -> CleanupDependency { + CleanupDependency { + before: vec!["identity"], + after: vec![], + } + } + + fn relations(&self) -> Vec { + vec![ + RelationRule { + parent_kind: NETWORK, + child_kind: SUBNET, + matches: |child, parent| { + value_str(&child.raw, "network_id") == value_str(&parent.raw, "id") + }, + effect: RelationEffect::Blocks, + }, + RelationRule { + parent_kind: NETWORK, + child_kind: SUBNET, + matches: |child, parent| { + value_str(&child.raw, "network_id") == value_str(&parent.raw, "id") + }, + effect: RelationEffect::CascadeGroup, + }, + RelationRule { + parent_kind: NETWORK, + child_kind: ROUTER_INTERFACE, + matches: |child, parent| { + value_str(&child.raw, "network_id") == value_str(&parent.raw, "id") + }, + effect: RelationEffect::Blocks, + }, + RelationRule { + parent_kind: NETWORK, + child_kind: ROUTER_INTERFACE, + matches: |child, parent| { + value_str(&child.raw, "network_id") == value_str(&parent.raw, "id") + }, + effect: RelationEffect::CascadeGroup, + }, + RelationRule { + parent_kind: ROUTER, + child_kind: ROUTER_INTERFACE, + matches: |child, parent| { + value_str(&child.raw, "device_id") == value_str(&parent.raw, "id") + }, + effect: RelationEffect::Blocks, + }, + ] + } + + async fn discover(&self, ctx: &CleanupContext<'_>) -> Result, CleanupError> { + // Listing endpoints have no single resource id to attach to a + // failure, so errors here are reported against an empty id; only + // `delete()` (below) attaches a real resource id. + let list_err = |kind: ResourceKind| { + move |e: crate::OpenStackError| CleanupError::Provider { + kind, + id: String::new(), + source: e, + } + }; + + let mut nodes = Vec::new(); + + let networks: Vec = paged(network::list::Request::builder().build().unwrap(), Pagination::All) + .query_async(ctx.client) + .await + .map_err(|e| list_err(NETWORK)(e.into()))?; + for v in networks { + nodes.push(to_planned(NETWORK, v)); + } + + let subnets: Vec = paged(subnet::list::Request::builder().build().unwrap(), Pagination::All) + .query_async(ctx.client) + .await + .map_err(|e| list_err(SUBNET)(e.into()))?; + for v in subnets { + nodes.push(to_planned(SUBNET, v)); + } + + let routers: Vec = paged(router::list::Request::builder().build().unwrap(), Pagination::All) + .query_async(ctx.client) + .await + .map_err(|e| list_err(ROUTER)(e.into()))?; + for router_v in &routers { + let router_id = value_str(router_v, "id").unwrap_or_default().to_string(); + if let Some(interfaces) = router_v.get("interfaces_info").and_then(|v| v.as_array()) { + for iface in interfaces { + let mut iface = iface.clone(); + if let Value::Object(map) = &mut iface { + map.insert("device_id".into(), Value::String(router_id.clone())); + if !map.contains_key("id") { + if let Some(port_id) = map.get("port_id").cloned() { + map.insert("id".into(), port_id); + } + } + } + nodes.push(to_planned(ROUTER_INTERFACE, iface)); + } + } + nodes.push(to_planned(ROUTER, router_v.clone())); + } + + Ok(nodes) + } + + async fn delete( + &self, + ctx: &CleanupContext<'_>, + resource: &PlannedResource, + ) -> Result<(), CleanupError> { + let err = |e: crate::OpenStackError| CleanupError::Provider { + kind: resource.kind, + id: resource.id.clone(), + source: e, + }; + + if resource.kind == NETWORK { + let req = network::delete::Request::builder() + .id(resource.id.clone()) + .build() + .unwrap(); + raw(req).query_async(ctx.client).await.map_err(|e| err(e.into()))?; + } else if resource.kind == SUBNET { + let req = subnet::delete::Request::builder() + .id(resource.id.clone()) + .build() + .unwrap(); + raw(req).query_async(ctx.client).await.map_err(|e| err(e.into()))?; + } else if resource.kind == ROUTER { + let req = router::delete::Request::builder() + .id(resource.id.clone()) + .build() + .unwrap(); + raw(req).query_async(ctx.client).await.map_err(|e| err(e.into()))?; + } else if resource.kind == ROUTER_INTERFACE { + let router_id = value_str(&resource.raw, "device_id").unwrap_or_default().to_string(); + let mut builder = remove_router_interface::Request::builder(); + builder.id(router_id); + if let Some(subnet_id) = value_str(&resource.raw, "subnet_id") { + builder.subnet_id(subnet_id.to_string()); + } + if let Some(port_id) = value_str(&resource.raw, "port_id") { + builder.port_id(port_id.to_string()); + } + let req = builder.build().unwrap(); + raw(req).query_async(ctx.client).await.map_err(|e| err(e.into()))?; + } else { + return Err(CleanupError::Engine(format!( + "NetworkCleanupProvider cannot delete resource kind {:?}", + resource.kind + ))); + } + Ok(()) + } +} +``` + +`add_router_interface` is never called by cleanup (it only ever removes interfaces), so its `use` is intentionally omitted above. + +Create `openstack_sdk/src/cleanup/providers/mod.rs`: + +```rust +// 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 + +//! Built-in [`crate::cleanup::CleanupProvider`] implementations, one per +//! supported service. + +#[cfg(feature = "network")] +pub mod network; +``` + +- [ ] **Step 2: Verify field/module names against the generated code before wiring in** + +The exact shape of `interfaces_info` on a router, and the precise builder method names on `remove_router_interface::Request`, come from generated code this plan didn't fully transcribe (the generator output is large). Before proceeding, run: + +```bash +grep -n "pub struct Request" -A 20 sdk/network/src/v2/router/remove_router_interface.rs +grep -n "interfaces_info" -r sdk/network/src +``` + +Adjust the builder call chain in `delete()`'s `ROUTER_INTERFACE` branch to match whatever fields `remove_router_interface::Request::builder()` actually exposes (likely `id` for the router id, and one of `subnet_id`/`port_id` for the interface identifier — confirm against the grep output rather than assuming). If `interfaces_info` is not present on the router list/get response in this codebase's generated types, discover router interfaces instead via `port::list::Request` filtered to `device_owner` values starting with `network:router_interface`, matching the python proxy's approach at `network/v2/_proxy.py:9972-9977` — in that case add `use crate::api::network::v2::port;` and replace the `interfaces_info` block with a `port::list` call filtered client-side on `device_owner`. + +- [ ] **Step 3: Register the module and the `openstack_sdk` crate is imported correctly** + +Update `openstack_sdk/src/cleanup/mod.rs`: + +```rust +pub mod engine; +pub mod provider; +pub mod providers; +pub mod relations; +pub mod types; + +pub use engine::{CleanupPlan, CleanupResult, PlanEdge, ProjectCleanup, ProjectCleanupBuilder}; +pub use provider::{CleanupContext, CleanupDependency, CleanupError, CleanupProvider, service_order}; +pub use relations::{Edge, RelationEffect, RelationRule, evaluate_edges}; +pub use types::{PlannedResource, ResourceKind}; +``` + +- [ ] **Step 4: Run to verify it compiles** + +Run: `cargo check -p openstack_sdk --features network 2>&1 | tail -60` +Expected: compiles cleanly. Fix any field/method-name mismatches surfaced here against the real generated request builders (this is expected — Step 2 flagged the likely spots). + +- [ ] **Step 5: Write the integration test (httpmock)** + +Append to `openstack_sdk/src/cleanup/providers/network.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::cleanup::engine::ProjectCleanupBuilder; + use httpmock::MockServer; + use std::collections::HashMap; + + async fn mock_client(server: &MockServer) -> crate::AsyncOpenStack { + let base_url = server.base_url(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/"); + then.status(200).json_body(serde_json::json!({"versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}]})); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v3/"); + then.status(200).json_body(serde_json::json!({"versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}]})); + }); + let expires = (chrono::Utc::now() + chrono::TimeDelta::hours(1)).to_rfc3339(); + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/v3/auth/tokens"); + then.status(201).header("x-subject-token", "test-token").json_body(serde_json::json!({"token": { + "id": "token-id", "expires_at": expires, + "project": {"id": "test-project", "name": "TestProject"}, + "user": {"id": "test-user", "name": "test-user"}, + "methods": ["password"], "audit_ids": ["audit-1"], + "catalog": [ + {"type": "identity", "name": "keystone", "endpoints": [{"id": "identity-1", + "url": format!("{base_url}/v3"), "region": "RegionOne", "interface": "public"}]}, + {"type": "network", "name": "neutron", "endpoints": [{"id": "network-1", + "url": format!("{base_url}/v2.0"), "region": "RegionOne", "interface": "public"}]} + ] + }})); + }); + let config = openstack_sdk_core::config::CloudConfig { + auth: Some(openstack_sdk_core::config::Auth { + auth_url: Some(format!("{base_url}/v3")), + username: Some("test-user".into()), + user_domain_name: Some("Default".into()), + password: Some("test-password".into()), + project_id: Some("test-project".into()), + ..Default::default() + }), + region_name: Some("RegionOne".into()), + interface: Some("public".into()), + auth_cache: Some(false), + ..Default::default() + }; + crate::AsyncOpenStack::new_with_authentication_helper( + &config, + crate::auth::auth_helper::Noop::default(), + false, + ) + .await + .expect("client creation failed") + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn discover_marks_network_with_subnet_as_blocked_until_subnet_selected() { + let server = MockServer::start_async().await; + let client = mock_client(&server).await; + + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v2.0/networks"); + then.status(200).json_body(serde_json::json!({"networks": [ + {"id": "net-1", "name": "private"} + ]})); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v2.0/subnets"); + then.status(200).json_body(serde_json::json!({"subnets": [ + {"id": "subnet-1", "name": "private-subnet", "network_id": "net-1"} + ]})); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v2.0/routers"); + then.status(200).json_body(serde_json::json!({"routers": []})); + }); + + let cleanup = ProjectCleanupBuilder::new(&client) + .with_provider(NetworkCleanupProvider) + .build(); + + // Evaluation function selects only the network by name; the + // subnet must still be pulled in via CascadeGroup, and net-1 must + // remain deletable (its only blocking child, subnet-1, is also + // selected via cascade). + let eval: std::sync::Arc bool + Send + Sync> = + std::sync::Arc::new(|r: &PlannedResource| r.kind == NETWORK && r.name.as_deref() == Some("private")); + + let plan = cleanup + .discover(HashMap::new(), Some(eval)) + .await + .expect("discover failed"); + + let net = plan.nodes.iter().find(|n| n.id == "net-1").unwrap(); + let subnet = plan.nodes.iter().find(|n| n.id == "subnet-1").unwrap(); + assert!(net.selected); + assert!(subnet.selected, "subnet must be pulled in by the cascade group"); + + server.mock(|when, then| { + when.method(httpmock::Method::DELETE).path("/v2.0/subnets/subnet-1"); + then.status(204); + }); + server.mock(|when, then| { + when.method(httpmock::Method::DELETE).path("/v2.0/networks/net-1"); + then.status(204); + }); + + let result = cleanup.apply(plan).await.expect("apply failed"); + assert!(result.errors.is_empty(), "unexpected errors: {:?}", result.errors); + assert!(result.deleted_ids.contains(&"subnet-1".to_string())); + assert!(result.deleted_ids.contains(&"net-1".to_string())); + let subnet_pos = result.deleted_ids.iter().position(|id| id == "subnet-1").unwrap(); + let net_pos = result.deleted_ids.iter().position(|id| id == "net-1").unwrap(); + assert!(subnet_pos < net_pos, "subnet must delete before its network"); + } +} +``` + +- [ ] **Step 6: Run to verify tests pass** + +Run: `cargo test -p openstack_sdk --features network --lib cleanup:: 2>&1 | tail -60` +Expected: PASS — all cleanup module tests, including this new integration test. + +- [ ] **Step 7: Commit** + +```bash +git add openstack_sdk/src/cleanup/mod.rs openstack_sdk/src/cleanup/providers/mod.rs openstack_sdk/src/cleanup/providers/network.rs +git commit -m "feat(sdk): add NetworkCleanupProvider proving Blocks/CascadeGroup rules" +``` + +--- + +## Follow-up work (not in this plan) + +- Additional built-in providers (compute, block-storage, image, identity-scoped resources), each a small follow-up plan that only adds a new `providers/.rs` file plus relation rules — no engine changes, per the extensibility goal in the design doc. +- `openstack_cli`/`openstack_tui` command surface for `osc project cleanup` presenting `CleanupPlan` as an editable table and calling `apply()` — separate plan, out of scope here since it's a CLI/TUI concern, not an SDK one. +- Floating IPs, security groups, and VPN resources on the network provider (python's `network/v2/_proxy.py` also handles these) — additive to Task 6's provider once merged. diff --git a/docs/superpowers/specs/2026-09-15-project-cleanup-design.md b/docs/superpowers/specs/2026-09-15-project-cleanup-design.md new file mode 100644 index 000000000..ffcab9553 --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-project-cleanup-design.md @@ -0,0 +1,227 @@ +# Project Cleanup Subsystem — Design + +## Context + +The python `openstacksdk` implements `OpenStackCloud.project_cleanup()` +(`openstack/cloud/openstackcloud.py`) as follows: + +- Each service proxy may implement `_get_cleanup_dependencies()` (returns + `{before: [...], after: [...]}` service names) and `_service_cleanup(...)`. +- `project_cleanup` builds a service-level DAG (`utils.TinyDAG`) from these + dependency hints and walks it with a thread pool, invoking each service's + `_service_cleanup` in dependency order. +- Each service's `_service_cleanup` is a single large imperative function + that lists, filters, and deletes its own resources. Resources discovered + as "to delete" are pushed into a shared `identified_resources` dict so + other services' cleanup functions can consult what's already marked, + and into a `status_queue` for caller visibility. +- `dry_run` gates whether `del_fn` actually gets called, but the same flag + is reused mid-function as a probe (network proxy calls + `_service_cleanup_del_res(..., dry_run=True)` to *check* whether a + network needs deleting, before making its real delete decision) — this + conflates "user asked for dry run" with "internal evaluation call." + +Problems this design causes, confirmed by reading +`openstack/network/v2/_proxy.py::_service_cleanup` (lines ~9837–10108): + +1. **Dependency graph is service-level only.** Any ordering that depends on + actual resource relationships (a specific port belongs to a specific + network; a router is attached via a specific interface port) can't be + expressed as a graph edge. The network proxy instead hand-codes this as + ~270 lines of imperative logic: list networks, list ports per network, + classify port by `device_owner`, decide if the network "has ports + allocated," detach router interfaces, delete ports, delete subnets, + delete network, delete orphaned routers — all inline, untestable in + isolation, and specific to network. +2. **No real plan/approve mode.** Selection ("should this resource be + deleted") and deletion happen interleaved in the same imperative pass, + with mutable shared state (`identified_resources`) mutated across + threads as services run concurrently. There's no point where a + complete, stable "here's what will be deleted and why" object exists + that a caller could inspect, edit, and then apply. `dry_run=True` only + suppresses the delete call; it doesn't produce an artifact. +3. **Not extensible.** Only services shipped in openstacksdk itself can + participate, by defining these two dunder-ish methods on their proxy. + A caller can't inject a cleanup hook for a service the SDK doesn't + support, or override/augment built-in behavior, without subclassing + the proxy classes. + +## Goals + +Design a project-cleanup subsystem for the rust `openstack_sdk` crate that: + +- Expresses dependencies at both the service level (coarse ordering hints) + and the resource level (relationships between actual discovered + resources), so no service needs to hand-code cascade/ordering logic. +- Produces a real two-phase plan/approve flow: a discovery phase builds a + complete, inspectable `CleanupPlan`; a separate apply phase executes + only what's selected in that plan. +- Lets callers inject their own cleanup providers (for services the SDK + doesn't support, or to customize/override built-in behavior) through + the same interface used by built-in providers — no special-casing. + +## Non-goals (v1) + +- Sync execution support (SDK's `sync` feature). This subsystem targets + the `async` feature only. +- Full parity with every service python covers. v1 ships compute, + network, block-storage, image, and identity-scoped resources; the + extension mechanism is designed so other services attach later with no + core changes. +- Automatic re-validation of plan freshness (re-listing resources between + discover and apply). Apply attempts deletes and tolerates 404s from + resources that vanished in the interim; it does not re-run discovery. + +## Architecture + +### Resource envelope + +Cleanup logic must be able to reason generically about resources without +being generic over every SDK resource type, mirroring python's untyped +`resource.Resource` handling in the cleanup path: + +```rust +pub struct PlannedResource { + pub kind: ResourceKind, // e.g. ResourceKind::new("network", "network") + pub id: String, + pub name: Option, + pub raw: serde_json::Value, // full resource body, for relation matching/filters + pub selected: bool, // discovery's filter verdict; caller may flip before apply + pub reason: Option, // why selected/skipped, for plan display +} + +pub struct ResourceKind { + pub service_type: &'static str, // "network", "compute", ... + pub resource_type: &'static str, // "port", "server", ... +} +``` + +### Two dependency layers + +**Service-level (`CleanupDependency`)** — same shape as python's +`{before, after}`, used only for ordering that isn't about specific +resource relationships (e.g. identity-scoped project resources should be +handled after everything else that lives inside the project). + +**Resource-level (`RelationRule`)** — declarative edges between resource +*kinds*, evaluated against actually discovered `PlannedResource`s during +the discovery phase: + +```rust +pub struct RelationRule { + pub parent_kind: ResourceKind, + pub child_kind: ResourceKind, + pub matches: fn(child: &PlannedResource, parent: &PlannedResource) -> bool, + pub effect: RelationEffect, +} + +pub enum RelationEffect { + /// Parent cannot be deleted while a matching, still-selected-or-existing + /// child exists. Generic replacement for network's + /// `network_has_ports_allocated` check. + Blocks, + /// Selecting any member of the group selects every member; the group + /// has its own internal sub-order. Generic replacement for network's + /// "networks are crazy, delete router+net+subnet together" cascade. + CascadeGroup { order: fn(&[PlannedResource]) -> Vec }, + /// Before the parent is deleted, run this action to sever the + /// relationship (does not delete the child). Generic replacement for + /// `remove_interface_from_router`. + Detach(fn(&CleanupContext, child: &PlannedResource) -> BoxFuture<'_, Result<(), CleanupError>>), +} +``` + +This is the direct fix for the network proxy's hacks: what's currently +270 lines of one-off imperative code becomes three `RelationRule` values +declared by the network provider, using primitives every other provider +can reuse. + +### Two-phase execution + +**Discover phase.** Every registered `CleanupProvider` lists its resources +concurrently (tokio tasks respecting only service-level `CleanupDependency` +ordering where a provider genuinely needs another service's data to list +its own — e.g. needing a project-scoped list). Each provider's discovered +resources are merged into one node set; `RelationRule`s are evaluated +against the merged set to compute edges and apply `Blocks`/`CascadeGroup` +effects. The per-resource filter/evaluation callback (equivalent of +python's `resource_evaluation_fn` and built-in filters like +`created_at`/`updated_at`) runs here too, setting `selected`. Output is a +`CleanupPlan { nodes: Vec, edges: Vec<(NodeIdx, NodeIdx, RelationEffect)> }`, +which is `serde`-serializable — it can be printed as a table/tree, diffed, +or handed back after a caller/CLI lets the user toggle `selected` flags. +No deletions happen in this phase. + +**Apply phase.** Takes a `CleanupPlan` (possibly edited) and walks it as a +DAG: for `Blocks` edges, children delete before parents; for +`CascadeGroup`s, members delete in the group's declared internal order; +`Detach` actions run immediately before their parent's delete call. Only +`selected` nodes are touched. Deletes run concurrently across independent +subgraphs (tokio tasks + a shared "node done" signal, the same shape as +python's `TinyDAG.walk`/`node_done`, implemented with `petgraph` for graph +structure and topological walking). A delete returning "not found" is +treated as success (plan may be stale relative to real state). + +### Extensibility + +```rust +#[async_trait] +pub trait CleanupProvider: Send + Sync { + fn service_type(&self) -> &'static str; + fn dependencies(&self) -> CleanupDependency { CleanupDependency::default() } + fn relations(&self) -> Vec { vec![] } + async fn discover(&self, ctx: &CleanupContext) -> Result, CleanupError>; + async fn delete(&self, ctx: &CleanupContext, r: &PlannedResource) -> Result<(), CleanupError>; +} +``` + +A `ProjectCleanupBuilder` registers providers: + +```rust +let cleanup = ProjectCleanupBuilder::new(session) + .with_provider(NetworkCleanupProvider::default()) // built-in + .with_provider(ComputeCleanupProvider::default()) // built-in + .with_provider(MyOrgCustomCleanupProvider::new(...)) // caller-injected, same trait + .build(); + +let plan = cleanup.discover(&filters).await?; +// caller inspects/edits plan.nodes[*].selected +let result = cleanup.apply(plan).await?; +``` + +Built-in and caller-supplied providers are indistinguishable to the +engine — this directly satisfies the extensibility requirement without +subclassing or special-casing. + +### Placement + +New module `openstack_sdk::cleanup`, gated behind the existing `async` +feature (matches the crate's current `#[cfg(feature = "async")]` +structure in `lib.rs`). Reusable from `openstack_cli`/`openstack_tui` +without duplicating logic. + +### Error handling + +Provider `discover`/`delete` errors are collected per-`PlannedResource` +into the plan/apply result (not per-service, as python does) and don't +abort the overall run — one resource failing to delete doesn't block +unrelated subgraphs. This is a strict improvement over python's per-service +`try/except` + log, since python's per-service scope hides which +individual resource failed inside a service that touches many resource +types. + +### Testing + +`RelationRule` evaluation, `Blocks`/`CascadeGroup` resolution, and DAG +ordering are unit-testable against synthetic `PlannedResource` sets with +no live cloud connection required — this was not possible in python, +where the equivalent logic is inline in one large imperative +`_service_cleanup` method per service. + +## v1 scope + +Providers: compute, network (proves `Blocks`/`CascadeGroup`/`Detach`), +block-storage, image, identity-scoped resources. `petgraph` added as a new +dependency for graph structure/topo-walk. Sync support and full +service-parity with python are deferred; the trait-based extension point +means later services need no core changes. diff --git a/openstack_sdk/Cargo.toml b/openstack_sdk/Cargo.toml index fd95bacc5..ff194f637 100644 --- a/openstack_sdk/Cargo.toml +++ b/openstack_sdk/Cargo.toml @@ -101,6 +101,7 @@ openstack-sdk-auth-totp = { version = "0.22", path = "../sdk/auth-totp/" } openstack-sdk-auth-websso = { version = "0.22", path = "../sdk/auth-websso/" } openstack_sdk_core.workspace = true parking_lot = "0.12" +petgraph = "0.6" openstack-sdk-block-storage = { path = "../sdk/block-storage/", version = "^0.22", optional = true } openstack-sdk-compute = { path = "../sdk/compute/", version = "^0.22", optional = true } openstack-sdk-container-infrastructure-management = { path = "../sdk/container-infrastructure-management/", version = "^0.22", optional = true } @@ -126,7 +127,7 @@ bytes.workspace = true httpmock.workspace = true reqwest = { workspace = true, features = ["rustls", "blocking"] } secrecy.workspace = true -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync"] } tracing-test.workspace = true [[test]] diff --git a/openstack_sdk/src/cleanup/engine.rs b/openstack_sdk/src/cleanup/engine.rs new file mode 100644 index 000000000..0226d7cf0 --- /dev/null +++ b/openstack_sdk/src/cleanup/engine.rs @@ -0,0 +1,1425 @@ +// 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 + +//! Discover/apply engine: turns registered [`CleanupProvider`]s into a +//! materialized, inspectable [`CleanupPlan`] (discover), then executes a +//! (possibly caller-edited) plan (apply, added in a later task). + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::AsyncOpenStack; +use crate::cleanup::provider::{ + CleanupContext, CleanupError, CleanupProvider, EvaluationFn, service_layers, +}; +use crate::cleanup::relations::{Edge, RelationEffect, evaluate_edges}; +use crate::cleanup::types::PlannedResource; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct PlanEdge { + pub child: usize, + pub parent: usize, + pub effect: RelationEffect, +} + +impl From for PlanEdge { + fn from(e: Edge) -> Self { + PlanEdge { + child: e.child, + parent: e.parent, + effect: e.effect, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CleanupPlan { + pub nodes: Vec, + pub edges: Vec, + /// Providers that failed during discovery, as `(service_type, message)`. + /// Discovery continues past a failing provider so the rest of the plan + /// still reflects every service that listed successfully; a caller + /// that needs an all-or-nothing guarantee should check this is empty + /// before calling `apply()`. + #[serde(default)] + pub errors: Vec<(String, String)>, +} + +/// Propagate `CascadeGroup` selection: if any node in a group formed by +/// `CascadeGroup` edges is selected, every node in that group becomes +/// selected. Pure function over plan data, used by both `discover()` and +/// tested directly here. +pub(crate) fn propagate_cascade_groups(nodes: &mut [PlannedResource], edges: &[Edge]) { + // Union-find over cascade-group edges only. + let mut parent: Vec = (0..nodes.len()).collect(); + fn find(parent: &mut [usize], x: usize) -> usize { + if parent[x] != x { + parent[x] = find(parent, parent[x]); + } + parent[x] + } + fn union(parent: &mut [usize], a: usize, b: usize) { + let ra = find(parent, a); + let rb = find(parent, b); + if ra != rb { + parent[ra] = rb; + } + } + for edge in edges + .iter() + .filter(|e| e.effect == RelationEffect::CascadeGroup) + { + union(&mut parent, edge.child, edge.parent); + } + + let mut group_selected: HashMap = HashMap::new(); // root -> index of a selected member + for (i, node) in nodes.iter().enumerate() { + if node.selected { + let root = find(&mut parent, i); + group_selected.entry(root).or_insert(i); + } + } + for i in 0..nodes.len() { + let root = find(&mut parent, i); + if let Some(&selected_idx) = group_selected.get(&root) + && !nodes[i].selected + { + let cause_id = nodes[selected_idx].id.clone(); + nodes[i].selected = true; + nodes[i].reason = Some(format!("cascade: {cause_id}")); + } + } +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct CleanupResult { + pub deleted: Vec, + pub deleted_ids: Vec, + pub skipped: Vec<(String, String)>, + pub errors: Vec<(String, String)>, +} + +pub struct ProjectCleanupBuilder<'a> { + client: &'a AsyncOpenStack, + providers: Vec>, +} + +impl<'a> ProjectCleanupBuilder<'a> { + pub fn new(client: &'a AsyncOpenStack) -> Self { + Self { + client, + providers: Vec::new(), + } + } + + pub fn with_provider(mut self, provider: impl CleanupProvider + 'static) -> Self { + self.providers.push(Box::new(provider)); + self + } + + pub fn build(self) -> ProjectCleanup<'a> { + ProjectCleanup { + client: self.client, + providers: self.providers, + } + } +} + +pub struct ProjectCleanup<'a> { + client: &'a AsyncOpenStack, + providers: Vec>, +} + +impl<'a> ProjectCleanup<'a> { + pub async fn discover( + &self, + filters: HashMap, + evaluation_fn: Option, + ) -> Result { + let provider_refs: Vec<&dyn CleanupProvider> = + self.providers.iter().map(|p| p.as_ref()).collect(); + let layers = service_layers(&provider_refs)?; + + let ctx = CleanupContext { + client: self.client, + filters, + evaluation_fn, + }; + + // Service-level ordering only gates *listing*, not selection: run + // providers in dependency order, but nothing stops a later + // provider's rules from referencing an earlier provider's nodes. + let mut nodes: Vec = Vec::new(); + let mut errors: Vec<(String, String)> = Vec::new(); + for layer in layers { + // Providers within one layer have no dependency relationship + // to each other (see `service_layers`), so list them + // concurrently; layers themselves still run in order. + let futures = layer.iter().map(|&idx| self.providers[idx].discover(&ctx)); + let results = futures::future::join_all(futures).await; + + for (&idx, result) in layer.iter().zip(results) { + let mut discovered = match result { + Ok(discovered) => discovered, + Err(e) => { + errors.push(( + self.providers[idx].service_type().to_string(), + e.to_string(), + )); + continue; + } + }; + for r in &mut discovered { + r.selected = if let Some(eval) = &ctx.evaluation_fn { + eval(r) + } else { + crate::cleanup::filters::evaluate_filters(r, &ctx.filters) + }; + if r.selected && r.reason.is_none() { + r.reason = Some(if ctx.evaluation_fn.is_some() { + "matched evaluation_fn".into() + } else { + "matched filters".into() + }); + } + } + nodes.append(&mut discovered); + } + } + + let all_rules: Vec<_> = self.providers.iter().flat_map(|p| p.relations()).collect(); + let edges = evaluate_edges(&nodes, &all_rules); + + propagate_cascade_groups(&mut nodes, &edges); + + Ok(CleanupPlan { + nodes, + edges: edges.into_iter().map(PlanEdge::from).collect(), + errors, + }) + } + + pub async fn apply(&self, plan: CleanupPlan) -> Result { + let ctx = CleanupContext { + client: self.client, + filters: HashMap::new(), + evaluation_fn: None, + }; + + // A parent is blocked while any `Blocks` child of it is not + // selected (i.e. is being kept), or -- once that child's own + // layer has been processed below -- while it turned out not to + // be actually deleted (itself blocked, or its delete call + // errored). `blocked` starts from the directly-unselected case + // and is then extended as each layer resolves. + let mut blocked: Vec = vec![false; plan.nodes.len()]; + let mut blocks_parents_of: Vec> = vec![Vec::new(); plan.nodes.len()]; + for edge in plan + .edges + .iter() + .filter(|e| e.effect == RelationEffect::Blocks) + { + blocks_parents_of[edge.child].push(edge.parent); + if !plan.nodes[edge.child].selected { + blocked[edge.parent] = true; + } + } + + // Deletion order: children (via `Blocks` edges) before parents, + // grouped into layers so that nodes with no `Blocks` relationship + // to each other -- which, by construction, is every pair within + // one layer -- can be deleted concurrently. A node's own + // `Blocks`-children are always in a strictly earlier layer, so by + // the time layer N starts, every flag that could affect it has + // already been finalized by earlier layers; no locking is needed + // for `blocked`/`result`, since all mutation of that shared state + // happens in a purely sequential phase after each layer's + // concurrent deletes have fully resolved. + let mut graph = petgraph::graph::DiGraph::::new(); + let node_ids: Vec<_> = (0..plan.nodes.len()).map(|i| graph.add_node(i)).collect(); + for edge in plan + .edges + .iter() + .filter(|e| e.effect == RelationEffect::Blocks) + { + // child must run before parent: edge child -> parent + graph.add_edge(node_ids[edge.child], node_ids[edge.parent], ()); + } + let layers = Self::compute_layers(&graph)?; + + let mut result = CleanupResult::default(); + for layer in layers { + let mut to_delete: Vec = Vec::new(); + for &idx in &layer { + if !plan.nodes[idx].selected { + continue; + } + if blocked[idx] { + let id = plan.nodes[idx].id.clone(); + result.skipped.push(( + id, + "blocked by a kept, skipped, or failed child resource".into(), + )); + for &parent_idx in &blocks_parents_of[idx] { + blocked[parent_idx] = true; + } + continue; + } + to_delete.push(idx); + } + + // Concurrent phase: no shared mutable state is touched here, + // only immutable `&ctx`/`&resource` borrows and each + // provider's own `delete()` call. + let mut futures = Vec::with_capacity(to_delete.len()); + for &idx in &to_delete { + let resource = &plan.nodes[idx]; + let provider = self.provider_for_kind(resource.kind).ok_or_else(|| { + CleanupError::Engine(format!("no provider registered for {:?}", resource.kind)) + })?; + futures.push(provider.delete(&ctx, resource)); + } + let delete_results = futures::future::join_all(futures).await; + + // Sequential phase: safe to mutate `result`/`blocked` here, + // since every future above has already resolved. + for (&idx, delete_result) in to_delete.iter().zip(delete_results) { + let resource = &plan.nodes[idx]; + match delete_result { + Ok(()) => { + result.deleted.push(resource.kind); + result.deleted_ids.push(resource.id.clone()); + } + Err(e) if e.is_not_found() => { + result.deleted.push(resource.kind); + result.deleted_ids.push(resource.id.clone()); + } + Err(e) => { + result.errors.push((resource.id.clone(), e.to_string())); + for &parent_idx in &blocks_parents_of[idx] { + blocked[parent_idx] = true; + } + } + } + } + } + + Ok(result) + } + + /// Group a `Blocks`-edge DAG into ordered layers: every node in layer + /// N has all of its incoming-edge predecessors in layers `0..N`, and + /// nodes within the same layer have no edge between them at all. + /// Layers must be processed in order; nodes within one layer have no + /// dependency relationship and may be processed concurrently. + fn compute_layers( + graph: &petgraph::graph::DiGraph, + ) -> Result>, CleanupError> { + let mut in_degree: Vec = graph + .node_indices() + .map(|n| { + graph + .neighbors_directed(n, petgraph::Direction::Incoming) + .count() + }) + .collect(); + let mut remaining = graph.node_count(); + let mut placed = vec![false; graph.node_count()]; + let mut layers: Vec> = Vec::new(); + + while remaining > 0 { + let ready: Vec<_> = graph + .node_indices() + .filter(|n| !placed[n.index()] && in_degree[n.index()] == 0) + .collect(); + if ready.is_empty() { + return Err(CleanupError::Engine( + "cycle in resource-level cleanup dependencies".into(), + )); + } + for &n in &ready { + placed[n.index()] = true; + remaining -= 1; + for succ in graph.neighbors_directed(n, petgraph::Direction::Outgoing) { + in_degree[succ.index()] -= 1; + } + } + layers.push(ready.into_iter().map(|n| graph[n]).collect()); + } + + Ok(layers) + } + + fn provider_for_kind( + &self, + kind: crate::cleanup::types::ResourceKind, + ) -> Option<&dyn CleanupProvider> { + self.providers + .iter() + .find(|p| p.service_type() == kind.service_type) + .map(|p| p.as_ref()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cleanup::relations::RelationEffect; + use crate::cleanup::types::ResourceKind; + use serde_json::json; + use std::sync::Arc; + + fn node(kind: ResourceKind, id: &str, selected: bool) -> PlannedResource { + PlannedResource { + kind, + id: id.into(), + name: None, + raw: json!({"id": id}), + selected, + reason: None, + } + } + + const NETWORK: ResourceKind = ResourceKind::new("network", "network"); + const ROUTER: ResourceKind = ResourceKind::new("network", "router"); + const SUBNET: ResourceKind = ResourceKind::new("network", "subnet"); + + #[test] + fn cascade_selection_spreads_across_group() { + // net-1 (selected) -- cascade -- router-1 + // router-1 -- cascade -- subnet-1 + // subnet-1 is not directly linked to net-1, only transitively. + let mut nodes = vec![ + node(NETWORK, "net-1", true), + node(ROUTER, "router-1", false), + node(SUBNET, "subnet-1", false), + node(NETWORK, "net-2", false), // unrelated, must stay false + ]; + let edges = vec![ + Edge { + child: 1, + parent: 0, + effect: RelationEffect::CascadeGroup, + }, + Edge { + child: 2, + parent: 1, + effect: RelationEffect::CascadeGroup, + }, + ]; + propagate_cascade_groups(&mut nodes, &edges); + assert!(nodes[0].selected); + assert!(nodes[1].selected, "router-1 must be pulled in transitively"); + assert!(nodes[2].selected, "subnet-1 must be pulled in transitively"); + assert!(!nodes[3].selected, "unrelated network must not be selected"); + assert_eq!(nodes[1].reason.as_deref(), Some("cascade: net-1")); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn discover_merges_providers_and_applies_evaluation_fn() { + use crate::cleanup::provider::{CleanupContext, CleanupDependency}; + use async_trait::async_trait; + + struct OnlyEvenIdsProvider; + #[async_trait] + impl CleanupProvider for OnlyEvenIdsProvider { + fn service_type(&self) -> &'static str { + "fake" + } + fn dependencies(&self) -> CleanupDependency { + CleanupDependency::default() + } + async fn discover( + &self, + _ctx: &CleanupContext<'_>, + ) -> Result, CleanupError> { + Ok(vec![ + node(ResourceKind::new("fake", "thing"), "1", false), + node(ResourceKind::new("fake", "thing"), "2", false), + ]) + } + async fn delete( + &self, + _ctx: &CleanupContext<'_>, + _r: &PlannedResource, + ) -> Result<(), CleanupError> { + Ok(()) + } + } + + // discover() needs a real AsyncOpenStack only to populate + // CleanupContext::client; no HTTP call happens because the fake + // provider never touches it. Build a minimally-configured client + // via the existing test helpers pattern used across the crate + // (see openstack_sdk/src/test.rs) — a mock server is started but + // no request is expected against it in this test. + // NOTE: this requires the httpmock dev-dependency already present + // in openstack_sdk/Cargo.toml. + let server = httpmock::MockServer::start_async().await; + let config = openstack_sdk_core::config::CloudConfig { + auth: Some(openstack_sdk_core::config::Auth { + auth_url: Some(format!("{}/v3", server.base_url())), + username: Some("test-user".into()), + user_domain_name: Some("Default".into()), + password: Some("test-password".into()), + project_id: Some("test-project".into()), + ..Default::default() + }), + region_name: Some("RegionOne".into()), + interface: Some("public".into()), + auth_cache: Some(false), + ..Default::default() + }; + let base_url = server.base_url(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/"); + then.status(200).json_body(serde_json::json!({ + "versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}] + })); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v3/"); + then.status(200).json_body(serde_json::json!({ + "versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}] + })); + }); + let expires = (chrono::Utc::now() + chrono::TimeDelta::hours(1)).to_rfc3339(); + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/v3/auth/tokens"); + then.status(201) + .header("x-subject-token", "test-token") + .json_body(serde_json::json!({"token": { + "id": "token-id", "expires_at": expires, + "project": {"id": "test-project", "name": "TestProject"}, + "user": {"id": "test-user", "name": "test-user"}, + "methods": ["password"], "audit_ids": ["audit-1"], + "catalog": [ + {"type": "identity", "name": "keystone", "endpoints": [{ + "id": "identity-1", "url": format!("{base_url}/v3"), + "region": "RegionOne", "interface": "public" + }]} + ] + }})); + }); + + let client = AsyncOpenStack::new_with_authentication_helper( + &config, + crate::auth::auth_helper::Noop::default(), + false, + ) + .await + .expect("client creation failed"); + + let cleanup = ProjectCleanupBuilder::new(&client) + .with_provider(OnlyEvenIdsProvider) + .build(); + + let eval: Arc bool + Send + Sync> = + Arc::new(|r: &PlannedResource| r.id == "2"); + let plan = cleanup + .discover(HashMap::new(), Some(eval)) + .await + .expect("discover failed"); + + assert_eq!(plan.nodes.len(), 2); + let selected: Vec<_> = plan + .nodes + .iter() + .filter(|n| n.selected) + .map(|n| n.id.clone()) + .collect(); + assert_eq!(selected, vec!["2".to_string()]); + } + + #[tokio::test] + async fn discover_applies_built_in_filters_when_no_evaluation_fn_given() { + use crate::cleanup::provider::CleanupDependency; + use async_trait::async_trait; + + struct TimestampedProvider; + #[async_trait] + impl CleanupProvider for TimestampedProvider { + fn service_type(&self) -> &'static str { + "fake" + } + fn dependencies(&self) -> CleanupDependency { + CleanupDependency::default() + } + async fn discover( + &self, + _ctx: &CleanupContext<'_>, + ) -> Result, CleanupError> { + Ok(vec![ + PlannedResource { + kind: ResourceKind::new("fake", "thing"), + id: "old-1".into(), + name: None, + raw: serde_json::json!({"created_at": "2024-01-01T00:00:00Z"}), + selected: false, + reason: None, + }, + PlannedResource { + kind: ResourceKind::new("fake", "thing"), + id: "new-1".into(), + name: None, + raw: serde_json::json!({"created_at": "2024-12-01T00:00:00Z"}), + selected: false, + reason: None, + }, + ]) + } + async fn delete( + &self, + _ctx: &CleanupContext<'_>, + _resource: &PlannedResource, + ) -> Result<(), CleanupError> { + Ok(()) + } + } + + // Build a client the same way as + // discover_merges_providers_and_applies_evaluation_fn (no HTTP + // calls are made because TimestampedProvider never touches + // ctx.client). + let server = httpmock::MockServer::start_async().await; + let config = openstack_sdk_core::config::CloudConfig { + auth: Some(openstack_sdk_core::config::Auth { + auth_url: Some(format!("{}/v3", server.base_url())), + username: Some("test-user".into()), + user_domain_name: Some("Default".into()), + password: Some("test-password".into()), + project_id: Some("test-project".into()), + ..Default::default() + }), + region_name: Some("RegionOne".into()), + interface: Some("public".into()), + auth_cache: Some(false), + ..Default::default() + }; + let base_url = server.base_url(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/"); + then.status(200).json_body(serde_json::json!({ + "versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}] + })); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v3/"); + then.status(200).json_body(serde_json::json!({ + "versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}] + })); + }); + let expires = (chrono::Utc::now() + chrono::TimeDelta::hours(1)).to_rfc3339(); + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/v3/auth/tokens"); + then.status(201) + .header("x-subject-token", "test-token") + .json_body(serde_json::json!({"token": { + "id": "token-id", "expires_at": expires, + "project": {"id": "test-project", "name": "TestProject"}, + "user": {"id": "test-user", "name": "test-user"}, + "methods": ["password"], "audit_ids": ["audit-1"], + "catalog": [ + {"type": "identity", "name": "keystone", "endpoints": [{ + "id": "identity-1", "url": format!("{base_url}/v3"), + "region": "RegionOne", "interface": "public" + }]} + ] + }})); + }); + + let client = AsyncOpenStack::new_with_authentication_helper( + &config, + crate::auth::auth_helper::Noop::default(), + false, + ) + .await + .expect("client creation failed"); + + let cleanup = ProjectCleanupBuilder::new(&client) + .with_provider(TimestampedProvider) + .build(); + + let mut filters = HashMap::new(); + filters.insert("created_at".to_string(), "2024-06-01T00:00:00Z".to_string()); + let plan = cleanup + .discover(filters, None) + .await + .expect("discover must succeed"); + + let old = plan + .nodes + .iter() + .find(|n| n.id == "old-1") + .expect("old-1 node must exist"); + let new = plan + .nodes + .iter() + .find(|n| n.id == "new-1") + .expect("new-1 node must exist"); + assert!( + old.selected, + "old-1 was created before the cutoff, must be selected" + ); + assert!( + !new.selected, + "new-1 was created after the cutoff, must not be selected" + ); + + // With NO filters and no evaluation_fn, everything must be + // selected -- the real project_cleanup()-style default. + let plan_no_filters = cleanup + .discover(HashMap::new(), None) + .await + .expect("discover must succeed"); + assert!(plan_no_filters.nodes.iter().all(|n| n.selected)); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn discover_continues_past_a_failing_provider() { + use crate::cleanup::provider::CleanupDependency; + use async_trait::async_trait; + + struct OkProvider; + #[async_trait] + impl CleanupProvider for OkProvider { + fn service_type(&self) -> &'static str { + "ok-service" + } + fn dependencies(&self) -> CleanupDependency { + CleanupDependency::default() + } + async fn discover( + &self, + _ctx: &CleanupContext<'_>, + ) -> Result, CleanupError> { + Ok(vec![node( + ResourceKind::new("ok-service", "thing"), + "ok-1", + true, + )]) + } + async fn delete( + &self, + _ctx: &CleanupContext<'_>, + _resource: &PlannedResource, + ) -> Result<(), CleanupError> { + Ok(()) + } + } + + struct FailingProvider; + #[async_trait] + impl CleanupProvider for FailingProvider { + fn service_type(&self) -> &'static str { + "failing-service" + } + fn dependencies(&self) -> CleanupDependency { + CleanupDependency::default() + } + async fn discover( + &self, + _ctx: &CleanupContext<'_>, + ) -> Result, CleanupError> { + Err(CleanupError::Engine("simulated listing failure".into())) + } + async fn delete( + &self, + _ctx: &CleanupContext<'_>, + _resource: &PlannedResource, + ) -> Result<(), CleanupError> { + Ok(()) + } + } + + // Build a client the same way as discover_merges_providers_and_applies_evaluation_fn + // (no HTTP calls are made because neither test double touches ctx.client). + let server = httpmock::MockServer::start_async().await; + let config = openstack_sdk_core::config::CloudConfig { + auth: Some(openstack_sdk_core::config::Auth { + auth_url: Some(format!("{}/v3", server.base_url())), + username: Some("test-user".into()), + user_domain_name: Some("Default".into()), + password: Some("test-password".into()), + project_id: Some("test-project".into()), + ..Default::default() + }), + region_name: Some("RegionOne".into()), + interface: Some("public".into()), + auth_cache: Some(false), + ..Default::default() + }; + let base_url = server.base_url(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/"); + then.status(200).json_body(serde_json::json!({ + "versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}] + })); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v3/"); + then.status(200).json_body(serde_json::json!({ + "versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}] + })); + }); + let expires = (chrono::Utc::now() + chrono::TimeDelta::hours(1)).to_rfc3339(); + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/v3/auth/tokens"); + then.status(201) + .header("x-subject-token", "test-token") + .json_body(serde_json::json!({"token": { + "id": "token-id", "expires_at": expires, + "project": {"id": "test-project", "name": "TestProject"}, + "user": {"id": "test-user", "name": "test-user"}, + "methods": ["password"], "audit_ids": ["audit-1"], + "catalog": [ + {"type": "identity", "name": "keystone", "endpoints": [{ + "id": "identity-1", "url": format!("{base_url}/v3"), + "region": "RegionOne", "interface": "public" + }]} + ] + }})); + }); + + let client = AsyncOpenStack::new_with_authentication_helper( + &config, + crate::auth::auth_helper::Noop::default(), + false, + ) + .await + .expect("client creation failed"); + + let cleanup = ProjectCleanupBuilder::new(&client) + .with_provider(FailingProvider) + .with_provider(OkProvider) + .build(); + + let plan = cleanup + .discover(HashMap::new(), None) + .await + .expect("discover must not abort on a single provider failure"); + + assert!( + plan.nodes.iter().any(|n| n.id == "ok-1"), + "ok-service's node must still be present even though failing-service errored" + ); + assert_eq!(plan.errors.len(), 1); + assert_eq!(plan.errors[0].0, "failing-service"); + assert!(plan.errors[0].1.contains("simulated listing failure")); + } + + #[tokio::test] + async fn discover_respects_before_after_ordering_across_layers() { + use crate::cleanup::provider::CleanupDependency; + use async_trait::async_trait; + use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering}; + + // `dependent` has a `before` dependency on `independent`, meaning + // `independent` must be listed first. `other_independent` has no + // dependency on anything and should share `independent`'s layer. + // A shared atomic counter records the order calls actually + // completed in, so this test catches a regression where layering + // stops respecting before/after (not just a node-presence check). + struct OrderRecordingProvider { + name: &'static str, + deps: CleanupDependency, + counter: std::sync::Arc, + recorded_at: std::sync::Arc>>, + } + #[async_trait] + impl CleanupProvider for OrderRecordingProvider { + fn service_type(&self) -> &'static str { + self.name + } + fn dependencies(&self) -> CleanupDependency { + self.deps.clone() + } + async fn discover( + &self, + _ctx: &CleanupContext<'_>, + ) -> Result, CleanupError> { + let seq = self.counter.fetch_add(1, AtomicOrdering::SeqCst); + *self.recorded_at.lock().unwrap_or_else(|p| p.into_inner()) = Some(seq); + Ok(vec![node( + ResourceKind::new(self.name, "thing"), + self.name, + true, + )]) + } + async fn delete( + &self, + _ctx: &CleanupContext<'_>, + _resource: &PlannedResource, + ) -> Result<(), CleanupError> { + Ok(()) + } + } + + let counter = std::sync::Arc::new(AtomicU32::new(0)); + let independent_at = std::sync::Arc::new(std::sync::Mutex::new(None)); + let dependent_at = std::sync::Arc::new(std::sync::Mutex::new(None)); + + let independent = OrderRecordingProvider { + name: "independent-svc", + deps: CleanupDependency::default(), + counter: counter.clone(), + recorded_at: independent_at.clone(), + }; + let dependent = OrderRecordingProvider { + name: "dependent-svc", + deps: CleanupDependency { + before: vec![], + after: vec!["independent-svc"], + }, + counter: counter.clone(), + recorded_at: dependent_at.clone(), + }; + + // Build a client the same way as discover_continues_past_a_failing_provider + // (no HTTP calls are made because neither test double touches ctx.client). + let server = httpmock::MockServer::start_async().await; + let config = openstack_sdk_core::config::CloudConfig { + auth: Some(openstack_sdk_core::config::Auth { + auth_url: Some(format!("{}/v3", server.base_url())), + username: Some("test-user".into()), + user_domain_name: Some("Default".into()), + password: Some("test-password".into()), + project_id: Some("test-project".into()), + ..Default::default() + }), + region_name: Some("RegionOne".into()), + interface: Some("public".into()), + auth_cache: Some(false), + ..Default::default() + }; + let base_url = server.base_url(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/"); + then.status(200).json_body(serde_json::json!({ + "versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}] + })); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v3/"); + then.status(200).json_body(serde_json::json!({ + "versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}] + })); + }); + let expires = (chrono::Utc::now() + chrono::TimeDelta::hours(1)).to_rfc3339(); + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/v3/auth/tokens"); + then.status(201) + .header("x-subject-token", "test-token") + .json_body(serde_json::json!({"token": { + "id": "token-id", "expires_at": expires, + "project": {"id": "test-project", "name": "TestProject"}, + "user": {"id": "test-user", "name": "test-user"}, + "methods": ["password"], "audit_ids": ["audit-1"], + "catalog": [ + {"type": "identity", "name": "keystone", "endpoints": [{ + "id": "identity-1", "url": format!("{base_url}/v3"), + "region": "RegionOne", "interface": "public" + }]} + ] + }})); + }); + + let client = AsyncOpenStack::new_with_authentication_helper( + &config, + crate::auth::auth_helper::Noop::default(), + false, + ) + .await + .expect("client creation failed"); + + // Register `dependent` before `independent` on purpose, to prove + // ordering comes from the dependency graph, not registration order. + let cleanup = ProjectCleanupBuilder::new(&client) + .with_provider(dependent) + .with_provider(independent) + .build(); + + let plan = cleanup + .discover(HashMap::new(), None) + .await + .expect("discover must succeed"); + + assert!(plan.nodes.iter().any(|n| n.id == "independent-svc")); + assert!(plan.nodes.iter().any(|n| n.id == "dependent-svc")); + + let independent_seq = independent_at + .lock() + .unwrap_or_else(|p| p.into_inner()) + .expect("independent must have run"); + let dependent_seq = dependent_at + .lock() + .unwrap_or_else(|p| p.into_inner()) + .expect("dependent must have run"); + assert!( + independent_seq < dependent_seq, + "independent-svc must complete its discover() call before dependent-svc's starts, \ + despite dependent-svc being registered first" + ); + } + + #[tokio::test] + async fn apply_deletes_children_before_parents_and_skips_blocked_parent() { + use crate::cleanup::provider::CleanupDependency; + use async_trait::async_trait; + use std::sync::Mutex; + + struct RecordingProvider { + log: Arc>>, + } + + #[async_trait] + impl CleanupProvider for RecordingProvider { + fn service_type(&self) -> &'static str { + "fake" + } + fn dependencies(&self) -> CleanupDependency { + CleanupDependency::default() + } + async fn discover( + &self, + _ctx: &CleanupContext<'_>, + ) -> Result, CleanupError> { + Ok(Vec::new()) + } + async fn delete( + &self, + _ctx: &CleanupContext<'_>, + resource: &PlannedResource, + ) -> Result<(), CleanupError> { + self.log.lock().unwrap().push(resource.id.clone()); + Ok(()) + } + } + + let log = Arc::new(Mutex::new(Vec::new())); + + // net-1 has one selected child (port-selected) and one kept child + // (port-kept). net-2 has only a selected child. Expect: net-1 is + // NOT deleted (blocked by port-kept), net-2 IS deleted, and + // port-selected/port-kept... wait, port-kept is not selected so it + // is never passed to delete() at all; only selected nodes are + // touched by apply(). + let mut net1 = node(ResourceKind::new("fake", "network"), "net-1", true); + net1.reason = Some("matched filter".into()); + let net2 = node(ResourceKind::new("fake", "network"), "net-2", true); + let port_selected = node(ResourceKind::new("fake", "port"), "port-on-net2", true); + let mut port_kept = node(ResourceKind::new("fake", "port"), "port-on-net1", false); + port_kept.selected = false; + + let nodes = vec![net1, net2, port_selected, port_kept]; + // index: 0 net-1, 1 net-2, 2 port-on-net2, 3 port-on-net1 + let edges = vec![ + PlanEdge { + child: 2, + parent: 1, + effect: RelationEffect::Blocks, + }, // port-on-net2 blocks net-2, but port-on-net2 IS selected -> not blocking + PlanEdge { + child: 3, + parent: 0, + effect: RelationEffect::Blocks, + }, // port-on-net1 blocks net-1, and port-on-net1 is NOT selected -> blocking + ]; + let plan = CleanupPlan { + nodes, + edges, + errors: Vec::new(), + }; + + // Build a client the same way as the discover test (no HTTP calls + // are made because RecordingProvider never touches ctx.client). + let server = httpmock::MockServer::start_async().await; + let base_url = server.base_url(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/"); + then.status(200).json_body( + serde_json::json!({"versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}]}), + ); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v3/"); + then.status(200).json_body( + serde_json::json!({"versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}]}), + ); + }); + let expires = (chrono::Utc::now() + chrono::TimeDelta::hours(1)).to_rfc3339(); + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/v3/auth/tokens"); + then.status(201) + .header("x-subject-token", "test-token") + .json_body(serde_json::json!({"token": { + "id": "token-id", "expires_at": expires, + "project": {"id": "test-project", "name": "TestProject"}, + "user": {"id": "test-user", "name": "test-user"}, + "methods": ["password"], "audit_ids": ["audit-1"], "catalog": [] + }})); + }); + let config = openstack_sdk_core::config::CloudConfig { + auth: Some(openstack_sdk_core::config::Auth { + auth_url: Some(format!("{}/v3", server.base_url())), + username: Some("test-user".into()), + user_domain_name: Some("Default".into()), + password: Some("test-password".into()), + project_id: Some("test-project".into()), + ..Default::default() + }), + region_name: Some("RegionOne".into()), + interface: Some("public".into()), + auth_cache: Some(false), + ..Default::default() + }; + let client = AsyncOpenStack::new_with_authentication_helper( + &config, + crate::auth::auth_helper::Noop::default(), + false, + ) + .await + .expect("client creation failed"); + + let cleanup = ProjectCleanupBuilder::new(&client) + .with_provider(RecordingProvider { log: log.clone() }) + .build(); + + let result = cleanup.apply(plan).await.expect("apply failed"); + + let deleted = log.lock().unwrap().clone(); + assert!(deleted.contains(&"port-on-net2".to_string())); + assert!(deleted.contains(&"net-2".to_string())); + assert!( + !deleted.contains(&"net-1".to_string()), + "net-1 must not be deleted while port-on-net1 is kept" + ); + assert!( + !deleted.contains(&"port-on-net1".to_string()), + "unselected node must never be passed to delete()" + ); + let port2_pos = deleted.iter().position(|id| id == "port-on-net2").unwrap(); + let net2_pos = deleted.iter().position(|id| id == "net-2").unwrap(); + assert!(port2_pos < net2_pos, "child must delete before parent"); + + assert!(result.skipped.iter().any(|(id, _)| id == "net-1")); + } + + #[tokio::test] + async fn apply_propagates_block_transitively_when_child_delete_errors() { + use crate::cleanup::provider::CleanupDependency; + use async_trait::async_trait; + + // grandparent <-Blocks- parent <-Blocks- child + // child's delete always errors (a real, non-"not found" failure). + // parent must therefore be skipped (it still "has" child), and + // grandparent must ALSO be skipped, transitively, even though its + // own direct Blocks-child (parent) was selected and not itself + // "kept" in the traditional sense -- it just never actually got + // deleted. + struct FailingChildProvider; + #[async_trait] + impl CleanupProvider for FailingChildProvider { + fn service_type(&self) -> &'static str { + "fake" + } + fn dependencies(&self) -> CleanupDependency { + CleanupDependency::default() + } + async fn discover( + &self, + _ctx: &CleanupContext<'_>, + ) -> Result, CleanupError> { + Ok(Vec::new()) + } + async fn delete( + &self, + _ctx: &CleanupContext<'_>, + resource: &PlannedResource, + ) -> Result<(), CleanupError> { + if resource.id == "child-1" { + Err(CleanupError::Engine("simulated delete failure".into())) + } else { + Ok(()) + } + } + } + + let grandparent = node(ResourceKind::new("fake", "thing"), "grandparent-1", true); + let parent = node(ResourceKind::new("fake", "thing"), "parent-1", true); + let child = node(ResourceKind::new("fake", "thing"), "child-1", true); + let nodes = vec![grandparent, parent, child]; + // index: 0 grandparent, 1 parent, 2 child + let edges = vec![ + PlanEdge { + child: 2, + parent: 1, + effect: RelationEffect::Blocks, + }, // child-1 blocks parent-1 + PlanEdge { + child: 1, + parent: 0, + effect: RelationEffect::Blocks, + }, // parent-1 blocks grandparent-1 + ]; + let plan = CleanupPlan { + nodes, + edges, + errors: Vec::new(), + }; + + let server = httpmock::MockServer::start_async().await; + let base_url = server.base_url(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/"); + then.status(200).json_body( + serde_json::json!({"versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}]}), + ); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v3/"); + then.status(200).json_body( + serde_json::json!({"versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}]}), + ); + }); + let expires = (chrono::Utc::now() + chrono::TimeDelta::hours(1)).to_rfc3339(); + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/v3/auth/tokens"); + then.status(201) + .header("x-subject-token", "test-token") + .json_body(serde_json::json!({"token": { + "id": "token-id", "expires_at": expires, + "project": {"id": "test-project", "name": "TestProject"}, + "user": {"id": "test-user", "name": "test-user"}, + "methods": ["password"], "audit_ids": ["audit-1"], "catalog": [] + }})); + }); + let config = openstack_sdk_core::config::CloudConfig { + auth: Some(openstack_sdk_core::config::Auth { + auth_url: Some(format!("{}/v3", server.base_url())), + username: Some("test-user".into()), + user_domain_name: Some("Default".into()), + password: Some("test-password".into()), + project_id: Some("test-project".into()), + ..Default::default() + }), + region_name: Some("RegionOne".into()), + interface: Some("public".into()), + auth_cache: Some(false), + ..Default::default() + }; + let client = AsyncOpenStack::new_with_authentication_helper( + &config, + crate::auth::auth_helper::Noop::default(), + false, + ) + .await + .expect("client creation failed"); + + let cleanup = ProjectCleanupBuilder::new(&client) + .with_provider(FailingChildProvider) + .build(); + + let result = cleanup.apply(plan).await.expect("apply failed"); + + assert!( + result.errors.iter().any(|(id, _)| id == "child-1"), + "child-1's real delete failure must be recorded" + ); + assert!( + result.skipped.iter().any(|(id, _)| id == "parent-1"), + "parent-1 must be skipped because child-1 was never actually deleted" + ); + assert!( + result.skipped.iter().any(|(id, _)| id == "grandparent-1"), + "grandparent-1 must be transitively skipped because parent-1 was never actually deleted, \ + even though grandparent-1's direct Blocks-child (parent-1) was itself selected" + ); + assert!( + !result.deleted_ids.contains(&"parent-1".to_string()) + && !result.deleted_ids.contains(&"grandparent-1".to_string()), + "neither parent-1 nor grandparent-1 may be deleted" + ); + } + + #[tokio::test] + async fn apply_deletes_independent_subgraphs_concurrently() { + use crate::cleanup::provider::CleanupDependency; + use async_trait::async_trait; + use tokio::sync::Barrier; + + // Two completely independent single-node "resources" (no edges + // between them at all) plus a two-node Blocks chain (child must + // finish before parent starts). If layering works, layer 0 + // contains {independent-a, independent-b, chain-child} and layer + // 1 contains {chain-parent} -- so independent-a and + // independent-b's delete() calls should be in flight + // concurrently within layer 0. Use a `Barrier` sized to the + // number of concurrent deletes expected in layer 0 to prove they + // actually overlap: each of the three layer-0 deletes waits on + // the barrier before completing, so the test would hang/timeout + // if the engine ran them one at a time instead of concurrently. + struct BarrierProvider { + barrier: Arc, + completed_order: Arc>>, + } + #[async_trait] + impl CleanupProvider for BarrierProvider { + fn service_type(&self) -> &'static str { + "fake" + } + fn dependencies(&self) -> CleanupDependency { + CleanupDependency::default() + } + async fn discover( + &self, + _ctx: &CleanupContext<'_>, + ) -> Result, CleanupError> { + Ok(Vec::new()) + } + async fn delete( + &self, + _ctx: &CleanupContext<'_>, + resource: &PlannedResource, + ) -> Result<(), CleanupError> { + if resource.id == "chain-parent" { + // Not part of the concurrent layer-0 barrier: runs + // alone in layer 1, after layer 0 fully resolves. + self.completed_order + .lock() + .unwrap_or_else(|p| p.into_inner()) + .push(resource.id.clone()); + return Ok(()); + } + // All three layer-0 members (independent-a, + // independent-b, chain-child) must reach this barrier + // concurrently for the test to proceed; if the engine + // serialized them, this would deadlock and the test + // would time out. + self.barrier.wait().await; + self.completed_order + .lock() + .unwrap_or_else(|p| p.into_inner()) + .push(resource.id.clone()); + Ok(()) + } + } + + let independent_a = node(ResourceKind::new("fake", "thing"), "independent-a", true); + let independent_b = node(ResourceKind::new("fake", "thing"), "independent-b", true); + let chain_child = node(ResourceKind::new("fake", "thing"), "chain-child", true); + let chain_parent = node(ResourceKind::new("fake", "thing"), "chain-parent", true); + let nodes = vec![independent_a, independent_b, chain_child, chain_parent]; + // index: 0 independent-a, 1 independent-b, 2 chain-child, 3 chain-parent + let edges = vec![PlanEdge { + child: 2, + parent: 3, + effect: RelationEffect::Blocks, + }]; + let plan = CleanupPlan { + nodes, + edges, + errors: Vec::new(), + }; + + let server = httpmock::MockServer::start_async().await; + let base_url = server.base_url(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/"); + then.status(200).json_body( + serde_json::json!({"versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}]}), + ); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v3/"); + then.status(200).json_body( + serde_json::json!({"versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}]}), + ); + }); + let expires = (chrono::Utc::now() + chrono::TimeDelta::hours(1)).to_rfc3339(); + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/v3/auth/tokens"); + then.status(201) + .header("x-subject-token", "test-token") + .json_body(serde_json::json!({"token": { + "id": "token-id", "expires_at": expires, + "project": {"id": "test-project", "name": "TestProject"}, + "user": {"id": "test-user", "name": "test-user"}, + "methods": ["password"], "audit_ids": ["audit-1"], "catalog": [] + }})); + }); + let config = openstack_sdk_core::config::CloudConfig { + auth: Some(openstack_sdk_core::config::Auth { + auth_url: Some(format!("{}/v3", server.base_url())), + username: Some("test-user".into()), + user_domain_name: Some("Default".into()), + password: Some("test-password".into()), + project_id: Some("test-project".into()), + ..Default::default() + }), + region_name: Some("RegionOne".into()), + interface: Some("public".into()), + auth_cache: Some(false), + ..Default::default() + }; + let client = AsyncOpenStack::new_with_authentication_helper( + &config, + crate::auth::auth_helper::Noop::default(), + false, + ) + .await + .expect("client creation failed"); + + let barrier = Arc::new(Barrier::new(3)); + let completed_order = Arc::new(std::sync::Mutex::new(Vec::new())); + let cleanup = ProjectCleanupBuilder::new(&client) + .with_provider(BarrierProvider { + barrier: barrier.clone(), + completed_order: completed_order.clone(), + }) + .build(); + + let result = tokio::time::timeout(std::time::Duration::from_secs(5), cleanup.apply(plan)) + .await + .expect("apply() must not hang/deadlock waiting on the barrier -- if this times out, layer 0's three independent deletes are NOT running concurrently") + .expect("apply failed"); + + assert_eq!(result.deleted_ids.len(), 4); + assert!(result.errors.is_empty()); + assert!(result.skipped.is_empty()); + + let order = completed_order.lock().unwrap_or_else(|p| p.into_inner()); + let chain_parent_pos = order.iter().position(|id| id == "chain-parent").unwrap(); + let chain_child_pos = order.iter().position(|id| id == "chain-child").unwrap(); + assert!( + chain_child_pos < chain_parent_pos, + "chain-child must still complete before chain-parent starts, even with concurrency enabled for independent nodes" + ); + } +} diff --git a/openstack_sdk/src/cleanup/filters.rs b/openstack_sdk/src/cleanup/filters.rs new file mode 100644 index 000000000..278dc59e2 --- /dev/null +++ b/openstack_sdk/src/cleanup/filters.rs @@ -0,0 +1,146 @@ +// 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 + +//! Built-in resource filters, evaluated during [`crate::cleanup::engine::ProjectCleanup::discover`] +//! when no `evaluation_fn` is supplied. + +use std::collections::HashMap; + +use chrono::DateTime; + +use crate::cleanup::types::PlannedResource; + +const TIMESTAMP_FILTER_KEYS: [&str; 2] = ["created_at", "updated_at"]; + +/// Evaluate the built-in `created_at`/`updated_at` timestamp filters +/// against a discovered resource, mirroring python openstacksdk's +/// `_service_cleanup_resource_filters_evaluation`: a resource is selected +/// only if, for every filter key present, the resource's own timestamp +/// field parses as an RFC3339 timestamp that is less than or equal to the +/// filter's own RFC3339 value. A resource missing the field, or either +/// value failing to parse, does not match that filter. An unrecognized +/// filter key never matches (mirrors python silently treating an +/// unsupported key as a failing condition, not an error). +/// +/// No filters at all is vacuously true (`all()` over an empty iterator), +/// so calling this with an empty `filters` map selects every resource -- +/// this matches python's `project_cleanup()` default of cleaning the +/// whole project when no filters/evaluation_fn are given. +pub fn evaluate_filters(resource: &PlannedResource, filters: &HashMap) -> bool { + filters.iter().all(|(key, value)| { + if !TIMESTAMP_FILTER_KEYS.contains(&key.as_str()) { + return false; + } + let Some(res_val) = resource.raw.get(key).and_then(|v| v.as_str()) else { + return false; + }; + let Ok(res_date) = DateTime::parse_from_rfc3339(res_val) else { + return false; + }; + let Ok(cmp_date) = DateTime::parse_from_rfc3339(value) else { + return false; + }; + res_date <= cmp_date + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cleanup::types::ResourceKind; + use serde_json::json; + + fn resource_with_raw(raw: serde_json::Value) -> PlannedResource { + PlannedResource { + kind: ResourceKind::new("fake", "thing"), + id: "thing-1".into(), + name: None, + raw, + selected: false, + reason: None, + } + } + + #[test] + fn empty_filters_select_everything() { + let resource = resource_with_raw(json!({})); + assert!(evaluate_filters(&resource, &HashMap::new())); + } + + #[test] + fn created_at_at_or_before_cutoff_matches() { + let resource = resource_with_raw(json!({"created_at": "2024-01-01T00:00:00Z"})); + let mut filters = HashMap::new(); + filters.insert("created_at".to_string(), "2024-06-01T00:00:00Z".to_string()); + assert!(evaluate_filters(&resource, &filters)); + } + + #[test] + fn created_at_after_cutoff_does_not_match() { + let resource = resource_with_raw(json!({"created_at": "2024-12-01T00:00:00Z"})); + let mut filters = HashMap::new(); + filters.insert("created_at".to_string(), "2024-06-01T00:00:00Z".to_string()); + assert!(!evaluate_filters(&resource, &filters)); + } + + #[test] + fn created_at_exactly_at_cutoff_matches() { + let resource = resource_with_raw(json!({"created_at": "2024-06-01T00:00:00Z"})); + let mut filters = HashMap::new(); + filters.insert("created_at".to_string(), "2024-06-01T00:00:00Z".to_string()); + assert!(evaluate_filters(&resource, &filters)); + } + + #[test] + fn missing_field_does_not_match() { + let resource = resource_with_raw(json!({})); + let mut filters = HashMap::new(); + filters.insert("created_at".to_string(), "2024-06-01T00:00:00Z".to_string()); + assert!(!evaluate_filters(&resource, &filters)); + } + + #[test] + fn unparsable_resource_timestamp_does_not_match() { + let resource = resource_with_raw(json!({"created_at": "not-a-date"})); + let mut filters = HashMap::new(); + filters.insert("created_at".to_string(), "2024-06-01T00:00:00Z".to_string()); + assert!(!evaluate_filters(&resource, &filters)); + } + + #[test] + fn unrecognized_filter_key_never_matches() { + let resource = resource_with_raw(json!({"name": "whatever"})); + let mut filters = HashMap::new(); + filters.insert("name".to_string(), "whatever".to_string()); + assert!( + !evaluate_filters(&resource, &filters), + "only created_at/updated_at are recognized filter keys" + ); + } + + #[test] + fn both_filters_must_match() { + let resource = resource_with_raw(json!({ + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-12-01T00:00:00Z" + })); + let mut filters = HashMap::new(); + filters.insert("created_at".to_string(), "2024-06-01T00:00:00Z".to_string()); + filters.insert("updated_at".to_string(), "2024-06-01T00:00:00Z".to_string()); + assert!( + !evaluate_filters(&resource, &filters), + "created_at matches but updated_at does not, so overall must be false" + ); + } +} diff --git a/openstack_sdk/src/cleanup/mod.rs b/openstack_sdk/src/cleanup/mod.rs new file mode 100644 index 000000000..168d78a5a --- /dev/null +++ b/openstack_sdk/src/cleanup/mod.rs @@ -0,0 +1,34 @@ +// 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 cleanup: resource-level dependency graph, discover/apply engine, +//! and the [`provider::CleanupProvider`] extension point. + +pub mod engine; +pub mod filters; +pub mod provider; +pub mod providers; +pub mod relations; +pub mod types; + +pub use engine::{CleanupPlan, CleanupResult, PlanEdge, ProjectCleanup, ProjectCleanupBuilder}; +pub use filters::evaluate_filters; +pub use provider::{ + CleanupContext, CleanupDependency, CleanupError, CleanupProvider, service_order, +}; +pub use relations::{Edge, RelationEffect, RelationRule, evaluate_edges}; +pub use types::{PlannedResource, ResourceKind}; + +#[cfg(feature = "network")] +pub use providers::network::NetworkCleanupProvider; diff --git a/openstack_sdk/src/cleanup/provider.rs b/openstack_sdk/src/cleanup/provider.rs new file mode 100644 index 000000000..914a0b5c6 --- /dev/null +++ b/openstack_sdk/src/cleanup/provider.rs @@ -0,0 +1,336 @@ +// 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 + +//! The [`CleanupProvider`] extension point: the same trait built-in +//! service providers and caller-injected providers both implement, so the +//! engine treats them identically. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use petgraph::algo::toposort; +use petgraph::graph::DiGraph; + +use crate::AsyncOpenStack; +use crate::OpenStackError; +use crate::cleanup::relations::RelationRule; +use crate::cleanup::types::{PlannedResource, ResourceKind}; + +/// Coarse, service-level ordering hint. Used only for orderings that are +/// not derivable from a resource-level [`RelationRule`] — e.g. a service +/// that must run after everything else because it drops the project +/// itself. +#[derive(Debug, Clone, Default)] +pub struct CleanupDependency { + pub before: Vec<&'static str>, + pub after: Vec<&'static str>, +} + +/// Caller-supplied predicate used to override built-in filter evaluation +/// for a [`PlannedResource`] during discovery. +pub type EvaluationFn = Arc bool + Send + Sync>; + +/// Per-run context handed to every provider call. +pub struct CleanupContext<'a> { + pub client: &'a AsyncOpenStack, + pub filters: HashMap, + pub evaluation_fn: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum CleanupError { + #[error("cleanup error for {kind:?} {id}: {source}")] + Provider { + kind: ResourceKind, + id: String, + #[source] + source: OpenStackError, + }, + #[error("cleanup engine error: {0}")] + Engine(String), +} + +impl CleanupError { + pub fn is_not_found(&self) -> bool { + match self { + CleanupError::Provider { source, .. } => match source { + OpenStackError::Api { source } => source.is_not_found(), + _ => false, + }, + CleanupError::Engine(_) => false, + } + } +} + +#[async_trait] +pub trait CleanupProvider: Send + Sync { + fn service_type(&self) -> &'static str; + + fn dependencies(&self) -> CleanupDependency { + CleanupDependency::default() + } + + fn relations(&self) -> Vec { + Vec::new() + } + + async fn discover( + &self, + ctx: &CleanupContext<'_>, + ) -> Result, CleanupError>; + + async fn delete( + &self, + ctx: &CleanupContext<'_>, + resource: &PlannedResource, + ) -> Result<(), CleanupError>; +} + +fn build_service_graph(providers: &[&dyn CleanupProvider]) -> DiGraph { + let mut graph = DiGraph::::new(); + let node_ids: Vec<_> = (0..providers.len()).map(|i| graph.add_node(i)).collect(); + let index_of = |service_type: &str| { + providers + .iter() + .position(|p| p.service_type() == service_type) + }; + + for (idx, provider) in providers.iter().enumerate() { + let deps = provider.dependencies(); + for before in &deps.before { + if let Some(other) = index_of(before) { + // `idx` must run before `other`: edge idx -> other + graph.add_edge(node_ids[idx], node_ids[other], ()); + } + } + for after in &deps.after { + if let Some(other) = index_of(after) { + // `idx` must run after `other`: edge other -> idx + graph.add_edge(node_ids[other], node_ids[idx], ()); + } + } + } + + graph +} + +/// Order providers so that every `before`/`after` hint is satisfied. +/// Returns indices into `providers`. Kept for callers that only need a +/// flat order (e.g. anything that doesn't care about concurrency); prefer +/// `service_layers` when providers with no dependency relationship should +/// run concurrently. +pub fn service_order(providers: &[&dyn CleanupProvider]) -> Result, CleanupError> { + let graph = build_service_graph(providers); + toposort(&graph, None) + .map(|order| order.into_iter().map(|n| graph[n]).collect()) + .map_err(|_| CleanupError::Engine("cycle in service-level cleanup dependencies".into())) +} + +/// Group providers into ordered layers: every provider in layer N has all +/// of its `before`/`after` dependencies satisfied by providers in layers +/// `0..N`, and providers within the same layer have no dependency +/// relationship to each other. Layers must be processed in order; the +/// providers within one layer may be processed concurrently. +pub fn service_layers(providers: &[&dyn CleanupProvider]) -> Result>, CleanupError> { + let graph = build_service_graph(providers); + + // Repeated Kahn peeling: each round, every node with no remaining + // incoming edge (from nodes not yet placed in an earlier layer) forms + // the next layer. + let mut in_degree: Vec = graph + .node_indices() + .map(|n| { + graph + .neighbors_directed(n, petgraph::Direction::Incoming) + .count() + }) + .collect(); + let mut remaining: usize = graph.node_count(); + let mut placed = vec![false; graph.node_count()]; + let mut layers: Vec> = Vec::new(); + + while remaining > 0 { + let ready: Vec<_> = graph + .node_indices() + .filter(|n| !placed[n.index()] && in_degree[n.index()] == 0) + .collect(); + if ready.is_empty() { + return Err(CleanupError::Engine( + "cycle in service-level cleanup dependencies".into(), + )); + } + for &n in &ready { + placed[n.index()] = true; + remaining -= 1; + for succ in graph.neighbors_directed(n, petgraph::Direction::Outgoing) { + in_degree[succ.index()] -= 1; + } + } + layers.push(ready.into_iter().map(|n| graph[n]).collect()); + } + + Ok(layers) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FakeProvider { + service_type: &'static str, + deps: CleanupDependency, + } + + #[async_trait] + impl CleanupProvider for FakeProvider { + fn service_type(&self) -> &'static str { + self.service_type + } + fn dependencies(&self) -> CleanupDependency { + self.deps.clone() + } + async fn discover( + &self, + _ctx: &CleanupContext<'_>, + ) -> Result, CleanupError> { + Ok(Vec::new()) + } + async fn delete( + &self, + _ctx: &CleanupContext<'_>, + _r: &PlannedResource, + ) -> Result<(), CleanupError> { + Ok(()) + } + } + + #[test] + fn network_before_identity_is_respected() { + let network = FakeProvider { + service_type: "network", + deps: CleanupDependency { + before: vec!["identity"], + after: vec![], + }, + }; + let identity = FakeProvider { + service_type: "identity", + deps: CleanupDependency::default(), + }; + // Registered in the "wrong" order on purpose. + let providers: Vec<&dyn CleanupProvider> = vec![&identity, &network]; + let order = service_order(&providers).unwrap(); + let network_pos = order.iter().position(|&i| i == 1).unwrap(); + let identity_pos = order.iter().position(|&i| i == 0).unwrap(); + assert!( + network_pos < identity_pos, + "network must be ordered before identity" + ); + } + + #[test] + fn cycle_is_reported_as_engine_error() { + let a = FakeProvider { + service_type: "a", + deps: CleanupDependency { + before: vec!["b"], + after: vec![], + }, + }; + let b = FakeProvider { + service_type: "b", + deps: CleanupDependency { + before: vec!["a"], + after: vec![], + }, + }; + let providers: Vec<&dyn CleanupProvider> = vec![&a, &b]; + let err = service_order(&providers).unwrap_err(); + assert!(matches!(err, CleanupError::Engine(_))); + } + + #[test] + fn service_layers_groups_independent_providers_together() { + // identity has no deps; network must run before identity; + // compute has no deps either, so compute and network should + // land in the same layer (both are "ready" immediately), while + // identity must land in a strictly later layer than network. + let network = FakeProvider { + service_type: "network", + deps: CleanupDependency { + before: vec!["identity"], + after: vec![], + }, + }; + let identity = FakeProvider { + service_type: "identity", + deps: CleanupDependency::default(), + }; + let compute = FakeProvider { + service_type: "compute", + deps: CleanupDependency::default(), + }; + let providers: Vec<&dyn CleanupProvider> = vec![&identity, &network, &compute]; + let layers = service_layers(&providers).unwrap(); + + let layer_of = |idx: usize| { + layers + .iter() + .position(|layer| layer.contains(&idx)) + .unwrap() + }; + let network_idx = 1; + let identity_idx = 0; + let compute_idx = 2; + + assert!( + layer_of(network_idx) < layer_of(identity_idx), + "network must be in a strictly earlier layer than identity" + ); + // compute has no dependency on anything, so it must share + // network's layer (both are immediately ready) rather than being + // serialized after it for no reason. + assert_eq!( + layer_of(compute_idx), + layer_of(network_idx), + "compute and network have no dependency relationship and must share a layer" + ); + + // Every provider must appear in exactly one layer. + let total: usize = layers.iter().map(|l| l.len()).sum(); + assert_eq!(total, 3); + } + + #[test] + fn service_layers_reports_cycle_as_engine_error() { + let a = FakeProvider { + service_type: "a", + deps: CleanupDependency { + before: vec!["b"], + after: vec![], + }, + }; + let b = FakeProvider { + service_type: "b", + deps: CleanupDependency { + before: vec!["a"], + after: vec![], + }, + }; + let providers: Vec<&dyn CleanupProvider> = vec![&a, &b]; + let err = service_layers(&providers).unwrap_err(); + assert!(matches!(err, CleanupError::Engine(_))); + } +} diff --git a/openstack_sdk/src/cleanup/providers/mod.rs b/openstack_sdk/src/cleanup/providers/mod.rs new file mode 100644 index 000000000..2afb15c99 --- /dev/null +++ b/openstack_sdk/src/cleanup/providers/mod.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 + +//! Built-in [`crate::cleanup::CleanupProvider`] implementations, one per +//! supported service. + +#[cfg(feature = "network")] +pub mod network; diff --git a/openstack_sdk/src/cleanup/providers/network.rs b/openstack_sdk/src/cleanup/providers/network.rs new file mode 100644 index 000000000..f8ef271c6 --- /dev/null +++ b/openstack_sdk/src/cleanup/providers/network.rs @@ -0,0 +1,623 @@ +// 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 + +//! Network (Neutron) cleanup provider. +//! +//! Proves the resource-level `Blocks`/`CascadeGroup` primitives against +//! the case the python SDK hand-codes imperatively: a network cannot be +//! deleted while it still has subnets, router interfaces, or other ports +//! allocated to it. Deleting a network cascades to its subnets, its +//! router interfaces (detached, not deleted, via `remove_router_interface`), +//! and its other ports (deleted normally via the port-delete endpoint). +//! +//! A router is cleaned up independently of any network's cascade group: it +//! is only ever deleted if separately selected, and only once every +//! `ROUTER_INTERFACE` attached to it (via `device_id`) has itself been +//! detached/deleted (expressed by the `ROUTER_INTERFACE Blocks ROUTER` +//! rule below). Routers are deliberately NOT part of a network's cascade +//! group. +//! +//! Router interfaces are not exposed by the generated router list/get +//! response (there is no `interfaces_info` field on it in this codebase's +//! generated types — confirmed by grepping `sdk/network/src`). Instead, +//! following the python SDK proxy's own approach +//! (`network/v2/_proxy.py`'s router-interface helpers), router interfaces +//! are discovered by listing ports and classifying each one by its exact +//! `device_owner` value: `network:router_interface`, +//! `network:router_interface_distributed`, and +//! `network:ha_router_replicated_interface` become `ROUTER_INTERFACE` +//! resources; `network:dhcp` is skipped entirely (never a node, never +//! blocking — DHCP-agent-managed ports are not deletable this way); every +//! other port (unowned, or owned by another service such as `compute:nova`) +//! becomes a plain `PORT` resource that blocks and cascades with its +//! network. + +use async_trait::async_trait; +use serde_json::Value; + +use crate::api::network::v2::network; +use crate::api::network::v2::port; +use crate::api::network::v2::router; +use crate::api::network::v2::router::remove_router_interface; +use crate::api::network::v2::subnet; +use crate::api::{Pagination, QueryAsync, paged, raw}; + +use crate::cleanup::provider::{CleanupContext, CleanupDependency, CleanupError, CleanupProvider}; +use crate::cleanup::relations::{RelationEffect, RelationRule}; +use crate::cleanup::types::{PlannedResource, ResourceKind}; + +pub const NETWORK: ResourceKind = ResourceKind::new("network", "network"); +pub const SUBNET: ResourceKind = ResourceKind::new("network", "subnet"); +pub const ROUTER: ResourceKind = ResourceKind::new("network", "router"); +pub const ROUTER_INTERFACE: ResourceKind = ResourceKind::new("network", "router_interface"); +pub const PORT: ResourceKind = ResourceKind::new("network", "port"); + +const ROUTER_INTERFACE_OWNERS: [&str; 3] = [ + "network:router_interface", + "network:router_interface_distributed", + "network:ha_router_replicated_interface", +]; + +fn is_router_interface_owner(owner: Option<&str>) -> bool { + owner.is_some_and(|o| ROUTER_INTERFACE_OWNERS.contains(&o)) +} + +fn value_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> { + v.get(key).and_then(|x| x.as_str()) +} + +fn to_planned(kind: ResourceKind, v: Value) -> PlannedResource { + let id = value_str(&v, "id").unwrap_or_default().to_string(); + let name = value_str(&v, "name").map(str::to_string); + PlannedResource { + kind, + id, + name, + raw: v, + selected: false, + reason: None, + } +} + +/// Extracts the first subnet id from a port's `fixed_ips` array, if any. +fn first_fixed_ip_subnet_id(port: &Value) -> Option { + port.get("fixed_ips") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|fip| fip.get("subnet_id")) + .and_then(|v| v.as_str()) + .map(str::to_string) +} + +#[derive(Debug, Default)] +pub struct NetworkCleanupProvider; + +#[async_trait] +impl CleanupProvider for NetworkCleanupProvider { + fn service_type(&self) -> &'static str { + "network" + } + + fn dependencies(&self) -> CleanupDependency { + CleanupDependency { + before: vec!["identity"], + after: vec![], + } + } + + fn relations(&self) -> Vec { + vec![ + RelationRule { + parent_kind: NETWORK, + child_kind: SUBNET, + matches: |child, parent| { + value_str(&child.raw, "network_id") == value_str(&parent.raw, "id") + }, + effect: RelationEffect::Blocks, + }, + RelationRule { + parent_kind: NETWORK, + child_kind: SUBNET, + matches: |child, parent| { + value_str(&child.raw, "network_id") == value_str(&parent.raw, "id") + }, + effect: RelationEffect::CascadeGroup, + }, + RelationRule { + parent_kind: NETWORK, + child_kind: ROUTER_INTERFACE, + matches: |child, parent| { + value_str(&child.raw, "network_id") == value_str(&parent.raw, "id") + }, + effect: RelationEffect::Blocks, + }, + RelationRule { + parent_kind: NETWORK, + child_kind: ROUTER_INTERFACE, + matches: |child, parent| { + value_str(&child.raw, "network_id") == value_str(&parent.raw, "id") + }, + effect: RelationEffect::CascadeGroup, + }, + RelationRule { + parent_kind: ROUTER, + child_kind: ROUTER_INTERFACE, + matches: |child, parent| { + value_str(&child.raw, "device_id") == value_str(&parent.raw, "id") + }, + effect: RelationEffect::Blocks, + }, + RelationRule { + parent_kind: NETWORK, + child_kind: PORT, + matches: |child, parent| { + value_str(&child.raw, "network_id") == value_str(&parent.raw, "id") + }, + effect: RelationEffect::Blocks, + }, + RelationRule { + parent_kind: NETWORK, + child_kind: PORT, + matches: |child, parent| { + value_str(&child.raw, "network_id") == value_str(&parent.raw, "id") + }, + effect: RelationEffect::CascadeGroup, + }, + ] + } + + async fn discover( + &self, + ctx: &CleanupContext<'_>, + ) -> Result, CleanupError> { + // Listing endpoints have no single resource id to attach to a + // failure, so errors here are reported against an empty id; only + // `delete()` (below) attaches a real resource id. + let list_err = |kind: ResourceKind| { + move |e: crate::OpenStackError| CleanupError::Provider { + kind, + id: String::new(), + source: e, + } + }; + + let mut nodes = Vec::new(); + + let networks: Vec = paged( + network::list::Request::builder().build().map_err(|e| { + CleanupError::Engine(format!("failed to build network list request: {e}")) + })?, + Pagination::All, + ) + .query_async(ctx.client) + .await + .map_err(|e| list_err(NETWORK)(e.into()))?; + for v in networks { + nodes.push(to_planned(NETWORK, v)); + } + + let subnets: Vec = paged( + subnet::list::Request::builder().build().map_err(|e| { + CleanupError::Engine(format!("failed to build subnet list request: {e}")) + })?, + Pagination::All, + ) + .query_async(ctx.client) + .await + .map_err(|e| list_err(SUBNET)(e.into()))?; + for v in subnets { + nodes.push(to_planned(SUBNET, v)); + } + + let routers: Vec = paged( + router::list::Request::builder().build().map_err(|e| { + CleanupError::Engine(format!("failed to build router list request: {e}")) + })?, + Pagination::All, + ) + .query_async(ctx.client) + .await + .map_err(|e| list_err(ROUTER)(e.into()))?; + for router_v in &routers { + nodes.push(to_planned(ROUTER, router_v.clone())); + } + + // Router interfaces are not exposed on the router list/get + // response in this codebase's generated types (no + // `interfaces_info` field). Discover them the way the python + // proxy does: list all ports, and classify each one by its exact + // `device_owner` value into one of three cases: + // - a router-interface owner (see `ROUTER_INTERFACE_OWNERS`) + // becomes a `ROUTER_INTERFACE` resource, with `device_id` + // giving the owning router, to be detached (not deleted) via + // `remove_router_interface`; + // - `network:dhcp` is skipped entirely — not a node, never + // blocking, matching the python comment "we don't treat DHCP + // as a real port"; + // - everything else (unowned, or owned by another service such + // as `compute:nova`) becomes a plain `PORT` resource that + // blocks and cascades with its network. + let ports: Vec = paged( + port::list::Request::builder().build().map_err(|e| { + CleanupError::Engine(format!("failed to build port list request: {e}")) + })?, + Pagination::All, + ) + .query_async(ctx.client) + .await + .map_err(|e| list_err(ROUTER_INTERFACE)(e.into()))?; + for port_v in ports { + let owner = value_str(&port_v, "device_owner"); + if is_router_interface_owner(owner) { + let mut iface = port_v; + let subnet_id = first_fixed_ip_subnet_id(&iface); + if let Value::Object(map) = &mut iface { + // `id` is already the port id, which doubles as + // `port_id` for `remove_router_interface`. + if let Some(port_id) = map.get("id").cloned() { + map.insert("port_id".into(), port_id); + } + if !map.contains_key("subnet_id") + && let Some(subnet_id) = subnet_id + { + map.insert("subnet_id".into(), Value::String(subnet_id)); + } + } + nodes.push(to_planned(ROUTER_INTERFACE, iface)); + } else if owner == Some("network:dhcp") { + // DHCP-managed ports are not treated as real ports: never + // blocking, never deleted directly (Neutron/the DHCP agent + // manages their lifecycle itself). + continue; + } else { + // Any other port (unowned, or owned by another service + // such as compute) blocks and cascades with its network, + // and gets deleted through the normal port-delete + // endpoint. + nodes.push(to_planned(PORT, port_v)); + } + } + + Ok(nodes) + } + + async fn delete( + &self, + ctx: &CleanupContext<'_>, + resource: &PlannedResource, + ) -> Result<(), CleanupError> { + let err = |e: crate::OpenStackError| CleanupError::Provider { + kind: resource.kind, + id: resource.id.clone(), + source: e, + }; + + if resource.kind == NETWORK { + let req = network::delete::Request::builder() + .id(resource.id.clone()) + .build() + .map_err(|e| { + CleanupError::Engine(format!("failed to build network delete request: {e}")) + })?; + raw(req) + .query_async(ctx.client) + .await + .map_err(|e| err(e.into()))?; + } else if resource.kind == SUBNET { + let req = subnet::delete::Request::builder() + .id(resource.id.clone()) + .build() + .map_err(|e| { + CleanupError::Engine(format!("failed to build subnet delete request: {e}")) + })?; + raw(req) + .query_async(ctx.client) + .await + .map_err(|e| err(e.into()))?; + } else if resource.kind == ROUTER { + let req = router::delete::Request::builder() + .id(resource.id.clone()) + .build() + .map_err(|e| { + CleanupError::Engine(format!("failed to build router delete request: {e}")) + })?; + raw(req) + .query_async(ctx.client) + .await + .map_err(|e| err(e.into()))?; + } else if resource.kind == ROUTER_INTERFACE { + let router_id = value_str(&resource.raw, "device_id") + .unwrap_or_default() + .to_string(); + let mut builder = remove_router_interface::Request::builder(); + builder.id(router_id); + if let Some(subnet_id) = value_str(&resource.raw, "subnet_id") { + builder.subnet_id(subnet_id.to_string()); + } + if let Some(port_id) = value_str(&resource.raw, "port_id") { + builder.port_id(port_id.to_string()); + } + let req = builder.build().map_err(|e| { + CleanupError::Engine(format!( + "failed to build remove_router_interface request: {e}" + )) + })?; + raw(req) + .query_async(ctx.client) + .await + .map_err(|e| err(e.into()))?; + } else if resource.kind == PORT { + let req = port::delete::Request::builder() + .id(resource.id.clone()) + .build() + .map_err(|e| { + CleanupError::Engine(format!("failed to build port delete request: {e}")) + })?; + raw(req) + .query_async(ctx.client) + .await + .map_err(|e| err(e.into()))?; + } else { + return Err(CleanupError::Engine(format!( + "NetworkCleanupProvider cannot delete resource kind {:?}", + resource.kind + ))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cleanup::engine::ProjectCleanupBuilder; + use httpmock::MockServer; + use std::collections::HashMap; + + async fn mock_client(server: &MockServer) -> crate::AsyncOpenStack { + let base_url = server.base_url(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/"); + then.status(200).json_body( + serde_json::json!({"versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}]}), + ); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v3/"); + then.status(200).json_body( + serde_json::json!({"versions": [{"id": "v3", "status": "SUPPORTED", + "links": [{"rel": "self", "href": format!("{base_url}/v3/")}]}]}), + ); + }); + let expires = (chrono::Utc::now() + chrono::TimeDelta::hours(1)).to_rfc3339(); + server.mock(|when, then| { + when.method(httpmock::Method::POST).path("/v3/auth/tokens"); + then.status(201).header("x-subject-token", "test-token").json_body(serde_json::json!({"token": { + "id": "token-id", "expires_at": expires, + "project": {"id": "test-project", "name": "TestProject"}, + "user": {"id": "test-user", "name": "test-user"}, + "methods": ["password"], "audit_ids": ["audit-1"], + "catalog": [ + {"type": "identity", "name": "keystone", "endpoints": [{"id": "identity-1", + "url": format!("{base_url}/v3"), "region": "RegionOne", "interface": "public"}]}, + {"type": "network", "name": "neutron", "endpoints": [{"id": "network-1", + "url": format!("{base_url}/v2.0"), "region": "RegionOne", "interface": "public"}]} + ] + }})); + }); + let config = openstack_sdk_core::config::CloudConfig { + auth: Some(openstack_sdk_core::config::Auth { + auth_url: Some(format!("{base_url}/v3")), + username: Some("test-user".into()), + user_domain_name: Some("Default".into()), + password: Some("test-password".into()), + project_id: Some("test-project".into()), + ..Default::default() + }), + region_name: Some("RegionOne".into()), + interface: Some("public".into()), + auth_cache: Some(false), + ..Default::default() + }; + crate::AsyncOpenStack::new_with_authentication_helper( + &config, + crate::auth::auth_helper::Noop::default(), + false, + ) + .await + .expect("client creation failed") + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn discover_marks_network_with_subnet_as_blocked_until_subnet_selected() { + let server = MockServer::start_async().await; + let client = mock_client(&server).await; + + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v2.0/networks"); + then.status(200).json_body(serde_json::json!({"networks": [ + {"id": "net-1", "name": "private"} + ]})); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v2.0/subnets"); + then.status(200).json_body(serde_json::json!({"subnets": [ + {"id": "subnet-1", "name": "private-subnet", "network_id": "net-1"} + ]})); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v2.0/routers"); + then.status(200) + .json_body(serde_json::json!({"routers": []})); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v2.0/ports"); + then.status(200).json_body(serde_json::json!({"ports": []})); + }); + + let cleanup = ProjectCleanupBuilder::new(&client) + .with_provider(NetworkCleanupProvider) + .build(); + + // Evaluation function selects only the network by name; the + // subnet must still be pulled in via CascadeGroup, and net-1 must + // remain deletable (its only blocking child, subnet-1, is also + // selected via cascade). + let eval: std::sync::Arc bool + Send + Sync> = + std::sync::Arc::new(|r: &PlannedResource| { + r.kind == NETWORK && r.name.as_deref() == Some("private") + }); + + let plan = cleanup + .discover(HashMap::new(), Some(eval)) + .await + .expect("discover failed"); + + let net = plan.nodes.iter().find(|n| n.id == "net-1").unwrap(); + let subnet = plan.nodes.iter().find(|n| n.id == "subnet-1").unwrap(); + assert!(net.selected); + assert!( + subnet.selected, + "subnet must be pulled in by the cascade group" + ); + + server.mock(|when, then| { + when.method(httpmock::Method::DELETE) + .path("/v2.0/subnets/subnet-1"); + then.status(204); + }); + server.mock(|when, then| { + when.method(httpmock::Method::DELETE) + .path("/v2.0/networks/net-1"); + then.status(204); + }); + + let result = cleanup.apply(plan).await.expect("apply failed"); + assert!( + result.errors.is_empty(), + "unexpected errors: {:?}", + result.errors + ); + assert!(result.deleted_ids.contains(&"subnet-1".to_string())); + assert!(result.deleted_ids.contains(&"net-1".to_string())); + let subnet_pos = result + .deleted_ids + .iter() + .position(|id| id == "subnet-1") + .unwrap(); + let net_pos = result + .deleted_ids + .iter() + .position(|id| id == "net-1") + .unwrap(); + assert!( + subnet_pos < net_pos, + "subnet must delete before its network" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn discover_classifies_ports_correctly() { + let server = MockServer::start_async().await; + let client = mock_client(&server).await; + + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v2.0/networks"); + then.status(200).json_body(serde_json::json!({"networks": [ + {"id": "net-1", "name": "private"} + ]})); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v2.0/subnets"); + then.status(200) + .json_body(serde_json::json!({"subnets": []})); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v2.0/routers"); + then.status(200) + .json_body(serde_json::json!({"routers": []})); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v2.0/ports"); + then.status(200).json_body(serde_json::json!({"ports": [ + {"id": "port-dhcp", "network_id": "net-1", "device_owner": "network:dhcp"}, + {"id": "port-ha-router", "network_id": "net-1", + "device_owner": "network:ha_router_replicated_interface", + "device_id": "router-1", + "fixed_ips": [{"subnet_id": "subnet-1"}]}, + {"id": "port-tenant", "network_id": "net-1", "device_owner": "compute:nova"} + ]})); + }); + + let cleanup = ProjectCleanupBuilder::new(&client) + .with_provider(NetworkCleanupProvider) + .build(); + + let plan = cleanup + .discover(HashMap::new(), None) + .await + .expect("discover failed"); + + assert!( + plan.nodes.iter().find(|n| n.id == "port-dhcp").is_none(), + "dhcp port must not be a node" + ); + + let ha_router_node = plan + .nodes + .iter() + .find(|n| n.id == "port-ha-router") + .expect("ha-router port must be a node"); + assert_eq!( + ha_router_node.kind, ROUTER_INTERFACE, + "ha-router-replicated-interface port must classify as ROUTER_INTERFACE" + ); + + let tenant_node = plan + .nodes + .iter() + .find(|n| n.id == "port-tenant") + .expect("tenant port must be a node"); + assert_eq!(tenant_node.kind, PORT); + + let net_idx = plan + .nodes + .iter() + .position(|n| n.id == "net-1") + .expect("network node must exist"); + let port_idx = plan + .nodes + .iter() + .position(|n| n.id == "port-tenant") + .expect("tenant port node must exist"); + + assert!( + plan.edges + .iter() + .any(|e| matches!(e.effect, RelationEffect::Blocks) + && e.child == port_idx + && e.parent == net_idx), + "expected a Blocks edge from port-tenant to net-1" + ); + assert!( + plan.edges + .iter() + .any(|e| matches!(e.effect, RelationEffect::CascadeGroup) + && e.child == port_idx + && e.parent == net_idx), + "expected a CascadeGroup edge from port-tenant to net-1" + ); + } +} diff --git a/openstack_sdk/src/cleanup/relations.rs b/openstack_sdk/src/cleanup/relations.rs new file mode 100644 index 000000000..0f06b0f9b --- /dev/null +++ b/openstack_sdk/src/cleanup/relations.rs @@ -0,0 +1,153 @@ +// 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 + +//! Declarative, resource-level dependency rules between resource kinds, +//! and their evaluation against a concrete set of discovered resources. +//! +//! This replaces the imperative, per-service ordering logic (e.g. the +//! python network proxy's inline "does this network still have ports" +//! check) with a rule every provider can reuse. + +use crate::cleanup::types::{PlannedResource, ResourceKind}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum RelationEffect { + /// The parent cannot be deleted while a matching child still exists + /// and is not itself selected for deletion. + Blocks, + /// If either side of a matching pair is selected for deletion, both + /// become selected (and, transitively, every node reachable through + /// other `CascadeGroup` edges). + CascadeGroup, +} + +pub struct RelationRule { + pub parent_kind: ResourceKind, + pub child_kind: ResourceKind, + pub matches: fn(child: &PlannedResource, parent: &PlannedResource) -> bool, + pub effect: RelationEffect, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Edge { + pub child: usize, + pub parent: usize, + pub effect: RelationEffect, +} + +/// Evaluate every rule against every (child, parent) pair of matching kind +/// in `nodes`, returning the resulting edges as node indices. +pub fn evaluate_edges(nodes: &[PlannedResource], rules: &[RelationRule]) -> Vec { + let mut edges = Vec::new(); + for rule in rules { + for (child_idx, child) in nodes.iter().enumerate() { + if child.kind != rule.child_kind { + continue; + } + for (parent_idx, parent) in nodes.iter().enumerate() { + if parent.kind != rule.parent_kind || parent_idx == child_idx { + continue; + } + if (rule.matches)(child, parent) { + edges.push(Edge { + child: child_idx, + parent: parent_idx, + effect: rule.effect, + }); + } + } + } + } + edges +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn node(kind: ResourceKind, id: &str, extra: serde_json::Value) -> PlannedResource { + PlannedResource { + kind, + id: id.into(), + name: None, + raw: extra, + selected: false, + reason: None, + } + } + + const NETWORK: ResourceKind = ResourceKind::new("network", "network"); + const PORT: ResourceKind = ResourceKind::new("network", "port"); + + fn port_blocks_network_rule() -> RelationRule { + RelationRule { + parent_kind: NETWORK, + child_kind: PORT, + matches: |child, parent| { + child.raw.get("network_id").and_then(|v| v.as_str()) + == parent.raw.get("id").and_then(|v| v.as_str()) + }, + effect: RelationEffect::Blocks, + } + } + + #[test] + fn blocks_edge_created_only_for_matching_pair() { + let nodes = vec![ + node(NETWORK, "net-1", json!({"id": "net-1"})), + node(NETWORK, "net-2", json!({"id": "net-2"})), + node( + PORT, + "port-1", + json!({"id": "port-1", "network_id": "net-1"}), + ), + ]; + let edges = evaluate_edges(&nodes, &[port_blocks_network_rule()]); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].child, 2); // port-1 + assert_eq!(edges[0].parent, 0); // net-1 + assert_eq!(edges[0].effect, RelationEffect::Blocks); + } + + #[test] + fn self_pair_never_produces_an_edge() { + // A rule whose parent_kind == child_kind (e.g. a same-service + // "group" relation) must never match a node against itself, even + // if `matches` would otherwise return true for identical raw data. + let nodes = vec![node(NETWORK, "net-1", json!({"id": "net-1"}))]; + let self_matching_rule = RelationRule { + parent_kind: NETWORK, + child_kind: NETWORK, + matches: |_child, _parent| true, + effect: RelationEffect::Blocks, + }; + let edges = evaluate_edges(&nodes, &[self_matching_rule]); + assert!(edges.is_empty()); + } + + #[test] + fn no_edges_when_nothing_matches() { + let nodes = vec![ + node(NETWORK, "net-1", json!({"id": "net-1"})), + node( + PORT, + "port-1", + json!({"id": "port-1", "network_id": "net-2"}), + ), + ]; + let edges = evaluate_edges(&nodes, &[port_blocks_network_rule()]); + assert!(edges.is_empty()); + } +} diff --git a/openstack_sdk/src/cleanup/types.rs b/openstack_sdk/src/cleanup/types.rs new file mode 100644 index 000000000..140b7b152 --- /dev/null +++ b/openstack_sdk/src/cleanup/types.rs @@ -0,0 +1,195 @@ +// 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 + +//! Core envelope types shared by every cleanup provider and by the +//! discover/apply engine. + +use serde::{Deserialize, Serialize}; + +/// Identifies a resource type across services without requiring the engine +/// to be generic over every SDK resource struct. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +pub struct ResourceKind { + pub service_type: &'static str, + pub resource_type: &'static str, +} + +impl ResourceKind { + pub const fn new(service_type: &'static str, resource_type: &'static str) -> Self { + Self { + service_type, + resource_type, + } + } +} + +/// Interns `s`, returning a `'static` reference shared by every prior and +/// future call with an equal string value. This bounds total leaked memory +/// by the number of *distinct* strings ever deserialized, rather than by the +/// number of deserialize calls. +fn intern(s: String) -> &'static str { + static INTERN: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + let table = INTERN.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new())); + let mut table = table + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(existing) = table.get(s.as_str()) { + return existing; + } + let leaked: &'static str = Box::leak(s.into_boxed_str()); + table.insert(leaked); + leaked +} + +impl<'de> Deserialize<'de> for ResourceKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::{self, MapAccess, Visitor}; + use std::fmt; + + #[derive(Deserialize)] + #[serde(field_identifier, rename_all = "snake_case")] + enum Field { + ServiceType, + ResourceType, + } + + struct ResourceKindVisitor; + + impl<'de> Visitor<'de> for ResourceKindVisitor { + type Value = ResourceKind; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct ResourceKind") + } + + fn visit_map(self, mut map: V) -> Result + where + V: MapAccess<'de>, + { + let mut service_type: Option = None; + let mut resource_type: Option = None; + + while let Some(key) = map.next_key()? { + match key { + Field::ServiceType => { + if service_type.is_some() { + return Err(de::Error::duplicate_field("service_type")); + } + service_type = Some(map.next_value()?); + } + Field::ResourceType => { + if resource_type.is_some() { + return Err(de::Error::duplicate_field("resource_type")); + } + resource_type = Some(map.next_value()?); + } + } + } + + let service_type = + service_type.ok_or_else(|| de::Error::missing_field("service_type"))?; + let resource_type = + resource_type.ok_or_else(|| de::Error::missing_field("resource_type"))?; + + Ok(ResourceKind { + service_type: intern(service_type), + resource_type: intern(resource_type), + }) + } + } + + const FIELDS: &[&str] = &["service_type", "resource_type"]; + deserializer.deserialize_struct("ResourceKind", FIELDS, ResourceKindVisitor) + } +} + +/// A single resource discovered by a [`crate::cleanup::provider::CleanupProvider`], +/// carried through discovery, plan inspection/editing, and apply. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlannedResource { + pub kind: ResourceKind, + pub id: String, + pub name: Option, + /// Full resource body as returned by the API, used for relation + /// matching and caller-supplied filters. + pub raw: serde_json::Value, + /// Whether this resource is currently slated for deletion. Discovery + /// sets this from filters/cascade rules; a caller may flip it before + /// calling `apply()`. + pub selected: bool, + /// Human-readable reason `selected` has its current value, for plan + /// display (e.g. "matched filter", "cascade: network net-123"). + pub reason: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_kind_equality_and_hash() { + let a = ResourceKind::new("network", "network"); + let b = ResourceKind::new("network", "network"); + let c = ResourceKind::new("network", "port"); + assert_eq!(a, b); + assert_ne!(a, c); + + use std::collections::HashSet; + let mut set = HashSet::new(); + set.insert(a); + set.insert(b); + set.insert(c); + assert_eq!(set.len(), 2); + } + + #[test] + fn planned_resource_serde_roundtrip() { + let node = PlannedResource { + kind: ResourceKind::new("network", "network"), + id: "net-1".into(), + name: Some("private".into()), + raw: serde_json::json!({"id": "net-1", "name": "private"}), + selected: true, + reason: Some("matched filter".into()), + }; + let json = serde_json::to_string(&node).unwrap(); + let back: PlannedResource = serde_json::from_str(&json).unwrap(); + assert_eq!(back.id, "net-1"); + assert_eq!(back.kind, node.kind); + assert!(back.selected); + } + + #[test] + fn resource_kind_deserialize_interns_strings() { + let json_a = r#"{"service_type":"network","resource_type":"network"}"#; + let json_b = r#"{"service_type":"network","resource_type":"network"}"#; + + let a: ResourceKind = serde_json::from_str(json_a).unwrap(); + let b: ResourceKind = serde_json::from_str(json_b).unwrap(); + + assert_eq!(a, b); + assert!( + std::ptr::eq(a.service_type, b.service_type), + "service_type should be interned to the same allocation" + ); + assert!( + std::ptr::eq(a.resource_type, b.resource_type), + "resource_type should be interned to the same allocation" + ); + } +} diff --git a/openstack_sdk/src/lib.rs b/openstack_sdk/src/lib.rs index 663004e78..5a9676b4d 100644 --- a/openstack_sdk/src/lib.rs +++ b/openstack_sdk/src/lib.rs @@ -30,6 +30,8 @@ mod session; mod openstack_async; #[cfg(feature = "async")] pub use openstack_async::{AsyncOpenStack, AsyncOpenStackBuilder, RenewHandle}; +#[cfg(feature = "async")] +pub mod cleanup; #[cfg(all(feature = "sync", feature = "async"))] mod openstack; #[cfg(all(feature = "sync", feature = "async"))]