Skip to content

Commit c714fbf

Browse files
committed
feat(sdk): Extend project-cleanup providers
Signed-off-by: Artem Goncharov <artem.goncharov@gmail.com>
1 parent 314f2aa commit c714fbf

5 files changed

Lines changed: 1017 additions & 10 deletions

File tree

openstack_sdk/src/cleanup/providers/block_storage.rs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use async_trait::async_trait;
2828
use serde_json::Value;
2929

30+
use crate::api::block_storage::v3::backup;
3031
use crate::api::block_storage::v3::snapshot;
3132
use crate::api::block_storage::v3::volume;
3233
use crate::api::{Pagination, QueryAsync, paged, raw};
@@ -37,6 +38,7 @@ use crate::cleanup::types::{PlannedResource, ResourceKind};
3738

3839
pub const VOLUME: ResourceKind = ResourceKind::new("block-storage", "volume");
3940
pub const SNAPSHOT: ResourceKind = ResourceKind::new("block-storage", "snapshot");
41+
pub const BACKUP: ResourceKind = ResourceKind::new("block-storage", "backup");
4042

4143
fn value_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
4244
v.get(key).and_then(|x| x.as_str())
@@ -100,6 +102,22 @@ impl CleanupProvider for BlockStorageCleanupProvider {
100102
},
101103
effect: RelationEffect::CascadeGroup,
102104
},
105+
RelationRule {
106+
parent_kind: VOLUME,
107+
child_kind: BACKUP,
108+
matches: |child, parent| {
109+
value_str(&child.raw, "volume_id") == value_str(&parent.raw, "id")
110+
},
111+
effect: RelationEffect::Blocks,
112+
},
113+
RelationRule {
114+
parent_kind: VOLUME,
115+
child_kind: BACKUP,
116+
matches: |child, parent| {
117+
value_str(&child.raw, "volume_id") == value_str(&parent.raw, "id")
118+
},
119+
effect: RelationEffect::CascadeGroup,
120+
},
103121
];
104122

105123
#[cfg(feature = "compute")]
@@ -153,6 +171,19 @@ impl CleanupProvider for BlockStorageCleanupProvider {
153171
nodes.push(to_planned(SNAPSHOT, v));
154172
}
155173

174+
let backups: Vec<Value> = paged(
175+
backup::list::Request::builder().build().map_err(|e| {
176+
CleanupError::Engine(format!("failed to build backup list request: {e}"))
177+
})?,
178+
Pagination::All,
179+
)
180+
.query_async(ctx.client)
181+
.await
182+
.map_err(|e| list_err(BACKUP)(e.into()))?;
183+
for v in backups {
184+
nodes.push(to_planned(BACKUP, v));
185+
}
186+
156187
Ok(nodes)
157188
}
158189

@@ -189,6 +220,17 @@ impl CleanupProvider for BlockStorageCleanupProvider {
189220
.query_async(ctx.client)
190221
.await
191222
.map_err(|e| err(e.into()))?;
223+
} else if resource.kind == BACKUP {
224+
let req = backup::delete::Request::builder()
225+
.id(resource.id.clone())
226+
.build()
227+
.map_err(|e| {
228+
CleanupError::Engine(format!("failed to build backup delete request: {e}"))
229+
})?;
230+
raw(req)
231+
.query_async(ctx.client)
232+
.await
233+
.map_err(|e| err(e.into()))?;
192234
} else {
193235
return Err(CleanupError::Engine(format!(
194236
"BlockStorageCleanupProvider cannot delete resource kind {:?}",
@@ -282,6 +324,12 @@ mod tests {
282324
{"id": "snap-1", "name": "data-snap", "volume_id": "vol-1"}
283325
]}));
284326
});
327+
server.mock(|when, then| {
328+
when.method(httpmock::Method::GET)
329+
.path("/v3/test-project/backups");
330+
then.status(200)
331+
.json_body(serde_json::json!({"backups": []}));
332+
});
285333

286334
let cleanup = ProjectCleanupBuilder::new(&client)
287335
.with_provider(BlockStorageCleanupProvider)
@@ -335,6 +383,84 @@ mod tests {
335383
assert!(snap_pos < vol_pos, "snapshot must delete before its volume");
336384
}
337385

