Skip to content

Commit d40e7db

Browse files
committed
fix(operator): scope VPC Link/namespace per-VPC; teardown on ingress removal; prune stale routes
Round-7 review: - F1 (critical): VPC Link was matched by a hardcoded name ('oab-vpc-link') with no VPC check. A VPC Link's ENIs live in one VPC and cannot route to another, so a second bot in a *different* VPC would silently reuse the first VPC's link and get unreachable integrations. Fixed by naming the link 'oab-vpc-link-<vpc-id>', scoped per-VPC. - F2: same collision class for the Cloud Map namespace — matched by configured name only. Fixed by scoping the actual namespace name to '<cloudMapNamespace>-<vpc-id>' (the DnsConfig VPC association makes namespace-per-VPC the correct AWS-native mental model anyway; the private DNS only resolves inside that VPC). - F3: apply only ever added ingress resources — editing a manifest to remove spec.ingress orphaned the per-bot HTTP API + Cloud Map service. apply now detects 'had ingress before, doesn't now' by comparing against the previously-stored S3 manifest and calls the same best-effort ingress::teardown used by . - F4: ensure_route only ever added routes; renaming/removing a webhook path left a dead route on the bot's API forever. Added prune_stale_routes, which deletes any route on the bot's API whose key isn't in the current ingress.paths after ensuring the desired ones. Added 3 unit tests (vpc_link_name, vpc_scoped_namespace) — 13 total. Updated README + module doc to describe per-VPC scoping and the new apply-time teardown/pruning behavior. Verified: build, clippy --all-targets -D warnings, cargo test (13 passed).
1 parent df9f14d commit d40e7db

3 files changed

Lines changed: 158 additions & 36 deletions

File tree

operator/README.md

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,9 @@ spec:
241241

242242
On `apply` this reconciles (idempotently, reused by name):
243243

244-
1. **Cloud Map** private DNS namespace + a per-service **SRV** record (carries the container port; a plain A record does not work as a VPC-Link integration target)
244+
1. **Cloud Map** private DNS namespace (`<cloudMapNamespace>-<vpc-id>`, shared per-VPC) + a per-service **SRV** record (carries the container port; a plain A record does not work as a VPC-Link integration target)
245245
2. **ECS service registry** wiring (attached at service creation)
246-
3. **VPC Link** (shared `oab-vpc-link`), waits until `AVAILABLE`
246+
3. **VPC Link** (`oab-vpc-link-<vpc-id>`, shared per-VPC), waits until `AVAILABLE`
247247
4. **API Gateway HTTP API** (`oab-webhook-<ns>-<name>`, one per bot) + `HTTP_PROXY` integration over the VPC Link
248248
5. One **route** per path + a `prod` auto-deploy **stage**
249249
6. A self-referencing **security-group** inbound rule on `containerPort`
@@ -265,19 +265,27 @@ LINE console:
265265
> If the service already exists without service discovery, `apply` provisions the
266266
> ingress resources and prints how to recreate the service so traffic can reach it.
267267
>
268-
> **Shared VPC Link:** all ingress-enabled bots in a VPC share one `oab-vpc-link`.
269-
> A VPC Link's subnets/security groups are fixed at creation and cannot be changed,
270-
> so every ingress bot in the VPC must use the same `networking.subnets` /
271-
> `securityGroups` as whichever bot created the link first. `apply` prints a
272-
> reminder when it reuses an existing link.
268+
> **Shared per-VPC (not per-account):** all ingress-enabled bots in the *same VPC*
269+
> share one VPC Link (`oab-vpc-link-<vpc-id>`) and one Cloud Map namespace
270+
> (`<cloudMapNamespace>-<vpc-id>`) — both are named by VPC ID so bots in different
271+
> VPCs never collide or reuse each other's link/namespace. A VPC Link's
272+
> subnets/security groups are fixed at creation and cannot be changed, so every
273+
> ingress bot in a given VPC must use the same `networking.subnets` /
274+
> `securityGroups` as whichever bot created that VPC's link first. `apply` prints
275+
> a reminder when it reuses an existing link.
273276
>
274277
> **Teardown:** `oabctl delete oabservice <name>` removes the bot's per-bot ingress
275278
> resources — its Cloud Map service and its own HTTP API (`oab-webhook-<ns>-<name>`,
276279
> which cascades its routes/integration/stage) — on a best-effort basis (it never
277-
> blocks service deletion). The **shared** `oab-vpc-link` and the security-group
278-
> inbound rule are intentionally left in place for other bots. If the Cloud Map
279-
> service still has registered instances (tasks not yet drained), delete prints a
280-
> note to retry.
280+
> blocks service deletion). The same teardown also runs automatically from `apply`
281+
> if you edit a manifest to remove `spec.ingress` entirely. The **shared** VPC Link
282+
> and the security-group inbound rule are intentionally left in place for other
283+
> bots. If the Cloud Map service still has registered instances (tasks not yet
284+
> drained), delete prints a note to retry.
285+
>
286+
> **Changing `paths`:** `apply` prunes routes on the bot's API that are no longer
287+
> in the manifest's `ingress.paths`, so renaming or removing a webhook path never
288+
> leaves a dangling route.
281289
>
282290
> **Per-bot API, no path collisions:** each ingress bot gets its own HTTP API, so
283291
> two bots can both use `/webhook/telegram` without clashing — each has a distinct

