Skip to content

Commit 0dcc2d1

Browse files
authored
fix(operator): strip stage prefix on private API GW integrations (#1283)
Private (VPC_LINK) HTTP API integrations forward the stage-prefixed request path to the backend by default (e.g. `/prod/webhook/telegram` instead of `/webhook/telegram`), per AWS's documented behavior for private integrations. OpenAB's webhook router matches the exact configured path, so every request 404s at the container despite the VPC Link, Cloud Map, and route wiring all being otherwise correct. Found via live E2E testing against a real deployed Telegram bot: the integration, route, and Cloud Map instance all checked out correctly via the AWS API, and API Gateway access logs confirmed the route was matched and the integration invoked ($context.integrationStatus=404), but the request never reached openab's handler. Earlier scratch tests with an echo-image backend didn't catch this because that image doesn't do path-based routing. Fix: set `overwrite:path=$request.path` on the integration's request parameters when creating it, and self-heal existing integrations created before this fix by patching the parameter in on the next `oabctl apply` (no manual intervention or service recreate needed). Verified live: 404 reproduced on a fresh deploy, manually patching the parameter via the AWS CLI immediately fixed it, then re-ran two full apply/delete cycles with the fixed binary — both created the integration with the override baked in automatically and returned 200 OK end-to-end (API Gateway -> VPC Link -> Cloud Map -> Fargate). Co-authored-by: chaodu-agent <chaodu-agent@users.noreply.github.com>
1 parent a35fa46 commit 0dcc2d1

2 files changed

Lines changed: 78 additions & 2 deletions

File tree

operator/README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ On `apply` this reconciles (idempotently, reused by name):
247247
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)
248248
2. **ECS service registry** wiring (attached at service creation)
249249
3. **VPC Link** (`oab-vpc-link-<vpc-id>`, shared per-VPC), waits until `AVAILABLE`
250-
4. **API Gateway HTTP API** (`oab-webhook-<ns>-<name>`, one per bot) + `HTTP_PROXY` integration over the VPC Link
250+
4. **API Gateway HTTP API** (`oab-webhook-<ns>-<name>`, one per bot) + `HTTP_PROXY` integration over the VPC Link, with an `overwrite:path` request parameter mapping so the backend receives the raw path (e.g. `/webhook/telegram`) instead of the stage-prefixed one (e.g. `/prod/webhook/telegram`) — see caveat below
251251
5. One **route** per path + a `prod` auto-deploy **stage**
252252
6. A self-referencing **security-group** inbound rule on `containerPort`
253253

@@ -269,6 +269,19 @@ LINE console:
269269
> `TELEGRAM_SECRET_TOKEN` when registering the webhook with BotFather to enable
270270
> that check.
271271
>
272+
> **Stage prefix stripped before it reaches the backend:** for private
273+
> (VPC_LINK) integrations, API Gateway forwards the *stage-prefixed* request
274+
> path to the backend by default (e.g. `/prod/webhook/telegram`, not
275+
> `/webhook/telegram`) — [documented AWS
276+
> behavior](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-private.html).
277+
> OpenAB's webhook router matches the exact configured path, so without
278+
> stripping the prefix every request 404s at the container despite the
279+
> integration and Cloud Map wiring being otherwise correct. `apply` sets an
280+
> `overwrite:path=$request.path` request parameter on the integration to strip
281+
> it, and self-heals existing integrations created before this fix by patching
282+
> the parameter in on the next `apply` — no manual intervention or recreate
283+
> needed.
284+
>
272285
> **Adding/fixing service discovery never requires recreating the service:**
273286
> if an existing ECS service has no Cloud Map registry, or has one pointing at a
274287
> different Cloud Map service than the one currently resolved for

operator/src/ingress.rs

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use crate::manifest::{Ingress, OABServiceManifest};
2828
use anyhow::{Context, Result};
2929
use aws_sdk_apigatewayv2::types::{ConnectionType, IntegrationType, ProtocolType};
3030
use aws_sdk_servicediscovery::types::{DnsConfig, DnsRecord, RecordType};
31+
use std::collections::HashMap;
3132

3233
const STAGE_NAME: &str = "prod";
3334

@@ -143,6 +144,19 @@ fn route_key(path: &str) -> String {
143144
format!("POST {path}")
144145
}
145146

