diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/00-vpc-gateway-setup.ipynb b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/00-vpc-gateway-setup.ipynb index e0e0c00c..9b92f65a 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/00-vpc-gateway-setup.ipynb +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/00-vpc-gateway-setup.ipynb @@ -141,7 +141,7 @@ "\n", "We bootstrap **two regions** in Account A:\n", "- `us-west-2` (primary): used by all labs\n", - "- `us-east-1`: required for the [VPC Peering lab](../01-managed-lattice/02-peering.ipynb)\n" + "- `us-east-1`: required for the [VPC Peering lab](../01-managed-vpc-resource/02-peering.ipynb)\n" ] }, { @@ -178,9 +178,9 @@ "| Stack | Region | Description | Required for |\n", "|-------|--------|-------------|-------------|\n", "| VpcegressStack-USWest2 | us-west-2 | VPC (10.0.0.0/16) | All labs |\n", - "| VpcegressStack-USEast1 | us-east-1 | VPC (10.1.0.0/16) | [VPC Peering lab](../01-managed-lattice/02-peering.ipynb) |\n", - "| PeeringApigw-USEast1 | us-east-1 | Private API Gateway + VPCE | [VPC Peering lab](../01-managed-lattice/02-peering.ipynb) |\n", - "| VpcPeeringStack | us-west-2 | VPC peering + routes | [VPC Peering lab](../01-managed-lattice/02-peering.ipynb) |\n", + "| VpcegressStack-USEast1 | us-east-1 | VPC (10.1.0.0/16) | [VPC Peering lab](../01-managed-vpc-resource/02-peering.ipynb) |\n", + "| PeeringApigw-USEast1 | us-east-1 | Private API Gateway + VPCE | [VPC Peering lab](../01-managed-vpc-resource/02-peering.ipynb) |\n", + "| VpcPeeringStack | us-west-2 | VPC peering + routes | [VPC Peering lab](../01-managed-vpc-resource/02-peering.ipynb) |\n", "\n", "![acca](./images/account-a.png)" ] @@ -270,7 +270,7 @@ "\n", "# # VPC Peering\n", "# peering_stack = peering_outputs[\"VpcPeeringStack\"]\n", - "# PEERING_CONNECTION_ID = peering_stack[\"PeeringConnectionId\"]\n", + "# PEERING_CONNECTION_ID = \"peering_stack[\"PeeringConnectionId\"]\"\n", "# %store PEERING_CONNECTION_ID\n", "# print(f\"\\n=== VPC Peering ===\")\n", "# print(f\"Peering ID: {PEERING_CONNECTION_ID}\")" diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/README.md b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/README.md index 8d676677..49891eea 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/README.md +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/README.md @@ -11,20 +11,13 @@ This folder contains the setup notebook and reference guides needed before runni | Notebook | Description | |----------|-------------| -| [00-vpc-gateway-setup.ipynb](./00-vpc-gateway-setup.ipynb) | Deploys the foundational infrastructure: VPCs across two regions (us-west-2, us-east-1), bootstraps CDK, and creates the shared AgentCore Gateway with Cognito M2M authentication. Includes optional sections for [VPC Peering](../01-managed-lattice/02-peering.ipynb) (us-east-1 VPC + API Gateway + peering connection) and [Cross-Account](../02-self-managed-lattice/02-cross-account.ipynb) (Account B VPC) setup. All subsequent labs depend on this notebook. | +| [00-vpc-gateway-setup.ipynb](./00-vpc-gateway-setup.ipynb) | Deploys the foundational infrastructure: VPCs across two regions (us-west-2, us-east-1), bootstraps CDK, and creates the shared AgentCore Gateway with Cognito M2M authentication. Includes optional sections for [VPC Peering](../01-managed-vpc-resource/02-peering.ipynb) (us-east-1 VPC + API Gateway + peering connection) and [Cross-Account](../02-self-managed-lattice/02-cross-account.ipynb) (Account B VPC) setup. All subsequent labs depend on this notebook. | ## Domain and Certificate Guides AgentCore Gateway VPC egress requires a **publicly trusted TLS certificate** on the target endpoint. Depending on whether your DNS is public or private, different patterns apply. These guides explain each combination: -### Concept Guides - -| Guide | Description | -|-------|-------------| -| [Public Certificate + Public Domain](./public-certificate-public-domain.md) | Domain is publicly resolvable (resolves to private IPs). Simplest setup: no `routingDomain` needed. | -| [Public Certificate + Private Domain](./public-certificate-private-domain.md) | Domain resolves only inside the VPC. Uses `routingDomain` to route via the load balancer's publicly resolvable DNS. | -| [Private Certificate + Public Domain](./private-certificate-public-domain.md) | Not directly supported by AgentCore. Workaround: place a load balancer with a public cert in front. | -| [Private Certificate + Private Domain](./private-certificate-private-domain.md) | Not directly supported by AgentCore. Workaround: public cert on load balancer + `routingDomain` for private DNS. | +> **If your VPC has DNS enabled, AgentCore Gateway VPC egress reaches private endpoints via Private DNS automatically. If DNS is not enabled in your VPC, use `routingDomain` as a fallback.** ### How-To Guides diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/public-certificate-private-domain.md b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/public-certificate-private-domain.md index 20d9d4b9..a4f2dc3a 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/public-certificate-private-domain.md +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/public-certificate-private-domain.md @@ -25,12 +25,13 @@ dig (from inside VPC) internal-mcp.example.com → 10.0.2.52 (load balancer priv ## How does this work with AgentCore Gateway? -AgentCore Gateway requires a publicly resolvable domain for VPC Lattice routing. Since the domain is private, you use the **`routingDomain`** parameter: +With **Private DNS** enabled on the VPC Lattice Resource Gateway (the default in Gateway-managed mode), AgentCore uses your VPC's DNS resolver to look up the endpoint domain. Because your VPC is associated with the private hosted zone, `internal-mcp.example.com` resolves to the internal load balancer's private IPs, and TLS completes against the publicly trusted certificate on the load balancer. -- **`endpoint`**: `https://internal-mcp.example.com/mcp` (private DNS) -- **`routingDomain`**: `internal-xxx.elb.amazonaws.com` (load balancer DNS: publicly resolvable) +- **`endpoint`**: `https://internal-mcp.example.com/mcp` (resolves via your VPC's private hosted zone) +- **VPC requirement**: `enableDnsSupport` and `enableDnsHostnames` must be `true` on the VPC (the default) +- **Private hosted zone**: must be associated with the VPC that the Resource Gateway lives in -VPC Lattice uses `routingDomain` for traffic routing while AgentCore invokes the endpoint domain. +> The private hosted zone association is the #1 missable prerequisite. Verify from an EC2 instance inside the VPC with `dig internal-mcp.example.com` before expecting AgentCore to reach the endpoint. ## When to use this @@ -43,7 +44,7 @@ VPC Lattice uses `routingDomain` for traffic routing while AgentCore invokes the ``` AgentCore Gateway - → VPC Lattice (routes via routingDomain: *.elb.amazonaws.com) + → VPC Lattice Resource Gateway (resolves domain via VPC private DNS) → Resource Gateway ENIs → Internal Load Balancer (TLS termination with public cert) → Your private resource diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/public-certificate-public-domain.md b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/public-certificate-public-domain.md index 47e93783..33a3f626 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/public-certificate-public-domain.md +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/00-prerequisites/public-certificate-public-domain.md @@ -24,7 +24,7 @@ dig @8.8.8.8 mcp.example.com → 10.0.2.52, 10.0.3.60 (private IPs) ## When to use this -- Simplest setup: no `routingDomain` needed since the domain is already publicly resolvable +- Simplest setup: domain resolves globally, no DNS workaround needed - You're comfortable with the domain name being visible in public DNS - You have a domain with a public hosted zone in Route 53 (same or different account) diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/images/api-gw.png b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/images/api-gw.png deleted file mode 100644 index 53318a87..00000000 Binary files a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/images/api-gw.png and /dev/null differ diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/01-getting-started.ipynb b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/01-getting-started.ipynb similarity index 95% rename from 01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/01-getting-started.ipynb rename to 01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/01-getting-started.ipynb index 6e59ecfc..0a0b3aa2 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/01-getting-started.ipynb +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/01-getting-started.ipynb @@ -11,7 +11,7 @@ "\n", "The [API-VPCE DNS format](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-private-api-create.html) (`{api-id}-{vpce-id}.execute-api.{region}.amazonaws.com`) is publicly resolvable with a valid AWS-managed TLS certificate.\n", "\n", - "For background on VPC egress and managed VPC Lattice, see the [project README](../README.md) and [Managed Lattice](./README.md).\n", + "For background on VPC egress and managed VPC resource, see the [project README](../README.md) and [Managed VPC Resource](./README.md).\n", "\n", "## Architecture\n", "\n", @@ -122,7 +122,7 @@ "https://{api-id}-{vpce-id}.execute-api.{region}.amazonaws.com/{stage}\n", "```\n", "\n", - "This DNS name is **publicly resolvable** (resolves to VPCE private IPs) and uses a valid AWS-managed TLS certificate. No custom domain, no ACM cert, and no `routingDomain` needed." + "This DNS name is **publicly resolvable** (resolves to VPCE private IPs) and uses a valid AWS-managed TLS certificate." ] }, { @@ -200,7 +200,7 @@ "source": [ "## Step 4: Create AgentCore Gateway Target\n", "\n", - "Create a gateway target using [managed VPC Lattice](./README.md). The endpoint uses the API-VPCE DNS format which is publicly resolvable — no `routingDomain` needed.\n", + "Create a gateway target using [managed VPC resource](./README.md). The endpoint uses the API-VPCE DNS format which is publicly resolvable.\n", "\n", "The `credentialProviderConfigurations` parameter tells AgentCore to use the API key credential provider to authenticate to the API Gateway.\n", "\n", @@ -215,7 +215,7 @@ "outputs": [], "source": [ "# Load the OpenAPI schema and inject the server URL\n", - "with open(\"01-managed-lattice/openapi-private-apigw.json\") as f:\n", + "with open(\"01-managed-vpc-resource/openapi-private-apigw.json\") as f:\n", " openapi_schema = json.load(f)\n", "\n", "# Set the server URL to the actual API-VPCE DNS endpoint\n", @@ -255,7 +255,7 @@ " }\n", " ],\n", " privateEndpoint={\n", - " \"managedLatticeResource\": {\n", + " \"managedVpcResource\": {\n", " \"vpcIdentifier\": VPC_USW2_ID,\n", " \"subnetIds\": VPC_USW2_PRIVATE_SUBNETS,\n", " \"endpointIpAddressType\": \"IPV4\",\n", @@ -462,13 +462,21 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "VPC Egress", "language": "python", - "name": "python3" + "name": "vpcegress" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.12.0" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.3" } }, "nbformat": 4, diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/02-peering.ipynb b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/02-peering.ipynb similarity index 96% rename from 01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/02-peering.ipynb rename to 01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/02-peering.ipynb index 953ffbf1..7166c28e 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/02-peering.ipynb +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/02-peering.ipynb @@ -21,11 +21,11 @@ "4. AgentCore Gateway creates a **managed Resource Gateway** in the owner VPC (us-west-2, 10.0.0.0/16)\n", "5. The Resource Gateway ENI resolves the API-VPCE DNS to the VPCE's private IPs (10.1.x.x) and routes traffic through the peering connection\n", "\n", - "The API-VPCE DNS format (`{api-id}-{vpce-id}.execute-api.us-east-1.amazonaws.com`) is **publicly resolvable** — it resolves to the VPCE's private IPs. No `routingDomain` is needed.\n", + "The API-VPCE DNS format (`{api-id}-{vpce-id}.execute-api.us-east-1.amazonaws.com`) is **publicly resolvable** — it resolves to the VPCE's private IPs.\n", "\n", "> **Why does this work?** Interface VPC Endpoints (powered by AWS PrivateLink) create ENIs with private IP addresses. These IPs are routable through VPC peering connections, unlike Gateway VPC Endpoints (S3/DynamoDB) which are route-table-based and not accessible through peering.\n", "\n", - "For background on VPC egress and managed VPC Lattice, see the [project README](../README.md) and [Managed Lattice README](./README.md)." + "For background on VPC egress and managed VPC resource, see the [project README](../README.md) and [Managed VPC Resource README](./README.md)." ] }, { @@ -181,7 +181,7 @@ "- **`VpcPeeringStack`** created the peering connection, accepted it via a custom resource, and added routes in both VPCs\n", "- **`PeeringApigw-USEast1`** created the VPCE security group with inbound rules for both VPC CIDRs (10.0.0.0/16 and 10.1.0.0/16)\n", "\n", - "Now create a gateway target using managed VPC Lattice. The Resource Gateway is placed in the **us-west-2 VPC** (owner VPC), and the target endpoint is the API-VPCE DNS in **us-east-1** (peered VPC).\n", + "Now create a gateway target using managed VPC resource. The Resource Gateway is placed in the **us-west-2 VPC** (owner VPC), and the target endpoint is the API-VPCE DNS in **us-east-1** (peered VPC).\n", "\n", "Traffic flow:\n", "```\n", @@ -232,7 +232,7 @@ "outputs": [], "source": [ "# Load the OpenAPI schema and set the server URL to the peered API-VPCE DNS\n", - "with open(\"01-managed-lattice/openapi-private-apigw.json\") as f:\n", + "with open(\"01-managed-vpc-resource/openapi-private-apigw.json\") as f:\n", " openapi_schema = json.load(f)\n", "\n", "TARGET_ENDPOINT = f\"https://{API_VPCE_DNS}/prod\"\n", @@ -267,7 +267,7 @@ " }\n", " ],\n", " privateEndpoint={\n", - " \"managedLatticeResource\": {\n", + " \"managedVpcResource\": {\n", " \"vpcIdentifier\": VPC_USW2_ID,\n", " \"subnetIds\": VPC_USW2_PRIVATE_SUBNETS,\n", " \"endpointIpAddressType\": \"IPV4\",\n", diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/README.md b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/README.md similarity index 61% rename from 01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/README.md rename to 01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/README.md index 39fcd78d..03803d9a 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/README.md +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/README.md @@ -1,9 +1,9 @@ -# Managed Amazon VPC Lattice +# Managed VPC Resource -> This feature is made available to you as a "Beta Service" as defined in the [AWS Service Terms](https://aws.amazon.com/service-terms/). It is subject to your Agreement with AWS and the AWS Service Terms. +> AgentCore Gateway-managed mode for VPC egress. Uses Amazon VPC Lattice under the hood; you don't manage the Lattice resources directly. Amazon Bedrock AgentCore Gateway creates and manages the VPC Lattice resource gateway and resource configuration on your behalf. You provide your VPC, subnets, and optional security groups — AgentCore handles the rest. @@ -11,17 +11,18 @@ Amazon Bedrock AgentCore Gateway creates and manages the VPC Lattice resource ga ## How it works -When you call `CreateGatewayTarget` with `privateEndpoint.managedLatticeResource`, AgentCore: +When you call `CreateGatewayTarget` with `privateEndpoint.managedVpcResource`, AgentCore: 1. **Creates a Resource Gateway** in your VPC — provisions one ENI per subnet you specify. These ENIs are the entry point for AgentCore traffic into your VPC. 2. **Creates a Resource Configuration** scoped to your target endpoint — this defines what AgentCore is allowed to reach through the Resource Gateway. 3. **Associates the Resource Configuration** with the AgentCore service network — this enables end-to-end connectivity. +4. **Resolves the target endpoint via Private DNS** — at invocation time, the Resource Gateway uses your VPC's DNS resolver (including any associated Route 53 private hosted zones) to look up the endpoint domain. See [Private DNS](#private-dns) below. If a Resource Gateway already exists in your account with the same VPC, subnet, and security group IDs, AgentCore reuses it rather than creating a new one. AgentCore uses the `AWSServiceRoleForBedrockAgentCoreGatewayNetwork` service-linked role to manage these resources. This role is created automatically the first time you create a gateway target with a managed private endpoint. You do not need VPC Lattice permissions in your own IAM policies. -- Make sure you have correct IAM permissions for [AgentCore Gateway managed Amazon VPC Lattice](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/vpc-egress-private-endpoints.html#lattice-vpc-egress-managed-lattice) +- Make sure you have correct IAM permissions for [AgentCore Gateway managed VPC resource](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/vpc-egress-private-endpoints.html#lattice-vpc-egress-managed-lattice) - Learn about Amazon Bedrock AgentCore Gateway - [Service Linked role](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/vpc-egress-private-endpoints.html#lattice-vpc-egress-slr). @@ -30,12 +31,11 @@ AgentCore uses the `AWSServiceRoleForBedrockAgentCoreGatewayNetwork` service-lin ```json { "privateEndpoint": { - "managedLatticeResource": { + "managedVpcResource": { "vpcIdentifier": "vpc-0abc123def456", "subnetIds": ["subnet-0abc123", "subnet-0def456"], "endpointIpAddressType": "IPV4", - "securityGroupIds": ["sg-0abc123def"], - "routingDomain": "internal-xxx.elb.amazonaws.com" + "securityGroupIds": ["sg-0abc123def"] } } } @@ -49,7 +49,7 @@ AgentCore uses the `AWSServiceRoleForBedrockAgentCoreGatewayNetwork` service-lin | `subnetIds` | Yes | Subnet IDs where the Resource Gateway ENIs will be placed. | | `endpointIpAddressType` | Yes | IP address type. Valid values: `IPV4`, `IPV6`. | | `securityGroupIds` | No | Security groups for the Resource Gateway ENIs. See [Security groups](#security-groups). | -| `routingDomain` | No | Publicly resolvable domain for VPC Lattice routing. See [Routing domain](#routing-domain). | +| `routingDomain` | No | Fallback for VPCs that do not have DNS enabled. Publicly resolvable domain used for VPC Lattice routing. See [Routing domain](#routing-domain). | | `tags` | No | Tags for the managed Resource Gateway. `BedrockAgentCoreGatewayManaged` is reserved. | ## Security groups @@ -65,9 +65,27 @@ Example from the [Getting Started lab](./01-getting-started.ipynb): "securityGroupIds": [VPCE_SG_ID] # VPCE SG allows inbound 443 from VPC CIDR ``` -## Routing domain +## Private DNS -VPC Lattice requires a **publicly resolvable domain** for the resource configuration. If your target endpoint uses a domain that is not publicly resolvable (e.g., a Route 53 private hosted zone), you must set `routingDomain` to an intermediate publicly resolvable domain. +With **Private DNS** (the default for VPCs with DNS support enabled), the Resource Gateway resolves the target endpoint domain using your VPC's DNS resolver. If your VPC is associated with a Route 53 private hosted zone for the domain, the resolver returns that record — so a target like `https://internal.example.com/api` reaches your private resource without any extra configuration. + +### Requirements + +- **VPC DNS support** — `enableDnsSupport` and `enableDnsHostnames` must both be `true` on the VPC (the default for new VPCs). +- **Hosted zone association** — the Route 53 private hosted zone must be associated with the VPC where the Resource Gateway ENIs live. +- **Publicly trusted TLS certificate** — AgentCore Gateway validates certificates against public root CAs. The endpoint must present a cert (typically on a load balancer) covering the target FQDN. + +### What you do not need + +- A public DNS record for the target domain +- A `routingDomain` parameter +- A custom TLS SNI workaround + +## Routing domain (fallback) + +> **Use `routingDomain` only when DNS is not enabled in your VPC.** If your VPC has DNS enabled (the default), Private DNS handles resolution automatically — no `routingDomain` needed. + +If your target endpoint uses a domain that is not publicly resolvable (e.g., a Route 53 private hosted zone) **and** your VPC does not have DNS support enabled, set `routingDomain` to an intermediate publicly resolvable domain (typically the load balancer's DNS name). When `routingDomain` is set, AgentCore routes traffic through the routing domain but sends requests with the actual endpoint domain as the TLS SNI hostname, so your resource receives requests addressed to its actual domain. @@ -76,7 +94,7 @@ When `routingDomain` is set, AgentCore routes traffic through the routing domain | Notebook | Description | |----------|-------------| | [01-getting-started.ipynb](./01-getting-started.ipynb) | Deploy a private API Gateway with mock integrations and connect it to AgentCore Gateway. No domain or certificate needed — uses the API-VPCE DNS format. | -| [02-peering.ipynb](./02-peering.ipynb) | Connect to a Private API Gateway in a peered VPC (cross-region) using managed VPC Lattice and VPC peering. | +| [02-peering.ipynb](./02-peering.ipynb) | Connect to a Private API Gateway in a peered VPC (cross-region) using managed VPC resource and VPC peering. | ## License diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/images/api-gw.png b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/images/api-gw.png new file mode 100644 index 00000000..59ad0788 Binary files /dev/null and b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/images/api-gw.png differ diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/images/arch.png b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/images/arch.png similarity index 100% rename from 01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/images/arch.png rename to 01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/images/arch.png diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/images/peering.png b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/images/peering.png similarity index 100% rename from 01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/images/peering.png rename to 01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/images/peering.png diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/openapi-private-apigw.json b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/openapi-private-apigw.json similarity index 100% rename from 01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-lattice/openapi-private-apigw.json rename to 01-tutorials/02-AgentCore-gateway/16-vpc-egress/01-managed-vpc-resource/openapi-private-apigw.json diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/02-self-managed-lattice/01-getting-started.ipynb b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/02-self-managed-lattice/01-getting-started.ipynb index b41e77e3..8668fec6 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/02-self-managed-lattice/01-getting-started.ipynb +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/02-self-managed-lattice/01-getting-started.ipynb @@ -7,15 +7,15 @@ "source": [ "# Getting Started: Private API Gateway with Self-Managed VPC Lattice\n", "\n", - "This lab deploys the same private [Amazon API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-private-apis.html) with mock integrations as the [managed Lattice lab](../01-managed-lattice/01-getting-started.ipynb), but instead of letting AgentCore manage the VPC Lattice resources, **you create and manage the Resource Gateway and Resource Configuration yourself** using boto3.\n", + "This lab deploys the same private [Amazon API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-private-apis.html) with mock integrations as the [managed VPC resource lab](../01-managed-vpc-resource/01-getting-started.ipynb), but instead of letting AgentCore manage the VPC Lattice resources, **you create and manage the Resource Gateway and Resource Configuration yourself** using boto3.\n", "\n", - "### What's different from the managed Lattice lab?\n", + "### What's different from the managed VPC resource lab?\n", "\n", "| | Managed | Self-Managed (this lab) |\n", "|---|---------|----------------------|\n", "| **Resource Gateway** | Created by AgentCore | You create it via `create_resource_gateway` |\n", "| **Resource Configuration** | Created by AgentCore | You create it via `create_resource_configuration` |\n", - "| **CreateGatewayTarget** | `managedLatticeResource` (VPC, subnets, SGs) | `selfManagedLatticeResource` (just the RC ARN) |\n", + "| **CreateGatewayTarget** | `managedVpcResource` (VPC, subnets, SGs) | `selfManagedLatticeResource` (just the RC ARN) |\n", "| **Cleanup** | Delete target only | Delete target, Resource Configuration, and Resource Gateway |\n", "| **Control** | AgentCore manages lifecycle | You control subnet placement, SGs, IPs per ENI |\n", "\n", @@ -133,7 +133,7 @@ "\n", "This DNS name is **publicly resolvable** (resolves to VPCE private IPs) and uses a valid AWS-managed TLS certificate. No custom domain or ACM cert needed.\n", "\n", - "> This is the same stack used in the [managed Lattice lab](../01-managed-lattice/01-getting-started.ipynb). If you already deployed it, this step will show \"no changes\"." + "> This is the same stack used in the [managed VPC resource lab](../01-managed-vpc-resource/01-getting-started.ipynb). If you already deployed it, this step will show \"no changes\"." ] }, { @@ -306,7 +306,7 @@ "source": [ "## Step 5: Create API Key Credential Provider\n", "\n", - "Same as the managed Lattice lab — AgentCore needs an API key credential provider to authenticate to the API Gateway." + "Same as the managed VPC resource lab — AgentCore needs an API key credential provider to authenticate to the API Gateway." ] }, { diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/02-self-managed-lattice/README.md b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/02-self-managed-lattice/README.md index bcb92348..1ab489b4 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/02-self-managed-lattice/README.md +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/02-self-managed-lattice/README.md @@ -3,8 +3,6 @@ # Self-Managed Amazon VPC Lattice -> This feature is made available to you as a "Beta Service" as defined in the [AWS Service Terms](https://aws.amazon.com/service-terms/). It is subject to your Agreement with AWS and the AWS Service Terms. - You create the VPC Lattice resource gateway and resource configuration yourself, then provide the resource configuration identifier to AgentCore. Use this option for cross-account connectivity, if you already have VPC Lattice resources set up, or if you need fine-grained control over the Lattice configuration. ![arch](./images/create-target.png) @@ -20,7 +18,7 @@ You create the VPC Lattice resource gateway and resource configuration yourself, Before creating a gateway target with a self-managed private endpoint, complete the following steps. -- Make sure you have correct IAM permissions for [AgentCore Gateway managed Amazon VPC Lattice](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/vpc-egress-private-endpoints.html#lattice-vpc-egress-self-managed-lattice) +- Make sure you have correct IAM permissions for [AgentCore Gateway managed VPC resource](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/vpc-egress-private-endpoints.html#lattice-vpc-egress-self-managed-lattice) ### Step 1: Create a Resource Gateway diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/02-self-managed-lattice/images/api-gw.png b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/02-self-managed-lattice/images/api-gw.png index e8c776aa..75e1c1c2 100644 Binary files a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/02-self-managed-lattice/images/api-gw.png and b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/02-self-managed-lattice/images/api-gw.png differ diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/01-private-domain.ipynb b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/01-private-domain.ipynb index fb7dbc19..a4c90bab 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/01-private-domain.ipynb +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/01-private-domain.ipynb @@ -5,17 +5,22 @@ "id": "intro", "metadata": {}, "source": [ - "# Private Domain: Using `routingDomain` with AgentCore Gateway\n", + "# Private Domain with AgentCore Gateway (Private DNS)\n", "\n", - "This lab demonstrates how to connect [Amazon Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html) to a resource that uses a **private domain**, a domain that only resolves inside the VPC via a [Route 53 private hosted zone](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zones-private.html).\n", + "This lab demonstrates how to connect [Amazon Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html) to a resource that uses a **private domain** — a domain that only resolves inside your VPC via a [Route 53 private hosted zone](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zones-private.html).\n", "\n", - "## Architecture (this lab)\n", + "With **Private DNS** enabled on your VPC (the default), AgentCore Gateway's managed Resource Gateway resolves the private domain via your VPC's DNS resolver.\n", + "\n", + "## Architecture\n", "\n", "![arch](./images/private-domain.png)\n", "\n", - "The target URL uses the domain covered by the ALB's public certificate. VPC Lattice routes traffic via the ALB's DNS (`routingDomain`). No public DNS record is needed for the target domain.\n", + "- The target URL uses your private FQDN (e.g., `https://internal.yourcompany.com`)\n", + "- A Route 53 **private hosted zone** for that FQDN, associated with your VPC, aliases the domain to an internal ALB\n", + "- The ALB terminates TLS with a publicly trusted ACM certificate for the same FQDN\n", + "- AgentCore Gateway's Resource Gateway resolves the domain via Private DNS → gets the ALB's private IPs → establishes TLS against the public cert → requests land on your backend\n", "\n", - "For background on VPC egress and managed VPC Lattice, see the [project README](../README.md) and [Advanced Concepts README](./README.md)." + "For background on VPC egress and managed VPC resource, see the [project README](../README.md) and [Advanced Concepts README](./README.md)." ] }, { @@ -23,31 +28,18 @@ "id": "391530dc", "metadata": {}, "source": [ - "## The Problem\n", + "## How Private DNS makes this work\n", "\n", - "Amazon VPC Lattice requires a **publicly resolvable domain** for its resource configuration. If your resource uses a domain that only resolves inside the VPC (private hosted zone), VPC Lattice cannot create the resource configuration.\n", + "VPC Lattice's managed Resource Gateway (created by AgentCore when you call `CreateGatewayTarget` with `managedVpcResource`) uses your VPC's DNS resolver to look up the target endpoint domain. If your VPC is associated with a Route 53 private hosted zone for that domain, the resolver returns the record from that zone.\n", "\n", - "### When your private domain already points to an ALB, NLB, or VPCE\n", + "### Requirements\n", "\n", - "If your private hosted zone resolves to a load balancer or VPC endpoint, you already have a publicly resolvable DNS name you can use as `routingDomain`. At invocation time, VPC Lattice routes traffic through the `routingDomain` while setting the TLS SNI to the actual target domain, so the TLS handshake succeeds against your resource's certificate.\n", + "- **VPC DNS enabled** — `enableDnsSupport` and `enableDnsHostnames` are both `true` on the VPC (the default; set in the workshop's `VpcegressStack`).\n", + "- **Private hosted zone association** — the hosted zone must be associated with the VPC where the Resource Gateway ENIs live.\n", + "- **Publicly trusted TLS certificate** — the ALB must present a cert issued by a public CA (ACM public certificate) covering the target FQDN. AgentCore Gateway validates certs against public root CAs.\n", + "- **Target FQDN covered by the cert** — the domain you pass in the `endpoint` URL must match the cert's subject/SAN.\n", "\n", - "| Resource | routingDomain |\n", - "|----------|---------------|\n", - "| **Internal ALB** | `internal--..elb.amazonaws.com` |\n", - "| **Internal NLB** | `internal--.elb..amazonaws.com` |\n", - "| **VPC Endpoint** | `.execute-api..vpce.amazonaws.com` |\n", - "\n", - "These DNS names are publicly resolvable (they resolve to private IPs) and satisfy VPC Lattice's requirement. **This is the easy case**, no additional infrastructure needed.\n", - "\n", - "### When your private domain points directly to an IP address\n", - "\n", - "If your private hosted zone resolves to an EC2 private IP (or any resource without a publicly resolvable DNS name), there is no intermediate to use as `routingDomain`. The workaround is to **place an internal ALB in front** of the resource:\n", - "\n", - "1. Deploy an internal ALB with a public ACM certificate\n", - "2. Point the ALB at your backend resource\n", - "3. Use the ALB's DNS name as `routingDomain`\n", - "\n", - "This is covered in the [private certificate authority](./02-private-certificate-authority.ipynb) and [self-signed certificate](./03-self-signed-certificate.ipynb) labs." + "> **Backend without HTTPS?** If your backend doesn't speak TLS (e.g., a plain HTTP service on port 8000), the ALB in this lab handles TLS termination. If your backend already terminates TLS with a public cert on a domain you own, you can skip the ALB and point the private hosted zone directly at it. If your backend uses a **private** certificate, see the [Private Certificate Authority](./02-private-certificate-authority.ipynb) and [Self-Signed Certificate](./03-self-signed-certificate.ipynb) labs for the ALB + host-header-transform pattern." ] }, { @@ -143,9 +135,9 @@ "source": [ "## Step 2: Configure domain and certificate\n", "\n", - "Provide your ACM public certificate ARN and the domain it covers. The domain must match the certificate, for example, if your cert is for `*.internal.yourcompany.com`, enter a subdomain like `api.internal.yourcompany.com`.\n", + "Provide your ACM public certificate ARN and the **private FQDN** it covers. The FQDN must match the certificate's subject/SAN — e.g., if your cert is for `internal.yourcompany.com`, enter `internal.yourcompany.com` here.\n", "\n", - "This domain is used as the AgentCore Gateway target URL. You do **not** need a public DNS record for it, `routingDomain` handles routing via the ALB DNS." + "The CDK stack will create a **Route 53 private hosted zone** with this exact zone name, associated with your VPC, and add an apex Alias record pointing to the internal ALB. Inside the VPC, `https://` will resolve to the ALB's private IPs via Private DNS — no public DNS record needed." ] }, { @@ -155,14 +147,15 @@ "metadata": {}, "outputs": [], "source": [ - "CERT_ARN = input(\"ACM public certificate ARN: \")\n", + "CERT_ARN = input(\"ACM public certificate ARN: \").strip()\n", "DOMAIN = input(\n", " \"Domain name covered by the certificate (e.g., api.internal.yourcompany.com): \"\n", - ")\n", + ").strip()\n", "\n", "assert CERT_ARN.startswith(\"arn:aws:acm:\"), \"Invalid certificate ARN\"\n", "assert not DOMAIN.startswith(\"http\"), \"Domain should not include http:// or https://\"\n", "assert \".\" in DOMAIN, \"Domain must contain at least one dot\"\n", + "assert \" \" not in DOMAIN, \"Domain must not contain whitespace\"\n", "\n", "print(f\"Cert ARN: {CERT_ARN}\")\n", "print(f\"Domain: {DOMAIN}\")" @@ -177,12 +170,10 @@ "\n", "This stack deploys:\n", "- An **EC2 instance** running a REST API (FastAPI) on HTTP port 8000\n", - "- An **internal ALB** with your public ACM certificate on HTTPS port 443, forwarding HTTP to the EC2\n", - "- A **Route 53 private hosted zone** (`internal.{baseDomain}`) with:\n", - " - `api.internal.{baseDomain}`, Alias record pointing to the ALB (Scenario 1)\n", - " - `direct.internal.{baseDomain}`, A record pointing to the EC2 private IP (Scenario 2)\n", + "- An **internal ALB** with your public ACM certificate on HTTPS port 443, forwarding to the EC2 over HTTP\n", + "- A **Route 53 private hosted zone** named `` associated with the VPC, with an **apex Alias** record pointing to the ALB\n", "\n", - "Inside the VPC, `api.internal.{baseDomain}` resolves to the ALB. Outside the VPC, this domain does not resolve, hence the need for `routingDomain`." + "Inside the VPC, `` resolves to the ALB's private IPs. Outside the VPC, the domain does not resolve — which is fine, because AgentCore Gateway's Resource Gateway lives inside the VPC and uses Private DNS to look it up." ] }, { @@ -192,7 +183,12 @@ "metadata": {}, "outputs": [], "source": [ - "!cdk deploy PrivateDomain -c publicCertArn={CERT_ARN} --profile {ACCOUNT_A_PROFILE} --require-approval never --outputs-file pd-outputs.json" + "!cdk deploy PrivateDomain \\\n", + " -c \"publicCertArn={CERT_ARN}\" \\\n", + " -c \"privateDomain={DOMAIN}\" \\\n", + " --profile {ACCOUNT_A_PROFILE} \\\n", + " --require-approval never \\\n", + " --outputs-file pd-outputs.json" ] }, { @@ -210,29 +206,15 @@ "API_KEY_VALUE = pd_outputs[\"ApiKey\"]\n", "EC2_INSTANCE_ID = pd_outputs[\"Ec2InstanceId\"]\n", "EC2_PRIVATE_IP = pd_outputs[\"Ec2PrivateIp\"]\n", - "PRIVATE_DOMAIN_ALB = pd_outputs[\"PrivateDomainAlb\"]\n", - "PRIVATE_DOMAIN_DIRECT = pd_outputs[\"PrivateDomainDirect\"]\n", + "PRIVATE_DOMAIN = pd_outputs[\"PrivateDomain\"]\n", "\n", - "print(f\"ALB DNS: {ALB_DNS}\")\n", - "print(f\"ALB SG: {ALB_SG_ID}\")\n", - "print(f\"EC2 instance: {EC2_INSTANCE_ID}\")\n", - "print(f\"EC2 private IP: {EC2_PRIVATE_IP}\")\n", - "print(f\"Private domain (ALB): {PRIVATE_DOMAIN_ALB}\")\n", - "print(f\"Private domain (EC2): {PRIVATE_DOMAIN_DIRECT}\")" - ] - }, - { - "cell_type": "markdown", - "id": "scenario1-md", - "metadata": {}, - "source": [ - "## Scenario 1: Private domain resolves to ALB / NLB / VPCE\n", - "\n", - "Our private domain `api.internal.{baseDomain}` resolves to the ALB inside the VPC. The ALB has a public certificate. The ALB's DNS name (`internal-*.elb.amazonaws.com`) is **publicly resolvable**, we use it as `routingDomain`.\n", - "\n", - "This pattern works the same way for **NLBs** (`internal-*.elb.*.amazonaws.com`) and **VPC endpoints** (`*.vpce.amazonaws.com`), any privately-deployed resource that has a publicly resolvable DNS name can be used as `routingDomain`.\n", - "\n", - "> **Key insight:** `routingDomain` is only used by VPC Lattice for DNS resolution and routing. The target URL domain (from your public cert) is what's sent as the TLS SNI. The two domains don't need to match." + "print(\n", + " f\"Private FQDN: {PRIVATE_DOMAIN} (resolves via Private DNS inside VPC → ALB)\"\n", + ")\n", + "print(f\"ALB DNS: {ALB_DNS}\")\n", + "print(f\"ALB SG: {ALB_SG_ID}\")\n", + "print(f\"EC2 instance: {EC2_INSTANCE_ID}\")\n", + "print(f\"EC2 private IP: {EC2_PRIVATE_IP}\")" ] }, { @@ -242,8 +224,10 @@ "source": [ "## Step 4: Create AgentCore Gateway Target\n", "\n", - "- **Target URL** (`https://{DOMAIN}`), matches the public certificate on the ALB\n", - "- **`routingDomain`** (`{ALB_DNS}`), the publicly resolvable ALB DNS name\n", + "Point the target directly at your private endpoint.\n", + "\n", + "- **Target URL**: `https://{DOMAIN}` — resolves to the ALB's private IPs via the private hosted zone\n", + "- **`managedVpcResource`**: VPC ID, subnet IDs, and the ALB's security group so the Resource Gateway ENIs can reach the ALB on port 443\n", "\n", "> **Security group:** We pass the ALB's security group to `securityGroupIds` so the Resource Gateway ENIs can reach the ALB on port 443." ] @@ -262,8 +246,7 @@ "openapi_schema[\"servers\"] = [{\"url\": TARGET_ENDPOINT}]\n", "\n", "OPENAPI_SCHEMA = json.dumps(openapi_schema)\n", - "print(f\"Server URL: {TARGET_ENDPOINT}\")\n", - "print(f\"routingDomain: {ALB_DNS}\")\n", + "print(f\"Server URL: {TARGET_ENDPOINT} (resolves via Private DNS inside the VPC)\")\n", "print(f\"Endpoints: {list(openapi_schema['paths'].keys())}\")" ] }, @@ -293,7 +276,7 @@ "response = agentcore.create_gateway_target(\n", " gatewayIdentifier=GATEWAY_ID,\n", " name=\"private-domain\",\n", - " description=\"Private domain with public cert, routed via ALB routingDomain\",\n", + " description=\"Private domain via Private DNS\",\n", " targetConfiguration={\n", " \"mcp\": {\n", " \"openApiSchema\": {\n", @@ -314,12 +297,11 @@ " }\n", " ],\n", " privateEndpoint={\n", - " \"managedLatticeResource\": {\n", + " \"managedVpcResource\": {\n", " \"vpcIdentifier\": VPC_USW2_ID,\n", " \"subnetIds\": VPC_USW2_PRIVATE_SUBNETS,\n", " \"endpointIpAddressType\": \"IPV4\",\n", " \"securityGroupIds\": [ALB_SG_ID],\n", - " \"routingDomain\": ALB_DNS,\n", " }\n", " },\n", ")\n", @@ -361,13 +343,8 @@ "source": [ "## Step 5: Invoke the API through AgentCore Gateway\n", "\n", - "Get an access token from Cognito, then invoke the API through the gateway. The traffic flows:\n", - "\n", - "```\n", - "Your request to AgentCore Gateway to VPC Lattice (via ALB DNS) to ALB (public cert) to EC2 (:8000)\n", - "```\n", - "\n", - "The private domain (`api.internal.{baseDomain}`) was never needed in the data path, only `routingDomain` (ALB DNS) was used for routing." + "Get an access token from Cognito, then invoke the API through the gateway.\n", + "The target's private domain is resolved entirely via your VPC's DNS resolver — nothing is exposed publicly." ] }, { @@ -477,34 +454,6 @@ "print(json.dumps(response.json(), indent=2))" ] }, - { - "cell_type": "markdown", - "id": "scenario2-md", - "metadata": {}, - "source": [ - "## Scenario 2: Private domain points directly to an IP address\n", - "\n", - "Our stack also created `direct.internal.{baseDomain}` → EC2 private IP. This represents a common pattern where the private domain resolves directly to a compute resource, **no ALB, NLB, or VPCE** in front.\n", - "\n", - "```\n", - "Inside VPC: direct.internal.{baseDomain} to 10.0.x.x (EC2 private IP)\n", - "Outside VPC: direct.internal.{baseDomain} to does not resolve\n", - "```\n", - "\n", - "In this case, there is **no publicly resolvable DNS name** to use as `routingDomain`. The workaround is to place an **internal ALB with a public certificate** in front of the resource:\n", - "\n", - "1. Deploy an internal ALB with a public ACM certificate\n", - "2. Point the ALB at the backend (EC2 on port 8000)\n", - "3. Use the ALB DNS as `routingDomain`\n", - "\n", - "This is exactly the pattern from Step 3, the ALB we already deployed serves as the publicly resolvable intermediate.\n", - "\n", - "For a step-by-step walkthrough of adding an ALB in front of an existing resource, see:\n", - "- [Private Certificate Authority lab](./02-private-certificate-authority.ipynb), also covers private CA certs\n", - "- [Self-Signed Certificate lab](./03-self-signed-certificate.ipynb), also covers self-signed certs\n", - "- [Private Domain + Private Certificate lab](./04-private-domain-and-certificate.ipynb), covers both private DNS and private certs together" - ] - }, { "cell_type": "markdown", "id": "cleanup-md", @@ -551,7 +500,10 @@ "outputs": [], "source": [ "# # Step 2: Destroy the stack\n", - "# !cdk destroy PrivateDomain -c publicCertArn={CERT_ARN} --profile {ACCOUNT_A_PROFILE} --force" + "# !cdk destroy PrivateDomain \\\n", + "# -c \"publicCertArn={CERT_ARN}\" \\\n", + "# -c \"privateDomain={DOMAIN}\" \\\n", + "# --profile {ACCOUNT_A_PROFILE} --force" ] }, { @@ -573,6 +525,14 @@ "# else:\n", "# raise" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "67d608e1", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/02-private-certificate-authority.ipynb b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/02-private-certificate-authority.ipynb index 14b43ea1..89b04ba6 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/02-private-certificate-authority.ipynb +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/02-private-certificate-authority.ipynb @@ -7,7 +7,7 @@ "source": [ "# Private Certificate Authority: ALB Solution for AgentCore Gateway\n", "\n", - "This lab demonstrates how to connect [Amazon Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html) to a backend API that uses a **private certificate** (from AWS Private CA).\n" + "This lab demonstrates how to connect [Amazon Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html) to a backend API that uses a **private certificate** (from AWS Private CA)." ] }, { @@ -61,7 +61,7 @@ "4. The ALB **terminates TLS** and applies a **host header transform** to rewrite the `Host` header from `my-server.my-company.com` to the private resource's domain (e.g., `my-server.my-company.internal`)\n", "5. The ALB **forwards the request to your backend over HTTPS** using the private certificate. All traffic stays inside your VPC\n", "\n", - "For background on VPC egress and managed VPC Lattice, see the [project README](../README.md) and [Advanced Concepts README](./README.md).\n" + "For background on VPC egress and managed VPC resource, see the [project README](../README.md) and [Advanced Concepts README](./README.md).\n" ] }, { @@ -320,7 +320,7 @@ "# }\n", "# ],\n", "# privateEndpoint={\n", - "# \"managedLatticeResource\": {\n", + "# \"managedVpcResource\": {\n", "# \"vpcIdentifier\": VPC_USW2_ID,\n", "# \"subnetIds\": VPC_USW2_PRIVATE_SUBNETS,\n", "# \"endpointIpAddressType\": \"IPV4\",\n", @@ -657,7 +657,7 @@ "source": [ "## Step 5: Create AgentCore Gateway Target\n", "\n", - "Create a gateway target using [managed VPC Lattice](../01-managed-lattice/README.md) with `routingDomain`.\n", + "Create a gateway target using [managed VPC resource](../01-managed-vpc-resource/README.md) with `routingDomain`.\n", "\n", "The full traffic flow:\n", "\n", @@ -727,7 +727,7 @@ " }\n", " ],\n", " privateEndpoint={\n", - " \"managedLatticeResource\": {\n", + " \"managedVpcResource\": {\n", " \"vpcIdentifier\": VPC_USW2_ID,\n", " \"subnetIds\": VPC_USW2_PRIVATE_SUBNETS,\n", " \"endpointIpAddressType\": \"IPV4\",\n", diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/03-self-signed-certificate.ipynb b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/03-self-signed-certificate.ipynb index 47d0b138..efa327d7 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/03-self-signed-certificate.ipynb +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/03-self-signed-certificate.ipynb @@ -13,8 +13,7 @@ "\n", "AgentCore Gateway validates TLS certificates against **public root CAs only**. If your API uses a self-signed certificate, AgentCore Gateway cannot establish a TLS connection to it. This applies regardless of what your API runs on — it could be behind an ALB, NLB, on an EC2 instance, in ECS/EKS, or any other compute platform.\n", "\n", - "The standard fix is to replace the self-signed certificate with a public ACM certificate. However, **public ACM certificates require DNS validation** to prove domain ownership. If you don't own the domain on the certificate (e.g., it's an internal domain managed by another team), you can't obtain a public certificate for it.\n", - "\n" + "The standard fix is to replace the self-signed certificate with a public ACM certificate. However, **public ACM certificates require DNS validation** to prove domain ownership. If you don't own the domain on the certificate (e.g., it's an internal domain managed by another team), you can't obtain a public certificate for it.\n" ] }, { @@ -54,7 +53,7 @@ "4. The ALB **terminates TLS** and applies a **host header transform** to rewrite the `Host` header from `my-server.my-company.com` to the private resource's domain (e.g., `my-server.my-company.internal`)\n", "5. The ALB **forwards the request to your backend over HTTPS** using the self-signed certificate. All traffic stays inside your VPC\n", "\n", - "For background on VPC egress and managed VPC Lattice, see the [project README](../README.md) and [Advanced Concepts README](./README.md)." + "For background on VPC egress and managed VPC resource, see the [project README](../README.md) and [Advanced Concepts README](./README.md)." ] }, { @@ -308,7 +307,7 @@ "# }\n", "# ],\n", "# privateEndpoint={\n", - "# \"managedLatticeResource\": {\n", + "# \"managedVpcResource\": {\n", "# \"vpcIdentifier\": VPC_USW2_ID,\n", "# \"subnetIds\": VPC_USW2_PRIVATE_SUBNETS,\n", "# \"endpointIpAddressType\": \"IPV4\",\n", @@ -643,7 +642,7 @@ "source": [ "## Step 5: Create AgentCore Gateway Target\n", "\n", - "Create a gateway target using [managed VPC Lattice](../01-managed-lattice/README.md) with `routingDomain`.\n", + "Create a gateway target using [managed VPC resource](../01-managed-vpc-resource/README.md) with `routingDomain`.\n", "\n", "The full traffic flow:\n", "\n", @@ -713,7 +712,7 @@ " }\n", " ],\n", " privateEndpoint={\n", - " \"managedLatticeResource\": {\n", + " \"managedVpcResource\": {\n", " \"vpcIdentifier\": VPC_USW2_ID,\n", " \"subnetIds\": VPC_USW2_PRIVATE_SUBNETS,\n", " \"endpointIpAddressType\": \"IPV4\",\n", diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/04-static-gateway-ip.ipynb b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/04-static-gateway-ip.ipynb index 4c76d837..663685d9 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/04-static-gateway-ip.ipynb +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/04-static-gateway-ip.ipynb @@ -27,7 +27,7 @@ "source": [ "## How it works\n", "\n", - "1. AgentCore Gateway uses **managed VPC Lattice** to route traffic into your VPC through a Resource Gateway\n", + "1. AgentCore Gateway uses **managed VPC resource** to route traffic into your VPC through a Resource Gateway\n", "2. The Resource Gateway ENIs are placed in a **private subnet** that routes outbound traffic (0.0.0.0/0) through a NAT Gateway\n", "3. The NAT Gateway has an **Elastic IP** — a static, public IP address\n", "4. All traffic to the external MCP server exits through this single Elastic IP\n", @@ -35,7 +35,7 @@ "\n", "> **Resilience vs. simplicity:** This lab uses a single NAT Gateway for a single static IP. In production, consider deploying one NAT Gateway per Availability Zone for high availability. Each NAT Gateway has its own Elastic IP, so you would provide multiple IPs to the MCP server for allowlisting.\n", "\n", - "For background on VPC egress and managed VPC Lattice, see the [project README](../README.md) and [Advanced Concepts README](./README.md)." + "For background on VPC egress and managed VPC resource, see the [project README](../README.md) and [Advanced Concepts README](./README.md)." ] }, { @@ -289,12 +289,11 @@ " }\n", " ],\n", " privateEndpoint={\n", - " \"managedLatticeResource\": {\n", + " \"managedVpcResource\": {\n", " \"vpcIdentifier\": VPC_USW2_ID,\n", " \"subnetIds\": [STATIC_IP_SUBNET_ID],\n", " \"endpointIpAddressType\": \"IPV4\",\n", " \"securityGroupIds\": [STATIC_IP_SG_ID],\n", - " # \"routingDomain\": \"10.0.0.219\",\n", " }\n", " },\n", ")\n", @@ -445,12 +444,11 @@ " }\n", " },\n", " privateEndpoint={\n", - " \"managedLatticeResource\": {\n", + " \"managedVpcResource\": {\n", " \"vpcIdentifier\": VPC_USW2_ID,\n", " \"subnetIds\": [STATIC_IP_SUBNET_ID],\n", " \"endpointIpAddressType\": \"IPV4\",\n", " \"securityGroupIds\": [STATIC_IP_SG_ID],\n", - " # \"routingDomain\": \"10.0.0.219\",\n", " }\n", " },\n", ")\n", diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/README.md b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/README.md index d6db3f0e..3b30d5e9 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/README.md +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/README.md @@ -3,15 +3,14 @@ # Advanced Concepts -> This feature is made available to you as a "Beta Service" as defined in the [AWS Service Terms](https://aws.amazon.com/service-terms/). It is subject to your Agreement with AWS and the AWS Service Terms. - - This section covers private DNS, private certificates, static IP egress, and the patterns needed to make them work with AgentCore Gateway VPC egress. -## Private DNS: Routing Domain +## Disabled VPC DNS: Routing Domain -Amazon VPC Lattice requires that the domain used in a resource configuration be publicly resolvable. If your private endpoint uses a domain that is only resolvable within your VPC (for example, a Route 53 private hosted zone), you must use the `routingDomain` field. +> **Use `routingDomain` only when DNS is not enabled in your VPC.** If your VPC has DNS enabled (the default), AgentCore Gateway VPC egress reaches private endpoints via Private DNS automatically — no `routingDomain` needed. + +Amazon VPC Lattice requires that the domain used in a resource configuration be resolvable. If your VPC does not have DNS enabled and your private endpoint uses a domain that is only resolvable within your VPC (for example, a Route 53 private hosted zone), use the `routingDomain` field as a fallback. ![arch](./images/private-domain.png) @@ -34,22 +33,12 @@ The routing domain can be any publicly resolvable domain that routes to your pri | **Internal NLB** | `internal--.us-west-2.elb.amazonaws.com` | Private DNS name of the resource behind the NLB | | **VPC Endpoint (VPCE)** | `.execute-api..vpce.amazonaws.com` | Private API Gateway hostname (e.g., `https://.execute-api..amazonaws.com`) | -### Traffic flow with routing domain - -``` -1. AgentCore resolves the VPC Lattice-generated DNS name to reach the resource gateway -2. Traffic enters your VPC through the resource gateway, addressed to the routing domain -3. The routing domain (ALB/NLB/VPCE) forwards the request to your private resource -4. The TLS SNI header contains the actual target domain, so your resource receives - the request with the correct hostname -``` - -> **Note:** The `routingDomain` field is only available for the `managedLatticeResource` option. For self-managed Lattice, configure the routing domain directly in your resource configuration when you create it. - ## Private Certificates: ALB Workaround VPC egress requires your target endpoint to have a **publicly trusted TLS certificate**. If your private resource uses a certificate issued by a private certificate authority (CA), the recommended workaround is to place an internal Application Load Balancer (ALB) in front of your resource. +![privateCA](./images/private-ca.png) + ### How it works ``` @@ -77,7 +66,7 @@ If your external MCP server requires IP-based allowlisting, you can route AgentC ### How it works -1. Use **VPC egress** (managed VPC Lattice) to route AgentCore Gateway traffic into your VPC through a Resource Gateway +1. Use **VPC egress** (managed VPC resource) to route AgentCore Gateway traffic into your VPC through a Resource Gateway 2. Place the Resource Gateway ENIs in a **private subnet** that routes outbound traffic (0.0.0.0/0) through a NAT Gateway 3. The NAT Gateway has an **Elastic IP** — a static, public IP address 4. All traffic to external MCP servers exits through this Elastic IP @@ -89,7 +78,7 @@ For high availability, deploy one NAT Gateway per Availability Zone. Each NAT Ga | Notebook | Description | |----------|-------------| -| [01-private-domain.ipynb](./01-private-domain.ipynb) | Use a private hosted zone with `routingDomain` and a public certificate. | +| [01-private-domain.ipynb](./01-private-domain.ipynb) | Connect AgentCore Gateway to a privately resolvable endpoint | | [02-private-certificate-authority.ipynb](./02-private-certificate-authority.ipynb) | Use the ALB workaround for APIs with AWS Private CA certificates. | | [03-self-signed-certificate.ipynb](./03-self-signed-certificate.ipynb) | Use the ALB workaround for APIs with self-signed certificates (no Private CA cost). | | [04-static-gateway-ip.ipynb](./04-static-gateway-ip.ipynb) | Route AgentCore Gateway traffic through a NAT Gateway with a static Elastic IP for allowlisting. | diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/images/private-ca.png b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/images/private-ca.png index cb5b7e04..8960dd8c 100644 Binary files a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/images/private-ca.png and b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/images/private-ca.png differ diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/images/private-domain.png b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/images/private-domain.png index 06a56700..d1a42f04 100644 Binary files a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/images/private-domain.png and b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/images/private-domain.png differ diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/images/self-signed.png b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/images/self-signed.png index 2cbdf1e8..0a5b8778 100644 Binary files a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/images/self-signed.png and b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/03-advanced-concepts/images/self-signed.png differ diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/04-ecs-deployment/README.md b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/04-ecs-deployment/README.md index 6bd3df06..7baf654d 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/04-ecs-deployment/README.md +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/04-ecs-deployment/README.md @@ -3,8 +3,6 @@ # ECS Deployment -> This feature is made available to you as a "Beta Service" as defined in the [AWS Service Terms](https://aws.amazon.com/service-terms/). It is subject to your Agreement with AWS and the AWS Service Terms. - Deploy MCP servers on Amazon ECS and connect them to AgentCore Gateway using VPC egress. @@ -33,7 +31,7 @@ AgentCore Gateway → VPC Lattice (routingDomain: ALB *.elb.amazonaws.com) | Notebook | Description | |----------|-------------| -| [fargate-mcp-gateway-managed.ipynb](./fargate-mcp-gateway-managed.ipynb) | Deploy a FastMCP server on ECS Fargate behind an internal ALB with private DNS and `routingDomain`, then connect to AgentCore Gateway using managed VPC Lattice. | +| [fargate-mcp-gateway-managed.ipynb](./fargate-mcp-gateway-managed.ipynb) | Deploy a FastMCP server on ECS Fargate behind an internal ALB with private DNS and `routingDomain`, then connect to AgentCore Gateway using managed VPC resource. | ## License diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/04-ecs-deployment/fargate-mcp-gateway-managed.ipynb b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/04-ecs-deployment/fargate-mcp-gateway-managed.ipynb index 4d83b129..fe566693 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/04-ecs-deployment/fargate-mcp-gateway-managed.ipynb +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/04-ecs-deployment/fargate-mcp-gateway-managed.ipynb @@ -7,11 +7,11 @@ "source": [ "# Connecting Amazon ECS MCP Servers (Fargate) to AgentCore Gateway\n", "\n", - "This lab deploys a [FastMCP](https://github.com/jlowin/fastmcp) server on Amazon ECS Fargate inside a private VPC, then connects it to [Amazon Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html) using managed VPC egress with an internal ALB and private DNS.\n", + "This lab deploys a [FastMCP](https://github.com/jlowin/fastmcp) server on Amazon ECS Fargate inside a private VPC, then connects it to [Amazon Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html) using managed VPC egress with an internal ALB.\n", "\n", - "The MCP server uses a **private domain** (Route 53 private hosted zone) that only resolves inside the VPC. The `routingDomain` parameter tells VPC Lattice to route via the ALB's publicly resolvable DNS.\n", + "The MCP server is reachable via a **private domain** in a Route 53 private hosted zone associated with your VPC. With **Private DNS** enabled on the VPC (the default), AgentCore Gateway's managed Resource Gateway resolves the domain via your VPC's DNS resolver.\n", "\n", - "For background on VPC egress, routing domains, and certificate requirements, see the [project README](../README.md) and [prerequisites](../00-prerequisites/).\n", + "For background on VPC egress, certificate requirements, and Private DNS, see the [project README](../README.md), [01-managed-vpc-resource README](../01-managed-vpc-resource/README.md), and [prerequisites](../00-prerequisites/).\n", "\n", "![arch](./images/ecs-fargate.png)\n" ] @@ -110,14 +110,15 @@ "metadata": {}, "outputs": [], "source": [ - "CERT_ARN = input(\"ACM public certificate ARN: \")\n", + "CERT_ARN = input(\"ACM public certificate ARN: \").strip()\n", "DOMAIN = input(\n", - " \"Domain name covered by the certificate (e.g., api.external.yourcompany.com): \"\n", - ")\n", + " \"Domain name covered by the certificate (e.g., api.internal.yourcompany.com): \"\n", + ").strip()\n", "\n", "assert CERT_ARN.startswith(\"arn:aws:acm:\"), \"Invalid certificate ARN\"\n", "assert not DOMAIN.startswith(\"http\"), \"Domain should not include http:// or https://\"\n", "assert \".\" in DOMAIN, \"Domain must contain at least one dot\"\n", + "assert \" \" not in DOMAIN, \"Domain must not contain whitespace\"\n", "\n", "print(f\"Cert ARN: {CERT_ARN}\")\n", "print(f\"Domain: {DOMAIN}\")" @@ -133,8 +134,10 @@ "This CDK stack deploys:\n", "- **ECS Fargate service** running FastMCP (echo, add, get_time tools) on port 8000, registered in Cloud Map (`mcp.local`)\n", "- **Internal ALB** with HTTPS listener (port 443) using your ACM public certificate for TLS termination\n", - "- **Private hosted zone** with an alias record pointing to the ALB\n", - "- **Bastion instance** (t3.micro) with SSM Session Manager for testing\n" + "- **Route 53 private hosted zone** named `` associated with the VPC, with an apex Alias record pointing to the ALB\n", + "- **Bastion instance** (t3.micro) with SSM Session Manager for testing\n", + "\n", + "Inside the VPC, `` resolves to the ALB's private IPs via Private DNS. AgentCore Gateway's Resource Gateway uses this resolution path.\n" ] }, { @@ -145,7 +148,8 @@ "outputs": [], "source": [ "!cdk deploy McpEcs \\\n", - " -c publicCertArn={CERT_ARN} \\\n", + " -c \"publicCertArn={CERT_ARN}\" \\\n", + " -c \"privateDomain={DOMAIN}\" \\\n", " --profile {ACCOUNT_A_PROFILE} \\\n", " --require-approval never \\\n", " --outputs-file ecs-outputs.json" @@ -163,9 +167,11 @@ "\n", "ALB_DNS = ecs_outputs[\"AlbDnsName\"]\n", "ALB_SG_ID = ecs_outputs[\"AlbSgId\"]\n", + "PRIVATE_DOMAIN = ecs_outputs[\"PrivateDomain\"]\n", "\n", - "print(f\"ALB DNS: {ALB_DNS} (publicly resolvable, used as routingDomain)\")\n", - "print(f\"ALB SG: {ALB_SG_ID}\")" + "print(f\"Private FQDN: {PRIVATE_DOMAIN} (resolves via Private DNS inside VPC → ALB)\")\n", + "print(f\"ALB DNS: {ALB_DNS}\")\n", + "print(f\"ALB SG: {ALB_SG_ID}\")" ] }, { @@ -175,12 +181,10 @@ "source": [ "## Step 3: Create AgentCore Gateway target\n", "\n", - "Create a gateway target using [managed VPC Lattice](../01-managed-lattice/).\n", + "Create a gateway target using [managed VPC resource](../01-managed-vpc-resource/). Point the target at your private FQDN — Private DNS resolves it to the ALB inside the VPC.\n", "\n", - "- **Target URL** (`https://{DOMAIN}/mcp`) — matches the public certificate on the ALB\n", - "- **`routingDomain`** (`{ALB_DNS}`) — the publicly resolvable ALB DNS name\n", - "\n", - "VPC Lattice routes traffic via the ALB DNS while setting the TLS SNI to the target domain, so the TLS handshake succeeds against the ALB's certificate. You do **not** need a public DNS record for the target domain.\n", + "- **Target URL** (`https://{DOMAIN}/mcp`) — resolves to the ALB's private IPs via the private hosted zone; matches the public certificate on the ALB\n", + "- **`managedVpcResource`** — VPC, subnets, and the ALB's security group so the Resource Gateway ENIs can reach the ALB on port 443\n", "\n", "> **Security group:** We pass the ALB's security group to `securityGroupIds` so the Resource Gateway ENIs can reach the ALB on port 443.\n" ] @@ -194,13 +198,12 @@ "source": [ "TARGET_ENDPOINT = f\"https://{DOMAIN}/mcp\"\n", "\n", - "print(f\"Target endpoint: {TARGET_ENDPOINT}\")\n", - "print(f\"Routing domain: {ALB_DNS}\")\n", + "print(f\"Target endpoint: {TARGET_ENDPOINT} (resolves via Private DNS inside the VPC)\")\n", "\n", "response = agentcore.create_gateway_target(\n", " gatewayIdentifier=GATEWAY_ID,\n", " name=\"ecs-mcp-server\",\n", - " description=\"MCP server on ECS Fargate via internal ALB and managed VPC egress\",\n", + " description=\"MCP server on ECS Fargate via internal ALB and managed VPC egress (Private DNS)\",\n", " targetConfiguration={\n", " \"mcp\": {\n", " \"mcpServer\": {\n", @@ -209,12 +212,11 @@ " }\n", " },\n", " privateEndpoint={\n", - " \"managedLatticeResource\": {\n", + " \"managedVpcResource\": {\n", " \"vpcIdentifier\": VPC_USW2_ID,\n", " \"subnetIds\": VPC_USW2_PRIVATE_SUBNETS,\n", " \"endpointIpAddressType\": \"IPV4\",\n", " \"securityGroupIds\": [ALB_SG_ID],\n", - " \"routingDomain\": ALB_DNS,\n", " }\n", " },\n", ")\n", @@ -334,7 +336,7 @@ "- `/mcp` → original MCP server (echo, add, get_time)\n", "- `/stock-mcp/*` → stock MCP server (get_stock_price, get_market_summary)\n", "\n", - "Both share the same ALB, certificate, and security group. This demonstrates connecting **multiple MCP servers** through a single load balancer." + "Both share the same ALB, certificate, private hosted zone, and security group. The second target reuses the same Resource Gateway in the VPC (since the VPC/subnet/SG config matches)." ] }, { @@ -347,12 +349,11 @@ "STOCK_TARGET_ENDPOINT = f\"https://{DOMAIN}/stock-mcp/\"\n", "\n", "print(f\"Stock target endpoint: {STOCK_TARGET_ENDPOINT}\")\n", - "print(f\"Routing domain: {ALB_DNS}\")\n", "\n", "stock_response = agentcore.create_gateway_target(\n", " gatewayIdentifier=GATEWAY_ID,\n", " name=\"ecs-stock-mcp\",\n", - " description=\"Stock price MCP server on ECS Fargate via same ALB (path-based routing)\",\n", + " description=\"Stock price MCP server on ECS Fargate via same ALB (path-based routing, Private DNS)\",\n", " targetConfiguration={\n", " \"mcp\": {\n", " \"mcpServer\": {\n", @@ -361,12 +362,11 @@ " }\n", " },\n", " privateEndpoint={\n", - " \"managedLatticeResource\": {\n", + " \"managedVpcResource\": {\n", " \"vpcIdentifier\": VPC_USW2_ID,\n", " \"subnetIds\": VPC_USW2_PRIVATE_SUBNETS,\n", " \"endpointIpAddressType\": \"IPV4\",\n", " \"securityGroupIds\": [ALB_SG_ID],\n", - " \"routingDomain\": ALB_DNS,\n", " }\n", " },\n", ")\n", @@ -511,7 +511,10 @@ "outputs": [], "source": [ "# # Step 2: Destroy CDK stack (ALB SG is retained, deleted in next cell)\n", - "# !ACCOUNT_A_ID={ACCOUNT_A_ID} cdk destroy McpEcs -c publicCertArn={CERT_ARN} --profile {ACCOUNT_A_PROFILE} --force" + "# !ACCOUNT_A_ID={ACCOUNT_A_ID} cdk destroy McpEcs \\\n", + "# -c \"publicCertArn={CERT_ARN}\" \\\n", + "# -c \"privateDomain={DOMAIN}\" \\\n", + "# --profile {ACCOUNT_A_PROFILE} --force" ] }, { diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/04-ecs-deployment/images/ecs-fargate.png b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/04-ecs-deployment/images/ecs-fargate.png index 864a3c58..959087bf 100644 Binary files a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/04-ecs-deployment/images/ecs-fargate.png and b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/04-ecs-deployment/images/ecs-fargate.png differ diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/README.md b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/README.md index 8c9479aa..82b1c8ef 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/README.md +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/README.md @@ -3,7 +3,6 @@ # EKS Deployment -> This feature is made available to you as a "Beta Service" as defined in the [AWS Service Terms](https://aws.amazon.com/service-terms/). It is subject to your Agreement with AWS and the AWS Service Terms. Deploy MCP servers and REST APIs on Amazon EKS and connect them to AgentCore Gateway using VPC egress. @@ -43,7 +42,7 @@ AgentCore Gateway | Notebook | Description | |----------|-------------| -| [mcp-server-gateway-managed.ipynb](./mcp-server-gateway-managed.ipynb) | Deploy FastMCP servers on EKS behind an NGINX Ingress Controller (single NLB, path-based routing), with a private hosted zone and `routingDomain`. Uses managed VPC Lattice. | +| [mcp-server-gateway-managed.ipynb](./mcp-server-gateway-managed.ipynb) | Deploy FastMCP servers on EKS behind an NGINX Ingress Controller (single NLB, path-based routing), with a private hosted zone and `routingDomain`. Uses managed VPC resource. | | [api-server-gateway-managed.ipynb](./api-server-gateway-managed.ipynb) | Deploy a REST API (FastAPI) on EKS behind an internal NLB, connected to AgentCore Gateway with an OpenAPI schema. Uses private hosted zone and `routingDomain`. | ## License diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/api-server-gateway-managed.ipynb b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/api-server-gateway-managed.ipynb index 5ae0eabb..db2f5fa9 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/api-server-gateway-managed.ipynb +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/api-server-gateway-managed.ipynb @@ -9,9 +9,11 @@ "\n", "This lab deploys a REST API (FastAPI) on Amazon EKS inside a private VPC, then connects it to [Amazon Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html) as an **MCP target with an OpenAPI schema**, using managed VPC egress with an internal NLB.\n", "\n", + "The API is reachable via a **private domain** in a Route 53 private hosted zone associated with your VPC. With **Private DNS** enabled on the VPC (the default), AgentCore Gateway's managed Resource Gateway resolves the domain via your VPC's DNS resolver.\n", + "\n", "Unlike the MCP server lab, this lab uses an **OpenAPI schema** to describe the API endpoints. AgentCore Gateway uses the schema to expose the API operations as tools that AI agents can invoke.\n", "\n", - "For background on VPC egress, routing domains, and certificate requirements, see the [project README](../README.md) and [prerequisites](../00-prerequisites/).\n", + "For background on VPC egress, certificate requirements, and Private DNS, see the [project README](../README.md), [01-managed-vpc-resource README](../01-managed-vpc-resource/README.md), and [prerequisites](../00-prerequisites/).\n", "\n", "![arch](./images/eks-api.png)" ] @@ -112,14 +114,15 @@ "metadata": {}, "outputs": [], "source": [ - "CERT_ARN = input(\"ACM public certificate ARN: \")\n", + "CERT_ARN = input(\"ACM public certificate ARN: \").strip()\n", "DOMAIN = input(\n", - " \"Domain name covered by the certificate (e.g., api.external.yourcompany.com): \"\n", - ")\n", + " \"Domain name covered by the certificate (e.g., api.internal.yourcompany.com): \"\n", + ").strip()\n", "\n", "assert CERT_ARN.startswith(\"arn:aws:acm:\"), \"Invalid certificate ARN\"\n", "assert not DOMAIN.startswith(\"http\"), \"Domain should not include http:// or https://\"\n", "assert \".\" in DOMAIN, \"Domain must contain at least one dot\"\n", + "assert \" \" not in DOMAIN, \"Domain must not contain whitespace\"\n", "\n", "print(f\"Cert ARN: {CERT_ARN}\")\n", "print(f\"Domain: {DOMAIN}\")" @@ -135,6 +138,7 @@ "This CDK stack deploys:\n", "- **REST API** (FastAPI: /health, /items GET/POST) as a Kubernetes Deployment on port 8080\n", "- **Internal NLB** with TLS listener (port 443) using your ACM certificate, created via K8s Service annotations\n", + "- **Route 53 private hosted zone** named `` associated with the VPC (empty — the notebook adds an Alias record to the NLB once it's provisioned)\n", "\n", "> **Note:** This assumes the Shared EKS Cluster (with AWS Load Balancer Controller) is already deployed from the MCP server lab or Lab 0." ] @@ -161,9 +165,10 @@ "metadata": {}, "outputs": [], "source": [ - "# Deploy API server on EKS with NLB\n", + "# Deploy API server on EKS with NLB + private hosted zone\n", "!ACCOUNT_A_ID={ACCOUNT_A_ID} cdk deploy ApiEks \\\n", - " -c publicCertArn={CERT_ARN} \\\n", + " -c \"publicCertArn={CERT_ARN}\" \\\n", + " -c \"privateDomain={DOMAIN}\" \\\n", " --profile {ACCOUNT_A_PROFILE} \\\n", " --require-approval never \\\n", " --outputs-file eks-api-outputs.json" @@ -176,16 +181,47 @@ "metadata": {}, "outputs": [], "source": [ - "print(\"Waiting for NLB to be provisioned by the AWS Load Balancer Controller...\")\n", + "# Read CDK outputs (private hosted zone is created at deploy time; NLB DNS is filled in below)\n", + "with open(\"eks-api-outputs.json\") as f:\n", + " eks_api_outputs = json.load(f)[\"ApiEks\"]\n", + "\n", + "PRIVATE_ZONE_ID = eks_api_outputs[\"PrivateZoneId\"]\n", + "PRIVATE_DOMAIN = eks_api_outputs[\"PrivateDomain\"]\n", + "print(f\"Private hosted zone: {PRIVATE_DOMAIN} (zone ID: {PRIVATE_ZONE_ID})\")\n", + "\n", + "# Discover the K8s-managed NLB\n", + "print(\"\\nWaiting for NLB to be provisioned by the AWS Load Balancer Controller...\")\n", "\n", "elbv2_client = session.client(\"elbv2\")\n", "ec2_client = session.client(\"ec2\")\n", + "route53_client = session.client(\"route53\")\n", + "\n", + "\n", + "def _nlb_has_healthy_target(nlb_arn):\n", + " \"\"\"Return True if any target group on this NLB has at least one healthy target.\n", + "\n", + " Why this matters: if the K8s Service has been redeployed (or moved between\n", + " stack iterations), the AWS Load Balancer Controller can leave behind an\n", + " orphaned NLB pointing at a stale pod IP. Both NLBs match the name filter,\n", + " but only one routes to a live pod. Picking the first match by chance can\n", + " silently route to the dead one and you'll see \"Connection closed by peer\"\n", + " when invoking the gateway target.\n", + " \"\"\"\n", + " tgs = elbv2_client.describe_target_groups(LoadBalancerArn=nlb_arn)[\"TargetGroups\"]\n", + " for tg in tgs:\n", + " health = elbv2_client.describe_target_health(\n", + " TargetGroupArn=tg[\"TargetGroupArn\"]\n", + " )[\"TargetHealthDescriptions\"]\n", + " if any(t[\"TargetHealth\"][\"State\"] == \"healthy\" for t in health):\n", + " return True\n", + " return False\n", + "\n", "\n", "NLB_DNS = None\n", + "NLB_HOSTED_ZONE_ID = None\n", "NLB_SG_ID = None\n", "for attempt in range(20):\n", " nlbs = elbv2_client.describe_load_balancers()[\"LoadBalancers\"]\n", - " # Look for NLB with \"restapi\" in the name (created by K8s Service rest-api-nlb)\n", " api_nlbs = [\n", " n\n", " for n in nlbs\n", @@ -194,10 +230,21 @@ " and n[\"Type\"] == \"network\"\n", " and \"restapi\" in n.get(\"LoadBalancerName\", \"\").lower().replace(\"-\", \"\")\n", " ]\n", - " if api_nlbs:\n", - " nlb = api_nlbs[0]\n", + " # Prefer NLBs with at least one healthy target — skips orphaned NLBs\n", + " healthy = [n for n in api_nlbs if _nlb_has_healthy_target(n[\"LoadBalancerArn\"])]\n", + " chosen = healthy[0] if healthy else (api_nlbs[0] if api_nlbs else None)\n", + " if chosen and (healthy or attempt >= 5):\n", + " # Either a healthy NLB exists, or we've waited long enough — accept the only candidate\n", + " nlb = chosen\n", " NLB_DNS = nlb[\"DNSName\"]\n", + " NLB_HOSTED_ZONE_ID = nlb[\"CanonicalHostedZoneId\"]\n", " NLB_SG_ID = nlb[\"SecurityGroups\"][0] if nlb.get(\"SecurityGroups\") else None\n", + " if len(api_nlbs) > 1:\n", + " stale = [n[\"LoadBalancerName\"] for n in api_nlbs if n is not nlb]\n", + " print(\n", + " f\"WARNING: Multiple matching NLBs found; chose {nlb['LoadBalancerName']}. \"\n", + " f\"Stale (orphaned by AWS LB Controller): {stale}\"\n", + " )\n", " break\n", " print(f\" Waiting... (attempt {attempt + 1}/20)\")\n", " time.sleep(15)\n", @@ -206,7 +253,10 @@ " NLB_DNS\n", "), \"NLB not found. Check if the K8s Service was created and the LB controller is running.\"\n", "\n", - "# Ensure the NLB SG allows inbound on port 443 from VPC CIDR\n", + "print(f\"\\nNLB DNS: {NLB_DNS}\")\n", + "print(f\"NLB SG: {NLB_SG_ID}\")\n", + "\n", + "# Open NLB SG for inbound 443 from the VPC CIDR\n", "if NLB_SG_ID:\n", " try:\n", " ec2_client.authorize_security_group_ingress(\n", @@ -229,8 +279,28 @@ " else:\n", " raise\n", "\n", - "print(f\"\\nNLB DNS (routingDomain): {NLB_DNS}\")\n", - "print(f\"NLB SG: {NLB_SG_ID}\")" + "# UPSERT an Alias A record in the private hosted zone pointing at the NLB\n", + "route53_client.change_resource_record_sets(\n", + " HostedZoneId=PRIVATE_ZONE_ID,\n", + " ChangeBatch={\n", + " \"Changes\": [\n", + " {\n", + " \"Action\": \"UPSERT\",\n", + " \"ResourceRecordSet\": {\n", + " \"Name\": PRIVATE_DOMAIN,\n", + " \"Type\": \"A\",\n", + " \"AliasTarget\": {\n", + " \"HostedZoneId\": NLB_HOSTED_ZONE_ID,\n", + " \"DNSName\": NLB_DNS,\n", + " \"EvaluateTargetHealth\": False,\n", + " },\n", + " },\n", + " }\n", + " ]\n", + " },\n", + ")\n", + "print(f\"\\nUPSERT-ed Alias record: {PRIVATE_DOMAIN} -> {NLB_DNS}\")\n", + "print(\"Inside the VPC, the private domain now resolves to the NLB's private IPs.\")" ] }, { @@ -242,9 +312,9 @@ "\n", "For REST APIs, we use the MCP target type with an **OpenAPI schema**. AgentCore Gateway uses the schema to discover the API's operations and expose them as tools that AI agents can invoke.\n", "\n", - "The `routingDomain` parameter directs VPC Lattice to route via the NLB's publicly resolvable DNS.\n", + "The target endpoint is your private FQDN. Private DNS resolves it to the NLB's private IPs inside the VPC — no `routingDomain` needed.\n", "\n", - "> **Security group:** We pass the NLB's security group to `securityGroupIds`. See [Security group considerations](../01-managed-lattice/README.md#security-group-considerations)." + "> **Security group:** We pass the NLB's security group to `securityGroupIds` so the Resource Gateway ENIs can reach the NLB on port 443." ] }, { @@ -294,22 +364,20 @@ "source": [ "TARGET_ENDPOINT = f\"https://{DOMAIN}\"\n", "\n", - "print(f\"Target endpoint: {TARGET_ENDPOINT}\")\n", - "print(f\"Routing domain: {NLB_DNS}\")\n", + "print(f\"Target endpoint: {TARGET_ENDPOINT} (resolves via Private DNS inside the VPC)\")\n", "\n", - "managed_lattice_config = {\n", + "managed_vpc_resource_config = {\n", " \"vpcIdentifier\": VPC_USW2_ID,\n", " \"subnetIds\": VPC_USW2_PRIVATE_SUBNETS,\n", " \"endpointIpAddressType\": \"IPV4\",\n", - " \"routingDomain\": NLB_DNS,\n", "}\n", "if NLB_SG_ID:\n", - " managed_lattice_config[\"securityGroupIds\"] = [NLB_SG_ID]\n", + " managed_vpc_resource_config[\"securityGroupIds\"] = [NLB_SG_ID]\n", "\n", "response = agentcore.create_gateway_target(\n", " gatewayIdentifier=GATEWAY_ID,\n", " name=\"eks-api-server\",\n", - " description=\"REST API on EKS via internal NLB and managed VPC egress\",\n", + " description=\"REST API on EKS via internal NLB and managed VPC egress (Private DNS)\",\n", " targetConfiguration={\n", " \"mcp\": {\n", " \"openApiSchema\": {\n", @@ -330,7 +398,7 @@ " }\n", " ],\n", " privateEndpoint={\n", - " \"managedLatticeResource\": managed_lattice_config,\n", + " \"managedVpcResource\": managed_vpc_resource_config,\n", " },\n", ")\n", "\n", @@ -497,7 +565,30 @@ "\n", "# # Delete credential provider\n", "# agentcore.delete_api_key_credential_provider(name=\"eks-api-server-api-key\")\n", - "# print(\"Deleted credential provider\")" + "# print(\"Deleted credential provider\")\n", + "\n", + "# # Delete the Alias record from the private hosted zone\n", + "# # (CDK can't delete a hosted zone that still contains records)\n", + "# route53_client.change_resource_record_sets(\n", + "# HostedZoneId=PRIVATE_ZONE_ID,\n", + "# ChangeBatch={\n", + "# \"Changes\": [\n", + "# {\n", + "# \"Action\": \"DELETE\",\n", + "# \"ResourceRecordSet\": {\n", + "# \"Name\": PRIVATE_DOMAIN,\n", + "# \"Type\": \"A\",\n", + "# \"AliasTarget\": {\n", + "# \"HostedZoneId\": NLB_HOSTED_ZONE_ID,\n", + "# \"DNSName\": NLB_DNS,\n", + "# \"EvaluateTargetHealth\": False,\n", + "# },\n", + "# },\n", + "# }\n", + "# ]\n", + "# },\n", + "# )\n", + "# print(f\"Deleted Alias record: {PRIVATE_DOMAIN} -> {NLB_DNS}\")" ] }, { @@ -507,9 +598,10 @@ "metadata": {}, "outputs": [], "source": [ - "# # Step 2: Destroy CDK stacks\n", + "# # Step 2: Destroy CDK stack\n", "# !ACCOUNT_A_ID={ACCOUNT_A_ID} cdk destroy ApiEks \\\n", - "# -c publicCertArn={CERT_ARN} \\\n", + "# -c \"publicCertArn={CERT_ARN}\" \\\n", + "# -c \"privateDomain={DOMAIN}\" \\\n", "# --profile {ACCOUNT_A_PROFILE} --force" ] }, @@ -524,14 +616,6 @@ "# !ACCOUNT_A_ID={ACCOUNT_A_ID} cdk destroy SharedEksCluster \\\n", "# --profile {ACCOUNT_A_PROFILE} --force" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "872cbba1", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/images/eks-api.png b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/images/eks-api.png index 0576aca7..b2dd6fd2 100644 Binary files a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/images/eks-api.png and b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/images/eks-api.png differ diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/images/eks-mcp.png b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/images/eks-mcp.png index 0535b449..a122d1b4 100644 Binary files a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/images/eks-mcp.png and b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/images/eks-mcp.png differ diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/mcp-server-gateway-managed.ipynb b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/mcp-server-gateway-managed.ipynb index 9a929b09..750245e3 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/mcp-server-gateway-managed.ipynb +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/05-eks-deployment/mcp-server-gateway-managed.ipynb @@ -5,13 +5,13 @@ "id": "intro", "metadata": {}, "source": [ - "# Connecting EKS MCP Server to AgentCore Gateway\n", + "# Connecting EKS MCP Servers to AgentCore Gateway\n", "\n", - "This lab deploys a [FastMCP](https://github.com/jlowin/fastmcp) server on Amazon EKS inside a private VPC, then connects it to [Amazon Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html) using managed VPC egress with an internal NLB.\n", + "This lab deploys two [FastMCP](https://github.com/jlowin/fastmcp) servers on Amazon EKS inside a private VPC, fronted by an [NGINX Ingress Controller](https://kubernetes.github.io/ingress-nginx/) behind a single internal NLB, then connects them to [Amazon Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html) using managed VPC egress.\n", "\n", - "The `routingDomain` parameter tells VPC Lattice to route via the NLB's publicly resolvable DNS, while the target URL uses a domain covered by your ACM public certificate.\n", + "The MCP servers are reachable via a **private domain** in a Route 53 private hosted zone associated with your VPC. With **Private DNS** enabled on the VPC (the default), AgentCore Gateway's managed Resource Gateway resolves the domain via your VPC's DNS resolver — no `routingDomain` workaround needed. NGINX does path-based routing so a single NLB serves both MCP servers (`/mcp-server/mcp` and `/stock-mcp/mcp`).\n", "\n", - "For background on VPC egress, routing domains, and certificate requirements, see the [project README](../README.md) and [prerequisites](../00-prerequisites/).\n", + "For background on VPC egress, certificate requirements, and Private DNS, see the [project README](../README.md), [01-managed-vpc-resource README](../01-managed-vpc-resource/README.md), and [prerequisites](../00-prerequisites/).\n", "\n", "![arch](./images/eks-mcp.png)" ] @@ -110,14 +110,15 @@ "metadata": {}, "outputs": [], "source": [ - "CERT_ARN = input(\"ACM public certificate ARN: \")\n", + "CERT_ARN = input(\"ACM public certificate ARN: \").strip()\n", "DOMAIN = input(\n", - " \"Domain name covered by the certificate (e.g., api.external.yourcompany.com): \"\n", - ")\n", + " \"Domain name covered by the certificate (e.g., api.internal.yourcompany.com): \"\n", + ").strip()\n", "\n", "assert CERT_ARN.startswith(\"arn:aws:acm:\"), \"Invalid certificate ARN\"\n", "assert not DOMAIN.startswith(\"http\"), \"Domain should not include http:// or https://\"\n", "assert \".\" in DOMAIN, \"Domain must contain at least one dot\"\n", + "assert \" \" not in DOMAIN, \"Domain must not contain whitespace\"\n", "\n", "print(f\"Cert ARN: {CERT_ARN}\")\n", "print(f\"Domain: {DOMAIN}\")" @@ -128,14 +129,14 @@ "id": "step2-md", "metadata": {}, "source": [ - "## Step 2: Deploy MCP server on EKS\n", + "## Step 2: Deploy MCP servers on EKS\n", "\n", "This CDK stack deploys:\n", - "- **Shared EKS cluster** with an [NGINX Ingress Controller](https://kubernetes.github.io/ingress-nginx/) behind a single internal NLB (TLS termination on port 443 using your ACM public certificate)\n", "- **Two MCP servers** running FastMCP as Kubernetes Deployments, each with a ClusterIP Service\n", "- **Ingress resource** with path-based routing: `/mcp-server/*` → port 8000, `/stock-mcp/*` → port 8001\n", + "- **Route 53 private hosted zone** named `` associated with the VPC (empty — the notebook adds an Alias record to the NGINX NLB once it's provisioned)\n", "\n", - "A single NLB serves both MCP servers through NGINX path-based routing, instead of one NLB per server." + "The Shared EKS Cluster (with NGINX Ingress Controller behind a single internal NLB and your ACM certificate) is deployed separately. A single NLB serves both MCP servers through NGINX path-based routing." ] }, { @@ -162,10 +163,11 @@ "metadata": {}, "outputs": [], "source": [ - "# Deploy MCP servers on EKS (ClusterIP services + Ingress resource)\n", + "# Deploy MCP servers on EKS (ClusterIP services + Ingress resource + private hosted zone)\n", "# publicCertArn is needed so CDK synthesizes the McpEks stack (gated by the cert check)\n", "!ACCOUNT_A_ID={ACCOUNT_A_ID} cdk deploy McpEks \\\n", - " -c publicCertArn={CERT_ARN} \\\n", + " -c \"publicCertArn={CERT_ARN}\" \\\n", + " -c \"privateDomain={DOMAIN}\" \\\n", " --profile {ACCOUNT_A_PROFILE} \\\n", " --require-approval never \\\n", " --outputs-file eks-mcp-outputs.json" @@ -178,17 +180,48 @@ "metadata": {}, "outputs": [], "source": [ - "print(\"Waiting for NGINX Ingress NLB to be provisioned...\")\n", + "# Read CDK outputs (private hosted zone is created at deploy time; NLB DNS is filled in below)\n", + "with open(\"eks-mcp-outputs.json\") as f:\n", + " eks_mcp_outputs = json.load(f)[\"McpEks\"]\n", + "\n", + "PRIVATE_ZONE_ID = eks_mcp_outputs[\"PrivateZoneId\"]\n", + "PRIVATE_DOMAIN = eks_mcp_outputs[\"PrivateDomain\"]\n", + "print(f\"Private hosted zone: {PRIVATE_DOMAIN} (zone ID: {PRIVATE_ZONE_ID})\")\n", + "\n", + "# Discover the NGINX Ingress NLB\n", + "print(\"\\nWaiting for NGINX Ingress NLB to be provisioned...\")\n", "\n", "elbv2_client = session.client(\"elbv2\")\n", "ec2_client = session.client(\"ec2\")\n", + "route53_client = session.client(\"route53\")\n", + "\n", + "\n", + "def _nlb_has_healthy_target(nlb_arn):\n", + " \"\"\"Return True if any target group on this NLB has at least one healthy target.\n", + "\n", + " Why this matters: if the NGINX Ingress Service has been redeployed, the\n", + " AWS Load Balancer Controller can leave behind an orphaned NLB pointing at\n", + " a stale pod IP. Both NLBs match the name filter, but only one routes to a\n", + " live pod. Picking the first match by chance can silently route to the dead\n", + " one and you'll see \"Connection closed by peer\" when invoking the gateway\n", + " target.\n", + " \"\"\"\n", + " tgs = elbv2_client.describe_target_groups(LoadBalancerArn=nlb_arn)[\"TargetGroups\"]\n", + " for tg in tgs:\n", + " health = elbv2_client.describe_target_health(\n", + " TargetGroupArn=tg[\"TargetGroupArn\"]\n", + " )[\"TargetHealthDescriptions\"]\n", + " if any(t[\"TargetHealth\"][\"State\"] == \"healthy\" for t in health):\n", + " return True\n", + " return False\n", + "\n", "\n", "NLB_DNS = None\n", + "NLB_HOSTED_ZONE_ID = None\n", "NLB_SG_ID = None\n", "for attempt in range(20):\n", " nlbs = elbv2_client.describe_load_balancers()[\"LoadBalancers\"]\n", - " # Look for NLB created by the NGINX Ingress Controller\n", - " # AWS LB Controller names NLBs like \"k8s-ingressn-ingressn-\" (truncated from ingress-nginx)\n", + " # AWS LB Controller names NGINX NLBs like \"k8s-ingressn-...\" (truncated from ingress-nginx)\n", " nginx_nlbs = [\n", " n\n", " for n in nlbs\n", @@ -197,10 +230,21 @@ " and n[\"Type\"] == \"network\"\n", " and \"k8s-ingressn\" in n.get(\"LoadBalancerName\", \"\").lower()\n", " ]\n", - " if nginx_nlbs:\n", - " nlb = nginx_nlbs[0]\n", + " # Prefer NLBs with at least one healthy target — skips orphaned NLBs\n", + " healthy = [n for n in nginx_nlbs if _nlb_has_healthy_target(n[\"LoadBalancerArn\"])]\n", + " chosen = healthy[0] if healthy else (nginx_nlbs[0] if nginx_nlbs else None)\n", + " if chosen and (healthy or attempt >= 5):\n", + " # Either a healthy NLB exists, or we've waited long enough — accept the only candidate\n", + " nlb = chosen\n", " NLB_DNS = nlb[\"DNSName\"]\n", + " NLB_HOSTED_ZONE_ID = nlb[\"CanonicalHostedZoneId\"]\n", " NLB_SG_ID = nlb[\"SecurityGroups\"][0] if nlb.get(\"SecurityGroups\") else None\n", + " if len(nginx_nlbs) > 1:\n", + " stale = [n[\"LoadBalancerName\"] for n in nginx_nlbs if n is not nlb]\n", + " print(\n", + " f\"WARNING: Multiple matching NLBs found; chose {nlb['LoadBalancerName']}. \"\n", + " f\"Stale (orphaned by AWS LB Controller): {stale}\"\n", + " )\n", " break\n", " print(f\" Waiting... (attempt {attempt + 1}/20)\")\n", " time.sleep(15)\n", @@ -209,7 +253,10 @@ " NLB_DNS\n", "), \"NGINX Ingress NLB not found. Check if the NGINX Ingress Controller is running.\"\n", "\n", - "# Ensure the NLB SG allows inbound on port 443 from VPC CIDR\n", + "print(f\"\\nNLB DNS: {NLB_DNS}\")\n", + "print(f\"NLB SG: {NLB_SG_ID}\")\n", + "\n", + "# Open NLB SG for inbound 443 from the VPC CIDR\n", "if NLB_SG_ID:\n", " try:\n", " ec2_client.authorize_security_group_ingress(\n", @@ -232,8 +279,28 @@ " else:\n", " raise\n", "\n", - "print(f\"\\nNLB DNS (routingDomain): {NLB_DNS}\")\n", - "print(f\"NLB SG: {NLB_SG_ID}\")" + "# UPSERT an Alias A record in the private hosted zone pointing at the NLB\n", + "route53_client.change_resource_record_sets(\n", + " HostedZoneId=PRIVATE_ZONE_ID,\n", + " ChangeBatch={\n", + " \"Changes\": [\n", + " {\n", + " \"Action\": \"UPSERT\",\n", + " \"ResourceRecordSet\": {\n", + " \"Name\": PRIVATE_DOMAIN,\n", + " \"Type\": \"A\",\n", + " \"AliasTarget\": {\n", + " \"HostedZoneId\": NLB_HOSTED_ZONE_ID,\n", + " \"DNSName\": NLB_DNS,\n", + " \"EvaluateTargetHealth\": False,\n", + " },\n", + " },\n", + " }\n", + " ]\n", + " },\n", + ")\n", + "print(f\"\\nUPSERT-ed Alias record: {PRIVATE_DOMAIN} -> {NLB_DNS}\")\n", + "print(\"Inside the VPC, the private domain now resolves to the NLB's private IPs.\")" ] }, { @@ -243,12 +310,12 @@ "source": [ "## Step 3: Create AgentCore Gateway target\n", "\n", - "Create a gateway target using [managed VPC Lattice](../01-managed-lattice/).\n", + "Create a gateway target using [managed VPC resource](../01-managed-vpc-resource/).\n", "\n", - "- **Target URL** (`https://{DOMAIN}/mcp-server/mcp`) — the NGINX Ingress rewrites `/mcp-server/mcp` to `/mcp` before forwarding to the pod\n", - "- **`routingDomain`** (`{NLB_DNS}`) — the NGINX Ingress NLB's publicly resolvable DNS name\n", + "- **Target URL** (`https://{DOMAIN}/mcp-server/mcp`) — resolves to the NLB's private IPs via the private hosted zone; the NGINX Ingress rewrites `/mcp-server/mcp` to `/mcp` before forwarding to the pod\n", + "- **`managedVpcResource`** — VPC, subnets, and the NLB's security group so the Resource Gateway ENIs can reach the NLB on port 443\n", "\n", - "Both MCP servers share the same NLB via path-based routing. VPC Lattice routes traffic to the NLB using its DNS while setting the TLS SNI to the target domain, so the TLS handshake succeeds against the NLB's certificate. You do **not** need a public DNS record for the target domain.\n", + "Both MCP servers share the same NLB via path-based routing. AgentCore Gateway's Resource Gateway resolves `{DOMAIN}` via Private DNS, then NGINX dispatches based on path. No `routingDomain` needed.\n", "\n", "> **Security group:** We pass the NLB's security group to `securityGroupIds` so the Resource Gateway ENIs can reach the NLB on port 443." ] @@ -262,31 +329,31 @@ "source": [ "TARGET_ENDPOINT = f\"https://{DOMAIN}/mcp-server/mcp\"\n", "\n", - "print(f\"Target endpoint: {TARGET_ENDPOINT}\")\n", - "print(f\"Routing domain: {NLB_DNS}\")\n", + "print(f\"Target endpoint: {TARGET_ENDPOINT} (resolves via Private DNS inside the VPC)\")\n", "\n", - "managed_lattice_config = {\n", + "managed_vpc_resource_config = {\n", " \"vpcIdentifier\": VPC_USW2_ID,\n", " \"subnetIds\": VPC_USW2_PRIVATE_SUBNETS,\n", " \"endpointIpAddressType\": \"IPV4\",\n", - " \"routingDomain\": NLB_DNS,\n", "}\n", "if NLB_SG_ID:\n", - " managed_lattice_config[\"securityGroupIds\"] = [NLB_SG_ID]\n", + " managed_vpc_resource_config[\"securityGroupIds\"] = [NLB_SG_ID]\n", "\n", "response = agentcore.create_gateway_target(\n", " gatewayIdentifier=GATEWAY_ID,\n", " name=\"eks-mcp-server\",\n", - " description=\"MCP server on EKS via NGINX Ingress and managed VPC egress\",\n", + " description=\"MCP server on EKS via NGINX Ingress and managed VPC egress (Private DNS)\",\n", " targetConfiguration={\n", " \"mcp\": {\n", " \"mcpServer\": {\n", " \"endpoint\": TARGET_ENDPOINT,\n", + " # Lower wins on resource URI collisions across targets — see Step 8\n", + " \"resourcePriority\": 10,\n", " }\n", " }\n", " },\n", " privateEndpoint={\n", - " \"managedLatticeResource\": managed_lattice_config,\n", + " \"managedVpcResource\": managed_vpc_resource_config,\n", " },\n", ")\n", "\n", @@ -359,6 +426,20 @@ " \"Content-Type\": \"application/json\",\n", "}\n", "\n", + "\n", + "def _print_req_ids(resp):\n", + " \"\"\"Print AgentCore Gateway request/trace IDs for correlating with service logs.\"\"\"\n", + " req_id = resp.headers.get(\"x-amzn-RequestId\") or resp.headers.get(\n", + " \"x-amz-request-id\"\n", + " )\n", + " trace_id = resp.headers.get(\"X-Amzn-Trace-Id\") or resp.headers.get(\n", + " \"x-amzn-trace-id\"\n", + " )\n", + " print(f\" x-amzn-RequestId: {req_id}\")\n", + " if trace_id:\n", + " print(f\" X-Amzn-Trace-Id: {trace_id}\")\n", + "\n", + "\n", "# List available tools\n", "response = requests.post(\n", " GATEWAY_URL,\n", @@ -366,6 +447,7 @@ " json={\"jsonrpc\": \"2.0\", \"method\": \"tools/list\", \"id\": 1},\n", ")\n", "print(\"Available tools:\")\n", + "_print_req_ids(response)\n", "print(json.dumps(response.json(), indent=2))" ] }, @@ -391,6 +473,7 @@ " },\n", ")\n", "print(\"Result of add(5, 3):\")\n", + "_print_req_ids(response)\n", "print(json.dumps(response.json(), indent=2))" ] }, @@ -401,7 +484,9 @@ "source": [ "## Step 5 (Optional): Connect the Stock Price MCP Server\n", "\n", - "The CDK stack deployed a second MCP server — a **stock price mock** — routed through the same NGINX Ingress NLB at a different path (`/stock-mcp/mcp`). This demonstrates connecting **multiple MCP servers** through a single load balancer using path-based routing." + "The CDK stack deployed a second MCP server — a **stock price mock** — routed through the same NGINX Ingress NLB at a different path (`/stock-mcp/mcp`). This demonstrates connecting **multiple MCP servers** through a single load balancer using path-based routing.\n", + "\n", + "The second target reuses the same Resource Gateway in the VPC (since the VPC/subnet/SG config matches)." ] }, { @@ -414,31 +499,31 @@ "STOCK_TARGET_ENDPOINT = f\"https://{DOMAIN}/stock-mcp/mcp\"\n", "\n", "print(f\"Stock target endpoint: {STOCK_TARGET_ENDPOINT}\")\n", - "print(f\"Routing domain: {NLB_DNS} (same NLB)\")\n", "\n", - "# Same NLB and routingDomain as the first MCP server — different path\n", - "stock_lattice_config = {\n", + "# Same VPC/subnets/SG as the first MCP server — different path\n", + "stock_vpc_resource_config = {\n", " \"vpcIdentifier\": VPC_USW2_ID,\n", " \"subnetIds\": VPC_USW2_PRIVATE_SUBNETS,\n", " \"endpointIpAddressType\": \"IPV4\",\n", - " \"routingDomain\": NLB_DNS,\n", "}\n", "if NLB_SG_ID:\n", - " stock_lattice_config[\"securityGroupIds\"] = [NLB_SG_ID]\n", + " stock_vpc_resource_config[\"securityGroupIds\"] = [NLB_SG_ID]\n", "\n", "stock_response = agentcore.create_gateway_target(\n", " gatewayIdentifier=GATEWAY_ID,\n", " name=\"eks-stock-mcp\",\n", - " description=\"Stock price MCP server on EKS via NGINX Ingress (shared NLB)\",\n", + " description=\"Stock price MCP server on EKS via NGINX Ingress (shared NLB, Private DNS)\",\n", " targetConfiguration={\n", " \"mcp\": {\n", " \"mcpServer\": {\n", " \"endpoint\": STOCK_TARGET_ENDPOINT,\n", + " # Higher than mcp-server's 10 — mcp-server wins resource collisions (see Step 8)\n", + " \"resourcePriority\": 100,\n", " }\n", " }\n", " },\n", " privateEndpoint={\n", - " \"managedLatticeResource\": stock_lattice_config,\n", + " \"managedVpcResource\": stock_vpc_resource_config,\n", " },\n", ")\n", "\n", @@ -482,6 +567,8 @@ " headers=headers,\n", " json={\"jsonrpc\": \"2.0\", \"method\": \"tools/list\", \"id\": 1},\n", ")\n", + "print(\"tools/list:\")\n", + "_print_req_ids(response)\n", "tools = response.json().get(\"result\", {}).get(\"tools\", [])\n", "stock_tools = [t for t in tools if t[\"name\"].startswith(\"eks-stock-mcp___\")]\n", "print(f\"Stock MCP tools ({len(stock_tools)}):\")\n", @@ -503,9 +590,246 @@ " },\n", ")\n", "print(\"\\nAAPL stock price:\")\n", + "_print_req_ids(response)\n", "print(json.dumps(response.json(), indent=2))" ] }, + { + "cell_type": "markdown", + "id": "1b564fd1", + "metadata": {}, + "source": [ + "## Step 6: Use prompts through the Gateway\n", + "\n", + "MCP **prompts** are parameterized message templates the server exposes. Gateway forwards two MCP methods:\n", + "\n", + "- `prompts/list` — returns Gateway's cached catalog. Prompt names are returned with the target prefix `{targetName}___{promptName}` (triple underscore, same convention as tools).\n", + "- `prompts/get` — proxied **live** to the downstream MCP server. The `name` argument must include the `targetName___` prefix.\n", + "\n", + "The mcp-server exposes one prompt: `order_summary_prompt(orderId)`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1ea4d356", + "metadata": {}, + "outputs": [], + "source": [ + "# prompts/list\n", + "response = requests.post(\n", + " GATEWAY_URL,\n", + " headers=headers,\n", + " json={\"jsonrpc\": \"2.0\", \"method\": \"prompts/list\", \"id\": 10},\n", + ")\n", + "print(\"Available prompts:\")\n", + "_print_req_ids(response)\n", + "print(json.dumps(response.json(), indent=2))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eb8afc63", + "metadata": {}, + "outputs": [], + "source": [ + "# prompts/get — proxied live to the MCP server. Prefix the prompt name with the target name.\n", + "response = requests.post(\n", + " GATEWAY_URL,\n", + " headers=headers,\n", + " json={\n", + " \"jsonrpc\": \"2.0\",\n", + " \"method\": \"prompts/get\",\n", + " \"params\": {\n", + " \"name\": \"eks-mcp-server___order_summary_prompt\",\n", + " \"arguments\": {\"orderId\": \"123\"},\n", + " },\n", + " \"id\": 11,\n", + " },\n", + ")\n", + "print(\"Rendered prompt:\")\n", + "_print_req_ids(response)\n", + "print(json.dumps(response.json(), indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "0072a3a3", + "metadata": {}, + "source": [ + "## Step 7: Use resources through the Gateway\n", + "\n", + "MCP **resources** are addressable content the server exposes via URIs. Templated resources use [RFC 6570 URI templates](https://datatracker.ietf.org/doc/html/rfc6570) so a single handler serves many concrete URIs. Gateway forwards three methods:\n", + "\n", + "- `resources/list` and `resources/templates/list` — served from Gateway's catalog. Resource URIs are returned **as-is** — there is no `target___` prefix.\n", + "- `resources/read` — proxied **live** to the downstream MCP server.\n", + "\n", + "The mcp-server exposes:\n", + "- `orders://catalog` — static\n", + "- `orders://{orderId}/details` — templated\n", + "- `shared://collision-demo` — also exposed by stock-mcp (Step 8 demos `resourcePriority`)\n", + "\n", + "> **Security warning:** Resource URIs are provided by the downstream MCP server target and are not validated by the gateway. A malicious or compromised MCP server could return URIs pointing to internal endpoints (SSRF) or local filesystem paths (e.g. `file:///etc/passwd`). Validate and sanitize resource URIs before fetching/rendering them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6389ae7c", + "metadata": {}, + "outputs": [], + "source": [ + "# resources/list — merged catalog from all targets, URIs returned as-is\n", + "response = requests.post(\n", + " GATEWAY_URL,\n", + " headers=headers,\n", + " json={\"jsonrpc\": \"2.0\", \"method\": \"resources/list\", \"id\": 20},\n", + ")\n", + "print(\"Available resources:\")\n", + "_print_req_ids(response)\n", + "print(json.dumps(response.json(), indent=2))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dfead019", + "metadata": {}, + "outputs": [], + "source": [ + "# resources/read — proxied live to the MCP server (static URI)\n", + "response = requests.post(\n", + " GATEWAY_URL,\n", + " headers=headers,\n", + " json={\n", + " \"jsonrpc\": \"2.0\",\n", + " \"method\": \"resources/read\",\n", + " \"params\": {\"uri\": \"orders://catalog\"},\n", + " \"id\": 21,\n", + " },\n", + ")\n", + "print(\"orders://catalog →\")\n", + "_print_req_ids(response)\n", + "print(json.dumps(response.json(), indent=2))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93ad5beb", + "metadata": {}, + "outputs": [], + "source": [ + "# resources/templates/list — RFC 6570 URI templates the server registered\n", + "response = requests.post(\n", + " GATEWAY_URL,\n", + " headers=headers,\n", + " json={\"jsonrpc\": \"2.0\", \"method\": \"resources/templates/list\", \"id\": 22},\n", + ")\n", + "print(\"Resource templates:\")\n", + "_print_req_ids(response)\n", + "print(json.dumps(response.json(), indent=2))\n", + "\n", + "# resources/read against a concrete URI derived from the template\n", + "response = requests.post(\n", + " GATEWAY_URL,\n", + " headers=headers,\n", + " json={\n", + " \"jsonrpc\": \"2.0\",\n", + " \"method\": \"resources/read\",\n", + " \"params\": {\"uri\": \"orders://123/details\"},\n", + " \"id\": 23,\n", + " },\n", + ")\n", + "print(\"\\norders://123/details →\")\n", + "_print_req_ids(response)\n", + "print(json.dumps(response.json(), indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "70c3ea7f", + "metadata": {}, + "source": [ + "## Step 8: Multi-target conflict resolution with `resourcePriority`\n", + "\n", + "Tools and prompts are auto-namespaced with `{targetName}___{name}`, so collisions across targets are impossible. **Resources are different** — Gateway returns resource URIs raw. When two targets expose the same URI, Gateway resolves the read by routing to the target with the **lowest `resourcePriority`** (range `0–1000`; default `1000`; lower wins).\n", + "\n", + "In this lab both MCP servers expose `shared://collision-demo` with different content:\n", + "\n", + "| Target | `resourcePriority` | Returned content |\n", + "|---|---|---|\n", + "| `eks-mcp-server` | **10** (lower → wins) | `served by mcp-server (resourcePriority=10 wins over stock-mcp=100)` |\n", + "| `eks-stock-mcp` | 100 | `served by stock-mcp (resourcePriority=100 — should be shadowed by mcp-server=10)` |\n", + "\n", + "Calling `resources/list` will show `shared://collision-demo` **twice** (once per target). `resources/read` resolves to the lower-priority target — mcp-server." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3030cda5", + "metadata": {}, + "outputs": [], + "source": [ + "# resources/list — shared://collision-demo appears twice (once per target)\n", + "response = requests.post(\n", + " GATEWAY_URL,\n", + " headers=headers,\n", + " json={\"jsonrpc\": \"2.0\", \"method\": \"resources/list\", \"id\": 30},\n", + ")\n", + "print(\"resources/list:\")\n", + "_print_req_ids(response)\n", + "collision_entries = [\n", + " r\n", + " for r in response.json().get(\"result\", {}).get(\"resources\", [])\n", + " if r.get(\"uri\") == \"shared://collision-demo\"\n", + "]\n", + "print(f\"shared://collision-demo entries in resources/list: {len(collision_entries)}\")\n", + "print(json.dumps(collision_entries, indent=2))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4492f099", + "metadata": {}, + "outputs": [], + "source": [ + "# resources/read — Gateway routes to the lower-priority target (mcp-server, priority=10)\n", + "response = requests.post(\n", + " GATEWAY_URL,\n", + " headers=headers,\n", + " json={\n", + " \"jsonrpc\": \"2.0\",\n", + " \"method\": \"resources/read\",\n", + " \"params\": {\"uri\": \"shared://collision-demo\"},\n", + " \"id\": 31,\n", + " },\n", + ")\n", + "print(\"shared://collision-demo →\")\n", + "_print_req_ids(response)\n", + "print(json.dumps(response.json(), indent=2))\n", + "print()\n", + "print(\"Expected: 'served by mcp-server (resourcePriority=10 wins over stock-mcp=100)'\")" + ] + }, + { + "cell_type": "markdown", + "id": "6b1c83f0", + "metadata": {}, + "source": [ + "### Naming and conflict-resolution recap\n", + "\n", + "| Capability | Naming across targets | Conflict resolution |\n", + "|---|---|---|\n", + "| Tools | `targetName___toolName` (triple underscore) | impossible — names are namespaced |\n", + "| Prompts | `targetName___promptName` (triple underscore) | impossible — names are namespaced |\n", + "| Resources | URI returned as-is, no prefix | `resourcePriority` on target (lower wins; default 1000) |\n", + "| Resource templates | URI template returned as-is, no prefix | follows `resourcePriority` |" + ] + }, { "cell_type": "markdown", "id": "cleanup-md", @@ -551,7 +875,30 @@ "# print(\" Stock target deleted.\")\n", "# break\n", "# except NameError:\n", - "# pass # Stock target was not created (Step 5 skipped)" + "# pass # Stock target was not created (Step 5 skipped)\n", + "\n", + "# # Delete the Alias record from the private hosted zone\n", + "# # (CDK can't delete a hosted zone that still contains records)\n", + "# route53_client.change_resource_record_sets(\n", + "# HostedZoneId=PRIVATE_ZONE_ID,\n", + "# ChangeBatch={\n", + "# \"Changes\": [\n", + "# {\n", + "# \"Action\": \"DELETE\",\n", + "# \"ResourceRecordSet\": {\n", + "# \"Name\": PRIVATE_DOMAIN,\n", + "# \"Type\": \"A\",\n", + "# \"AliasTarget\": {\n", + "# \"HostedZoneId\": NLB_HOSTED_ZONE_ID,\n", + "# \"DNSName\": NLB_DNS,\n", + "# \"EvaluateTargetHealth\": False,\n", + "# },\n", + "# },\n", + "# }\n", + "# ]\n", + "# },\n", + "# )\n", + "# print(f\"Deleted Alias record: {PRIVATE_DOMAIN} -> {NLB_DNS}\")" ] }, { @@ -563,7 +910,8 @@ "source": [ "# # Step 2: Destroy CDK stacks\n", "# !ACCOUNT_A_ID={ACCOUNT_A_ID} cdk destroy McpEks \\\n", - "# -c publicCertArn={CERT_ARN} \\\n", + "# -c \"publicCertArn={CERT_ARN}\" \\\n", + "# -c \"privateDomain={DOMAIN}\" \\\n", "# --profile {ACCOUNT_A_PROFILE} --force" ] }, diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/README.md b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/README.md index d3909024..55624d40 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/README.md +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/README.md @@ -3,35 +3,33 @@ # Configure Amazon Bedrock AgentCore Gateway VPC Egress for Gateway Targets using VPC Lattice -> This feature is made available to you as a "Beta Service" as defined in the [AWS Service Terms](https://aws.amazon.com/service-terms/). It is subject to your Agreement with AWS and the AWS Service Terms. - Learn about connecting [private resources in your VPC using Amazon VPC Lattice](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/vpc-egress-private-endpoints.html) and [configuring Amazon Bedrock AgentCore Gateway VPC Egress for Gateway Targets](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-vpc-egress.html). - Amazon Bedrock AgentCore supports private connectivity to resources hosted inside your AWS VPC or on-premises environments connected to your VPC, such as private MCP servers, and internal REST APIs, without exposing those services to the public internet. ![arch](./images/architecture.png) Private connectivity is established using [Amazon VPC Lattice](https://docs.aws.amazon.com/vpc-lattice/latest/ug/what-is-vpc-lattice.html) resource gateways and resource configurations. Two modes are supported for configuring this connectivity: -- Managed Lattice - Amazon Bedrock AgentCore creates and manages the VPC Lattice resource gateway and resource configuration in your account on your behalf, using a service-linked role. +- Managed VPC Resource - In this mode, AgentCore Gateway handles everything on your behalf. You provide your VPC ID, subnet IDs, and security groups as part of your target configuration, and AgentCore automatically creates and manages the VPC Resource in your account. This mode integrates with existing network architectures, whether you use VPC peering for same-region or cross-region connectivity or a hub-and-spoke model with AWS Transit Gateways for multi-VPC and hybrid environments. -- Self-managed Lattice - You create the VPC Lattice resource gateway and resource configuration yourself, then provide the resource configuration identifier to AgentCore. Use this option for cross-account connectivity, if you already have VPC Lattice resources set up, or if you need fine-grained control over the Lattice configuration. +![managed](./images/managed.png) +- Self-managed Lattice - In this mode, you create and manage the VPC Lattice Resource Gateway before referencing it during target creation on AgentCore Gateway. This gives you full visibility and control over the Resource Gateway configuration, including the number of IPv4 addresses per ENI, subnet placement, and security group rules. More importantly, it gives you direct visibility into the resource configuration itself — with the ability to view it, share it using AWS RAM, see all associations tied to it, and retain the ability to revoke those associations at any time. Through this mode you can avoid using VPC Peering or AWS Transit Gateways and enable networking access directly to the resource VPC. + +![selfmanaged](./images/self-managed.png) ## Key Terms -- Resource VPC: The [Amazon VPC](https://docs.aws.amazon.com/vpc/latest/userguide/what-is-amazon-vpc.html) where your private resource lives, for example, the VPC containing your privately hosted MCP server or API endpoint. This is the VPC that AgentCore Gateway needs to reach. +- Resource VPC: The [Amazon VPC](https://docs.aws.amazon.com/vpc/latest/userguide/what-is-amazon-vpc.html) where your private resource lives, for example, the VPC containing your privately hosted MCP server or API endpoint. This is the VPC that AgentCore Gateway needs to reach. - Gateway account: The AWS account that owns Amazon Bedrock AgentCore Gateway. Resource VPC can either be in the same AWS account as Gateway account or be in a different account. -- Resource Gateway: [ Resource gateway in VPC Lattice](https://docs.aws.amazon.com/vpc/latest/privatelink/resource-gateway.html) acts as the private entry point into your Resource VPC. When created, it provisions one Elastic Network Interface (ENI) per subnet you specify, each sitting inside your VPC. All traffic from AgentCore Gateway to your private resource arrives through these ENIs. +- Resource Gateway: [Resource gateway in VPC Lattice](https://docs.aws.amazon.com/vpc/latest/privatelink/resource-gateway.html) acts as the private entry point into your Resource VPC. When created, it provisions one Elastic Network Interface (ENI) per subnet you specify, each sitting inside your VPC. All traffic from AgentCore Gateway to your private resource arrives through these ENIs. -- Resource Configuration: [Resource configuration for VPC resources](https://docs.aws.amazon.com/vpc/latest/privatelink/resource-configuration.html) defines the specific resource AgentCore Gateway is allowed to reach through the Resource Gateway, identified by a domain name, IP address, or AWS ARN. Rather than granting access to your entire VPC, a Resource Configuration scopes connectivity to a single endpoint. +- Resource Configuration: [Resource configuration for VPC resources](https://docs.aws.amazon.com/vpc/latest/privatelink/resource-configuration.html) defines the specific resource AgentCore Gateway is allowed to reach through the Resource Gateway, identified by a domain name, IP address, or AWS ARN. Rather than granting access to your entire VPC, a Resource Configuration scopes connectivity to a single endpoint. -- Service Network Resource Association: A [service network resource association](https://docs.aws.amazon.com/vpc-lattice/latest/ug/service-network-associations.html) connects a resource configuration to the AgentCore service network, enabling the AgentCore service to invoke your private endpoint. AgentCore always creates and manages this association on your behalf, regardless of whether you use managed or self-managed Lattice. - -- Routing domain: An optional field that specifies an intermediate publicly resolvable domain that AgentCore uses as the resource configuration domain instead of the actual target domain. This is required when your private endpoint uses a domain that is not publicly resolvable, because Amazon VPC Lattice requires publicly resolvable DNS for resource configurations. The AgentCore service continues to invoke the actual target domain using SNI override. +- Service Network Resource Association: A service network resource association connects a resource configuration to the AgentCore service network, enabling the AgentCore Gateway service to invoke your private endpoint. AgentCore always creates and manages this association on your behalf, regardless of which mode you use. ## Labs @@ -52,17 +50,17 @@ Make sure to run the **Cleanup** section in each notebook after completing the l | Lab | Folder | Description | |-----|--------|-------------| | **Prerequisites** | [`00-prerequisites/`](./00-prerequisites/) | Deploy VPCs across accounts and regions, bootstrap CDK, and set up the shared AgentCore Gateway with Cognito M2M authentication. All subsequent labs depend on this. | -| **Managed Lattice** | [`01-managed-lattice/`](./01-managed-lattice/) | Getting started with AgentCore-managed VPC Lattice. AgentCore automatically creates the Resource Gateway, Resource Configuration, and service network association. Includes a VPC peering example. | +| **Managed VPC Resource** | [`01-managed-vpc-resource/`](./01-managed-vpc-resource/) | Getting started with AgentCore Gateway managed VPC resource. AgentCore automatically creates the Resource Gateway, Resource Configuration, and service network association. Includes a VPC peering example. | | **Self-Managed Lattice** | [`02-self-managed-lattice/`](./02-self-managed-lattice/) | Create and manage VPC Lattice Resource Gateways and Resource Configurations yourself. Includes cross-account connectivity via AWS Resource Access Manager (RAM). | | **Advanced Concepts** | [`03-advanced-concepts/`](./03-advanced-concepts/) | Explores private domains (Route 53 private hosted zones), private certificates (AWS Private CA and self-signed), and static IP egress (NAT Gateway with Elastic IP for allowlisting) with AgentCore Gateway VPC egress. | -| **ECS Deployment** | [`04-ecs-deployment/`](./04-ecs-deployment/) | Deploy MCP servers on Amazon ECS Fargate behind an internal ALB with TLS termination, then connect to AgentCore Gateway using managed VPC Lattice. | -| **EKS Deployment** | [`05-eks-deployment/`](./05-eks-deployment/) | Deploy MCP servers and REST APIs on Amazon EKS behind an internal NLB with TLS termination, using private hosted zones and `routingDomain` for private DNS patterns. | +| **ECS Deployment** | [`04-ecs-deployment/`](./04-ecs-deployment/) | Deploy MCP servers on Amazon ECS Fargate behind an internal ALB with TLS termination, then connect to AgentCore Gateway using managed VPC resource. | +| **EKS Deployment** | [`05-eks-deployment/`](./05-eks-deployment/) | Deploy MCP servers and REST APIs on Amazon EKS behind an internal NLB with TLS termination, using private hosted zones. | ### Regions and accounts These labs are tested in **us-west-2** (primary region). Ensure AgentCore Gateway and its features are available in your region: see [AgentCore supported regions](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-regions.html). The following labs require additional setup: -- [VPC Peering lab](./01-managed-lattice/02-peering.ipynb): requires a VPC in **us-east-1** (deployed in Lab 0, Step 5) +- [VPC Peering lab](./01-managed-vpc-resource/02-peering.ipynb): requires a VPC in **us-east-1** (deployed in Lab 0, Step 5) - [Cross-Account lab](./02-self-managed-lattice/02-cross-account.ipynb): requires a **second AWS account** with a VPC in us-west-2 (deployed in Lab 0, Step 7) ### Prerequisites for all labs @@ -81,7 +79,7 @@ Labs that deploy private resources behind load balancers (ECS, EKS) require: - A **domain name** you own with a Route 53 hosted zone (can be in any AWS account) - An **ACM public certificate** for that domain -See the [Prerequisites](./00-prerequisites/) folder for domain and certificate setup guides covering public domains, private domains, and `routingDomain` patterns. +See the [Prerequisites](./00-prerequisites/) folder for domain and certificate setup guides covering public domains, and private domains. ## License diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/bin/vpcegress.ts b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/bin/vpcegress.ts index b85622cf..54528eec 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/bin/vpcegress.ts +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/bin/vpcegress.ts @@ -24,186 +24,191 @@ cdk.Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true })); const accountA = process.env.ACCOUNT_A_ID || app.node.tryGetContext("accountA"); const accountB = process.env.ACCOUNT_B_ID || app.node.tryGetContext("accountB"); const baseDomain = - app.node.tryGetContext("baseDomain") || "egress-test.example.com"; + app.node.tryGetContext("baseDomain") || "egress-test.example.com"; +const privateDomain = + app.node.tryGetContext("privateDomain") || `internal.${baseDomain}`; const publicCertArn = app.node.tryGetContext("publicCertArn") || ""; const hostedZoneId = app.node.tryGetContext("hostedZoneId") || ""; if (!accountA) { - throw new Error( - "Account A ID is required. Set ACCOUNT_A_ID env var or pass -c accountA=\n" + - "Example: ACCOUNT_A_ID=123456789012 cdk deploy ...\n" + - "Or: cdk deploy -c accountA=123456789012 ...", - ); + throw new Error( + "Account A ID is required. Set ACCOUNT_A_ID env var or pass -c accountA=\n" + + "Example: ACCOUNT_A_ID=123456789012 cdk deploy ...\n" + + "Or: cdk deploy -c accountA=123456789012 ...", + ); } const envA = { account: accountA, region: "us-west-2" }; // Existing VPC stacks const vpcUsWest2 = new VpcegressStack(app, "VpcegressStack-USWest2", { - env: envA, - vpcCidr: "10.0.0.0/16", + env: envA, + vpcCidr: "10.0.0.0/16", }); const vpcUsEast1 = new VpcegressStack(app, "VpcegressStack-USEast1", { - env: { account: accountA, region: "us-east-1" }, - vpcCidr: "10.1.0.0/16", - crossRegionReferences: true, + env: { account: accountA, region: "us-east-1" }, + vpcCidr: "10.1.0.0/16", + crossRegionReferences: true, }); // Peering lab: Private API Gateway in us-east-1 + VPC peering new PrivateApigwStack(app, "PeeringApigw-USEast1", { - env: { account: accountA, region: "us-east-1" }, - vpc: vpcUsEast1.vpc, - peerVpcCidr: "10.0.0.0/16", - privateDnsEnabled: false, + env: { account: accountA, region: "us-east-1" }, + vpc: vpcUsEast1.vpc, + peerVpcCidr: "10.0.0.0/16", + privateDnsEnabled: false, }); new VpcPeeringStack(app, "VpcPeeringStack", { - env: envA, - crossRegionReferences: true, - vpc: vpcUsWest2.vpc, - peerVpcId: vpcUsEast1.vpc.vpcId, - peerRegion: "us-east-1", - peerVpcCidr: "10.1.0.0/16", - localVpcCidr: "10.0.0.0/16", - peerPrivateRouteTableIds: vpcUsEast1.vpc.privateSubnets.map( - (s) => s.routeTable.routeTableId, - ), + env: envA, + crossRegionReferences: true, + vpc: vpcUsWest2.vpc, + peerVpcId: vpcUsEast1.vpc.vpcId, + peerRegion: "us-east-1", + peerVpcCidr: "10.1.0.0/16", + localVpcCidr: "10.0.0.0/16", + peerPrivateRouteTableIds: vpcUsEast1.vpc.privateSubnets.map( + (s) => s.routeTable.routeTableId, + ), }); if (accountB) { - const vpcAccountB = new VpcegressStack( - app, - "VpcegressStack-USWest2-AccountB", - { - env: { account: accountB, region: "us-west-2" }, - vpcCidr: "10.2.0.0/16", - }, - ); + const vpcAccountB = new VpcegressStack( + app, + "VpcegressStack-USWest2-AccountB", + { + env: { account: accountB, region: "us-west-2" }, + vpcCidr: "10.2.0.0/16", + }, + ); - // Cross-account lab: Private API Gateway in Account B - new PrivateApigwStack(app, "CrossAccountApigw-AccountB", { - env: { account: accountB, region: "us-west-2" }, - vpc: vpcAccountB.vpc, - }); + // Cross-account lab: Private API Gateway in Account B + new PrivateApigwStack(app, "CrossAccountApigw-AccountB", { + env: { account: accountB, region: "us-west-2" }, + vpc: vpcAccountB.vpc, + }); } // MCP Server on ECS (requires publicCertArn) if (publicCertArn) { - new McpEcsStack(app, "McpEcs", { - env: envA, - vpc: vpcUsWest2.vpc, - certificateArn: publicCertArn, - }); + new McpEcsStack(app, "McpEcs", { + env: envA, + vpc: vpcUsWest2.vpc, + certificateArn: publicCertArn, + privateDomain, + }); } // Shared EKS Cluster const eksCluster = new EksClusterStack(app, "SharedEksCluster", { - env: envA, - vpc: vpcUsWest2.vpc, + env: envA, + vpc: vpcUsWest2.vpc, }); // MCP Server on EKS (requires NGINX Ingress + publicCertArn for NLB TLS) if (publicCertArn) { - new McpEksStack(app, "McpEks", { - env: envA, - clusterName: eksCluster.cluster.clusterName, - kubectlRoleArn: eksCluster.cluster.kubectlRole!.roleArn, - kubectlSecurityGroupId: - eksCluster.cluster.kubectlSecurityGroup!.securityGroupId, - kubectlPrivateSubnetIds: eksCluster.cluster.kubectlPrivateSubnets!.map( - (s) => s.subnetId, - ), - vpc: vpcUsWest2.vpc, - certificateArn: publicCertArn, - }); + new McpEksStack(app, "McpEks", { + env: envA, + clusterName: eksCluster.cluster.clusterName, + kubectlRoleArn: eksCluster.cluster.kubectlRole!.roleArn, + kubectlSecurityGroupId: + eksCluster.cluster.kubectlSecurityGroup!.securityGroupId, + kubectlPrivateSubnetIds: eksCluster.cluster.kubectlPrivateSubnets!.map( + (s) => s.subnetId, + ), + vpc: vpcUsWest2.vpc, + certificateArn: publicCertArn, + privateDomain, + }); - // REST API on EKS - new ApiEksStack(app, "ApiEks", { - env: envA, - clusterName: eksCluster.cluster.clusterName, - kubectlRoleArn: eksCluster.cluster.kubectlRole!.roleArn, - kubectlSecurityGroupId: - eksCluster.cluster.kubectlSecurityGroup!.securityGroupId, - kubectlPrivateSubnetIds: eksCluster.cluster.kubectlPrivateSubnets!.map( - (s) => s.subnetId, - ), - vpc: vpcUsWest2.vpc, - certificateArn: publicCertArn, - }); + // REST API on EKS + new ApiEksStack(app, "ApiEks", { + env: envA, + clusterName: eksCluster.cluster.clusterName, + kubectlRoleArn: eksCluster.cluster.kubectlRole!.roleArn, + kubectlSecurityGroupId: + eksCluster.cluster.kubectlSecurityGroup!.securityGroupId, + kubectlPrivateSubnetIds: eksCluster.cluster.kubectlPrivateSubnets!.map( + (s) => s.subnetId, + ), + vpc: vpcUsWest2.vpc, + certificateArn: publicCertArn, + privateDomain, + }); } // Private API Gateway new PrivateApigwStack(app, "PrivateApigw", { - env: envA, - vpc: vpcUsWest2.vpc, + env: envA, + vpc: vpcUsWest2.vpc, }); // Test 5: Private DNS + Public Certificate new PrivateApiPublicCertStack(app, "Test5-PrivateApiPublicCert", { - env: envA, - vpc: vpcUsWest2.vpc, - baseDomain, - publicCertArn, + env: envA, + vpc: vpcUsWest2.vpc, + baseDomain, + publicCertArn, }); // Shared Private CA (for Tests 6 and 7) const privateCa = new PrivateCaStack(app, "SharedPrivateCa", { - env: envA, - baseDomain, + env: envA, + baseDomain, }); // Test 6: Public DNS + Private Certificate new PublicDnsPrivateCertStack(app, "Test6-PublicDnsPrivateCert", { - env: envA, - vpc: vpcUsWest2.vpc, - baseDomain, - certificateAuthorityArn: privateCa.caArn, - hostedZoneId, + env: envA, + vpc: vpcUsWest2.vpc, + baseDomain, + certificateAuthorityArn: privateCa.caArn, + hostedZoneId, }); // Shared AgentCore Gateway (Cognito M2M auth) new AgentCoreGatewayStack(app, "SharedAgentCoreGateway", { - env: envA, + env: envA, }); // Test 7: Private DNS + Private Certificate (requires publicCertArn for ALB workaround) if (publicCertArn) { - new PrivateDnsPrivateCertStack(app, "Test7-PrivateDnsPrivateCert", { - env: envA, - vpc: vpcUsWest2.vpc, - baseDomain, - certificateAuthorityArn: privateCa.caArn, - publicCertArn, - }); + new PrivateDnsPrivateCertStack(app, "Test7-PrivateDnsPrivateCert", { + env: envA, + vpc: vpcUsWest2.vpc, + baseDomain, + certificateAuthorityArn: privateCa.caArn, + publicCertArn, + }); } -// Private domain lab: ALB with public cert + private hosted zone +// Private domain lab: ALB with public cert + private hosted zone (Private DNS) if (publicCertArn) { - new PrivateDomainStack(app, "PrivateDomain", { - env: envA, - vpc: vpcUsWest2.vpc, - baseDomain, - publicCertArn, - }); + new PrivateDomainStack(app, "PrivateDomain", { + env: envA, + vpc: vpcUsWest2.vpc, + privateDomain, + publicCertArn, + }); } // Short-lived Private CA ($50/month) for the private-certificate-authority lab const shortLivedCa = new ShortLivedCaStack(app, "ShortLivedPrivateCa", { - env: envA, - baseDomain, + env: envA, + baseDomain, }); // Private CA lab: backend with private CA cert (EC2 serves HTTPS:443) new PrivateCertBackendStack(app, "PrivateCaBackend", { - env: envA, - vpc: vpcUsWest2.vpc, - baseDomain, - certificateAuthorityArn: shortLivedCa.caArn, + env: envA, + vpc: vpcUsWest2.vpc, + baseDomain, + certificateAuthorityArn: shortLivedCa.caArn, }); // Self-signed lab: backend with self-signed cert new PrivateCertBackendStack(app, "SelfSignedBackend", { - env: envA, - vpc: vpcUsWest2.vpc, - baseDomain, + env: envA, + vpc: vpcUsWest2.vpc, + baseDomain, }); diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/images/managed.png b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/images/managed.png new file mode 100644 index 00000000..59ad0788 Binary files /dev/null and b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/images/managed.png differ diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/images/self-managed.png b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/images/self-managed.png new file mode 100644 index 00000000..1f5899d9 Binary files /dev/null and b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/images/self-managed.png differ diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/private-domain-stack.ts b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/private-domain-stack.ts index 36a6e770..101e3a58 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/private-domain-stack.ts +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/private-domain-stack.ts @@ -14,250 +14,236 @@ import { Construct } from "constructs"; * with a public certificate, and a Route 53 private hosted zone * that resolves to the ALB within the VPC. * - * This represents a setup where the domain is only resolvable inside - * the VPC (private hosted zone), but the TLS certificate is publicly - * trusted. VPC Lattice requires a publicly resolvable domain — the - * routingDomain (ALB DNS) provides this. + * The private hosted zone name matches the target domain covered by + * the public certificate, with an apex Alias record pointing to the + * ALB. AgentCore Gateway's managed Resource Gateway resolves this + * domain via the VPC's Private DNS — no routingDomain needed. */ export interface PrivateDomainStackProps extends cdk.StackProps { - vpc: ec2.IVpc; - baseDomain: string; - publicCertArn: string; + vpc: ec2.IVpc; + /** FQDN covered by the public certificate, e.g. "internal.example.com" */ + privateDomain: string; + publicCertArn: string; } export class PrivateDomainStack extends cdk.Stack { - public readonly instance: ec2.Instance; - public readonly ec2Sg: ec2.SecurityGroup; + public readonly instance: ec2.Instance; + public readonly ec2Sg: ec2.SecurityGroup; - constructor(scope: Construct, id: string, props: PrivateDomainStackProps) { - super(scope, id, props); + constructor(scope: Construct, id: string, props: PrivateDomainStackProps) { + super(scope, id, props); - const publicCert = acm.Certificate.fromCertificateArn( - this, - "PublicCert", - props.publicCertArn, - ); + const publicCert = acm.Certificate.fromCertificateArn( + this, + "PublicCert", + props.publicCertArn, + ); - // --- EC2 Instance running simple REST API on HTTP :8000 --- - this.ec2Sg = new ec2.SecurityGroup(this, "Ec2Sg", { - vpc: props.vpc, - description: "Simple API EC2 instance", - allowAllOutbound: true, - }); - this.ec2Sg.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); + // --- EC2 Instance running simple REST API on HTTP :8000 --- + this.ec2Sg = new ec2.SecurityGroup(this, "Ec2Sg", { + vpc: props.vpc, + description: "Simple API EC2 instance", + allowAllOutbound: true, + }); + this.ec2Sg.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); - this.instance = new ec2.Instance(this, "SimpleApiInstance", { - vpc: props.vpc, - instanceType: ec2.InstanceType.of( - ec2.InstanceClass.T3, - ec2.InstanceSize.MICRO, - ), - machineImage: ec2.MachineImage.latestAmazonLinux2023(), - vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, - securityGroup: this.ec2Sg, - ssmSessionPermissions: true, - }); + this.instance = new ec2.Instance(this, "SimpleApiInstance", { + vpc: props.vpc, + instanceType: ec2.InstanceType.of( + ec2.InstanceClass.T3, + ec2.InstanceSize.MICRO, + ), + machineImage: ec2.MachineImage.latestAmazonLinux2023(), + vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroup: this.ec2Sg, + ssmSessionPermissions: true, + }); - this.instance.addUserData( - "#!/bin/bash", - "dnf update -y", - "dnf install -y python3-pip", - "pip3 install fastapi uvicorn", - "mkdir -p /opt/simple-api", - "cat > /opt/simple-api/app.py << 'PYEOF'", - "from fastapi import FastAPI, Depends, Header, HTTPException", - "", - 'API_KEY = "vpc-egress-lab-api-key"', - "", - "", - "def verify_api_key(x_api_key: str = Header(...)):", - " if x_api_key != API_KEY:", - ' raise HTTPException(status_code=403, detail="Invalid API key")', - "", - "", - "app = FastAPI(dependencies=[Depends(verify_api_key)])", - "items: list[dict] = []", - "", - "", - '@app.get("/health")', - "def health():", - ' return {"status": "ok"}', - "", - "", - '@app.get("/items")', - "def list_items():", - " return items", - "", - "", - '@app.post("/items")', - "def create_item(item: dict):", - " items.append(item)", - " return item", - "PYEOF", - "cat > /etc/systemd/system/simple-api.service << 'SVCEOF'", - "[Unit]", - "Description=Simple API Server", - "After=network.target", - "", - "[Service]", - "Type=simple", - "WorkingDirectory=/opt/simple-api", - "ExecStart=/usr/bin/python3 -m uvicorn app:app --host 0.0.0.0 --port 8000", - "Restart=always", - "", - "[Install]", - "WantedBy=multi-user.target", - "SVCEOF", - "systemctl daemon-reload", - "systemctl enable simple-api", - "systemctl start simple-api", - ); + this.instance.addUserData( + "#!/bin/bash", + "dnf update -y", + "dnf install -y python3-pip", + "pip3 install fastapi uvicorn", + "mkdir -p /opt/simple-api", + "cat > /opt/simple-api/app.py << 'PYEOF'", + "from fastapi import FastAPI, Depends, Header, HTTPException", + "", + 'API_KEY = "vpc-egress-lab-api-key"', + "", + "", + "def verify_api_key(x_api_key: str = Header(...)):", + " if x_api_key != API_KEY:", + ' raise HTTPException(status_code=403, detail="Invalid API key")', + "", + "", + "app = FastAPI()", + "items: list[dict] = []", + "", + "", + '@app.get("/health")', + "def health():", + ' return {"status": "ok"}', + "", + "", + '@app.get("/items", dependencies=[Depends(verify_api_key)])', + "def list_items():", + " return items", + "", + "", + '@app.post("/items", dependencies=[Depends(verify_api_key)])', + "def create_item(item: dict):", + " items.append(item)", + " return item", + "PYEOF", + "cat > /etc/systemd/system/simple-api.service << 'SVCEOF'", + "[Unit]", + "Description=Simple API Server", + "After=network.target", + "", + "[Service]", + "Type=simple", + "WorkingDirectory=/opt/simple-api", + "ExecStart=/usr/bin/python3 -m uvicorn app:app --host 0.0.0.0 --port 8000", + "Restart=always", + "", + "[Install]", + "WantedBy=multi-user.target", + "SVCEOF", + "systemctl daemon-reload", + "systemctl enable simple-api", + "systemctl start simple-api", + ); - // --- Internal ALB with public certificate --- - const albSg = new ec2.SecurityGroup(this, "AlbSg", { - vpc: props.vpc, - description: "Internal ALB with public cert - HTTPS from VPC", - allowAllOutbound: true, - }); - albSg.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); - albSg.addIngressRule( - ec2.Peer.ipv4(props.vpc.vpcCidrBlock), - ec2.Port.tcp(443), - "Allow HTTPS from VPC", - ); + // --- Internal ALB with public certificate --- + const albSg = new ec2.SecurityGroup(this, "AlbSg", { + vpc: props.vpc, + description: "Internal ALB with public cert - HTTPS from VPC", + allowAllOutbound: true, + }); + albSg.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); + albSg.addIngressRule( + ec2.Peer.ipv4(props.vpc.vpcCidrBlock), + ec2.Port.tcp(443), + "Allow HTTPS from VPC", + ); - this.ec2Sg.addIngressRule( - albSg, - ec2.Port.tcp(8000), - "Allow traffic from ALB", - ); + this.ec2Sg.addIngressRule( + albSg, + ec2.Port.tcp(8000), + "Allow traffic from ALB", + ); - const accessLogBucket = new s3.Bucket(this, "AlbAccessLogs", { - removalPolicy: cdk.RemovalPolicy.DESTROY, - autoDeleteObjects: true, - enforceSSL: true, - encryption: s3.BucketEncryption.S3_MANAGED, - lifecycleRules: [{ expiration: cdk.Duration.days(30) }], - }); + const accessLogBucket = new s3.Bucket(this, "AlbAccessLogs", { + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, + enforceSSL: true, + encryption: s3.BucketEncryption.S3_MANAGED, + lifecycleRules: [{ expiration: cdk.Duration.days(30) }], + }); - const alb = new elbv2.ApplicationLoadBalancer(this, "InternalAlb", { - vpc: props.vpc, - internetFacing: false, - securityGroup: albSg, - vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, - }); + const alb = new elbv2.ApplicationLoadBalancer(this, "InternalAlb", { + vpc: props.vpc, + internetFacing: false, + securityGroup: albSg, + vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + }); - alb.logAccessLogs(accessLogBucket, "alb-logs"); + alb.logAccessLogs(accessLogBucket, "alb-logs"); - const httpsListener = alb.addListener("HttpsListener", { - port: 443, - protocol: elbv2.ApplicationProtocol.HTTPS, - certificates: [publicCert], - }); + const httpsListener = alb.addListener("HttpsListener", { + port: 443, + protocol: elbv2.ApplicationProtocol.HTTPS, + certificates: [publicCert], + }); - httpsListener.addTargets("Ec2Target", { - port: 8000, - protocol: elbv2.ApplicationProtocol.HTTP, - targets: [new elbv2targets.InstanceTarget(this.instance, 8000)], - healthCheck: { - path: "/health", - port: "8000", - healthyHttpCodes: "200", - }, - }); + httpsListener.addTargets("Ec2Target", { + port: 8000, + protocol: elbv2.ApplicationProtocol.HTTP, + targets: [new elbv2targets.InstanceTarget(this.instance, 8000)], + healthCheck: { + path: "/health", + port: "8000", + healthyHttpCodes: "200", + }, + }); - // --- Route 53 Private Hosted Zone --- - // This domain only resolves inside the VPC. VPC Lattice cannot use it - // for its resource configuration — that's what routingDomain solves. - const privateZone = new route53.PrivateHostedZone(this, "PrivateZone", { - zoneName: `internal.${props.baseDomain}`, - vpc: props.vpc, - }); + // --- Route 53 Private Hosted Zone --- + // Zone name matches the target FQDN. An apex Alias record points at + // the ALB so `https://` resolves to ALB private IPs + // inside the VPC. AgentCore's Resource Gateway uses Private DNS to + // resolve this domain — no routingDomain workaround needed. + const privateZone = new route53.PrivateHostedZone(this, "PrivateZone", { + zoneName: props.privateDomain, + vpc: props.vpc, + }); - new route53.ARecord(this, "AlbAliasRecord", { - zone: privateZone, - recordName: "api", - target: route53.RecordTarget.fromAlias( - new route53targets.LoadBalancerTarget(alb), - ), - }); + new route53.ARecord(this, "AlbAliasRecord", { + zone: privateZone, + target: route53.RecordTarget.fromAlias( + new route53targets.LoadBalancerTarget(alb), + ), + }); - // Also add a direct EC2 record for the "no ALB" scenario explanation - new route53.ARecord(this, "Ec2DirectRecord", { - zone: privateZone, - recordName: "direct", - target: route53.RecordTarget.fromIpAddresses( - this.instance.instancePrivateIp, - ), - }); + // --- Outputs --- + new cdk.CfnOutput(this, "AlbDnsName", { + value: alb.loadBalancerDnsName, + description: "Internal ALB DNS (private IPs behind this DNS)", + }); - // --- Outputs --- - new cdk.CfnOutput(this, "AlbDnsName", { - value: alb.loadBalancerDnsName, - description: - "Internal ALB DNS (publicly resolvable — use as routingDomain)", - }); + new cdk.CfnOutput(this, "AlbSgId", { + value: albSg.securityGroupId, + }); - new cdk.CfnOutput(this, "AlbSgId", { - value: albSg.securityGroupId, - }); + new cdk.CfnOutput(this, "Ec2InstanceId", { + value: this.instance.instanceId, + description: "SSM Session Manager: aws ssm start-session --target ", + }); - new cdk.CfnOutput(this, "Ec2InstanceId", { - value: this.instance.instanceId, - description: "SSM Session Manager: aws ssm start-session --target ", - }); + new cdk.CfnOutput(this, "Ec2PrivateIp", { + value: this.instance.instancePrivateIp, + }); - new cdk.CfnOutput(this, "Ec2PrivateIp", { - value: this.instance.instancePrivateIp, - }); + new cdk.CfnOutput(this, "PrivateDomain", { + value: props.privateDomain, + description: + "Private domain (Alias → ALB, resolvable via Private DNS inside the VPC)", + }); - new cdk.CfnOutput(this, "PrivateDomainAlb", { - value: `api.internal.${props.baseDomain}`, - description: - "Private domain pointing to ALB (only resolvable inside VPC)", - }); + new cdk.CfnOutput(this, "ApiKey", { + value: "vpc-egress-lab-api-key", + description: "API key for the simple REST API (x-api-key header)", + }); - new cdk.CfnOutput(this, "ApiKey", { - value: "vpc-egress-lab-api-key", - description: "API key for the simple REST API (x-api-key header)", - }); - - new cdk.CfnOutput(this, "PrivateDomainDirect", { - value: `direct.internal.${props.baseDomain}`, - description: - "Private domain pointing to EC2 IP directly (only resolvable inside VPC)", - }); - - NagSuppressions.addStackSuppressions(this, [ - { - id: "AwsSolutions-IAM4", - reason: "SSM managed policy required for Session Manager access", - }, - { id: "AwsSolutions-IAM5", reason: "SSM managed policies use wildcards" }, - { - id: "AwsSolutions-EC26", - reason: "EBS encryption not needed for lab instance", - }, - { - id: "AwsSolutions-EC28", - reason: "Detailed monitoring not needed for lab instance", - }, - { - id: "AwsSolutions-EC29", - reason: "Lab instance does not need termination protection", - }, - { - id: "AwsSolutions-S1", - reason: "Access log bucket does not need its own access logs", - }, - { - id: "AwsSolutions-EC23", - reason: "ALB is internal, SG allows VPC CIDR only", - }, - { - id: "CdkNagValidationFailure", - reason: "Security group uses VPC CIDR intrinsic reference", - }, - ]); - } + NagSuppressions.addStackSuppressions(this, [ + { + id: "AwsSolutions-IAM4", + reason: "SSM managed policy required for Session Manager access", + }, + { id: "AwsSolutions-IAM5", reason: "SSM managed policies use wildcards" }, + { + id: "AwsSolutions-EC26", + reason: "EBS encryption not needed for lab instance", + }, + { + id: "AwsSolutions-EC28", + reason: "Detailed monitoring not needed for lab instance", + }, + { + id: "AwsSolutions-EC29", + reason: "Lab instance does not need termination protection", + }, + { + id: "AwsSolutions-S1", + reason: "Access log bucket does not need its own access logs", + }, + { + id: "AwsSolutions-EC23", + reason: "ALB is internal, SG allows VPC CIDR only", + }, + { + id: "CdkNagValidationFailure", + reason: "Security group uses VPC CIDR intrinsic reference", + }, + ]); + } } diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/shared/agentcore-gateway-stack.ts b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/shared/agentcore-gateway-stack.ts index aab946f5..ca850096 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/shared/agentcore-gateway-stack.ts +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/shared/agentcore-gateway-stack.ts @@ -55,7 +55,7 @@ export class AgentCoreGatewayStack extends cdk.Stack { }), ); - // EC2 permissions for managed VPC Lattice (Resource Gateway ENI provisioning) + // EC2 permissions for managed VPC resource (Resource Gateway ENI provisioning) this.gateway.role!.addToPrincipalPolicy( new iam.PolicyStatement({ actions: [ diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/test1-mcp-ecs-stack.ts b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/test1-mcp-ecs-stack.ts index 81389a1e..84d0ee93 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/test1-mcp-ecs-stack.ts +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/test1-mcp-ecs-stack.ts @@ -5,328 +5,354 @@ import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as ecs from "aws-cdk-lib/aws-ecs"; import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2"; import * as logs from "aws-cdk-lib/aws-logs"; +import * as route53 from "aws-cdk-lib/aws-route53"; +import * as route53targets from "aws-cdk-lib/aws-route53-targets"; import * as s3 from "aws-cdk-lib/aws-s3"; import * as servicediscovery from "aws-cdk-lib/aws-servicediscovery"; import { NagSuppressions } from "cdk-nag"; import { Construct } from "constructs"; export interface McpEcsStackProps extends cdk.StackProps { - vpc: ec2.IVpc; - certificateArn: string; + vpc: ec2.IVpc; + certificateArn: string; + /** FQDN covered by the public certificate, e.g. "internal.example.com" */ + privateDomain: string; } export class McpEcsStack extends cdk.Stack { - public readonly albDnsName: string; + public readonly albDnsName: string; - constructor(scope: Construct, id: string, props: McpEcsStackProps) { - super(scope, id, props); + constructor(scope: Construct, id: string, props: McpEcsStackProps) { + super(scope, id, props); - const certificate = acm.Certificate.fromCertificateArn( - this, - "AlbCert", - props.certificateArn, - ); + const certificate = acm.Certificate.fromCertificateArn( + this, + "AlbCert", + props.certificateArn, + ); - const cluster = new ecs.Cluster(this, "McpCluster", { - vpc: props.vpc, - containerInsightsV2: ecs.ContainerInsights.ENHANCED, - }); + const cluster = new ecs.Cluster(this, "McpCluster", { + vpc: props.vpc, + containerInsightsV2: ecs.ContainerInsights.ENHANCED, + }); - const namespace = new servicediscovery.PrivateDnsNamespace( - this, - "McpNamespace", - { - name: "mcp.local", - vpc: props.vpc, - }, - ); + const namespace = new servicediscovery.PrivateDnsNamespace( + this, + "McpNamespace", + { + name: "mcp.local", + vpc: props.vpc, + }, + ); - const mcpImage = ecs.ContainerImage.fromAsset("docker/fastmcp-mock", { - platform: Platform.LINUX_AMD64, - }); + const mcpImage = ecs.ContainerImage.fromAsset("docker/fastmcp-mock", { + platform: Platform.LINUX_AMD64, + }); - const serviceSg = new ec2.SecurityGroup(this, "McpServiceSg", { - vpc: props.vpc, - description: "MCP ECS Service - VPC only", - allowAllOutbound: true, - }); - serviceSg.addIngressRule( - ec2.Peer.ipv4(props.vpc.vpcCidrBlock), - ec2.Port.tcp(8000), - "Allow MCP traffic from VPC", - ); + const serviceSg = new ec2.SecurityGroup(this, "McpServiceSg", { + vpc: props.vpc, + description: "MCP ECS Service - VPC only", + allowAllOutbound: true, + }); + serviceSg.addIngressRule( + ec2.Peer.ipv4(props.vpc.vpcCidrBlock), + ec2.Port.tcp(8000), + "Allow MCP traffic from VPC", + ); - // --- Internal ALB with public cert --- - const albSg = new ec2.SecurityGroup(this, "McpAlbSg", { - vpc: props.vpc, - description: "MCP ALB - HTTPS from VPC", - allowAllOutbound: true, - }); - albSg.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); - albSg.addIngressRule( - ec2.Peer.ipv4(props.vpc.vpcCidrBlock), - ec2.Port.tcp(443), - "Allow HTTPS from VPC", - ); + // --- Internal ALB with public cert --- + const albSg = new ec2.SecurityGroup(this, "McpAlbSg", { + vpc: props.vpc, + description: "MCP ALB - HTTPS from VPC", + allowAllOutbound: true, + }); + albSg.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); + albSg.addIngressRule( + ec2.Peer.ipv4(props.vpc.vpcCidrBlock), + ec2.Port.tcp(443), + "Allow HTTPS from VPC", + ); - // Allow ALB to reach ECS tasks on ports 8000 and 8001 - serviceSg.addIngressRule( - albSg, - ec2.Port.tcp(8000), - "Allow traffic from ALB to MCP server", - ); - serviceSg.addIngressRule( - albSg, - ec2.Port.tcp(8001), - "Allow traffic from ALB to Stock MCP server", - ); + // Allow ALB to reach ECS tasks on ports 8000 and 8001 + serviceSg.addIngressRule( + albSg, + ec2.Port.tcp(8000), + "Allow traffic from ALB to MCP server", + ); + serviceSg.addIngressRule( + albSg, + ec2.Port.tcp(8001), + "Allow traffic from ALB to Stock MCP server", + ); - const accessLogBucket = new s3.Bucket(this, "McpAlbAccessLogs", { - removalPolicy: cdk.RemovalPolicy.DESTROY, - autoDeleteObjects: true, - enforceSSL: true, - encryption: s3.BucketEncryption.S3_MANAGED, - lifecycleRules: [{ expiration: cdk.Duration.days(30) }], - }); + const accessLogBucket = new s3.Bucket(this, "McpAlbAccessLogs", { + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, + enforceSSL: true, + encryption: s3.BucketEncryption.S3_MANAGED, + lifecycleRules: [{ expiration: cdk.Duration.days(30) }], + }); - const alb = new elbv2.ApplicationLoadBalancer(this, "McpAlb", { - vpc: props.vpc, - internetFacing: false, - securityGroup: albSg, - vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, - }); + const alb = new elbv2.ApplicationLoadBalancer(this, "McpAlb", { + vpc: props.vpc, + internetFacing: false, + securityGroup: albSg, + vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + }); - alb.logAccessLogs(accessLogBucket, "alb-logs"); + alb.logAccessLogs(accessLogBucket, "alb-logs"); - // HTTPS listener with public cert - const httpsListener = alb.addListener("HttpsListener", { - port: 443, - protocol: elbv2.ApplicationProtocol.HTTPS, - certificates: [certificate], - }); + // HTTPS listener with public cert + const httpsListener = alb.addListener("HttpsListener", { + port: 443, + protocol: elbv2.ApplicationProtocol.HTTPS, + certificates: [certificate], + }); - // Fargate target group - const fargateTargetGroup = new elbv2.ApplicationTargetGroup( - this, - "FargateTargetGroup", - { - vpc: props.vpc, - port: 8000, - protocol: elbv2.ApplicationProtocol.HTTP, - targetType: elbv2.TargetType.IP, - healthCheck: { - path: "/health", - port: "8000", - healthyHttpCodes: "200,404,405", - }, - }, - ); + // Fargate target group + const fargateTargetGroup = new elbv2.ApplicationTargetGroup( + this, + "FargateTargetGroup", + { + vpc: props.vpc, + port: 8000, + protocol: elbv2.ApplicationProtocol.HTTP, + targetType: elbv2.TargetType.IP, + healthCheck: { + path: "/health", + port: "8000", + healthyHttpCodes: "200,404,405", + }, + }, + ); - httpsListener.addTargetGroups("DefaultRoute", { - targetGroups: [fargateTargetGroup], - }); + httpsListener.addTargetGroups("DefaultRoute", { + targetGroups: [fargateTargetGroup], + }); - // --- Fargate Service --- - const fargateTaskDef = new ecs.FargateTaskDefinition( - this, - "McpFargateTaskDef", - { - memoryLimitMiB: 512, - cpu: 256, - }, - ); + // --- Fargate Service --- + const fargateTaskDef = new ecs.FargateTaskDefinition( + this, + "McpFargateTaskDef", + { + memoryLimitMiB: 512, + cpu: 256, + }, + ); - const fargateLogGroup = new logs.LogGroup(this, "McpFargateLogGroup", { - retention: logs.RetentionDays.ONE_MONTH, - removalPolicy: cdk.RemovalPolicy.DESTROY, - }); + const fargateLogGroup = new logs.LogGroup(this, "McpFargateLogGroup", { + retention: logs.RetentionDays.ONE_MONTH, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); - fargateTaskDef.addContainer("McpContainer", { - image: mcpImage, - portMappings: [{ containerPort: 8000 }], - logging: ecs.LogDrivers.awsLogs({ - logGroup: fargateLogGroup, - streamPrefix: "mcp-fargate", - }), - }); + fargateTaskDef.addContainer("McpContainer", { + image: mcpImage, + portMappings: [{ containerPort: 8000 }], + logging: ecs.LogDrivers.awsLogs({ + logGroup: fargateLogGroup, + streamPrefix: "mcp-fargate", + }), + }); - const fargateService = new ecs.FargateService(this, "McpFargateService", { - cluster, - taskDefinition: fargateTaskDef, - desiredCount: 1, - vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, - securityGroups: [serviceSg], - assignPublicIp: false, - circuitBreaker: { enable: true, rollback: true }, - cloudMapOptions: { - name: "mcp-fargate", - cloudMapNamespace: namespace, - dnsRecordType: servicediscovery.DnsRecordType.A, - }, - }); + const fargateService = new ecs.FargateService(this, "McpFargateService", { + cluster, + taskDefinition: fargateTaskDef, + desiredCount: 1, + vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroups: [serviceSg], + assignPublicIp: false, + circuitBreaker: { enable: true, rollback: true }, + cloudMapOptions: { + name: "mcp-fargate", + cloudMapNamespace: namespace, + dnsRecordType: servicediscovery.DnsRecordType.A, + }, + }); - fargateService.attachToApplicationTargetGroup(fargateTargetGroup); + fargateService.attachToApplicationTargetGroup(fargateTargetGroup); - // --- Stock MCP Server (second MCP server, same ALB, path-based routing) --- - // The stock server mounts at /stock-mcp/ so ALB forwards /stock-mcp/* as-is. - const stockImage = ecs.ContainerImage.fromAsset("docker/stock-mcp-mock", { - platform: Platform.LINUX_AMD64, - }); + // --- Stock MCP Server (second MCP server, same ALB, path-based routing) --- + // The stock server mounts at /stock-mcp/ so ALB forwards /stock-mcp/* as-is. + const stockImage = ecs.ContainerImage.fromAsset("docker/stock-mcp-mock", { + platform: Platform.LINUX_AMD64, + }); - const stockTargetGroup = new elbv2.ApplicationTargetGroup( - this, - "StockTargetGroup", - { - vpc: props.vpc, - port: 8001, - protocol: elbv2.ApplicationProtocol.HTTP, - targetType: elbv2.TargetType.IP, - healthCheck: { - path: "/stock-mcp/", - port: "8001", - healthyHttpCodes: "200,404,405,406", - }, - }, - ); + const stockTargetGroup = new elbv2.ApplicationTargetGroup( + this, + "StockTargetGroup", + { + vpc: props.vpc, + port: 8001, + protocol: elbv2.ApplicationProtocol.HTTP, + targetType: elbv2.TargetType.IP, + healthCheck: { + path: "/stock-mcp/", + port: "8001", + healthyHttpCodes: "200,404,405,406", + }, + }, + ); - httpsListener.addTargetGroups("StockMcpRoute", { - targetGroups: [stockTargetGroup], - priority: 1, - conditions: [elbv2.ListenerCondition.pathPatterns(["/stock-mcp/*"])], - }); + httpsListener.addTargetGroups("StockMcpRoute", { + targetGroups: [stockTargetGroup], + priority: 1, + conditions: [elbv2.ListenerCondition.pathPatterns(["/stock-mcp/*"])], + }); - const stockTaskDef = new ecs.FargateTaskDefinition( - this, - "StockMcpTaskDef", - { - memoryLimitMiB: 512, - cpu: 256, - }, - ); + const stockTaskDef = new ecs.FargateTaskDefinition( + this, + "StockMcpTaskDef", + { + memoryLimitMiB: 512, + cpu: 256, + }, + ); - const stockLogGroup = new logs.LogGroup(this, "StockMcpLogGroup", { - retention: logs.RetentionDays.ONE_MONTH, - removalPolicy: cdk.RemovalPolicy.DESTROY, - }); + const stockLogGroup = new logs.LogGroup(this, "StockMcpLogGroup", { + retention: logs.RetentionDays.ONE_MONTH, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); - stockTaskDef.addContainer("StockMcpContainer", { - image: stockImage, - portMappings: [{ containerPort: 8001 }], - logging: ecs.LogDrivers.awsLogs({ - logGroup: stockLogGroup, - streamPrefix: "stock-mcp", - }), - }); + stockTaskDef.addContainer("StockMcpContainer", { + image: stockImage, + portMappings: [{ containerPort: 8001 }], + logging: ecs.LogDrivers.awsLogs({ + logGroup: stockLogGroup, + streamPrefix: "stock-mcp", + }), + }); - const stockService = new ecs.FargateService( - this, - "StockMcpFargateService", - { - cluster, - taskDefinition: stockTaskDef, - desiredCount: 1, - vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, - securityGroups: [serviceSg], - assignPublicIp: false, - circuitBreaker: { enable: true, rollback: true }, - cloudMapOptions: { - name: "stock-mcp", - cloudMapNamespace: namespace, - dnsRecordType: servicediscovery.DnsRecordType.A, - }, - }, - ); + const stockService = new ecs.FargateService( + this, + "StockMcpFargateService", + { + cluster, + taskDefinition: stockTaskDef, + desiredCount: 1, + vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroups: [serviceSg], + assignPublicIp: false, + circuitBreaker: { enable: true, rollback: true }, + cloudMapOptions: { + name: "stock-mcp", + cloudMapNamespace: namespace, + dnsRecordType: servicediscovery.DnsRecordType.A, + }, + }, + ); - stockService.attachToApplicationTargetGroup(stockTargetGroup); + stockService.attachToApplicationTargetGroup(stockTargetGroup); - // --- Bastion for SSM testing --- - const bastionSg = new ec2.SecurityGroup(this, "BastionSg", { - vpc: props.vpc, - description: "Bastion - outbound only for SSM", - allowAllOutbound: true, - }); + // --- Bastion for SSM testing --- + const bastionSg = new ec2.SecurityGroup(this, "BastionSg", { + vpc: props.vpc, + description: "Bastion - outbound only for SSM", + allowAllOutbound: true, + }); - const bastion = new ec2.Instance(this, "Bastion", { - vpc: props.vpc, - instanceType: ec2.InstanceType.of( - ec2.InstanceClass.T3, - ec2.InstanceSize.MICRO, - ), - machineImage: ec2.MachineImage.latestAmazonLinux2023(), - vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, - securityGroup: bastionSg, - ssmSessionPermissions: true, - }); + const bastion = new ec2.Instance(this, "Bastion", { + vpc: props.vpc, + instanceType: ec2.InstanceType.of( + ec2.InstanceClass.T3, + ec2.InstanceSize.MICRO, + ), + machineImage: ec2.MachineImage.latestAmazonLinux2023(), + vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroup: bastionSg, + ssmSessionPermissions: true, + }); - // Allow bastion to test ALB and MCP tasks - albSg.addIngressRule( - bastionSg, - ec2.Port.tcp(443), - "Allow bastion to test ALB HTTPS", - ); - serviceSg.addIngressRule( - bastionSg, - ec2.Port.tcp(8000), - "Allow bastion to test MCP directly", - ); - serviceSg.addIngressRule( - bastionSg, - ec2.Port.tcp(8001), - "Allow bastion to test Stock MCP directly", - ); + // Allow bastion to test ALB and MCP tasks + albSg.addIngressRule( + bastionSg, + ec2.Port.tcp(443), + "Allow bastion to test ALB HTTPS", + ); + serviceSg.addIngressRule( + bastionSg, + ec2.Port.tcp(8000), + "Allow bastion to test MCP directly", + ); + serviceSg.addIngressRule( + bastionSg, + ec2.Port.tcp(8001), + "Allow bastion to test Stock MCP directly", + ); - // --- Outputs --- - new cdk.CfnOutput(this, "AlbDnsName", { - value: alb.loadBalancerDnsName, - description: - "Internal ALB DNS name (publicly resolvable, used as routingDomain)", - }); + // --- Route 53 Private Hosted Zone --- + // Zone name matches the target FQDN. An apex Alias record points at + // the ALB so `https://` resolves to ALB private IPs + // inside the VPC. AgentCore's Resource Gateway uses Private DNS to + // resolve this domain — no routingDomain workaround needed. + const privateZone = new route53.PrivateHostedZone(this, "PrivateZone", { + zoneName: props.privateDomain, + vpc: props.vpc, + }); - new cdk.CfnOutput(this, "AlbSgId", { - value: albSg.securityGroupId, - }); + new route53.ARecord(this, "AlbAliasRecord", { + zone: privateZone, + target: route53.RecordTarget.fromAlias( + new route53targets.LoadBalancerTarget(alb), + ), + }); - new cdk.CfnOutput(this, "BastionInstanceId", { - value: bastion.instanceId, - description: "SSM Session Manager: aws ssm start-session --target ", - }); + // --- Outputs --- + new cdk.CfnOutput(this, "AlbDnsName", { + value: alb.loadBalancerDnsName, + description: "Internal ALB DNS (private IPs behind this DNS)", + }); - NagSuppressions.addStackSuppressions(this, [ - { - id: "AwsSolutions-IAM5", - reason: - "ECS task execution role wildcards required for ECR image pulls and log writes", - }, - { - id: "AwsSolutions-ECS2", - reason: "No secrets in environment variables", - }, - { - id: "AwsSolutions-IAM4", - reason: - "SSM managed policy required for Session Manager access on bastion", - }, - { - id: "AwsSolutions-EC26", - reason: "EBS encryption not needed for bastion test instance", - }, - { - id: "AwsSolutions-EC28", - reason: "Detailed monitoring not needed for bastion test instance", - }, - { - id: "AwsSolutions-EC29", - reason: - "Bastion is ephemeral test instance, no termination protection needed", - }, - { - id: "AwsSolutions-S1", - reason: "Access log bucket does not need its own access logs", - }, - { - id: "AwsSolutions-EC23", - reason: "ALB is internal, SG allows VPC CIDR only", - }, - ]); - } + new cdk.CfnOutput(this, "PrivateDomain", { + value: props.privateDomain, + description: + "Private domain (Alias → ALB, resolvable via Private DNS inside the VPC)", + }); + + new cdk.CfnOutput(this, "AlbSgId", { + value: albSg.securityGroupId, + }); + + new cdk.CfnOutput(this, "BastionInstanceId", { + value: bastion.instanceId, + description: "SSM Session Manager: aws ssm start-session --target ", + }); + + NagSuppressions.addStackSuppressions(this, [ + { + id: "AwsSolutions-IAM5", + reason: + "ECS task execution role wildcards required for ECR image pulls and log writes", + }, + { + id: "AwsSolutions-ECS2", + reason: "No secrets in environment variables", + }, + { + id: "AwsSolutions-IAM4", + reason: + "SSM managed policy required for Session Manager access on bastion", + }, + { + id: "AwsSolutions-EC26", + reason: "EBS encryption not needed for bastion test instance", + }, + { + id: "AwsSolutions-EC28", + reason: "Detailed monitoring not needed for bastion test instance", + }, + { + id: "AwsSolutions-EC29", + reason: + "Bastion is ephemeral test instance, no termination protection needed", + }, + { + id: "AwsSolutions-S1", + reason: "Access log bucket does not need its own access logs", + }, + { + id: "AwsSolutions-EC23", + reason: "ALB is internal, SG allows VPC CIDR only", + }, + ]); + } } diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/test2-mcp-eks-stack.ts b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/test2-mcp-eks-stack.ts index 35577775..2eacf396 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/test2-mcp-eks-stack.ts +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/test2-mcp-eks-stack.ts @@ -1,305 +1,347 @@ import * as cdk from "aws-cdk-lib/core"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as eks from "aws-cdk-lib/aws-eks"; +import * as route53 from "aws-cdk-lib/aws-route53"; import { KubectlV31Layer } from "@aws-cdk/lambda-layer-kubectl-v31"; import { NagSuppressions } from "cdk-nag"; import { Construct } from "constructs"; export interface McpEksStackProps extends cdk.StackProps { - clusterName: string; - kubectlRoleArn: string; - kubectlSecurityGroupId: string; - kubectlPrivateSubnetIds: string[]; - vpc: ec2.IVpc; - certificateArn: string; + clusterName: string; + kubectlRoleArn: string; + kubectlSecurityGroupId: string; + kubectlPrivateSubnetIds: string[]; + vpc: ec2.IVpc; + certificateArn: string; + /** FQDN covered by the public certificate, e.g. "internal.example.com" */ + privateDomain: string; } export class McpEksStack extends cdk.Stack { - constructor(scope: Construct, id: string, props: McpEksStackProps) { - super(scope, id, props); + constructor(scope: Construct, id: string, props: McpEksStackProps) { + super(scope, id, props); - const cluster = eks.Cluster.fromClusterAttributes(this, "ImportedCluster", { - clusterName: props.clusterName, - kubectlRoleArn: props.kubectlRoleArn, - kubectlSecurityGroupId: props.kubectlSecurityGroupId, - kubectlPrivateSubnetIds: props.kubectlPrivateSubnetIds, - vpc: props.vpc, - kubectlLayer: new KubectlV31Layer(this, "KubectlLayer"), - }); + const cluster = eks.Cluster.fromClusterAttributes(this, "ImportedCluster", { + clusterName: props.clusterName, + kubectlRoleArn: props.kubectlRoleArn, + kubectlSecurityGroupId: props.kubectlSecurityGroupId, + kubectlPrivateSubnetIds: props.kubectlPrivateSubnetIds, + vpc: props.vpc, + kubectlLayer: new KubectlV31Layer(this, "KubectlLayer"), + }); - // --- Kubernetes resources --- - const namespace = cluster.addManifest("McpNamespace", { - apiVersion: "v1", - kind: "Namespace", - metadata: { name: "mcp-server" }, - }); + // --- Kubernetes resources --- + const namespace = cluster.addManifest("McpNamespace", { + apiVersion: "v1", + kind: "Namespace", + metadata: { name: "mcp-server" }, + }); - const deployment = cluster.addManifest("McpDeployment", { - apiVersion: "apps/v1", - kind: "Deployment", - metadata: { - name: "mcp-server", - namespace: "mcp-server", - }, - spec: { - replicas: 1, - selector: { matchLabels: { app: "mcp-server" } }, - template: { - metadata: { labels: { app: "mcp-server" } }, - spec: { - containers: [ - { - name: "mcp-server", - image: "python:3.12-slim", - command: [ - "sh", - "-c", - 'pip install "fastmcp>=2.0" && python -c "\n' + - "from fastmcp import FastMCP\n" + - "from datetime import datetime\n" + - "mcp = FastMCP('Mock MCP Server')\n" + - "@mcp.tool()\n" + - "def echo(message: str) -> str:\n" + - " return message\n" + - "@mcp.tool()\n" + - "def add(a: float, b: float) -> float:\n" + - " return a + b\n" + - "@mcp.tool()\n" + - "def get_time() -> str:\n" + - " return datetime.now().isoformat()\n" + - "mcp.run(transport='streamable-http', host='0.0.0.0', port=8000, stateless_http=True)\n" + - '"', - ], - ports: [{ containerPort: 8000 }], - }, - ], - }, - }, - }, - }); - deployment.node.addDependency(namespace); + const deployment = cluster.addManifest("McpDeployment", { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: "mcp-server", + namespace: "mcp-server", + }, + spec: { + replicas: 1, + selector: { matchLabels: { app: "mcp-server" } }, + template: { + metadata: { labels: { app: "mcp-server" } }, + spec: { + containers: [ + { + name: "mcp-server", + image: "python:3.12-slim", + command: [ + "sh", + "-c", + 'pip install "fastmcp>=2.0" && python -c "\n' + + "import json\n" + + "from datetime import datetime\n" + + "from fastmcp import FastMCP\n" + + "mcp = FastMCP('Mock MCP Server')\n" + + "@mcp.tool()\n" + + "def echo(message: str) -> str:\n" + + " return message\n" + + "@mcp.tool()\n" + + "def add(a: float, b: float) -> float:\n" + + " return a + b\n" + + "@mcp.tool()\n" + + "def get_time() -> str:\n" + + " return datetime.now().isoformat()\n" + + "@mcp.prompt()\n" + + "def order_summary_prompt(orderId: int) -> str:\n" + + " return f'Summarize the activity on order {orderId}.'\n" + + "@mcp.resource('orders://catalog')\n" + + "def order_catalog() -> str:\n" + + " return json.dumps({'orders': [{'id': 123, 'customer': 'alice', 'total': 42.0}, {'id': 456, 'customer': 'bob', 'total': 99.5}]})\n" + + "@mcp.resource('orders://{orderId}/details')\n" + + "def order_details(orderId: str) -> str:\n" + + " return json.dumps({'orderId': orderId, 'status': 'shipped', 'carrier': 'UPS'})\n" + + "@mcp.resource('shared://collision-demo')\n" + + "def collision_demo() -> str:\n" + + " return 'served by mcp-server (resourcePriority=10 wins over stock-mcp=100)'\n" + + "mcp.run(transport='streamable-http', host='0.0.0.0', port=8000, stateless_http=True)\n" + + '"', + ], + ports: [{ containerPort: 8000 }], + }, + ], + }, + }, + }, + }); + deployment.node.addDependency(namespace); - // ClusterIP Service — NGINX Ingress routes traffic here via path-based rules - const mcpService = cluster.addManifest("McpService", { - apiVersion: "v1", - kind: "Service", - metadata: { - name: "mcp-server", - namespace: "mcp-server", - }, - spec: { - type: "ClusterIP", - selector: { app: "mcp-server" }, - ports: [ - { - name: "http", - port: 8000, - targetPort: 8000, - protocol: "TCP", - }, - ], - }, - }); - mcpService.node.addDependency(deployment); + // ClusterIP Service — NGINX Ingress routes traffic here via path-based rules + const mcpService = cluster.addManifest("McpService", { + apiVersion: "v1", + kind: "Service", + metadata: { + name: "mcp-server", + namespace: "mcp-server", + }, + spec: { + type: "ClusterIP", + selector: { app: "mcp-server" }, + ports: [ + { + name: "http", + port: 8000, + targetPort: 8000, + protocol: "TCP", + }, + ], + }, + }); + mcpService.node.addDependency(deployment); - // --- Stock MCP Server (second MCP server, routed via NGINX Ingress) --- - const stockDeployment = cluster.addManifest("StockMcpDeployment", { - apiVersion: "apps/v1", - kind: "Deployment", - metadata: { - name: "stock-mcp-server", - namespace: "mcp-server", - }, - spec: { - replicas: 1, - selector: { matchLabels: { app: "stock-mcp-server" } }, - template: { - metadata: { labels: { app: "stock-mcp-server" } }, - spec: { - containers: [ - { - name: "stock-mcp-server", - image: "python:3.12-slim", - command: [ - "sh", - "-c", - 'pip install "fastmcp>=2.0" && python -c "\n' + - "import random\n" + - "from fastmcp import FastMCP\n" + - "mcp = FastMCP('Stock Price MCP Server')\n" + - "STOCKS = {'AAPL': {'name': 'Apple Inc.', 'base_price': 195.50}, 'GOOGL': {'name': 'Alphabet Inc.', 'base_price': 141.80}, 'AMZN': {'name': 'Amazon.com Inc.', 'base_price': 185.60}, 'MSFT': {'name': 'Microsoft Corp.', 'base_price': 420.30}, 'TSLA': {'name': 'Tesla Inc.', 'base_price': 245.20}}\n" + - "@mcp.tool()\n" + - "def get_stock_price(symbol: str) -> dict:\n" + - " symbol = symbol.upper()\n" + - " if symbol not in STOCKS:\n" + - " return {'error': f'Unknown symbol: {symbol}', 'available': list(STOCKS.keys())}\n" + - " stock = STOCKS[symbol]\n" + - " price = round(stock['base_price'] * (1 + random.uniform(-0.05, 0.05)), 2)\n" + - " change = round(price - stock['base_price'], 2)\n" + - " return {'symbol': symbol, 'name': stock['name'], 'price': price, 'change': change, 'volume': random.randint(1000000, 50000000)}\n" + - "@mcp.tool()\n" + - "def get_market_summary() -> dict:\n" + - " return {'indices': [{'name': 'S&P 500', 'value': round(5200 + random.uniform(-50, 50), 2)}, {'name': 'NASDAQ', 'value': round(16400 + random.uniform(-150, 150), 2)}, {'name': 'DOW', 'value': round(39200 + random.uniform(-200, 200), 2)}]}\n" + - "mcp.run(transport='streamable-http', host='0.0.0.0', port=8001, stateless_http=True)\n" + - '"', - ], - ports: [{ containerPort: 8001 }], - }, - ], - }, - }, - }, - }); - stockDeployment.node.addDependency(namespace); + // --- Stock MCP Server (second MCP server, routed via NGINX Ingress) --- + const stockDeployment = cluster.addManifest("StockMcpDeployment", { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: "stock-mcp-server", + namespace: "mcp-server", + }, + spec: { + replicas: 1, + selector: { matchLabels: { app: "stock-mcp-server" } }, + template: { + metadata: { labels: { app: "stock-mcp-server" } }, + spec: { + containers: [ + { + name: "stock-mcp-server", + image: "python:3.12-slim", + command: [ + "sh", + "-c", + 'pip install "fastmcp>=2.0" && python -c "\n' + + "import random\n" + + "from fastmcp import FastMCP\n" + + "mcp = FastMCP('Stock Price MCP Server')\n" + + "STOCKS = {'AAPL': {'name': 'Apple Inc.', 'base_price': 195.50}, 'GOOGL': {'name': 'Alphabet Inc.', 'base_price': 141.80}, 'AMZN': {'name': 'Amazon.com Inc.', 'base_price': 185.60}, 'MSFT': {'name': 'Microsoft Corp.', 'base_price': 420.30}, 'TSLA': {'name': 'Tesla Inc.', 'base_price': 245.20}}\n" + + "@mcp.tool()\n" + + "def get_stock_price(symbol: str) -> dict:\n" + + " symbol = symbol.upper()\n" + + " if symbol not in STOCKS:\n" + + " return {'error': f'Unknown symbol: {symbol}', 'available': list(STOCKS.keys())}\n" + + " stock = STOCKS[symbol]\n" + + " price = round(stock['base_price'] * (1 + random.uniform(-0.05, 0.05)), 2)\n" + + " change = round(price - stock['base_price'], 2)\n" + + " return {'symbol': symbol, 'name': stock['name'], 'price': price, 'change': change, 'volume': random.randint(1000000, 50000000)}\n" + + "@mcp.tool()\n" + + "def get_market_summary() -> dict:\n" + + " return {'indices': [{'name': 'S&P 500', 'value': round(5200 + random.uniform(-50, 50), 2)}, {'name': 'NASDAQ', 'value': round(16400 + random.uniform(-150, 150), 2)}, {'name': 'DOW', 'value': round(39200 + random.uniform(-200, 200), 2)}]}\n" + + "@mcp.resource('shared://collision-demo')\n" + + "def collision_demo() -> str:\n" + + " return 'served by stock-mcp (resourcePriority=100 — should be shadowed by mcp-server=10)'\n" + + "mcp.run(transport='streamable-http', host='0.0.0.0', port=8001, stateless_http=True)\n" + + '"', + ], + ports: [{ containerPort: 8001 }], + }, + ], + }, + }, + }, + }); + stockDeployment.node.addDependency(namespace); - const stockMcpService = cluster.addManifest("StockMcpService", { - apiVersion: "v1", - kind: "Service", - metadata: { - name: "stock-mcp-server", - namespace: "mcp-server", - }, - spec: { - type: "ClusterIP", - selector: { app: "stock-mcp-server" }, - ports: [ - { - name: "http", - port: 8001, - targetPort: 8001, - protocol: "TCP", - }, - ], - }, - }); - stockMcpService.node.addDependency(stockDeployment); + const stockMcpService = cluster.addManifest("StockMcpService", { + apiVersion: "v1", + kind: "Service", + metadata: { + name: "stock-mcp-server", + namespace: "mcp-server", + }, + spec: { + type: "ClusterIP", + selector: { app: "stock-mcp-server" }, + ports: [ + { + name: "http", + port: 8001, + targetPort: 8001, + protocol: "TCP", + }, + ], + }, + }); + stockMcpService.node.addDependency(stockDeployment); - // --- NGINX Ingress resource --- - // Path-based routing: /mcp-server/* → mcp-server:8000, /stock-mcp/* → stock-mcp-server:8001 - // The rewrite-target annotation strips the prefix so backends see /mcp (what FastMCP expects) - const ingress = cluster.addManifest("McpIngress", { - apiVersion: "networking.k8s.io/v1", - kind: "Ingress", - metadata: { - name: "mcp-ingress", - namespace: "mcp-server", - annotations: { - "kubernetes.io/ingress.class": "nginx", - "nginx.ingress.kubernetes.io/rewrite-target": "/$2", - "nginx.ingress.kubernetes.io/proxy-buffering": "off", - "nginx.ingress.kubernetes.io/proxy-read-timeout": "3600", - "nginx.ingress.kubernetes.io/proxy-send-timeout": "3600", - }, - }, - spec: { - ingressClassName: "nginx", - rules: [ - { - http: { - paths: [ - { - path: "/mcp-server(/|$)(.*)", - pathType: "ImplementationSpecific", - backend: { - service: { - name: "mcp-server", - port: { number: 8000 }, - }, - }, - }, - { - path: "/stock-mcp(/|$)(.*)", - pathType: "ImplementationSpecific", - backend: { - service: { - name: "stock-mcp-server", - port: { number: 8001 }, - }, - }, - }, - ], - }, - }, - ], - }, - }); - ingress.node.addDependency(mcpService); - ingress.node.addDependency(stockMcpService); + // --- NGINX Ingress resource --- + // Path-based routing: /mcp-server/* → mcp-server:8000, /stock-mcp/* → stock-mcp-server:8001 + // The rewrite-target annotation strips the prefix so backends see /mcp (what FastMCP expects) + const ingress = cluster.addManifest("McpIngress", { + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { + name: "mcp-ingress", + namespace: "mcp-server", + annotations: { + "kubernetes.io/ingress.class": "nginx", + "nginx.ingress.kubernetes.io/rewrite-target": "/$2", + "nginx.ingress.kubernetes.io/proxy-buffering": "off", + "nginx.ingress.kubernetes.io/proxy-read-timeout": "3600", + "nginx.ingress.kubernetes.io/proxy-send-timeout": "3600", + }, + }, + spec: { + ingressClassName: "nginx", + rules: [ + { + http: { + paths: [ + { + path: "/mcp-server(/|$)(.*)", + pathType: "ImplementationSpecific", + backend: { + service: { + name: "mcp-server", + port: { number: 8000 }, + }, + }, + }, + { + path: "/stock-mcp(/|$)(.*)", + pathType: "ImplementationSpecific", + backend: { + service: { + name: "stock-mcp-server", + port: { number: 8001 }, + }, + }, + }, + ], + }, + }, + ], + }, + }); + ingress.node.addDependency(mcpService); + ingress.node.addDependency(stockMcpService); - // --- NLB for NGINX Ingress Controller --- - // Created here (not in the Helm chart) to use numeric targetPort, - // avoiding named-port resolution issues with the AWS LB Controller. - const privateSubnetIds = props.kubectlPrivateSubnetIds.join(","); - const nlbService = cluster.addManifest("NginxNlbService", { - apiVersion: "v1", - kind: "Service", - metadata: { - name: "nginx-ingress-nlb", - namespace: "ingress-nginx", - annotations: { - "service.beta.kubernetes.io/aws-load-balancer-type": "nlb", - "service.beta.kubernetes.io/aws-load-balancer-scheme": "internal", - "service.beta.kubernetes.io/aws-load-balancer-nlb-target-type": "ip", - "service.beta.kubernetes.io/aws-load-balancer-ssl-cert": - props.certificateArn, - "service.beta.kubernetes.io/aws-load-balancer-ssl-ports": "443", - "service.beta.kubernetes.io/aws-load-balancer-subnets": - privateSubnetIds, - }, - }, - spec: { - type: "LoadBalancer", - selector: { - "app.kubernetes.io/name": "ingress-nginx", - "app.kubernetes.io/component": "controller", - }, - ports: [ - { - name: "https", - port: 443, - targetPort: 80, - protocol: "TCP", - }, - ], - }, - }); - nlbService.node.addDependency(ingress); + // --- NLB for NGINX Ingress Controller --- + // Created here (not in the Helm chart) to use numeric targetPort, + // avoiding named-port resolution issues with the AWS LB Controller. + const privateSubnetIds = props.kubectlPrivateSubnetIds.join(","); + const nlbService = cluster.addManifest("NginxNlbService", { + apiVersion: "v1", + kind: "Service", + metadata: { + name: "nginx-ingress-nlb", + namespace: "ingress-nginx", + annotations: { + "service.beta.kubernetes.io/aws-load-balancer-type": "nlb", + "service.beta.kubernetes.io/aws-load-balancer-scheme": "internal", + "service.beta.kubernetes.io/aws-load-balancer-nlb-target-type": "ip", + "service.beta.kubernetes.io/aws-load-balancer-ssl-cert": + props.certificateArn, + "service.beta.kubernetes.io/aws-load-balancer-ssl-ports": "443", + "service.beta.kubernetes.io/aws-load-balancer-subnets": + privateSubnetIds, + }, + }, + spec: { + type: "LoadBalancer", + selector: { + "app.kubernetes.io/name": "ingress-nginx", + "app.kubernetes.io/component": "controller", + }, + ports: [ + { + name: "https", + port: 443, + targetPort: 80, + protocol: "TCP", + }, + ], + }, + }); + nlbService.node.addDependency(ingress); - // Retain K8s manifests on stack deletion to avoid kubectl Lambda timeout. - // These resources are cleaned up when the EKS cluster is destroyed. - for (const manifest of [ - namespace, - deployment, - mcpService, - stockDeployment, - stockMcpService, - ingress, - nlbService, - ]) { - manifest.node.findAll().forEach((child) => { - if (child instanceof cdk.CfnResource) { - child.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); - } - }); - } + // Retain K8s manifests on stack deletion to avoid kubectl Lambda timeout. + // These resources are cleaned up when the EKS cluster is destroyed. + for (const manifest of [ + namespace, + deployment, + mcpService, + stockDeployment, + stockMcpService, + ingress, + nlbService, + ]) { + manifest.node.findAll().forEach((child) => { + if (child instanceof cdk.CfnResource) { + child.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); + } + }); + } - NagSuppressions.addStackSuppressions( - this, - [ - { - id: "AwsSolutions-IAM4", - reason: "EKS kubectl provider uses CDK-managed policies", - }, - { - id: "AwsSolutions-IAM5", - reason: "EKS kubectl provider uses CDK-managed wildcard permissions", - }, - { - id: "AwsSolutions-L1", - reason: "Lambda runtime is managed by CDK EKS construct", - }, - ], - true, - ); - } + // --- Route 53 Private Hosted Zone --- + // Empty zone associated with the VPC. The notebook adds an Alias A + // record pointing at the K8s-managed NGINX Ingress NLB once it's + // been provisioned (NLB DNS isn't known at deploy time). + // AgentCore's Resource Gateway uses Private DNS to resolve this + // domain — no routingDomain needed. + const privateZone = new route53.PrivateHostedZone(this, "PrivateZone", { + zoneName: props.privateDomain, + vpc: props.vpc, + }); + + new cdk.CfnOutput(this, "PrivateDomain", { + value: props.privateDomain, + description: + "Private domain — notebook adds an Alias A record to the NGINX NLB", + }); + + new cdk.CfnOutput(this, "PrivateZoneId", { + value: privateZone.hostedZoneId, + description: + "Route 53 private hosted zone ID (used by the notebook to UPSERT the NLB alias record)", + }); + + NagSuppressions.addStackSuppressions( + this, + [ + { + id: "AwsSolutions-IAM4", + reason: "EKS kubectl provider uses CDK-managed policies", + }, + { + id: "AwsSolutions-IAM5", + reason: "EKS kubectl provider uses CDK-managed wildcard permissions", + }, + { + id: "AwsSolutions-L1", + reason: "Lambda runtime is managed by CDK EKS construct", + }, + ], + true, + ); + } } diff --git a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/test3-api-eks-stack.ts b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/test3-api-eks-stack.ts index 74e2b07a..2c589e49 100644 --- a/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/test3-api-eks-stack.ts +++ b/01-tutorials/02-AgentCore-gateway/16-vpc-egress/lib/test3-api-eks-stack.ts @@ -1,148 +1,173 @@ import * as cdk from "aws-cdk-lib/core"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as eks from "aws-cdk-lib/aws-eks"; +import * as route53 from "aws-cdk-lib/aws-route53"; import { KubectlV31Layer } from "@aws-cdk/lambda-layer-kubectl-v31"; import { NagSuppressions } from "cdk-nag"; import { Construct } from "constructs"; export interface ApiEksStackProps extends cdk.StackProps { - clusterName: string; - kubectlRoleArn: string; - kubectlSecurityGroupId: string; - kubectlPrivateSubnetIds: string[]; - vpc: ec2.IVpc; - certificateArn: string; + clusterName: string; + kubectlRoleArn: string; + kubectlSecurityGroupId: string; + kubectlPrivateSubnetIds: string[]; + vpc: ec2.IVpc; + certificateArn: string; + /** FQDN covered by the public certificate, e.g. "internal.example.com" */ + privateDomain: string; } export class ApiEksStack extends cdk.Stack { - constructor(scope: Construct, id: string, props: ApiEksStackProps) { - super(scope, id, props); + constructor(scope: Construct, id: string, props: ApiEksStackProps) { + super(scope, id, props); - const cluster = eks.Cluster.fromClusterAttributes(this, "ImportedCluster", { - clusterName: props.clusterName, - kubectlRoleArn: props.kubectlRoleArn, - kubectlSecurityGroupId: props.kubectlSecurityGroupId, - kubectlPrivateSubnetIds: props.kubectlPrivateSubnetIds, - vpc: props.vpc, - kubectlLayer: new KubectlV31Layer(this, "KubectlLayer"), - }); + const cluster = eks.Cluster.fromClusterAttributes(this, "ImportedCluster", { + clusterName: props.clusterName, + kubectlRoleArn: props.kubectlRoleArn, + kubectlSecurityGroupId: props.kubectlSecurityGroupId, + kubectlPrivateSubnetIds: props.kubectlPrivateSubnetIds, + vpc: props.vpc, + kubectlLayer: new KubectlV31Layer(this, "KubectlLayer"), + }); - // --- Kubernetes resources --- - const namespace = cluster.addManifest("ApiNamespace", { - apiVersion: "v1", - kind: "Namespace", - metadata: { name: "rest-api" }, - }); + // --- Kubernetes resources --- + const namespace = cluster.addManifest("ApiNamespace", { + apiVersion: "v1", + kind: "Namespace", + metadata: { name: "rest-api" }, + }); - const deployment = cluster.addManifest("ApiDeployment", { - apiVersion: "apps/v1", - kind: "Deployment", - metadata: { - name: "rest-api", - namespace: "rest-api", - }, - spec: { - replicas: 1, - selector: { matchLabels: { app: "rest-api" } }, - template: { - metadata: { labels: { app: "rest-api" } }, - spec: { - containers: [ - { - name: "rest-api", - image: "python:3.12-slim", - command: [ - "sh", - "-c", - 'pip install fastapi uvicorn && python -c "\n' + - "from fastapi import FastAPI\n" + - "app = FastAPI()\n" + - "items = []\n" + - "@app.get('/health')\n" + - "def health():\n" + - " return {'status': 'ok'}\n" + - "@app.get('/items')\n" + - "def list_items():\n" + - " return items\n" + - "@app.post('/items')\n" + - "def create_item(item: dict):\n" + - " items.append(item)\n" + - " return item\n" + - "import uvicorn\n" + - "uvicorn.run(app, host='0.0.0.0', port=8080)\n" + - '"', - ], - ports: [{ containerPort: 8080 }], - }, - ], - }, - }, - }, - }); - deployment.node.addDependency(namespace); + const deployment = cluster.addManifest("ApiDeployment", { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: "rest-api", + namespace: "rest-api", + }, + spec: { + replicas: 1, + selector: { matchLabels: { app: "rest-api" } }, + template: { + metadata: { labels: { app: "rest-api" } }, + spec: { + containers: [ + { + name: "rest-api", + image: "python:3.12-slim", + command: [ + "sh", + "-c", + 'pip install fastapi uvicorn && python -c "\n' + + "from fastapi import FastAPI\n" + + "app = FastAPI()\n" + + "items = []\n" + + "@app.get('/health')\n" + + "def health():\n" + + " return {'status': 'ok'}\n" + + "@app.get('/items')\n" + + "def list_items():\n" + + " return items\n" + + "@app.post('/items')\n" + + "def create_item(item: dict):\n" + + " items.append(item)\n" + + " return item\n" + + "import uvicorn\n" + + "uvicorn.run(app, host='0.0.0.0', port=8080)\n" + + '"', + ], + ports: [{ containerPort: 8080 }], + }, + ], + }, + }, + }, + }); + deployment.node.addDependency(namespace); - // NLB created via Kubernetes Service type LoadBalancer with AWS annotations - const privateSubnetIds = props.kubectlPrivateSubnetIds.join(","); - const nlbService = cluster.addManifest("ApiNlbService", { - apiVersion: "v1", - kind: "Service", - metadata: { - name: "rest-api-nlb", - namespace: "rest-api", - annotations: { - "service.beta.kubernetes.io/aws-load-balancer-type": "nlb", - "service.beta.kubernetes.io/aws-load-balancer-scheme": "internal", - "service.beta.kubernetes.io/aws-load-balancer-nlb-target-type": "ip", - "service.beta.kubernetes.io/aws-load-balancer-ssl-cert": - props.certificateArn, - "service.beta.kubernetes.io/aws-load-balancer-ssl-ports": "443", - "service.beta.kubernetes.io/aws-load-balancer-subnets": - privateSubnetIds, - }, - }, - spec: { - type: "LoadBalancer", - selector: { app: "rest-api" }, - ports: [ - { - name: "https", - port: 443, - targetPort: 8080, - protocol: "TCP", - }, - ], - }, - }); - nlbService.node.addDependency(deployment); + // NLB created via Kubernetes Service type LoadBalancer with AWS annotations + const privateSubnetIds = props.kubectlPrivateSubnetIds.join(","); + const nlbService = cluster.addManifest("ApiNlbService", { + apiVersion: "v1", + kind: "Service", + metadata: { + name: "rest-api-nlb", + namespace: "rest-api", + annotations: { + "service.beta.kubernetes.io/aws-load-balancer-type": "nlb", + "service.beta.kubernetes.io/aws-load-balancer-scheme": "internal", + "service.beta.kubernetes.io/aws-load-balancer-nlb-target-type": "ip", + "service.beta.kubernetes.io/aws-load-balancer-ssl-cert": + props.certificateArn, + "service.beta.kubernetes.io/aws-load-balancer-ssl-ports": "443", + "service.beta.kubernetes.io/aws-load-balancer-subnets": + privateSubnetIds, + }, + }, + spec: { + type: "LoadBalancer", + selector: { app: "rest-api" }, + ports: [ + { + name: "https", + port: 443, + targetPort: 8080, + protocol: "TCP", + }, + ], + }, + }); + nlbService.node.addDependency(deployment); - // Retain K8s manifests on stack deletion to avoid kubectl Lambda timeout. - // The NLB deprovisioning can exceed Lambda's 15-min limit, causing cdk destroy to hang. - // These resources are cleaned up when the EKS cluster is destroyed. - for (const manifest of [namespace, deployment, nlbService]) { - manifest.node.findAll().forEach((child) => { - if (child instanceof cdk.CfnResource) { - child.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); - } - }); - } + // Retain K8s manifests on stack deletion to avoid kubectl Lambda timeout. + // The NLB deprovisioning can exceed Lambda's 15-min limit, causing cdk destroy to hang. + // These resources are cleaned up when the EKS cluster is destroyed. + for (const manifest of [namespace, deployment, nlbService]) { + manifest.node.findAll().forEach((child) => { + if (child instanceof cdk.CfnResource) { + child.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); + } + }); + } - NagSuppressions.addStackSuppressions( - this, - [ - { - id: "AwsSolutions-IAM4", - reason: "EKS kubectl provider uses CDK-managed policies", - }, - { - id: "AwsSolutions-IAM5", - reason: "EKS kubectl provider uses CDK-managed wildcard permissions", - }, - { - id: "AwsSolutions-L1", - reason: "Lambda runtime is managed by CDK EKS construct", - }, - ], - true, - ); - } + // --- Route 53 Private Hosted Zone --- + // Empty zone associated with the VPC. The notebook adds an Alias A + // record pointing at the K8s-managed NLB once it's been provisioned + // (NLB DNS isn't known at deploy time). AgentCore's Resource Gateway + // uses Private DNS to resolve this domain — no routingDomain needed. + const privateZone = new route53.PrivateHostedZone(this, "PrivateZone", { + zoneName: props.privateDomain, + vpc: props.vpc, + }); + + new cdk.CfnOutput(this, "PrivateDomain", { + value: props.privateDomain, + description: + "Private domain — notebook adds an Alias A record to the NLB", + }); + + new cdk.CfnOutput(this, "PrivateZoneId", { + value: privateZone.hostedZoneId, + description: + "Route 53 private hosted zone ID (used by the notebook to UPSERT the NLB alias record)", + }); + + NagSuppressions.addStackSuppressions( + this, + [ + { + id: "AwsSolutions-IAM4", + reason: "EKS kubectl provider uses CDK-managed policies", + }, + { + id: "AwsSolutions-IAM5", + reason: "EKS kubectl provider uses CDK-managed wildcard permissions", + }, + { + id: "AwsSolutions-L1", + reason: "Lambda runtime is managed by CDK EKS construct", + }, + ], + true, + ); + } }