operator/src/apply.rs

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -153,18 +153,35 @@ async fn apply_ecs(
153153
format!("oab-control-plane-{account}")
154154
};
155155

156-
// Read current generation from S3 manifest (if exists), increment
156+
// Read current generation from S3 manifest (if exists), increment.
157+
// Also capture whether the *previous* apply had ingress configured, so we
158+
// can detect "ingress was removed from the manifest" and tear it down
159+
// below — apply only ever provisioned ingress resources before this, so a
160+
// manifest edit that drops `spec.ingress` used to orphan the per-bot HTTP
161+
// API and Cloud Map service.
157162
let manifest_key = format!("manifests/{}/{}.yaml", m.metadata.namespace, m.metadata.name);
158-
let current_gen = match s3.get_object().bucket(&bucket).key(&manifest_key).send().await {
159-
Ok(resp) => {
160-
let bytes = resp.body.collect().await?.into_bytes();
161-
let existing: OABServiceManifest = serde_yaml::from_slice(&bytes)?;
162-
existing.metadata.generation
163-
}
164-
Err(_) => 0,
165-
};
163+
let (current_gen, previously_had_ingress) =
164+
match s3.get_object().bucket(&bucket).key(&manifest_key).send().await {
165+
Ok(resp) => {
166+
let bytes = resp.body.collect().await?.into_bytes();
167+
let existing: OABServiceManifest = serde_yaml::from_slice(&bytes)?;
168+
(existing.metadata.generation, existing.spec.ingress.is_some())
169+
}
170+
Err(_) => (0, false),
171+
};
166172
let generation = current_gen + 1;
167173

174+
// If ingress was configured before but is absent now, tear down the
175+
// orphaned per-bot ingress resources (best-effort, mirrors `oabctl delete`).
176+
if previously_had_ingress && m.spec.ingress.is_none() {
177+
eprintln!(" 🌐 ingress removed from manifest — tearing down orphaned resources...");
178+
if let Err(e) =
179+
crate::ingress::teardown(config, &m.metadata.namespace, &m.metadata.name).await
180+
{
181+
eprintln!(" ⚠ ingress teardown skipped: {e}");
182+
}
183+
}
184+
168185
// 1. Upload manifest to S3 (record of desired state)
169186
let mut manifest_to_store = serde_yaml::to_value(m)?;
170187
manifest_to_store["metadata"]["generation"] = serde_yaml::Value::Number(generation.into());

operator/src/ingress.rs

