@@ -120,6 +120,107 @@ fn webhook_urls(api_endpoint: &str, paths: &[String]) -> Vec<String> {
120120 . collect ( )
121121}
122122
123+ /// Best-effort teardown of the *per-bot* ingress resources for `namespace/name`:
124+ /// the API Gateway routes + integration for this bot and its Cloud Map service.
125+ ///
126+ /// The shared resources (VPC Link `oab-vpc-link`, HTTP API `oab-webhook`, and the
127+ /// security-group inbound rule) are intentionally left in place since other bots
128+ /// may still use them. Safe to call for bots that never had ingress — it simply
129+ /// finds nothing and returns. Errors are logged, not propagated, so teardown
130+ /// never blocks service deletion.
131+ pub async fn teardown (
132+ config : & aws_config:: SdkConfig ,
133+ namespace : & str ,
134+ name : & str ,
135+ ) -> Result < ( ) > {
136+ let service_name = format ! ( "oab-{namespace}-{name}" ) ;
137+ let api = aws_sdk_apigatewayv2:: Client :: new ( config) ;
138+
139+ // ── API Gateway: routes + integration for this bot (matched by DNS host) ─
140+ if let Some ( ( api_id, _) ) = find_api ( & api) . await ? {
141+ let mut integration_id: Option < String > = None ;
142+ let mut next: Option < String > = None ;
143+ ' find: loop {
144+ let mut req = api. get_integrations ( ) . api_id ( & api_id) ;
145+ if let Some ( t) = & next {
146+ req = req. next_token ( t) ;
147+ }
148+ let resp = req. send ( ) . await . context ( "failed to list integrations" ) ?;
149+ for i in resp. items ( ) {
150+ if i
151+ . integration_uri ( )
152+ . is_some_and ( |u| u. contains ( & format ! ( "//{service_name}." ) ) )
153+ {
154+ integration_id = i. integration_id ( ) . map ( |s| s. to_string ( ) ) ;
155+ break ' find;
156+ }
157+ }
158+ match resp. next_token ( ) {
159+ Some ( t) => next = Some ( t. to_string ( ) ) ,
160+ None => break ,
161+ }
162+ }
163+
164+ if let Some ( integration_id) = integration_id {
165+ let target = format ! ( "integrations/{integration_id}" ) ;
166+ let mut route_ids = Vec :: new ( ) ;
167+ let mut next: Option < String > = None ;
168+ loop {
169+ let mut req = api. get_routes ( ) . api_id ( & api_id) ;
170+ if let Some ( t) = & next {
171+ req = req. next_token ( t) ;
172+ }
173+ let resp = req. send ( ) . await . context ( "failed to list routes" ) ?;
174+ for r in resp. items ( ) {
175+ if r. target ( ) == Some ( target. as_str ( ) ) {
176+ if let Some ( rid) = r. route_id ( ) {
177+ route_ids. push ( rid. to_string ( ) ) ;
178+ }
179+ }
180+ }
181+ match resp. next_token ( ) {
182+ Some ( t) => next = Some ( t. to_string ( ) ) ,
183+ None => break ,
184+ }
185+ }
186+ for rid in route_ids {
187+ api. delete_route ( ) . api_id ( & api_id) . route_id ( & rid) . send ( ) . await . ok ( ) ;
188+ }
189+ api. delete_integration ( )
190+ . api_id ( & api_id)
191+ . integration_id ( & integration_id)
192+ . send ( )
193+ . await
194+ . ok ( ) ;
195+ eprintln ! ( " ✓ Removed API Gateway routes + integration for {name}" ) ;
196+ }
197+ }
198+
199+ // ── Cloud Map: delete the per-bot service (needs no live instances) ──────
200+ let sd = aws_sdk_servicediscovery:: Client :: new ( config) ;
201+ let mut service_id: Option < String > = None ;
202+ let mut pages = sd. list_services ( ) . into_paginator ( ) . send ( ) ;
203+ ' svc: while let Some ( page) = pages. next ( ) . await {
204+ let page = page. context ( "failed to list Cloud Map services" ) ?;
205+ for s in page. services ( ) {
206+ if s. name ( ) == Some ( service_name. as_str ( ) ) {
207+ service_id = s. id ( ) . map ( |x| x. to_string ( ) ) ;
208+ break ' svc;
209+ }
210+ }
211+ }
212+ if let Some ( service_id) = service_id {
213+ match sd. delete_service ( ) . id ( & service_id) . send ( ) . await {
214+ Ok ( _) => eprintln ! ( " ✓ Deleted Cloud Map service: {service_name}" ) ,
215+ Err ( e) => eprintln ! (
216+ " ⚠ Cloud Map service '{service_name}' not deleted — it may still have\n registered instances; retry once the ECS tasks have fully drained ({e})"
217+ ) ,
218+ }
219+ }
220+
221+ Ok ( ( ) )
222+ }
223+
123224// ─── VPC resolution ─────────────────────────────────────────────────────────
124225
125226async fn resolve_vpc_id ( ec2 : & aws_sdk_ec2:: Client , m : & OABServiceManifest ) -> Result < String > {
@@ -318,25 +419,33 @@ async fn ensure_vpc_link(
318419 use aws_sdk_apigatewayv2:: types:: VpcLinkStatus ;
319420
320421 // Reuse an existing, non-failed VPC Link with our name.
321- let existing = api
322- . get_vpc_links ( )
323- . send ( )
324- . await
325- . context ( "failed to list VPC Links" ) ?;
422+ // Reuse an existing, non-failed VPC Link with our name.
326423 let mut found: Option < ( String , Option < VpcLinkStatus > ) > = None ;
327- for link in existing. items ( ) {
328- if link. name ( ) == Some ( VPC_LINK_NAME ) {
329- if matches ! (
330- link. vpc_link_status( ) ,
331- Some ( VpcLinkStatus :: Failed ) | Some ( VpcLinkStatus :: Deleting )
332- ) {
333- continue ;
424+ let mut next: Option < String > = None ;
425+ ' outer: loop {
426+ let mut req = api. get_vpc_links ( ) ;
427+ if let Some ( t) = & next {
428+ req = req. next_token ( t) ;
429+ }
430+ let resp = req. send ( ) . await . context ( "failed to list VPC Links" ) ?;
431+ for link in resp. items ( ) {
432+ if link. name ( ) == Some ( VPC_LINK_NAME ) {
433+ if matches ! (
434+ link. vpc_link_status( ) ,
435+ Some ( VpcLinkStatus :: Failed ) | Some ( VpcLinkStatus :: Deleting )
436+ ) {
437+ continue ;
438+ }
439+ found = Some ( (
440+ link. vpc_link_id ( ) . unwrap_or_default ( ) . to_string ( ) ,
441+ link. vpc_link_status ( ) . cloned ( ) ,
442+ ) ) ;
443+ break ' outer;
334444 }
335- found = Some ( (
336- link. vpc_link_id ( ) . unwrap_or_default ( ) . to_string ( ) ,
337- link. vpc_link_status ( ) . cloned ( ) ,
338- ) ) ;
339- break ;
445+ }
446+ match resp. next_token ( ) {
447+ Some ( t) => next = Some ( t. to_string ( ) ) ,
448+ None => break ,
340449 }
341450 }
342451
@@ -389,14 +498,9 @@ async fn ensure_vpc_link(
389498// ─── HTTP API ───────────────────────────────────────────────────────────────
390499
391500async fn ensure_api ( api : & aws_sdk_apigatewayv2:: Client ) -> Result < ( String , String ) > {
392- let existing = api. get_apis ( ) . send ( ) . await . context ( "failed to list APIs" ) ?;
393- for a in existing. items ( ) {
394- if a. name ( ) == Some ( API_NAME ) {
395- let id = a. api_id ( ) . context ( "api missing id" ) ?. to_string ( ) ;
396- let endpoint = a. api_endpoint ( ) . unwrap_or_default ( ) . to_string ( ) ;
397- eprintln ! ( " ✓ HTTP API exists: {API_NAME} ({id})" ) ;
398- return Ok ( ( id, endpoint) ) ;
399- }
501+ if let Some ( ( id, endpoint) ) = find_api ( api) . await ? {
502+ eprintln ! ( " ✓ HTTP API exists: {API_NAME} ({id})" ) ;
503+ return Ok ( ( id, endpoint) ) ;
400504 }
401505 eprintln ! ( " ⊕ Creating HTTP API: {API_NAME}" ) ;
402506 let out = api
@@ -411,26 +515,57 @@ async fn ensure_api(api: &aws_sdk_apigatewayv2::Client) -> Result<(String, Strin
411515 Ok ( ( id, endpoint) )
412516}
413517
518+ /// Find the shared `oab-webhook` HTTP API, returning `(api_id, api_endpoint)`.
519+ async fn find_api ( api : & aws_sdk_apigatewayv2:: Client ) -> Result < Option < ( String , String ) > > {
520+ // apigatewayv2 has no smithy paginator for GetApis; page manually.
521+ let mut next: Option < String > = None ;
522+ loop {
523+ let mut req = api. get_apis ( ) ;
524+ if let Some ( t) = & next {
525+ req = req. next_token ( t) ;
526+ }
527+ let resp = req. send ( ) . await . context ( "failed to list APIs" ) ?;
528+ for a in resp. items ( ) {
529+ if a. name ( ) == Some ( API_NAME ) {
530+ let id = a. api_id ( ) . context ( "api missing id" ) ?. to_string ( ) ;
531+ let endpoint = a. api_endpoint ( ) . unwrap_or_default ( ) . to_string ( ) ;
532+ return Ok ( Some ( ( id, endpoint) ) ) ;
533+ }
534+ }
535+ match resp. next_token ( ) {
536+ Some ( t) => next = Some ( t. to_string ( ) ) ,
537+ None => return Ok ( None ) ,
538+ }
539+ }
540+ }
541+
414542async fn ensure_integration (
415543 api : & aws_sdk_apigatewayv2:: Client ,
416544 api_id : & str ,
417545 vpc_link_id : & str ,
418546 integration_uri : & str ,
419547) -> Result < String > {
420- let existing = api
421- . get_integrations ( )
422- . api_id ( api_id)
423- . send ( )
424- . await
425- . context ( "failed to list integrations" ) ?;
426- for i in existing. items ( ) {
427- if i. integration_uri ( ) == Some ( integration_uri) && i. connection_id ( ) == Some ( vpc_link_id) {
428- let id = i
429- . integration_id ( )
430- . context ( "integration missing id" ) ?
431- . to_string ( ) ;
432- eprintln ! ( " ✓ Integration exists → {integration_uri}" ) ;
433- return Ok ( id) ;
548+ let mut next: Option < String > = None ;
549+ loop {
550+ let mut req = api. get_integrations ( ) . api_id ( api_id) ;
551+ if let Some ( t) = & next {
552+ req = req. next_token ( t) ;
553+ }
554+ let resp = req. send ( ) . await . context ( "failed to list integrations" ) ?;
555+ for i in resp. items ( ) {
556+ if i. integration_uri ( ) == Some ( integration_uri) && i. connection_id ( ) == Some ( vpc_link_id)
557+ {
558+ let id = i
559+ . integration_id ( )
560+ . context ( "integration missing id" ) ?
561+ . to_string ( ) ;
562+ eprintln ! ( " ✓ Integration exists → {integration_uri}" ) ;
563+ return Ok ( id) ;
564+ }
565+ }
566+ match resp. next_token ( ) {
567+ Some ( t) => next = Some ( t. to_string ( ) ) ,
568+ None => break ,
434569 }
435570 }
436571 eprintln ! ( " ⊕ Creating integration → {integration_uri}" ) ;
@@ -460,16 +595,22 @@ async fn ensure_route(
460595) -> Result < ( ) > {
461596 let route_key = route_key ( path) ;
462597 let target = format ! ( "integrations/{integration_id}" ) ;
463- let existing = api
464- . get_routes ( )
465- . api_id ( api_id)
466- . send ( )
467- . await
468- . context ( "failed to list routes" ) ?;
469- for r in existing. items ( ) {
470- if r. route_key ( ) == Some ( route_key. as_str ( ) ) {
471- eprintln ! ( " ✓ Route exists: {route_key}" ) ;
472- return Ok ( ( ) ) ;
598+ let mut next: Option < String > = None ;
599+ loop {
600+ let mut req = api. get_routes ( ) . api_id ( api_id) ;
601+ if let Some ( t) = & next {
602+ req = req. next_token ( t) ;
603+ }
604+ let resp = req. send ( ) . await . context ( "failed to list routes" ) ?;
605+ for r in resp. items ( ) {
606+ if r. route_key ( ) == Some ( route_key. as_str ( ) ) {
607+ eprintln ! ( " ✓ Route exists: {route_key}" ) ;
608+ return Ok ( ( ) ) ;
609+ }
610+ }
611+ match resp. next_token ( ) {
612+ Some ( t) => next = Some ( t. to_string ( ) ) ,
613+ None => break ,
473614 }
474615 }
475616 api. create_route ( )
@@ -484,16 +625,22 @@ async fn ensure_route(
484625}
485626
486627async fn ensure_stage ( api : & aws_sdk_apigatewayv2:: Client , api_id : & str ) -> Result < ( ) > {
487- let existing = api
488- . get_stages ( )
489- . api_id ( api_id)
490- . send ( )
491- . await
492- . context ( "failed to list stages" ) ?;
493- for s in existing. items ( ) {
494- if s. stage_name ( ) == Some ( STAGE_NAME ) {
495- eprintln ! ( " ✓ Stage exists: {STAGE_NAME}" ) ;
496- return Ok ( ( ) ) ;
628+ let mut next: Option < String > = None ;
629+ loop {
630+ let mut req = api. get_stages ( ) . api_id ( api_id) ;
631+ if let Some ( t) = & next {
632+ req = req. next_token ( t) ;
633+ }
634+ let resp = req. send ( ) . await . context ( "failed to list stages" ) ?;
635+ for s in resp. items ( ) {
636+ if s. stage_name ( ) == Some ( STAGE_NAME ) {
637+ eprintln ! ( " ✓ Stage exists: {STAGE_NAME}" ) ;
638+ return Ok ( ( ) ) ;
639+ }
640+ }
641+ match resp. next_token ( ) {
642+ Some ( t) => next = Some ( t. to_string ( ) ) ,
643+ None => break ,
497644 }
498645 }
499646 api. create_stage ( )
0 commit comments