Skip to content

Commit 93c995b

Browse files
committed
fix(operator): correct recreate hint, add ingress teardown on delete, paginate API GW lists
Addresses PR review round 3: - F1: recreate hint used `oabctl delete service` (invalid) and omitted --cluster; now prints `oabctl delete oabservice <name> --cluster oab --namespace <ns>` which matches delete.rs's contract and the hardcoded `oab` cluster. - F2: oabctl delete oabservice now tears down the bot's per-bot ingress resources (Cloud Map service + API Gateway routes/integration), best-effort, leaving shared VPC Link / HTTP API / SG rule intact. No-op for bots that never had ingress. Documented in README. - F3: apigatewayv2 GetApis/GetVpcLinks/GetIntegrations/GetRoutes/GetStages have no smithy paginator in this SDK, so paginate manually via next_token loops instead of reading only the first page. Verified: cargo build, clippy --all-targets -D warnings, cargo test (11 passed) in a nested layout mirroring the CI operator job.
1 parent f464fd9 commit 93c995b

4 files changed

Lines changed: 221 additions & 60 deletions

File tree

operator/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,13 @@ LINE console:
270270
> so every ingress bot in the VPC must use the same `networking.subnets` /
271271
> `securityGroups` as whichever bot created the link first. `apply` prints a
272272
> reminder when it reuses an existing link.
273+
>
274+
> **Teardown:** `oabctl delete oabservice <name>` removes the bot's per-bot ingress
275+
> resources — its Cloud Map service and its API Gateway routes + integration — on a
276+
> best-effort basis (it never blocks service deletion). The **shared** `oab-vpc-link`,
277+
> `oab-webhook` HTTP API, and the security-group inbound rule are intentionally left
278+
> in place for other bots. If the Cloud Map service still has registered instances
279+
> (tasks not yet drained), delete prints a note to retry.
273280

274281
### OABFleet — batch deploy
275282

operator/src/apply.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ async fn apply_ecs(
274274
eprintln!(" resources were provisioned but traffic won't reach the task until you");
275275
eprintln!(" recreate the service (safe once desiredCount is drained):");
276276
eprintln!(
277-
" oabctl delete service {} --namespace {} && oabctl apply -f <manifest>",
277+
" oabctl delete oabservice {} --cluster oab --namespace {} && oabctl apply -f <manifest>",
278278
m.metadata.name, m.metadata.namespace
279279
);
280280
}

operator/src/delete.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ pub async fn run(
3838
.context("failed to delete ECS service")?;
3939
println!(" ✓ ECS service deleted");
4040

41+
// 2b. Best-effort ingress teardown (per-bot Cloud Map service + API Gateway
42+
// routes/integration). No-op for bots that never had ingress. Never blocks
43+
// deletion — failures are logged only.
44+
if let Err(e) = crate::ingress::teardown(aws_config, namespace, name).await {
45+
eprintln!(" ⚠ ingress teardown skipped: {e}");
46+
}
47+
4148
// 3. Clean up S3 manifest
4249
let manifest_key = format!("manifests/{}/{}.yaml", namespace, name);
4350
let _ = s3

operator/src/ingress.rs

Lines changed: 206 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -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

125226
async 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

391500
async 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+
414542
async 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

486627
async 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

Comments
 (0)