Lines changed: 113 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
//! API Gateway HTTP API → VPC Link → Cloud Map → ECS Fargate task on `:port`.
55
//!
66
//! All operations are idempotent — resources are looked up by name and reused,
7-
//! so repeated `oabctl apply` runs converge instead of duplicating.
7+
//! so repeated `oabctl apply` runs converge instead of duplicating. The shared
8+
//! VPC Link and Cloud Map namespace are scoped per-VPC (their names include the
9+
//! VPC ID) since a VPC Link's ENIs and a namespace's private DNS are only valid
10+
//! within the VPC they were created in — two VPCs never share either resource.
811
//!
912
//! The reconciliation is split in two because ECS service discovery
1013
//! (`--service-registries`) can only be set at service *creation* time:
@@ -18,9 +21,23 @@ use anyhow::{Context, Result};
1821
use aws_sdk_apigatewayv2::types::{ConnectionType, IntegrationType, ProtocolType};
1922
use aws_sdk_servicediscovery::types::{DnsConfig, DnsRecord, RecordType};
2023

21-
const VPC_LINK_NAME: &str = "oab-vpc-link";
2224
const STAGE_NAME: &str = "prod";
2325

26+
/// VPC Link name, scoped per-VPC. A VPC Link's ENIs live in one VPC and cannot
27+
/// route to another, so each VPC gets its own link — sharing a name across VPCs
28+
/// would silently misroute traffic through the wrong VPC's link.
29+
fn vpc_link_name(vpc_id: &str) -> String {
30+
format!("oab-vpc-link-{vpc_id}")
31+
}
32+
33+
/// Cloud Map private DNS namespace name, scoped per-VPC. A namespace's private
34+
/// DNS only resolves within the VPC it's associated with, so two VPCs both
35+
/// using the configured `cloudMapNamespace` (default `oab`) must not resolve to
36+
/// the same lookup — scope the actual namespace by VPC ID.
37+
fn vpc_scoped_namespace(configured_namespace: &str, vpc_id: &str) -> String {
38+
format!("{configured_namespace}-{vpc_id}")
39+
}
40+
2441
/// Per-bot HTTP API name. Each ingress bot gets its own API so webhook paths
2542
/// (e.g. `/webhook/telegram`) can never collide between bots on a shared API.
2643
fn api_name(namespace: &str, name: &str) -> String {
@@ -48,14 +65,17 @@ pub async fn ensure_cloud_map(
4865

4966
let vpc_id = resolve_vpc_id(&ec2, m).await?;
5067

51-
// ── Namespace (reused across all bots in the VPC) ──────────────────────
52-
let namespace_id = ensure_namespace(&sd, &ingress.cloud_map_namespace, &vpc_id).await?;
68+
// ── Namespace (shared per-VPC; scoped by VPC so two VPCs using the same
69+
// configured cloudMapNamespace name never collide — a namespace's DNS
70+
// is only resolvable within the VPC it's associated with) ────────────
71+
let namespace_name = vpc_scoped_namespace(&ingress.cloud_map_namespace, &vpc_id);
72+
let namespace_id = ensure_namespace(&sd, &namespace_name, &vpc_id).await?;
5373

5474
// ── Service (one per bot) ──────────────────────────────────────────────
5575
let service_name = m.cloud_map_service_name();
5676
let (registry_arn, existed) = ensure_service(&sd, &namespace_id, &service_name).await?;
5777

58-
let dns_name = format!("{}.{}", service_name, ingress.cloud_map_namespace);
78+
let dns_name = format!("{service_name}.{namespace_name}");
5979
if existed {
6080
eprintln!(" ✓ Cloud Map service exists: {dns_name}");
6181
} else {
@@ -83,8 +103,10 @@ pub async fn ensure_gateway(
83103
// ── Security group inbound rule (self-referencing on the container port) ─
84104
ensure_sg_ingress(&ec2, security_groups, ingress.container_port).await?;
85105

86-
// ── VPC Link (shared across all bots, waits for AVAILABLE) ─────────────
87-
let vpc_link_id = ensure_vpc_link(&api, subnets, security_groups).await?;
106+
// ── VPC Link (shared per-VPC, waits for AVAILABLE) ──────────────────────
107+
let subnet = subnets.first().context("ingress requires at least one subnet")?;
108+
let vpc_id = resolve_vpc_id_from_subnet(&ec2, subnet).await?;
109+
let vpc_link_id = ensure_vpc_link(&api, &vpc_id, subnets, security_groups).await?;
88110

89111
// ── HTTP API (one per bot — avoids cross-bot path collisions) ──────────
90112
let (api_id, api_endpoint) = ensure_api(&api, &api_name).await?;
@@ -99,6 +121,9 @@ pub async fn ensure_gateway(
99121
ensure_route(&api, &api_id, path, &integration_id).await?;
100122
}
101123

124+
// ── Prune routes for paths no longer in the manifest (rename/removal) ───
125+
prune_stale_routes(&api, &api_id, &ingress.paths).await?;
126+
102127
// ── Stage (auto-deploy) ────────────────────────────────────────────────
103128
ensure_stage(&api, &api_id).await?;
104129

@@ -177,6 +202,11 @@ async fn resolve_vpc_id(ec2: &aws_sdk_ec2::Client, m: &OABServiceManifest) -> Re
177202
.context("ingress requires at least one subnet")?,
178203
_ => anyhow::bail!("ingress is only supported for ECS runtime"),
179204
};
205+
resolve_vpc_id_from_subnet(ec2, subnet).await
206+
}
207+
208+
/// Resolve the VPC ID that a given subnet belongs to.
209+
async fn resolve_vpc_id_from_subnet(ec2: &aws_sdk_ec2::Client, subnet: &str) -> Result<String> {
180210
let resp = ec2
181211
.describe_subnets()
182212
.subnet_ids(subnet)
@@ -362,12 +392,14 @@ async fn ensure_sg_ingress(
362392

363393
async fn ensure_vpc_link(
364394
api: &aws_sdk_apigatewayv2::Client,
395+
vpc_id: &str,
365396
subnets: &[String],
366397
security_groups: &[String],
367398
) -> Result<String> {
368399
use aws_sdk_apigatewayv2::types::VpcLinkStatus;
400+
let link_name = vpc_link_name(vpc_id);
369401

370-
// Reuse an existing, non-failed VPC Link with our name.
402+
// Reuse an existing, non-failed VPC Link with our per-VPC name.
371403
let mut found: Option<(String, Option<VpcLinkStatus>)> = None;
372404
let mut next: Option<String> = None;
373405
'outer: loop {
@@ -377,7 +409,7 @@ async fn ensure_vpc_link(
377409
}
378410
let resp = req.send().await.context("failed to list VPC Links")?;
379411
for link in resp.items() {
380-
if link.name() == Some(VPC_LINK_NAME) {
412+
if link.name() == Some(link_name.as_str()) {
381413
if matches!(
382414
link.vpc_link_status(),
383415
Some(VpcLinkStatus::Failed) | Some(VpcLinkStatus::Deleting)
@@ -398,20 +430,21 @@ async fn ensure_vpc_link(
398430
}
399431

400432
let link_id = if let Some((id, _status)) = found {
401-
eprintln!(" ✓ VPC Link exists: {VPC_LINK_NAME} ({id})");
433+
eprintln!(" ✓ VPC Link exists: {link_name} ({id})");
402434
// A VPC Link's subnets/SGs are fixed at creation and cannot be updated.
403-
// All ingress-enabled bots in a VPC share this one link, so they must use
404-
// the same subnet/SG set as whichever bot created it first — otherwise the
405-
// link's ENIs won't cover this task's subnets and integrations may 503.
435+
// All ingress-enabled bots in this VPC share this one link, so they must
436+
// use the same subnet/SG set as whichever bot created it first —
437+
// otherwise the link's ENIs won't cover this task's subnets and
438+
// integrations may 503.
406439
eprintln!(
407-
" ↳ reusing shared link's subnets/SGs (fixed at creation); ensure all\n ingress bots in this VPC use the same subnets/securityGroups"
440+
" ↳ reusing this VPC's shared link subnets/SGs (fixed at creation);\n ensure all ingress bots in vpc {vpc_id} use the same subnets/securityGroups"
408441
);
409442
id
410443
} else {
411-
eprintln!(" ⊕ Creating VPC Link: {VPC_LINK_NAME}");
444+
eprintln!(" ⊕ Creating VPC Link: {link_name}");
412445
let out = api
413446
.create_vpc_link()
414-
.name(VPC_LINK_NAME)
447+
.name(&link_name)
415448
.set_subnet_ids(Some(subnets.to_vec()))
416449
.set_security_group_ids(Some(security_groups.to_vec()))
417450
.send()
@@ -578,6 +611,50 @@ async fn ensure_route(
578611
Ok(())
579612
}
580613

614+
/// Delete any route on the bot's API whose path isn't in `current_paths`.
615+
///
616+
/// `ensure_route` only ever adds routes; without this, renaming or removing a
617+
/// webhook path in the manifest leaves a dead route on the API permanently.
618+
async fn prune_stale_routes(
619+
api: &aws_sdk_apigatewayv2::Client,
620+
api_id: &str,
621+
current_paths: &[String],
622+
) -> Result<()> {
623+
let current_keys: std::collections::HashSet<String> =
624+
current_paths.iter().map(|p| route_key(p)).collect();
625+
626+
let mut stale: Vec<(String, String)> = Vec::new(); // (route_id, route_key)
627+
let mut next: Option<String> = None;
628+
loop {
629+
let mut req = api.get_routes().api_id(api_id);
630+
if let Some(t) = &next {
631+
req = req.next_token(t);
632+
}
633+
let resp = req.send().await.context("failed to list routes")?;
634+
for r in resp.items() {
635+
if let Some(key) = r.route_key() {
636+
if !current_keys.contains(key) {
637+
if let Some(id) = r.route_id() {
638+
stale.push((id.to_string(), key.to_string()));
639+
}
640+
}
641+
}
642+
}
643+
match resp.next_token() {
644+
Some(t) => next = Some(t.to_string()),
645+
None => break,
646+
}
647+
}
648+
649+
for (route_id, key) in stale {
650+
match api.delete_route().api_id(api_id).route_id(&route_id).send().await {
651+
Ok(_) => eprintln!(" ⊖ Removed stale route (no longer in manifest): {key}"),
652+
Err(e) => eprintln!(" ⚠ Failed to remove stale route {key}: {e}"),
653+
}
654+
}
655+
Ok(())
656+
}
657+
581658
async fn ensure_stage(api: &aws_sdk_apigatewayv2::Client, api_id: &str) -> Result<()> {
582659
let mut next: Option<String> = None;
583660
loop {
@@ -624,6 +701,26 @@ mod tests {
624701
assert_ne!(api_name("prod", "a"), api_name("prod", "b"));
625702
}
626703

704+
#[test]
705+
fn vpc_link_name_is_per_vpc() {
706+
assert_eq!(vpc_link_name("vpc-abc123"), "oab-vpc-link-vpc-abc123");
707+
assert_ne!(vpc_link_name("vpc-aaa"), vpc_link_name("vpc-bbb"));
708+
}
709+
710+
#[test]
711+
fn vpc_scoped_namespace_differs_per_vpc() {
712+
assert_eq!(vpc_scoped_namespace("oab", "vpc-aaa"), "oab-vpc-aaa");
713+
assert_ne!(
714+
vpc_scoped_namespace("oab", "vpc-aaa"),
715+
vpc_scoped_namespace("oab", "vpc-bbb")
716+
);
717+
// Same VPC, different configured namespace names still differ.
718+
assert_ne!(
719+
vpc_scoped_namespace("oab", "vpc-aaa"),
720+
vpc_scoped_namespace("custom", "vpc-aaa")
721+
);
722+
}
723+
627724
#[test]
628725
fn webhook_urls_join_endpoint_stage_and_path() {
629726
let paths = vec![

0 commit comments

Comments
 (0)