3434use async_trait:: async_trait;
3535use serde_json:: Value ;
3636
37+ use crate :: api:: compute:: v2:: keypair;
3738use crate :: api:: compute:: v2:: server;
3839use crate :: api:: { Pagination , QueryAsync , paged, raw} ;
3940
@@ -44,6 +45,7 @@ use crate::cleanup::relations::RelationRule;
4445use crate :: cleanup:: types:: { PlannedResource , ResourceKind } ;
4546
4647pub const SERVER : ResourceKind = ResourceKind :: new ( "compute" , "server" ) ;
48+ pub const KEYPAIR : ResourceKind = ResourceKind :: new ( "compute" , "keypair" ) ;
4749
4850fn 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