147+
/// Whether an integration's request parameters already carry the
148+
/// `overwrite:path` override needed to strip the stage prefix before it
149+
/// reaches the backend. Without this, private (VPC_LINK) integrations
150+
/// forward the stage-prefixed path (e.g. `/prod/webhook/telegram`) to the
151+
/// container, and openab's exact-match router 404s on it. See:
152+
/// <https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-private.html>
153+
fn has_stage_path_override(request_parameters: Option<&HashMap<String, String>>) -> bool {
154+
request_parameters
155+
.and_then(|p| p.get("overwrite:path"))
156+
.map(|v| v == "$request.path")
157+
.unwrap_or(false)
158+
}
159+
146160
/// Extract the Cloud Map service ID from its ARN
147161
/// (`arn:aws:servicediscovery:<region>:<account>:service/<id>`).
148162
fn cloud_map_service_id_from_arn(arn: &str) -> Option<String> {
@@ -733,7 +747,23 @@ async fn ensure_integration(
733747
.integration_id()
734748
.context("integration missing id")?
735749
.to_string();
736-
eprintln!(" ✓ Integration exists → {integration_uri}");
750+
if has_stage_path_override(i.request_parameters()) {
751+
eprintln!(" ✓ Integration exists → {integration_uri}");
752+
} else {
753+
// Self-heal: existing integrations created before this
754+
// fix forward the stage-prefixed path to the backend,
755+
// causing every request to 404. Patch in the override.
756+
eprintln!(
757+
" ↻ Integration exists but missing path override → {integration_uri}, patching"
758+
);
759+
api.update_integration()
760+
.api_id(api_id)
761+
.integration_id(&id)
762+
.request_parameters("overwrite:path", "$request.path")
763+
.send()
764+
.await
765+
.context("failed to patch integration path override")?;
766+
}
737767
return Ok(id);
738768
}
739769
}
@@ -743,6 +773,13 @@ async fn ensure_integration(
743773
}
744774
}
745775
eprintln!(" ⊕ Creating integration → {integration_uri}");
776+
// For private (VPC_LINK) integrations, API Gateway forwards the stage
777+
// portion of the request path to the backend by default (e.g.
778+
// `/prod/webhook/telegram` instead of `/webhook/telegram`), per AWS docs:
779+
// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-private.html
780+
// openab's router matches the exact configured path, so without this
781+
// override every request 404s at the backend. Overwrite the forwarded
782+
// path with $request.path (stage-stripped) to match.
746783
let out = api
747784
.create_integration()
748785
.api_id(api_id)
@@ -752,6 +789,7 @@ async fn ensure_integration(
752789
.connection_type(ConnectionType::VpcLink)
753790
.connection_id(vpc_link_id)
754791
.payload_format_version("1.0")
792+
.request_parameters("overwrite:path", "$request.path")
755793
.send()
756794
.await
757795
.context("failed to create integration")?;
@@ -882,6 +920,31 @@ mod tests {
882920
assert_eq!(route_key("/webhook/line"), "POST /webhook/line");
883921
}
884922

923+
#[test]
924+
fn stage_path_override_absent_when_no_request_parameters() {
925+
// Integrations created before this fix have no RequestParameters at
926+
// all, so they must be detected as needing the self-heal patch.
927+
assert!(!has_stage_path_override(None));
928+
}
929+
930+
#[test]
931+
fn stage_path_override_absent_when_other_params_present() {
932+
let params = HashMap::from([("someOtherKey".to_string(), "value".to_string())]);
933+
assert!(!has_stage_path_override(Some(&params)));
934+
}
935+
936+
#[test]
937+
fn stage_path_override_absent_when_value_wrong() {
938+
let params = HashMap::from([("overwrite:path".to_string(), "/literal/path".to_string())]);
939+
assert!(!has_stage_path_override(Some(&params)));
940+
}
941+
942+
#[test]
943+
fn stage_path_override_present_when_correctly_set() {
944+
let params = HashMap::from([("overwrite:path".to_string(), "$request.path".to_string())]);
945+
assert!(has_stage_path_override(Some(&params)));
946+
}
947+
885948
#[test]
886949
fn cloud_map_service_id_parses_from_arn() {
887950
assert_eq!(

0 commit comments

Comments
 (0)