386+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
387+
async fn discover_marks_volume_with_backup_blocked_until_backup_selected() {
388+
let server = MockServer::start_async().await;
389+
let client = mock_client(&server).await;
390+
391+
server.mock(|when, then| {
392+
when.method(httpmock::Method::GET)
393+
.path("/v3/test-project/volumes");
394+
then.status(200).json_body(serde_json::json!({"volumes": [
395+
{"id": "vol-1", "name": "data"}
396+
]}));
397+
});
398+
server.mock(|when, then| {
399+
when.method(httpmock::Method::GET)
400+
.path("/v3/test-project/snapshots");
401+
then.status(200)
402+
.json_body(serde_json::json!({"snapshots": []}));
403+
});
404+
server.mock(|when, then| {
405+
when.method(httpmock::Method::GET)
406+
.path("/v3/test-project/backups");
407+
then.status(200).json_body(serde_json::json!({"backups": [
408+
{"id": "backup-1", "name": "data-backup", "volume_id": "vol-1"}
409+
]}));
410+
});
411+
412+
let cleanup = ProjectCleanupBuilder::new(&client)
413+
.with_provider(BlockStorageCleanupProvider)
414+
.build();
415+
416+
let eval: crate::cleanup::provider::EvaluationFn =
417+
std::sync::Arc::new(|r: &PlannedResource| {
418+
r.kind == VOLUME && r.name.as_deref() == Some("data")
419+
});
420+
421+
let plan = cleanup
422+
.discover(HashMap::new(), Some(eval))
423+
.await
424+
.expect("discover failed");
425+
426+
let vol = plan.nodes.iter().find(|n| n.id == "vol-1").unwrap();
427+
let backup = plan.nodes.iter().find(|n| n.id == "backup-1").unwrap();
428+
assert!(vol.selected);
429+
assert!(
430+
backup.selected,
431+
"backup must be pulled in by the cascade group"
432+
);
433+
434+
server.mock(|when, then| {
435+
when.method(httpmock::Method::DELETE)
436+
.path("/v3/test-project/backups/backup-1");
437+
then.status(202);
438+
});
439+
server.mock(|when, then| {
440+
when.method(httpmock::Method::DELETE)
441+
.path("/v3/test-project/volumes/vol-1");
442+
then.status(202);
443+
});
444+
445+
let result = cleanup.apply(plan).await.expect("apply failed");
446+
assert!(
447+
result.errors.is_empty(),
448+
"unexpected errors: {:?}",
449+
result.errors
450+
);
451+
let backup_pos = result
452+
.deleted_ids
453+
.iter()
454+
.position(|id| id == "backup-1")
455+
.unwrap();
456+
let vol_pos = result
457+
.deleted_ids
458+
.iter()
459+
.position(|id| id == "vol-1")
460+
.unwrap();
461+
assert!(backup_pos < vol_pos, "backup must delete before its volume");
462+
}
463+
338464
#[cfg(feature = "compute")]
339465
#[test]
340466
fn volume_attached_to_server_produces_blocks_edge_against_that_server() {

openstack_sdk/src/cleanup/providers/compute.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
use async_trait::async_trait;
3535
use serde_json::Value;
3636

37+
use crate::api::compute::v2::keypair;
3738
use crate::api::compute::v2::server;
3839
use crate::api::{Pagination, QueryAsync, paged, raw};
3940

@@ -44,6 +45,7 @@ use crate::cleanup::relations::RelationRule;
4445
use crate::cleanup::types::{PlannedResource, ResourceKind};
4546

4647
pub const SERVER: ResourceKind = ResourceKind::new("compute", "server");
48+
pub const KEYPAIR: ResourceKind = ResourceKind::new("compute", "keypair");
4749

4850
fn value_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
4951
v.get(key).and_then(|x| x.as_str())
@@ -172,6 +174,40 @@ impl CleanupProvider for ComputeCleanupProvider {
172174
nodes.push(to_planned(SERVER, v));
173175
}
174176

177+
// Nova's `GET /os-keypairs` wraps each entry as `{"keypair": {...}}`
178+
// inside the `"keypairs"` array, but `keypair::list_20::Request`
179+
// declares `response_list_item_key() == Some("keypair")`, which the
180+
// paging machinery (`RestEndpoint`/`paged`) uses to unwrap that
181+
// per-element envelope before deserializing into `Vec<Value>` — so
182+
// by the time we get `keypairs` below, each entry is already the
183+
// flat keypair object (no further unwrapping needed here).
184+
let keypairs: Vec<Value> = paged(
185+
keypair::list_20::Request::builder().build().map_err(|e| {
186+
CleanupError::Engine(format!("failed to build keypair list request: {e}"))
187+
})?,
188+
Pagination::All,
189+
)
190+
.query_async(ctx.client)
191+
.await
192+
.map_err(|e| list_err(KEYPAIR)(e.into()))?;
193+
for v in keypairs {
194+
// Unlike every other resource this provider discovers, Nova
195+
// keypairs carry no `id` field at all - `name` is their sole
196+
// identifier, used by both `get`/`delete` and here as
197+
// `PlannedResource::id`. Using the generic `to_planned` (which
198+
// reads `id`) would silently produce an empty id against a
199+
// real cloud's response.
200+
let name = value_str(&v, "name").unwrap_or_default().to_string();
201+
nodes.push(PlannedResource {
202+
kind: KEYPAIR,
203+
id: name.clone(),
204+
name: Some(name),
205+
raw: v,
206+
selected: false,
207+
reason: None,
208+
});
209+
}
210+
175211
Ok(nodes)
176212
}
177213

@@ -233,6 +269,19 @@ impl CleanupProvider for ComputeCleanupProvider {
233269
"server {} did not disappear after delete within the timeout",
234270
resource.id
235271
)));
272+
} else if resource.kind == KEYPAIR {
273+
let req = keypair::delete_20::Request::builder()
274+
.id(resource.id.clone())
275+
.build()
276+
.map_err(|e| {
277+
CleanupError::Engine(format!("failed to build keypair delete request: {e}"))
278+
})?;
279+
raw(req)
280+
.query_async(ctx.client)
281+
.await
282+
.map_err(|e| err(e.into()))?;
283+
284+
Ok(())
236285
} else {
237286
return Err(CleanupError::Engine(format!(
238287
"ComputeCleanupProvider cannot delete resource kind {:?}",
@@ -319,6 +368,11 @@ mod tests {
319368
{"id": "server-1", "name": "vm-1"}
320369
]}));
321370
});
371+
server.mock(|when, then| {
372+
when.method(httpmock::Method::GET).path("/v2.1/os-keypairs");
373+
then.status(200)
374+
.json_body(serde_json::json!({"keypairs": []}));
375+
});
322376

323377
let cleanup = ProjectCleanupBuilder::new(&client)
324378
.with_provider(ComputeCleanupProvider)
@@ -345,6 +399,11 @@ mod tests {
345399
{"id": "server-1", "name": "vm-1"}
346400
]}));
347401
});
402+
server.mock(|when, then| {
403+
when.method(httpmock::Method::GET).path("/v2.1/os-keypairs");
404+
then.status(200)
405+
.json_body(serde_json::json!({"keypairs": []}));
406+
});
348407
server.mock(|when, then| {
349408
when.method(httpmock::Method::DELETE)
350409
.path("/v2.1/servers/server-1");
@@ -389,6 +448,78 @@ mod tests {
389448
assert!(result.deleted_ids.contains(&"server-1".to_string()));
390449
}
391450

451+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
452+
async fn discover_maps_keypairs_to_planned_resources() {
453+
let server = MockServer::start_async().await;
454+
let client = mock_client(&server).await;
455+
456+
server.mock(|when, then| {
457+
when.method(httpmock::Method::GET).path("/v2.1/servers");
458+
then.status(200)
459+
.json_body(serde_json::json!({"servers": []}));
460+
});
461+
server.mock(|when, then| {
462+
when.method(httpmock::Method::GET).path("/v2.1/os-keypairs");
463+
then.status(200).json_body(serde_json::json!({"keypairs": [
464+
{"keypair": {"id": "test-key", "name": "test-key"}}
465+
]}));
466+
});
467+
468+
let cleanup = ProjectCleanupBuilder::new(&client)
469+
.with_provider(ComputeCleanupProvider)
470+
.build();
471+
472+
let plan = cleanup
473+
.discover(HashMap::new(), None)
474+
.await
475+
.expect("discover failed");
476+
477+
let node = plan.nodes.iter().find(|n| n.id == "test-key").unwrap();
478+
assert_eq!(node.kind, KEYPAIR);
479+
}
480+
481+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
482+
async fn delete_issues_keypair_delete_request() {
483+
let server = MockServer::start_async().await;
484+
let client = mock_client(&server).await;
485+
486+
server.mock(|when, then| {
487+
when.method(httpmock::Method::GET).path("/v2.1/servers");
488+
then.status(200)
489+
.json_body(serde_json::json!({"servers": []}));
490+
});
491+
server.mock(|when, then| {
492+
when.method(httpmock::Method::GET).path("/v2.1/os-keypairs");
493+
then.status(200).json_body(serde_json::json!({"keypairs": [
494+
{"keypair": {"id": "test-key", "name": "test-key"}}
495+
]}));
496+
});
497+
server.mock(|when, then| {
498+
when.method(httpmock::Method::DELETE)
499+
.path("/v2.1/os-keypairs/test-key");
500+
then.status(202);
501+
});
502+
503+
let cleanup = ProjectCleanupBuilder::new(&client)
504+
.with_provider(ComputeCleanupProvider)
505+
.build();
506+
507+
let eval: crate::cleanup::provider::EvaluationFn =
508+
std::sync::Arc::new(|r: &PlannedResource| r.kind == KEYPAIR);
509+
let plan = cleanup
510+
.discover(HashMap::new(), Some(eval))
511+
.await
512+
.expect("discover failed");
513+
let result = cleanup.apply(plan).await.expect("apply failed");
514+
515+
assert!(
516+
result.errors.is_empty(),
517+
"unexpected errors: {:?}",
518+
result.errors
519+
);
520+
assert!(result.deleted_ids.contains(&"test-key".to_string()));
521+
}
522+
392523
#[cfg(feature = "network")]
393524
#[test]
394525
fn port_with_matching_device_id_produces_blocks_edge_against_that_server() {

0 commit comments

Comments
 (0)