diff --git a/cdk/bootstrap/BOOTSTRAP_HASH b/cdk/bootstrap/BOOTSTRAP_HASH index bc08e5ced..35b70e2bd 100644 --- a/cdk/bootstrap/BOOTSTRAP_HASH +++ b/cdk/bootstrap/BOOTSTRAP_HASH @@ -1 +1 @@ -b0501c8a57f20e4b5bf50d6fe8bbb934310adc4b64480228565d8392a19d1503 +40d0a8b2343663084f614423fc3e6210761377dedf0bc079956ba7d4cea84c5e diff --git a/cdk/bootstrap/BOOTSTRAP_VERSION b/cdk/bootstrap/BOOTSTRAP_VERSION index 26aaba0e8..f0bb29e76 100644 --- a/cdk/bootstrap/BOOTSTRAP_VERSION +++ b/cdk/bootstrap/BOOTSTRAP_VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/cdk/bootstrap/bootstrap-template.yaml b/cdk/bootstrap/bootstrap-template.yaml index 8026647ce..a2dcd08b4 100644 --- a/cdk/bootstrap/bootstrap-template.yaml +++ b/cdk/bootstrap/bootstrap-template.yaml @@ -1,13 +1,13 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # This template is generated by: npx tsx scripts/generate-bootstrap-template.ts -# ABCA Bootstrap Policy Version: 1.2.0 -# ABCA Bootstrap Policy Hash: b0501c8a57f20e4b5bf50d6fe8bbb934310adc4b64480228565d8392a19d1503 +# ABCA Bootstrap Policy Version: 1.3.0 +# ABCA Bootstrap Policy Hash: 40d0a8b2343663084f614423fc3e6210761377dedf0bc079956ba7d4cea84c5e # # Based on the default CDK bootstrap template with the following modifications: # - BootstrapVariant set to "ABCA: Least-Privilege Bootstrap" # - ComputeTypes parameter added for compute-variant selection -# - IncludeComputeEcs condition added -# - 5 inline AWS::IAM::ManagedPolicy resources replace AdministratorAccess +# - IncludeComputeEcs / IncludeComputeLambdaMicrovms conditions added +# - 6 inline AWS::IAM::ManagedPolicy resources replace AdministratorAccess # - CloudFormationExecutionRole references our least-privilege policies # - BootstrapPolicyVersion, BootstrapPolicyHash, BootstrapPolicySet outputs added Description: This stack includes resources needed to deploy AWS CDK apps into this environment @@ -77,7 +77,7 @@ Parameters: ComputeTypes: Type: CommaDelimitedList Default: agentcore - Description: 'Comma-separated list of compute backends to enable. Valid values: agentcore, ecs.' + Description: 'Comma-separated list of compute backends to enable. Valid values: agentcore, ecs, lambda-microvm.' Conditions: HasTrustedAccounts: Fn::Not: @@ -148,6 +148,19 @@ Conditions: - Fn::Join: - '' - Ref: ComputeTypes + IncludeComputeLambdaMicrovms: + Fn::Not: + - Fn::Equals: + - Fn::Select: + - 0 + - Fn::Split: + - lambda-microvm + - Fn::Join: + - '' + - Ref: ComputeTypes + - Fn::Join: + - '' + - Ref: ComputeTypes Resources: FileAssetsBucketEncryptionKey: Type: AWS::KMS::Key @@ -728,6 +741,10 @@ Resources: - IncludeComputeEcs - Ref: IaCRoleABCAComputeEcs - Ref: AWS::NoValue + - Fn::If: + - IncludeComputeLambdaMicrovms + - Ref: IaCRoleABCAComputeLambdaMicrovms + - Ref: AWS::NoValue RoleName: Fn::Sub: cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} PermissionsBoundary: @@ -1342,6 +1359,39 @@ Resources: Version: '2012-10-17' Description: 'ABCA Bootstrap: IaCRole-ABCA-Compute-ECS permissions for CloudFormation execution role' Condition: IncludeComputeEcs + IaCRoleABCAComputeLambdaMicrovms: + Type: AWS::IAM::ManagedPolicy + Properties: + ManagedPolicyName: + Fn::Sub: cdk-${Qualifier}-IaCRole-ABCA-Compute-LambdaMicrovms-${AWS::AccountId}-${AWS::Region} + PolicyDocument: + Statement: + - Action: + - lambda:CreateMicrovmImage + - lambda:GetMicrovmImage + - lambda:UpdateMicrovmImage + - lambda:DeleteMicrovmImage + - lambda:ListMicrovmImages + - lambda:GetMicrovmImageVersion + - lambda:UpdateMicrovmImageVersion + - lambda:DeleteMicrovmImageVersion + - lambda:ListMicrovmImageVersions + - lambda:GetMicrovmImageBuild + - lambda:ListMicrovmImageBuilds + - lambda:ListManagedMicrovmImages + - lambda:ListManagedMicrovmImageVersions + - lambda:CreateNetworkConnector + - lambda:GetNetworkConnector + - lambda:UpdateNetworkConnector + - lambda:DeleteNetworkConnector + - lambda:ListNetworkConnectors + - lambda:PassNetworkConnector + Effect: Allow + Resource: '*' + Sid: LambdaMicrovms + Version: '2012-10-17' + Description: 'ABCA Bootstrap: IaCRole-ABCA-Compute-LambdaMicrovms permissions for CloudFormation execution role' + Condition: IncludeComputeLambdaMicrovms Outputs: BucketName: Description: The name of the S3 bucket owned by the CDK toolkit stack @@ -1370,10 +1420,10 @@ Outputs: Value: '32' BootstrapPolicyVersion: Description: The version of the ABCA bootstrap policy bundle - Value: 1.2.0 + Value: 1.3.0 BootstrapPolicyHash: Description: SHA-256 hash of the ABCA bootstrap policy bundle for drift detection - Value: b0501c8a57f20e4b5bf50d6fe8bbb934310adc4b64480228565d8392a19d1503 + Value: 40d0a8b2343663084f614423fc3e6210761377dedf0bc079956ba7d4cea84c5e BootstrapPolicySet: Description: Comma-separated list of active ABCA bootstrap policy names Value: @@ -1387,3 +1437,7 @@ Outputs: - IncludeComputeEcs - Compute-ECS - Ref: AWS::NoValue + - Fn::If: + - IncludeComputeLambdaMicrovms + - Compute-LambdaMicrovms + - Ref: AWS::NoValue diff --git a/cdk/bootstrap/policies/compute-lambda-microvm.json b/cdk/bootstrap/policies/compute-lambda-microvm.json new file mode 100644 index 000000000..302a385a8 --- /dev/null +++ b/cdk/bootstrap/policies/compute-lambda-microvm.json @@ -0,0 +1,31 @@ +{ + "Statement": [ + { + "Action": [ + "lambda:CreateMicrovmImage", + "lambda:GetMicrovmImage", + "lambda:UpdateMicrovmImage", + "lambda:DeleteMicrovmImage", + "lambda:ListMicrovmImages", + "lambda:GetMicrovmImageVersion", + "lambda:UpdateMicrovmImageVersion", + "lambda:DeleteMicrovmImageVersion", + "lambda:ListMicrovmImageVersions", + "lambda:GetMicrovmImageBuild", + "lambda:ListMicrovmImageBuilds", + "lambda:ListManagedMicrovmImages", + "lambda:ListManagedMicrovmImageVersions", + "lambda:CreateNetworkConnector", + "lambda:GetNetworkConnector", + "lambda:UpdateNetworkConnector", + "lambda:DeleteNetworkConnector", + "lambda:ListNetworkConnectors", + "lambda:PassNetworkConnector" + ], + "Effect": "Allow", + "Resource": "*", + "Sid": "LambdaMicrovms" + } + ], + "Version": "2012-10-17" +} diff --git a/cdk/mise.toml b/cdk/mise.toml index 8847487db..7bc6e44ad 100644 --- a/cdk/mise.toml +++ b/cdk/mise.toml @@ -47,7 +47,7 @@ run = "npx cdk deploy" # parameter directly on the CDKToolkit stack: # aws cloudformation update-stack --stack-name CDKToolkit --use-previous-template \ # --capabilities CAPABILITY_NAMED_IAM \ -# --parameters ParameterKey=ComputeTypes,ParameterValue=agentcore\,ecs +# --parameters ParameterKey=ComputeTypes,ParameterValue=agentcore\,ecs\,lambda-microvm # # verify: aws cloudformation describe-stacks --stack-name CDKToolkit --query 'Stacks[0].Parameters' [tasks.bootstrap] description = "Bootstrap CDK with least-privilege policies (ComputeTypes=agentcore; for ECS set the ComputeTypes CFN param on CDKToolkit — see comment above the task)" diff --git a/cdk/package.json b/cdk/package.json index 38dc196be..d44590778 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -21,6 +21,7 @@ "@aws-sdk/client-dynamodb": "^3.1078.0", "@aws-sdk/client-ecs": "^3.1078.0", "@aws-sdk/client-lambda": "^3.1078.0", + "@aws-sdk/client-lambda-microvms": "^3.1098.0", "@aws-sdk/client-s3": "^3.1078.0", "@aws-sdk/client-secrets-manager": "^3.1078.0", "@aws-sdk/credential-provider-node": "^3.972.61", diff --git a/cdk/scripts/README.md b/cdk/scripts/README.md index ab37decd5..548533c34 100644 --- a/cdk/scripts/README.md +++ b/cdk/scripts/README.md @@ -1,3 +1,11 @@ # CDK helper scripts Bundling for Lambda assets is handled at synth time; the **`bundle`** task in **`cdk/mise.toml`** is a no-op placeholder for **`cdk/cdk.json`**. Prefer **`mise //cdk:*`** tasks from the repository root (`MISE_EXPERIMENTAL=1`). + +| Script | Purpose | Invoke via | +|--------|---------|------------| +| `generate-bootstrap-artifacts.ts` | Regenerates `cdk/bootstrap/policies/*.json`, `BOOTSTRAP_VERSION`, `BOOTSTRAP_HASH` from the typed policies in `src/bootstrap/policies/` | `mise //cdk:bootstrap:generate` | +| `generate-bootstrap-template.ts` | Regenerates `cdk/bootstrap/bootstrap-template.yaml` (least-privilege CDK bootstrap, `ComputeTypes`-gated compute policies) | `mise //cdk:bootstrap:generate` | +| `package-microvm-artifact.sh` | Packages `agent/` + `contracts/` + `Dockerfile` into the zip artifact an `AWS::Lambda::MicrovmImage` builds from, and uploads it to the CDK-created artifact bucket (ADR-021) | run directly — see the script header for the full bootstrap sequence | + +`package-microvm-artifact.sh` exists because CloudFormation cannot produce its own MicroVM `codeArtifact`: the image resource consumes a zip that must already be in S3, and there is no CDK asset type for "zip + Dockerfile a MicroVM image builds from". Everything else on that backend (buckets, roles, network connector, log group, the image resource itself) is CDK-managed in `src/constructs/lambda-microvm-compute.ts`. diff --git a/cdk/scripts/generate-bootstrap-artifacts.ts b/cdk/scripts/generate-bootstrap-artifacts.ts index 7d08935d0..ca4a32dd8 100644 --- a/cdk/scripts/generate-bootstrap-artifacts.ts +++ b/cdk/scripts/generate-bootstrap-artifacts.ts @@ -24,6 +24,7 @@ import { applicationPolicy, computeAgentcorePolicy, computeEcsPolicy, + computeLambdaMicrovmPolicy, infrastructurePolicy, observabilityPolicy, } from '../src/bootstrap/policies'; @@ -38,6 +39,7 @@ const policies = [ { name: 'observability', fn: observabilityPolicy }, { name: 'compute-agentcore', fn: computeAgentcorePolicy }, { name: 'compute-ecs', fn: computeEcsPolicy }, + { name: 'compute-lambda-microvm', fn: computeLambdaMicrovmPolicy }, ]; for (const { name, fn } of policies) { diff --git a/cdk/scripts/generate-bootstrap-template.ts b/cdk/scripts/generate-bootstrap-template.ts index 732e7cb05..75a741302 100644 --- a/cdk/scripts/generate-bootstrap-template.ts +++ b/cdk/scripts/generate-bootstrap-template.ts @@ -35,6 +35,7 @@ import { applicationPolicy, computeAgentcorePolicy, computeEcsPolicy, + computeLambdaMicrovmPolicy, infrastructurePolicy, observabilityPolicy, } from '../src/bootstrap/policies'; @@ -66,30 +67,36 @@ template.Parameters.ComputeTypes = { Type: 'CommaDelimitedList', Default: 'agentcore', Description: - 'Comma-separated list of compute backends to enable. Valid values: agentcore, ecs.', + 'Comma-separated list of compute backends to enable. Valid values: agentcore, ecs, lambda-microvm.', }; // --- Step 3: Add conditions --- -template.Conditions.IncludeComputeEcs = { - 'Fn::Not': [ - { - 'Fn::Equals': [ - { - 'Fn::Select': [ - 0, - { - 'Fn::Split': [ - 'ecs', - { 'Fn::Join': ['', { Ref: 'ComputeTypes' }] }, - ], - }, - ], - }, - { 'Fn::Join': ['', { Ref: 'ComputeTypes' }] }, - ], - }, - ], -}; +/** + * "The joined ComputeTypes list contains `needle`" as a CloudFormation + * condition. There is no `Fn::Contains`, so the trick is: split the joined list + * on the needle and compare element 0 against the whole string — they differ + * only if the needle was actually present. Extracted into a helper (it was + * inline for ECS) so a third backend cannot introduce a subtly different + * variant of the same expression. + */ +function includesComputeType(needle: string): Record { + const joined = { 'Fn::Join': ['', { Ref: 'ComputeTypes' }] }; + return { + 'Fn::Not': [ + { + 'Fn::Equals': [ + { 'Fn::Select': [0, { 'Fn::Split': [needle, joined] }] }, + joined, + ], + }, + ], + }; +} + +template.Conditions.IncludeComputeEcs = includesComputeType('ecs'); +// ADR-021: matched on the full `lambda-microvm` token, NOT a prefix — a bare +// `Fn::Split` on `lambda` would also fire for any future `lambda*` backend name. +template.Conditions.IncludeComputeLambdaMicrovms = includesComputeType('lambda-microvm'); // --- Step 4: Add managed policy resources --- interface PolicyDef { @@ -127,6 +134,12 @@ const policyDefs: PolicyDef[] = [ policyFn: computeEcsPolicy, condition: 'IncludeComputeEcs', }, + { + logicalId: 'IaCRoleABCAComputeLambdaMicrovms', + policyName: 'IaCRole-ABCA-Compute-LambdaMicrovms', + policyFn: computeLambdaMicrovmPolicy, + condition: 'IncludeComputeLambdaMicrovms', + }, ]; for (const { logicalId, policyName, policyFn, condition } of policyDefs) { @@ -164,6 +177,13 @@ const coreRefs = [ { Ref: 'IaCRoleABCAObservability' }, { Ref: 'IaCRoleABCAComputeAgentcore' }, { 'Fn::If': ['IncludeComputeEcs', { Ref: 'IaCRoleABCAComputeEcs' }, { Ref: 'AWS::NoValue' }] }, + { + 'Fn::If': [ + 'IncludeComputeLambdaMicrovms', + { Ref: 'IaCRoleABCAComputeLambdaMicrovms' }, + { Ref: 'AWS::NoValue' }, + ], + }, ]; template.Resources.CloudFormationExecutionRole.Properties.ManagedPolicyArns = { @@ -196,6 +216,13 @@ template.Outputs.BootstrapPolicySet = { 'Observability', 'Compute-Agentcore', { 'Fn::If': ['IncludeComputeEcs', 'Compute-ECS', { Ref: 'AWS::NoValue' }] }, + { + 'Fn::If': [ + 'IncludeComputeLambdaMicrovms', + 'Compute-LambdaMicrovms', + { Ref: 'AWS::NoValue' }, + ], + }, ], ], }, @@ -221,8 +248,8 @@ const header = [ '# Based on the default CDK bootstrap template with the following modifications:', '# - BootstrapVariant set to "ABCA: Least-Privilege Bootstrap"', '# - ComputeTypes parameter added for compute-variant selection', - '# - IncludeComputeEcs condition added', - '# - 5 inline AWS::IAM::ManagedPolicy resources replace AdministratorAccess', + '# - IncludeComputeEcs / IncludeComputeLambdaMicrovms conditions added', + '# - 6 inline AWS::IAM::ManagedPolicy resources replace AdministratorAccess', '# - CloudFormationExecutionRole references our least-privilege policies', '# - BootstrapPolicyVersion, BootstrapPolicyHash, BootstrapPolicySet outputs added', '', diff --git a/cdk/scripts/package-microvm-artifact.sh b/cdk/scripts/package-microvm-artifact.sh new file mode 100755 index 000000000..28d52e578 --- /dev/null +++ b/cdk/scripts/package-microvm-artifact.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# +# package-microvm-artifact.sh — build and upload the AWS Lambda MicroVMs image +# artifact for the ABCA agent (ADR-021, sub-decision 3). +# +# --------------------------------------------------------------------------- +# WHY THIS SCRIPT EXISTS +# --------------------------------------------------------------------------- +# `AWS::Lambda::MicrovmImage` *is* wired in CDK (see +# `cdk/src/constructs/lambda-microvm-compute.ts`), but CloudFormation cannot +# produce its own `codeArtifact`: the resource consumes a zip that already sits +# in S3, and that zip has to be assembled from the `agent/` tree. Unlike an ECR +# image there is no CDK asset type for "zip + Dockerfile a MicroVM image builds +# from", so packaging + upload is one out-of-band step, run once per agent +# change. Everything else — buckets, roles, connector, log group, the image +# resource itself — is CDK-managed. +# +# --------------------------------------------------------------------------- +# BOOTSTRAP SEQUENCE (first time) +# --------------------------------------------------------------------------- +# 1. Deploy the MicroVM substrate WITHOUT an image. Synth warns that no image +# is configured; that is expected — the artifact bucket must exist before +# you can upload to it. +# +# MISE_EXPERIMENTAL=1 mise //cdk:deploy -- --context compute_type=lambda-microvm +# +# 2. Package + upload the artifact (this script). It reads the bucket name and +# object key straight from the stack outputs, so there is nothing to copy +# by hand: +# +# cdk/scripts/package-microvm-artifact.sh --stack-name backgroundagent-dev +# +# 3. Pick a managed base image (the script prints the candidates; ADR-021's +# Region list applies — Lambda MicroVMs exist in 5 Regions at launch): +# +# aws lambda-microvms list-managed-microvm-images +# aws lambda-microvms list-managed-microvm-image-versions \ +# --image-arn +# +# 4. Redeploy with the base image pinned. THIS is the step that creates the +# image resource and injects MICROVM_IMAGE_IDENTIFIER into the orchestrator: +# +# MISE_EXPERIMENTAL=1 mise //cdk:deploy -- \ +# --context compute_type=lambda-microvm \ +# --context microvm_base_image_arn= \ +# --context microvm_base_image_version= +# +# On subsequent agent changes only step 2 is needed, followed by a CloudFormation +# update of the image resource (the service builds a NEW image version from the +# refreshed artifact). +# +# --------------------------------------------------------------------------- +# THE OUT-OF-BAND ALTERNATIVE (--create-image) +# --------------------------------------------------------------------------- +# Snapshot builds fail for ordinary Dockerfile reasons and take minutes, so +# iterating through CloudFormation is painful. With `--create-image` the script +# calls `aws lambda-microvms create-microvm-image` itself, using the CDK-created +# build role, connector and log group. Hand the resulting image to the +# orchestrator without CDK owning it: +# +# MISE_EXPERIMENTAL=1 mise //cdk:deploy -- \ +# --context compute_type=lambda-microvm \ +# --context microvm_image_identifier= +# +# --------------------------------------------------------------------------- +# WHAT GOES IN THE ZIP +# --------------------------------------------------------------------------- +# The service builds the zip's `Dockerfile` with the zip root as build context, +# so the layout must mirror the paths `agent/Dockerfile` COPYs from — which are +# repo-root-relative (`COPY agent/src/ …`, `COPY contracts/ …`). The staged tree +# is therefore: +# +# Dockerfile <- verbatim copy of agent/Dockerfile (MicroVM needs it at the root) +# agent/ <- minus .venv/, __pycache__, test caches +# contracts/ <- cross-language constants the agent reads at runtime +# +# --------------------------------------------------------------------------- +# !! A P1 IMAGE IS NOT RUNNABLE END TO END !! +# --------------------------------------------------------------------------- +# This script packages and uploads a real artifact, and the image the service +# builds from it will reach ACTIVE — but the backend does not work yet. +# ADR-021 sub-decision 3's hook-phasing table splits declaring a hook from +# serving it: +# +# /run declared by the CDK construct in P1, +# SERVED by the agent in P2 +# /ready, /validate declared + served in P2 (build hooks; a +# /validate that 404s fails every build) +# /terminate P2; /suspend, /resume P3 +# +# So an image built today boots the existing FastAPI server and then does not +# answer /run: a lambda-microvm task will start a MicroVM and fail to progress +# past session start. Keep production repos on compute_type=agentcore or ecs +# until P2 (smoke parity) lands. The Dockerfile is copied unmodified for the same +# reason — adapting it to a MicroVM base image and adding the hook endpoints is +# agent-side P2/P3 work. This script is the packaging half only. +# +# Requires: awscli v2, zip, rsync, python3 (none of which are installed by this script). + +set -euo pipefail + +STACK_NAME="${STACK_NAME:-backgroundagent-dev}" +CREATE_IMAGE=0 +BASE_IMAGE_ARN="${MICROVM_BASE_IMAGE_ARN:-}" +BASE_IMAGE_VERSION="${MICROVM_BASE_IMAGE_VERSION:-}" +IMAGE_NAME="${MICROVM_IMAGE_NAME:-}" +KEEP_STAGE=0 + +usage() { + cat <<'USAGE' +Usage: package-microvm-artifact.sh [options] + + --stack-name NAME ABCA stack to read outputs from (default: backgroundagent-dev, + or $STACK_NAME) + --create-image After uploading, call `aws lambda-microvms create-microvm-image` + using the CDK-created build role / connector / log group + --base-image-arn ARN Managed base image ARN (required with --create-image) + --base-image-version V Managed base image version (required with --create-image) + --image-name NAME Image name for --create-image (default: -abca-agent) + --keep-stage Leave the staging directory in place for inspection + -h, --help This message +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --stack-name) STACK_NAME="$2"; shift 2 ;; + --create-image) CREATE_IMAGE=1; shift ;; + --base-image-arn) BASE_IMAGE_ARN="$2"; shift 2 ;; + --base-image-version) BASE_IMAGE_VERSION="$2"; shift 2 ;; + --image-name) IMAGE_NAME="$2"; shift 2 ;; + --keep-stage) KEEP_STAGE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +for tool in aws zip python3 rsync; do + command -v "$tool" >/dev/null 2>&1 || { echo "error: '$tool' is required but not on PATH" >&2; exit 1; } +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# --- Stack outputs ------------------------------------------------------------ +# One describe-stacks call, then pull each output by key. Failing loudly here +# (rather than uploading to a guessed bucket) is deliberate: a typo'd bucket +# name would silently produce an artifact the build role cannot read, and the +# only symptom would be a failed snapshot build. +echo "==> Reading outputs from stack '${STACK_NAME}'" +STACK_JSON="$(aws cloudformation describe-stacks --stack-name "${STACK_NAME}" --output json)" + +stack_output() { + STACK_JSON="${STACK_JSON}" python3 -c ' +import json, os, sys +key = sys.argv[1] +stacks = json.loads(os.environ["STACK_JSON"])["Stacks"] +for output in stacks[0].get("Outputs", []): + if output["OutputKey"] == key: + print(output["OutputValue"]) + break +' "$1" +} + +ARTIFACT_BUCKET="$(stack_output MicrovmArtifactBucketName)" +ARTIFACT_KEY="$(stack_output MicrovmArtifactObjectKey)" +BUILD_ROLE_ARN="$(stack_output MicrovmBuildRoleArn)" +EGRESS_CONNECTORS="$(stack_output MicrovmEgressConnectorArns)" +LOG_GROUP="$(stack_output MicrovmLogGroupName)" + +if [[ -z "${ARTIFACT_BUCKET}" || -z "${ARTIFACT_KEY}" ]]; then + cat >&2 < Staging directory kept at ${STAGE_DIR}" + fi +} +trap cleanup EXIT + +echo "==> Staging agent tree in ${STAGE_DIR}" +# The MicroVM build context is the zip root, and agent/Dockerfile COPYs +# repo-root-relative paths — so the staged tree mirrors the repo, with the +# Dockerfile additionally promoted to the root where the service looks for it. +cp "${REPO_ROOT}/agent/Dockerfile" "${STAGE_DIR}/Dockerfile" + +# Excludes match the build inputs the Dockerfile never COPYs but which dominate +# the zip size: the local virtualenv, Python/pytest caches, and node_modules. +rsync -a \ + --exclude '.venv/' \ + --exclude '__pycache__/' \ + --exclude '.pytest_cache/' \ + --exclude '.ruff_cache/' \ + --exclude '.mypy_cache/' \ + --exclude 'node_modules/' \ + --exclude '*.pyc' \ + "${REPO_ROOT}/agent" "${STAGE_DIR}/" +rsync -a --exclude 'node_modules/' "${REPO_ROOT}/contracts" "${STAGE_DIR}/" + +ARTIFACT_ZIP="${STAGE_DIR}.zip" +rm -f "${ARTIFACT_ZIP}" +echo "==> Zipping to ${ARTIFACT_ZIP}" +( cd "${STAGE_DIR}" && zip -q -r "${ARTIFACT_ZIP}" . ) +echo " $(du -h "${ARTIFACT_ZIP}" | cut -f1) artifact" + +# --- Upload ------------------------------------------------------------------- +echo "==> Uploading to s3://${ARTIFACT_BUCKET}/${ARTIFACT_KEY}" +aws s3 cp "${ARTIFACT_ZIP}" "s3://${ARTIFACT_BUCKET}/${ARTIFACT_KEY}" +rm -f "${ARTIFACT_ZIP}" + +if [[ "${CREATE_IMAGE}" -eq 0 ]]; then + cat < Artifact uploaded. Next: create (or update) the image. + + CDK-managed (recommended) — redeploy with the base image pinned: + + aws lambda-microvms list-managed-microvm-images + MISE_EXPERIMENTAL=1 mise //cdk:deploy -- \\ + --context compute_type=lambda-microvm \\ + --context microvm_base_image_arn= \\ + --context microvm_base_image_version= + + Out of band — re-run this script with: + + --create-image --base-image-arn --base-image-version + +!! REMINDER (ADR-021 P1): the resulting image is NOT runnable end to end. It + advertises the /run lifecycle hook, which the agent does not serve until P2, + so a lambda-microvm task will start a MicroVM and then fail to progress past + session start. Keep production repos on compute_type=agentcore or ecs until + P2 (smoke parity) lands. +EOF + exit 0 +fi + +# --- Optional: create the image out of band ----------------------------------- +if [[ -z "${BASE_IMAGE_ARN}" || -z "${BASE_IMAGE_VERSION}" ]]; then + echo "error: --create-image requires --base-image-arn and --base-image-version." >&2 + echo " Discover them with: aws lambda-microvms list-managed-microvm-images" >&2 + exit 2 +fi + +IMAGE_NAME="${IMAGE_NAME:-${STACK_NAME}-abca-agent}" + +echo "==> Creating MicroVM image '${IMAGE_NAME}'" +# Flags use the Lambda MicroVMs service API shape (which differs from the +# CloudFormation shape used by CfnMicrovmImage). Notably: ARM_64, the platform +# egress connector (so build-time network egress is subject to the same DNS +# Firewall rules), and ENABLED for the service's fixed `/run` hook on port 8080 +# — the only lifecycle hook P1 configures. +aws lambda-microvms create-microvm-image \ + --name "${IMAGE_NAME}" \ + --description "ABCA agent snapshot for ${STACK_NAME} (ADR-021, out-of-band build)" \ + --base-image-arn "${BASE_IMAGE_ARN}" \ + --base-image-version "${BASE_IMAGE_VERSION}" \ + --build-role-arn "${BUILD_ROLE_ARN}" \ + --code-artifact "{\"uri\":\"s3://${ARTIFACT_BUCKET}/${ARTIFACT_KEY}\"}" \ + --cpu-configurations '[{"architecture":"ARM_64"}]' \ + --resources '[{"minimumMemoryInMiB":32768}]' \ + --egress-network-connectors "${EGRESS_CONNECTORS}" \ + --logging "{\"cloudWatch\":{\"logGroup\":\"${LOG_GROUP}\"}}" \ + --hooks '{"port":8080,"microvmHooks":{"run":"ENABLED","runTimeoutInSeconds":60}}' \ + --tags "abca:compute-backend=lambda-microvm" + +cat < Image creation started. Poll the build (snapshot builds take minutes): + + aws lambda-microvms list-microvm-image-builds --image-identifier ${IMAGE_NAME} --image-version 1 + aws logs tail ${LOG_GROUP} --since 20m --follow + +Once the build reports ACTIVE, point the orchestrator at it: + + MISE_EXPERIMENTAL=1 mise //cdk:deploy -- \\ + --context compute_type=lambda-microvm \\ + --context microvm_image_identifier=${IMAGE_NAME} + +!! REMINDER (ADR-021 P1): ACTIVE means the snapshot built, NOT that the backend + works. The image advertises the /run lifecycle hook, which the agent does not + serve until P2, so a lambda-microvm task will start a MicroVM and then fail to + progress past session start. Keep production repos on compute_type=agentcore + or ecs until P2 (smoke parity) lands. CDK synth emits the same warning + (abca:microvm-image-p1-not-runnable) on every deploy that configures an image. +EOF diff --git a/cdk/src/bootstrap/policies/compute-lambda-microvm.ts b/cdk/src/bootstrap/policies/compute-lambda-microvm.ts new file mode 100644 index 000000000..8e1c120d6 --- /dev/null +++ b/cdk/src/bootstrap/policies/compute-lambda-microvm.ts @@ -0,0 +1,92 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { aws_iam as iam } from 'aws-cdk-lib'; + +/** + * Returns the IAM PolicyDocument for the IaCRole-ABCA-Compute-LambdaMicrovms role. + * + * Covers: MicroVM image and network-connector management for the AWS Lambda + * MicroVMs compute backend (ADR-021 sub-decision 4). + * + * This is the *deploy-time* (CloudFormation execution role) policy — the + * `AWS::Lambda::MicrovmImage` and `AWS::Lambda::NetworkConnector` resources the + * `LambdaMicrovmCompute` construct synthesizes. It deliberately does NOT include + * the *runtime* lifecycle actions (`lambda:RunMicrovm` / `GetMicrovm` / + * `TerminateMicrovm`): those belong to the orchestrator's own role, are granted + * per-deployment in `task-orchestrator.ts`, and would be a privilege escalation + * on a deploy role that never starts a session. + * + * `Resource: '*'` matches the `compute-ecs` precedent and is load-bearing here + * rather than lazy: several of these actions (`CreateMicrovmImage`, + * `PassNetworkConnector`, the `List*` reads) support no resource-level + * permissions at all in the Service Authorization Reference, so a narrowed ARN + * would silently never match and the deploy would fail with AccessDenied. + * + * Dependent actions that are already covered elsewhere in the bundle and are + * therefore not repeated: `iam:PassRole` (the build role, → + * `lambda.amazonaws.com`) and `iam:CreateServiceLinkedRole` (network-connector + * ENI management) live in the `infrastructure` policy; `lambda:TagResource` / + * `lambda:UntagResource` live in `application`. + */ +export function computeLambdaMicrovmPolicy(): iam.PolicyDocument { + return new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + sid: 'LambdaMicrovms', + effect: iam.Effect.ALLOW, + actions: [ + // MicroVM images — the snapshot CloudFormation creates, updates, + // replaces and deletes. `Get*`/`List*` are required by the CFN + // resource handlers for read/stabilization, not just by humans. + 'lambda:CreateMicrovmImage', + 'lambda:GetMicrovmImage', + 'lambda:UpdateMicrovmImage', + 'lambda:DeleteMicrovmImage', + 'lambda:ListMicrovmImages', + // Image *versions* and their builds: every snapshot build creates a + // new version, and CFN must be able to read build state (a failed + // build otherwise stalls the stack with no diagnosis) and delete + // versions on replace/rollback — the ADR calls out that versions are + // billed storage and need lifecycle cleanup. + 'lambda:GetMicrovmImageVersion', + 'lambda:UpdateMicrovmImageVersion', + 'lambda:DeleteMicrovmImageVersion', + 'lambda:ListMicrovmImageVersions', + 'lambda:GetMicrovmImageBuild', + 'lambda:ListMicrovmImageBuilds', + // Base images: resolving/validating the managed base image the + // snapshot is built on. + 'lambda:ListManagedMicrovmImages', + 'lambda:ListManagedMicrovmImageVersions', + // Network connectors — the platform-VPC egress path. + 'lambda:CreateNetworkConnector', + 'lambda:GetNetworkConnector', + 'lambda:UpdateNetworkConnector', + 'lambda:DeleteNetworkConnector', + 'lambda:ListNetworkConnectors', + // Documented dependent of CreateMicrovmImage/UpdateMicrovmImage: the + // image declares its egress connectors, so creating it *passes* them. + 'lambda:PassNetworkConnector', + ], + resources: ['*'], + }), + ], + }); +} diff --git a/cdk/src/bootstrap/policies/index.ts b/cdk/src/bootstrap/policies/index.ts index ef89b5bb8..cb775bab8 100644 --- a/cdk/src/bootstrap/policies/index.ts +++ b/cdk/src/bootstrap/policies/index.ts @@ -22,12 +22,14 @@ import { aws_iam as iam } from 'aws-cdk-lib'; import { applicationPolicy } from './application'; import { computeAgentcorePolicy } from './compute-agentcore'; import { computeEcsPolicy } from './compute-ecs'; +import { computeLambdaMicrovmPolicy } from './compute-lambda-microvm'; import { infrastructurePolicy } from './infrastructure'; import { observabilityPolicy } from './observability'; export { applicationPolicy } from './application'; export { computeAgentcorePolicy } from './compute-agentcore'; export { computeEcsPolicy } from './compute-ecs'; +export { computeLambdaMicrovmPolicy } from './compute-lambda-microvm'; export { infrastructurePolicy } from './infrastructure'; export { observabilityPolicy } from './observability'; @@ -41,5 +43,6 @@ export function allPolicies(): iam.PolicyDocument[] { observabilityPolicy(), computeAgentcorePolicy(), computeEcsPolicy(), + computeLambdaMicrovmPolicy(), ]; } diff --git a/cdk/src/bootstrap/resource-action-map.ts b/cdk/src/bootstrap/resource-action-map.ts index f27394961..cb117290f 100644 --- a/cdk/src/bootstrap/resource-action-map.ts +++ b/cdk/src/bootstrap/resource-action-map.ts @@ -81,6 +81,12 @@ export const RESOURCE_ACTION_MAP: Record = { 'AWS::Lambda::EventSourceMapping': ['lambda:CreateEventSourceMapping'], 'AWS::Lambda::Function': ['lambda:CreateFunction'], 'AWS::Lambda::LayerVersion': ['lambda:PublishLayerVersion'], + // ADR-021 lambda-microvm backend. Only synthesized under + // `--context compute_type=lambda-microvm`, so the default-context + // synth-coverage test never sees these — they are mapped anyway so the map + // stays a complete statement of what the bootstrap bundle must cover. + 'AWS::Lambda::MicrovmImage': ['lambda:CreateMicrovmImage'], + 'AWS::Lambda::NetworkConnector': ['lambda:CreateNetworkConnector'], 'AWS::Logs::Delivery': ['logs:CreateDelivery'], 'AWS::Logs::DeliveryDestination': ['logs:PutDeliveryDestination'], 'AWS::Logs::DeliverySource': ['logs:PutDeliverySource'], @@ -104,7 +110,7 @@ export const RESOURCE_ACTION_MAP: Record = { * Returns true when {@link allowedAction} covers {@link requiredAction}. * Supports service-level wildcards (e.g. `bedrock-agentcore:*`). */ -export function actionIsAllowed(requiredAction: string, allowedAction: string): boolean { +function actionIsAllowed(requiredAction: string, allowedAction: string): boolean { if (allowedAction === requiredAction) { return true; } diff --git a/cdk/src/bootstrap/version.ts b/cdk/src/bootstrap/version.ts index d81d32326..17ad37ae7 100644 --- a/cdk/src/bootstrap/version.ts +++ b/cdk/src/bootstrap/version.ts @@ -21,8 +21,15 @@ import { createHash } from 'node:crypto'; import { allPolicies } from './policies'; -/** Semantic version of the bootstrap policy bundle. */ -export const BOOTSTRAP_VERSION = '1.2.0'; +/** + * Semantic version of the bootstrap policy bundle. + * + * Bump history: 1.0.0 → 1.1.0 added the `compute-ecs` policy (#162), 1.1.0 → + * 1.2.0 refreshed policies for a full deploy (#350), 1.2.0 → 1.3.0 adds the + * `compute-lambda-microvm` policy (#645 / ADR-021). Adding a policy to the + * bundle is a minor bump — that is the precedent `compute-ecs` set. + */ +export const BOOTSTRAP_VERSION = '1.3.0'; /** * Computes a SHA-256 hash over all bootstrap policies. diff --git a/cdk/src/constructs/lambda-microvm-compute.ts b/cdk/src/constructs/lambda-microvm-compute.ts new file mode 100644 index 000000000..9c3e85f34 --- /dev/null +++ b/cdk/src/constructs/lambda-microvm-compute.ts @@ -0,0 +1,808 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Annotations, ArnFormat, Duration, RemovalPolicy, Stack, Tags, Token } from 'aws-cdk-lib'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; +// Single source of truth for the supported-Region list. ADR-021's +// `microvm-regions.ts` header is explicit that the list "is the ONLY place the +// list is declared — do not copy it", so the synth-time gate IMPORTS it rather +// than duplicating it. The module is a dependency-free pair of pure constants +// (no AWS SDK, no Lambda-runtime code), so pulling it into the CDK app tree +// costs nothing and cannot drift. +import { AgentSessionRole } from './agent-session-role'; +import { LAMBDA_MICROVM_SUPPORTED_REGIONS, isLambdaMicrovmRegionSupported } from '../handlers/shared/microvm-regions'; + +/** + * Lifecycle expiry for MicroVM `/run` hook payloads, in days. + * + * Mirrors {@link ECS_PAYLOAD_TTL_DAYS} in shape but NOT in role: on ECS the + * orchestrator deletes the payload at finalize and the rule is only a crash + * backstop, whereas the MicroVM strategy never deletes (ADR-021 sub-decision 3 + * / `microvmPayloadKey`) — so on this backend the lifecycle rule is the ONLY + * reaper. Payloads carry the hydrated prompt context, so the TTL stays as tight + * as the read-once-at-`/run` access pattern allows. + */ +export const MICROVM_PAYLOAD_TTL_DAYS = 1; + +/** + * Cost-allocation tag applied to every resource this construct creates + * (ADR-021 sub-decision 4, "Cost attribution"). + * + * The stack-level `compute_type` tag in `main.ts` carries a single value and is + * already imprecise with two backends; these per-resource tags make MicroVM + * spend attributable regardless of what the stack-level tag says. + */ +export const MICROVM_BACKEND_TAG_KEY = 'abca:compute-backend'; + +/** Tag value identifying the Lambda MicroVMs backend. */ +export const MICROVM_BACKEND_TAG_VALUE = 'lambda-microvm'; + +/** + * CloudWatch Logs namespace Lambda MicroVMs writes build- and run-time logs + * under. Both the build role and the execution role are scoped to this prefix. + */ +export const MICROVM_LOG_GROUP_PREFIX = '/aws/lambda-microvms'; + +/** + * Default S3 key the packaging helper (`cdk/scripts/package-microvm-artifact.sh`) + * uploads the zip + Dockerfile artifact to, inside the artifact bucket this + * construct creates. Kept in one place so the script, the `CfnMicrovmImage` + * `codeArtifact.uri`, and the build role's `s3:GetObject` scope cannot drift. + */ +export const MICROVM_ARTIFACT_OBJECT_KEY = 'microvm-images/agent-artifact.zip'; + +/** + * TCP port the agent's FastAPI server listens on inside the snapshot + * (`agent/Dockerfile` → `EXPOSE 8080`), and therefore the port the MicroVM + * lifecycle-hook listener is configured for. + */ +const AGENT_HOOK_PORT = 8080; + +/** + * `/run` hook path. Load-bearing in P1: it is how the task payload reaches the + * agent (`runHookPayload`, ADR-021 sub-decision 3) — there is no other + * orchestrator→agent channel on this backend. + */ +const RUN_HOOK_PATH = '/run'; + +/** `/run` hook budget (seconds). The hook only validates + starts the pipeline + * asynchronously, so it stays well inside the service's 1–60 s hook window. */ +const RUN_HOOK_TIMEOUT_SECONDS = 60; + +/** + * Memory the image declares as its minimum, in MiB. 32 GiB is the service's + * hard ceiling for MicroVMs (ADR-021 capability table), and the ABCA agent is a + * build-heavy workload, so P1 asks for the ceiling rather than a smaller + * default it would only OOM against. Repos that genuinely need more stay on the + * `ecs` backend — that constraint is accepted in the ADR, not worked around here. + */ +const DEFAULT_MINIMUM_MEMORY_MIB = 32768; + +/** Retention for the MicroVM log group — parity with the ECS task log group. */ +const LOG_RETENTION = logs.RetentionDays.THREE_MONTHS; + +/** HTTPS port — the only egress allowed out of the MicroVM ENIs. */ +const HTTPS_PORT = 443; + +/** Graviton/ARM64: the agent image is ARM64 on every backend. */ +const CPU_ARCHITECTURE = 'arm64'; + +/** + * CDK context flag that bypasses the synth-time Region gate. + * + * Exists because {@link LAMBDA_MICROVM_SUPPORTED_REGIONS} rots by design: when + * AWS launches Lambda MicroVMs in a new Region, an operator there must not have + * to wait for an ABCA release. The live probes (CLI onboarding, `platform + * doctor`) already accept the new Region, so this flag only unblocks synth. + */ +export const MICROVM_REGION_OVERRIDE_CONTEXT = 'microvm_region_override'; + +/** + * Fail synth when the `lambda-microvm` backend is enabled in a Region that is + * not in the statically documented support list (ADR-021 sub-decision 4, + * "Regional availability enforcement" — the synth/deploy row). + * + * Three behaviours worth knowing: + * + * - **Unresolved (token) Region → check SKIPPED.** A region-agnostic app + * (`new Stack(app, 'X')` with no `env`, or `env.region` left to the CLI) + * resolves `Stack.region` to the `AWS::Region` pseudo-parameter, whose value + * is unknowable at synth. Comparing a token against a Region list would + * reject every region-agnostic synth — including `cdk synth` on a developer + * box with no `CDK_DEFAULT_REGION` — so the static layer stands down and the + * live probes (onboarding / doctor / orchestration classification) carry the + * enforcement. This is a deliberate hole in the *static* layer only. + * - **Escape hatch.** `--context microvm_region_override=true` skips the check; + * the error message names the flag so an operator in a just-launched Region + * is never blocked on a code change. + * - **Failure is a synth-time throw, not a warning.** ADR-021 requires "synth + * fails when ComputeTypes includes lambda-microvm in an unlisted Region"; a + * warning would let a broken deploy through to a runtime AccessDenied. + */ +export function assertLambdaMicrovmRegionSupported(scope: Construct): void { + const region = Stack.of(scope).region; + + if (Token.isUnresolved(region)) { + // Region-agnostic synth — nothing to compare. See the doc comment above. + return; + } + if (isLambdaMicrovmRegionSupported(region)) { + return; + } + if (scope.node.tryGetContext(MICROVM_REGION_OVERRIDE_CONTEXT)) { + Annotations.of(scope).addWarningV2( + 'abca:microvm-region-override', + `The lambda-microvm compute backend is enabled in ${region}, which is not in the ABCA ` + + `supported-Region list (${LAMBDA_MICROVM_SUPPORTED_REGIONS.join(', ')}). Proceeding because ` + + `--context ${MICROVM_REGION_OVERRIDE_CONTEXT} is set. If AWS has launched Lambda MicroVMs in ` + + `${region}, add it to LAMBDA_MICROVM_SUPPORTED_REGIONS (cdk/src/handlers/shared/microvm-regions.ts) ` + + 'and drop the flag.', + ); + return; + } + + throw new Error( + `AWS Lambda MicroVMs are not available in ${region}. The lambda-microvm compute backend is ` + + 'enabled (--context compute_type=lambda-microvm) but the stack Region is not one of: ' + + `${LAMBDA_MICROVM_SUPPORTED_REGIONS.join(', ')}. Either deploy the stack into a supported ` + + 'Region, drop the backend (--context compute_type=agentcore or ecs), or — if AWS has since ' + + `launched Lambda MicroVMs in ${region} — bypass this static check with ` + + `--context ${MICROVM_REGION_OVERRIDE_CONTEXT}=true and add ${region} to ` + + 'LAMBDA_MICROVM_SUPPORTED_REGIONS in cdk/src/handlers/shared/microvm-regions.ts.', + ); +} + +/** + * The four operator-supplied image inputs, read from CDK context by the stack. + * + * Extracted into a type so the stack can resolve them ONCE, before `TaskApi` is + * constructed, and hand the same object to this construct — see + * {@link isLambdaMicrovmImageConfigured} for why that ordering matters. + */ +export interface LambdaMicrovmImageInputs { + readonly baseImageArn?: string; + readonly baseImageVersion?: string; + readonly externalImageIdentifier?: string; + readonly externalImageVersion?: string; +} + +/** + * True when {@link inputs} selects one of the two image-provisioning states + * (managed-base-image build, or an out-of-band image), i.e. the deployment will + * have a MicroVM image and therefore an `imageArn` to scope IAM against. + * + * Exists so the three-state decision documented on {@link LambdaMicrovmCompute} + * is made in exactly ONE place. `TaskApi` is constructed before this construct + * (the cancel Lambda's ARN is needed earlier, hence the `Lazy.string` holders in + * `stacks/agent.ts`), so the stack must know whether an image will exist *before* + * the construct that creates it runs. Without a shared predicate the stack and + * the construct would each re-derive that answer and could drift — and a drift + * here means either a missing cancel grant or a grant scoped to an image that + * does not exist. + */ +export function isLambdaMicrovmImageConfigured(inputs: LambdaMicrovmImageInputs): boolean { + return Boolean((inputs.baseImageArn && inputs.baseImageVersion) || inputs.externalImageIdentifier); +} + +/** + * Properties for {@link LambdaMicrovmCompute}. + */ +export interface LambdaMicrovmComputeProps extends LambdaMicrovmImageInputs { + /** + * Platform VPC. Egress leaves the MicroVM through a `AWS::Lambda::NetworkConnector` + * bound to this VPC's private-with-egress subnets, so the DNS Firewall / + * security-group / flow-log stack applies to MicroVM traffic unchanged + * (ADR-021 security table: "Egress ... None" delta). + */ + readonly vpc: ec2.IVpc; + + /** + * Per-task SessionRole (#209). When provided, the MicroVM **execution role** + * is admitted to the SessionRole's trust via + * {@link AgentSessionRole.admitComputeRole} — the mechanism was designed for + * exactly this (ADR-021 sub-decision 4) — so tenant-data access stays on the + * tag-scoped SessionRole instead of the execution role. Mirrors how + * `EcsAgentCluster` delegates the Fargate task role. Omitted in isolated + * construct tests, in which case NO tenant-data access is granted at all + * (this construct never grants DynamoDB directly: unlike the ECS backend + * there is no legacy direct-grant path to preserve). + */ + readonly agentSessionRole?: AgentSessionRole; + + /** + * ARN of the Lambda-managed base MicroVM image to build on + * (`aws lambda-microvms list-managed-microvm-images`), e.g. + * `arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1`. + * + * Supplying this **and** {@link baseImageVersion} switches the construct into + * its primary mode: it synthesizes an `AWS::Lambda::MicrovmImage` (L1) whose + * `codeArtifact.uri` points at {@link artifactObjectKey} in the artifact + * bucket this construct creates. There is no default — base-image ARNs are + * account/Region-scoped service data that is only discoverable through a live + * API call, so hardcoding one would be a guess that rots. + */ + readonly baseImageArn?: string; + + /** + * Version of {@link baseImageArn} + * (`aws lambda-microvms list-managed-microvm-image-versions`). Required + * alongside `baseImageArn` because CloudFormation marks it required on + * `AWS::Lambda::MicrovmImage` even though the API treats it as optional. + */ + readonly baseImageVersion?: string; + + /** + * Identifier (name or ARN) of a MicroVM image built **out of band** — i.e. by + * running `cdk/scripts/package-microvm-artifact.sh` and then + * `aws lambda-microvms create-microvm-image` by hand. + * + * Only consulted when {@link baseImageArn} is absent. It exists so an + * operator can iterate on the snapshot (which takes minutes and often several + * attempts) without a stack update per attempt, then hand the finished image + * to the orchestrator. + */ + readonly externalImageIdentifier?: string; + + /** + * Version of {@link externalImageIdentifier}. Optional: the service resolves + * the latest active version when omitted, which is what a + * rebuild-in-place workflow wants. + */ + readonly externalImageVersion?: string; + + /** + * Name for the MicroVM image and its log group. Must be unique in the + * account. + * @default `-abca-agent` + */ + readonly imageName?: string; + + /** + * S3 key of the zip + Dockerfile artifact inside the artifact bucket. + * @default MICROVM_ARTIFACT_OBJECT_KEY + */ + readonly artifactObjectKey?: string; + + /** + * Minimum memory the image declares, in MiB. + * @default 32768 (the service ceiling — see DEFAULT_MINIMUM_MEMORY_MIB) + */ + readonly minimumMemoryInMiB?: number; + + /** + * Non-secret environment variables baked into the snapshot at build time. + * + * Deliberately empty by default. ADR-021 sub-decision 3 forbids secrets, + * tokens, and per-task identity in the snapshot; the agent's non-secret + * configuration parity with the ECS container (table names, `MEMORY_ID`, + * `ARTIFACTS_BUCKET_NAME`, …) is P2 "smoke parity" work and is wired here + * when it lands. + * @default {} — no baked configuration + */ + readonly imageEnvironmentVariables?: Record; +} + +/** + * AWS Lambda MicroVMs compute backend — infrastructure half (ADR-021, + * sub-decision 4; P1 of the phased rollout). + * + * Provisions, in dependency order: + * + * 1. **Egress network connector** (`AWS::Lambda::NetworkConnector`) on the + * platform VPC's private-with-egress subnets, with a 443-only security + * group. This is what keeps the ADR's "Egress: no delta vs AgentCore/ECS" + * claim true — MicroVM traffic traverses the same NAT / DNS Firewall / flow + * logs as the other two backends. + * 2. **Artifact bucket** for the zip + Dockerfile the service builds the + * snapshot from, and a **payload bucket** for `/run` payloads that exceed + * the 16 KB `runHookPayload` cap. + * 3. **Build role** — assumed by Lambda during image creation: `s3:GetObject` + * on the artifact object and CloudWatch Logs writes. Without it Lambda + * cannot emit build logs, which makes a failed snapshot build undebuggable. + * 4. **Execution role** — assumed by the running MicroVM: CloudWatch Logs, + * read-only on the payload bucket, and (when a SessionRole is wired) + * admission to the per-task SessionRole for tenant-data access. + * 5. **MicroVM image** (`AWS::Lambda::MicrovmImage`) — see {@link baseImageArn} + * for why this is conditional. + * + * ## Image provisioning: three states, one construct + * + * | Props supplied | What happens | When to use it | + * |---|---|---| + * | `baseImageArn` + `baseImageVersion` | `AWS::Lambda::MicrovmImage` L1 is synthesized from `s3:///`; {@link imageIdentifier} is its ARN | steady state | + * | `externalImageIdentifier` | no image resource; the supplied identifier is handed to the orchestrator | iterating on the snapshot out of band | + * | neither | roles + buckets + connector only; a synth-time **warning**, no image, and no `MICROVM_IMAGE_IDENTIFIER` for the orchestrator | first deploy — you cannot upload the artifact before the bucket that holds it exists | + * + * That third state is not an oversight: the artifact bucket is created by this + * stack, so the very first `--context compute_type=lambda-microvm` deploy has + * nowhere to have put the zip yet. It is a **warning rather than a throw** + * precisely so the bootstrap sequence (deploy → run the packaging script + * against the now-existing bucket → redeploy with `microvm_base_image_arn`) is + * possible at all. A `lambda-microvm` task submitted in that interim window + * fails fast with the strategy's own "stack deployed without the MicroVM + * substrate" error, which names the remedy. + * + * ## ⚠️ A P1 image is NOT runnable end to end + * + * Reaching state 1 or 2 provisions a *complete substrate* and a *buildable + * image* — not a working backend. ADR-021 sub-decision 3's hook-phasing table + * splits the two: P1 (this construct) **declares** the `/run` hook, but serving + * it is agent-side work delivered in **P2**. An image built from a P1 deployment + * boots the existing FastAPI server and then does not answer `/run`, so a + * `lambda-microvm` task started against it will not progress past session start. + * + * This is called out in three places so it cannot be missed: here, in the + * synth-time warning emitted whenever an image IS configured (see + * `abca:microvm-image-p1-not-runnable` below), and in + * `cdk/scripts/package-microvm-artifact.sh`. `/ready`, `/validate`, + * `/suspend`, `/resume` and `/terminate` are deliberately not declared at all + * yet — a hook the service calls but nothing answers fails the corresponding + * build or lifecycle transition. + * + * ## Deliberately NOT here (P1 scope) + * + * The execution role gets **no** Bedrock, Secrets Manager, AgentCore Memory, or + * artifacts-bucket grants. On the ECS backend those exist because the agent + * actually runs there today; ADR-021 puts agent parity on this backend in P2 + * ("smoke parity … AgentCore Memory parity (IAM grant + MEMORY_ID)"). Adding + * them now would hand a role permissions nothing exercises, and would have to + * be reviewed twice. `lambda:SuspendMicrovm` / `lambda:ResumeMicrovm` are + * likewise absent (P3) and `lambda:CreateMicrovmAuthToken` is granted to no + * role in any phase — no JWE consumer exists (sub-decision 3). + */ +export class LambdaMicrovmCompute extends Construct { + /** S3 bucket holding the zip + Dockerfile the snapshot is built from. */ + public readonly artifactBucket: s3.Bucket; + + /** Key of the artifact object inside {@link artifactBucket}. */ + public readonly artifactObjectKey: string; + + /** S3 bucket holding oversized `/run` payloads (S3-pointer delivery). */ + public readonly payloadBucket: s3.Bucket; + + /** Role Lambda assumes while building the snapshot image. */ + public readonly buildRole: iam.Role; + + /** Role the running MicroVM (and its runtime lifecycle hooks) assumes. */ + public readonly executionRole: iam.Role; + + /** Egress network connector bound to the platform VPC. */ + public readonly egressConnector: lambda.CfnNetworkConnector; + + /** ARNs for `MICROVM_EGRESS_CONNECTOR_ARNS` / `lambda:PassNetworkConnector`. */ + public readonly egressConnectorArns: string[]; + + /** 443-only security group applied to the connector's ENIs. */ + public readonly securityGroup: ec2.SecurityGroup; + + /** Log group for MicroVM build- and run-time logs. */ + public readonly logGroup: logs.LogGroup; + + /** The image resource, when this deployment builds one (see class docs). */ + public readonly image?: lambda.CfnMicrovmImage; + + /** Image name used for the image resource and the log group. */ + public readonly imageName: string; + + /** + * Value for `MICROVM_IMAGE_IDENTIFIER`. `undefined` in the + * neither-input-supplied bootstrap state, in which case the stack must not + * inject the MicroVM env block at all. + */ + public readonly imageIdentifier?: string; + + /** Value for `MICROVM_IMAGE_VERSION`, when pinned. */ + public readonly imageVersion?: string; + + /** + * The image's IAM resource ARN. Always set whenever {@link imageIdentifier} + * is — a bare image name is resolved to its full + * `…:microvm-image:` ARN — so the orchestrator's `lambda:RunMicrovm` / + * `GetMicrovm` / `TerminateMicrovm` grant is *always* scoped to this one + * platform-created image and never widens to an account-level wildcard. + * `undefined` only in the no-image bootstrap state, where no grant is issued + * at all. + */ + public readonly imageArn?: string; + + constructor(scope: Construct, id: string, props: LambdaMicrovmComputeProps) { + super(scope, id); + + // Regional availability is checked HERE rather than in the stack so it + // cannot be lost to a stack refactor: constructing this construct at all + // means the backend is enabled. + assertLambdaMicrovmRegionSupported(this); + + const stack = Stack.of(this); + this.artifactObjectKey = props.artifactObjectKey ?? MICROVM_ARTIFACT_OBJECT_KEY; + this.imageName = props.imageName ?? sanitizeImageName(`${stack.stackName}-abca-agent`); + + // Backend-identifying cost-allocation tags on every resource below + // (ADR-021: "MicroVM-specific resources shall carry backend-identifying + // cost-allocation tags"). Applied at the construct scope so a resource + // added later cannot be forgotten. L1 MicroVM resources are ITaggableV2, + // so the aspect reaches them too. + Tags.of(this).add(MICROVM_BACKEND_TAG_KEY, MICROVM_BACKEND_TAG_VALUE); + + // --- Networking: egress through the platform VPC --- + this.securityGroup = new ec2.SecurityGroup(this, 'MicrovmSG', { + vpc: props.vpc, + description: 'Lambda MicroVMs agent sessions - egress TCP 443 only', + allowAllOutbound: false, + }); + this.securityGroup.addEgressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(HTTPS_PORT), + 'Allow HTTPS egress (GitHub API, AWS services)', + ); + + // The connector, not the MicroVM, owns the ENIs — which is why + // `lambda:PassNetworkConnector` is required on the orchestrator even for + // AWS-managed connectors (ADR-021 sub-decision 4). + // + // `associatedComputeResourceTypes: ['MicroVm']` is the only value the + // service accepts today (CloudFormation: "Currently, only MicroVm is + // supported"). `operatorRole` is left unset so Lambda manages the ENIs with + // its own service-linked role rather than a role we would have to trust. + this.egressConnector = new lambda.CfnNetworkConnector(this, 'EgressConnector', { + name: sanitizeImageName(`${stack.stackName}-microvm-egress`), + configuration: { + vpcEgressConfiguration: { + associatedComputeResourceTypes: ['MicroVm'], + networkProtocol: 'IPv4', + securityGroupIds: [this.securityGroup.securityGroupId], + subnetIds: props.vpc.selectSubnets({ + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }).subnetIds, + }, + }, + }); + this.egressConnectorArns = [this.egressConnector.attrArn]; + + // --- Logs --- + // Explicitly named under the service's `/aws/lambda-microvms/` namespace so + // the build/execution role grants can be prefix-scoped AND so retention is + // under our control (a service-created group defaults to never expire). + this.logGroup = new logs.LogGroup(this, 'MicrovmLogGroup', { + logGroupName: `${MICROVM_LOG_GROUP_PREFIX}/${this.imageName}`, + retention: LOG_RETENTION, + removalPolicy: RemovalPolicy.DESTROY, + }); + + // --- Buckets --- + // Artifact bucket: the zip + Dockerfile the service builds the snapshot + // from. Dedicated (not a prefix on the attachments/trace bucket) for the + // same structural reason EcsPayloadBucket is dedicated — the build role's + // s3:GetObject then cannot reach tenant data. Deliberately NO expiry rule: + // the artifact must survive as long as the image versions built from it, + // which the service may re-read. + this.artifactBucket = new s3.Bucket(this, 'ArtifactBucket', { + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + enforceSSL: true, + lifecycleRules: [ + { + id: 'microvm-artifact-mpu-abort', + enabled: true, + // The artifact is tens/hundreds of MB, so uploads are multipart; a + // failed `aws s3 cp` otherwise leaves billable parts forever. + abortIncompleteMultipartUploadAfter: Duration.days(1), + }, + ], + removalPolicy: RemovalPolicy.DESTROY, + autoDeleteObjects: true, + }); + + // Payload bucket: mirrors EcsPayloadBucket's configuration (BLOCK_ALL + + // enforceSSL + S3_MANAGED + tight object expiry). See + // MICROVM_PAYLOAD_TTL_DAYS for the one behavioural difference. + this.payloadBucket = new s3.Bucket(this, 'PayloadBucket', { + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + enforceSSL: true, + lifecycleRules: [ + { + id: 'microvm-payload-ttl', + enabled: true, + expiration: Duration.days(MICROVM_PAYLOAD_TTL_DAYS), + abortIncompleteMultipartUploadAfter: Duration.days(1), + }, + ], + removalPolicy: RemovalPolicy.DESTROY, + autoDeleteObjects: true, + }); + + // --- Roles --- + // + // TRUST POLICY (verified against the AWS developer guide, "Lambda MicroVMs + // → Security and permissions"): *both* the build role and the execution + // role are assumed by the ORDINARY Lambda service principal + // `lambda.amazonaws.com`, and *both* need `sts:AssumeRole` AND + // `sts:TagSession`. There is no `microvms.lambda.amazonaws.com` principal — + // using one is rejected at role-creation time with MalformedPolicyDocument. + // + // CONFUSED-DEPUTY: `aws:SourceAccount` is pinned to this account, which is + // the meaningful protection here — `lambda.amazonaws.com` is shared with + // every other Lambda feature, so without it any caller who could make + // Lambda act in *some* account could target these roles. + // + // `aws:SourceArn` is deliberately NOT added. Field reports of this exact + // pattern (an `ArnLike` on `…:microvm-image/*`) have the service failing to + // satisfy the condition — the image does not exist yet at build time — so + // both `create-microvm-image` and `run-microvm` fail with "unable to assume + // role". The AWS docs themselves are inconsistent about the separator in + // MicroVM image ARNs (`microvm-image:` vs `microvm-image/`), + // which is a second reason an ARN condition here is a deploy-time + // foot-gun. Narrowing to `aws:SourceArn` is a candidate once ADR-021's P1 + // "IAM action names / ARN formats" verification item is closed live. + const microvmAssumedBy = new iam.ServicePrincipal('lambda.amazonaws.com', { + conditions: { + StringEquals: { 'aws:SourceAccount': stack.account }, + }, + }); + + this.buildRole = new iam.Role(this, 'BuildRole', { + assumedBy: microvmAssumedBy, + description: + 'ABCA Lambda MicroVMs image-build role: reads the zip+Dockerfile artifact from S3 ' + + 'and writes snapshot build logs to CloudWatch.', + }); + grantTagSession(this.buildRole, microvmAssumedBy); + + // s3:GetObject only, scoped to the single artifact key — not the bucket. + // The build role runs the `/ready` and `/validate` build hooks, i.e. code + // from the repo under build, so it gets the narrowest possible read. + this.buildRole.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['s3:GetObject'], + resources: [this.artifactBucket.arnForObjects(this.artifactObjectKey)], + })); + this.grantMicrovmLogWrites(this.buildRole); + + this.executionRole = new iam.Role(this, 'ExecutionRole', { + assumedBy: microvmAssumedBy, + description: + 'ABCA Lambda MicroVMs execution role: assumed by the running MicroVM and its runtime ' + + 'lifecycle hooks; writes logs and reads out-of-band /run payloads.', + }); + grantTagSession(this.executionRole, microvmAssumedBy); + this.grantMicrovmLogWrites(this.executionRole); + + // READ-ONLY on the payload bucket (ADR-021: "The MicroVM execution role + // shall hold read-only access to the payload bucket, scoped to that + // bucket"). Read-only is not a nicety: the MicroVM runs untrusted repo + // code, so it must not be able to clobber another task's payload. Write + + // lifecycle stay with the trusted orchestrator. + this.payloadBucket.grantRead(this.executionRole); + + // Tenant-data access is delegated to the per-task SessionRole, exactly as + // EcsAgentCluster does for the Fargate task role. NOTE the asymmetry with + // that construct: there is no `else` branch granting DynamoDB directly — + // this backend has no legacy deployments to keep working, so a missing + // SessionRole means no tenant-data access rather than broad access. + if (props.agentSessionRole) { + props.agentSessionRole.admitComputeRole(this.executionRole); + } + + // --- Image --- + // Branch through the shared predicate's two components rather than an + // ad-hoc condition, so this construct and the stack's pre-TaskApi decision + // (isLambdaMicrovmImageConfigured) can never disagree. + if (props.baseImageArn && props.baseImageVersion) { + this.image = new lambda.CfnMicrovmImage(this, 'Image', { + name: this.imageName, + description: `ABCA agent snapshot for ${stack.stackName} (ADR-021 lambda-microvm backend)`, + baseImageArn: props.baseImageArn, + baseImageVersion: props.baseImageVersion, + buildRoleArn: this.buildRole.roleArn, + codeArtifact: { + uri: this.artifactBucket.s3UrlForObject(this.artifactObjectKey), + }, + // ARM64 everywhere — the agent image is Graviton on all three backends. + cpuConfigurations: [{ architecture: CPU_ARCHITECTURE }], + resources: [{ minimumMemoryInMiB: props.minimumMemoryInMiB ?? DEFAULT_MINIMUM_MEMORY_MIB }], + // Build-time network egress rides the same VPC connector as runtime, so + // `pip`/`npm`/`uv` fetches during the snapshot build are subject to the + // same DNS Firewall rules as the agent itself. + egressNetworkConnectors: this.egressConnectorArns, + logging: { cloudWatch: { logGroup: this.logGroup.logGroupName } }, + // No extra OS capabilities: the agent runs ordinary user-space tooling. + additionalOsCapabilities: [], + // Nothing baked in — see `imageEnvironmentVariables`. + environmentVariables: Object.entries(props.imageEnvironmentVariables ?? {}) + .map(([key, value]) => ({ key, value })), + hooks: { + port: AGENT_HOOK_PORT, + microvmHooks: { + // ONLY `/run` in P1 — and note the agent does not SERVE it until P2 + // (ADR-021 sub-decision 3's hook-phasing table); it is declared here + // so the image shape and IAM are reviewed once rather than twice. + // `/suspend` and `/resume` land with the P3 interface widening, and + // `/terminate` with P2: declaring a runtime hook the agent does not + // answer fails the corresponding lifecycle transition. P1 + // termination is the orchestrator's `TerminateMicrovm`, which needs + // no in-guest cooperation. + run: RUN_HOOK_PATH, + runTimeoutInSeconds: RUN_HOOK_TIMEOUT_SECONDS, + }, + // `microvmImageHooks` (`/ready`, `/validate`) are the snapshot-quality + // hooks ADR-021 sub-decision 3 wants, delivered in P2. Omitted in P1 + // because the agent does not implement them yet: configuring a + // `/validate` endpoint that 404s would fail every image build. + }, + }); + // The image reads the artifact through the build role, so both must exist + // (and the artifact bucket policy be in place) before the build starts. + this.image.node.addDependency(this.buildRole, this.artifactBucket); + + this.imageIdentifier = this.image.attrImageArn; + this.imageArn = this.image.attrImageArn; + // Version intentionally unpinned: the service resolves the latest ACTIVE + // version, which is what a redeploy-after-rebuild flow wants. Pinning to + // `attrLatestActiveImageVersion` would be empty on the very first create + // (the build has not finished) and would force a stack update per rebuild. + this.imageVersion = undefined; + } else if (props.externalImageIdentifier) { + this.imageIdentifier = props.externalImageIdentifier; + this.imageVersion = props.externalImageVersion; + // `imageIdentifier` may legitimately be a bare image NAME (that is what + // `create-microvm-image --name` returns and what `run-microvm + // --image-identifier` accepts), which is not itself an IAM resource. Do + // NOT let that widen the orchestrator's grant: the Service Authorization + // Reference gives the `microvmImage` resource an unambiguous shape — + // `arn:${Partition}:lambda:${Region}:${Account}:microvm-image:${MicrovmImageName}` + // — so the exact ARN is derivable from the name plus this stack's + // partition/Region/account. `formatArn` emits the Aws.PARTITION / + // Aws.REGION / Aws.ACCOUNT_ID pseudo-parameters, so it is correct in a + // region-agnostic app too. + // + // Note the version is NOT part of the resource ARN: the SAR pattern ends + // at the image name, and `RunMicrovm` carries `imageVersion` as a separate + // request field, so pinning a version must never change the IAM scope. + this.imageArn = props.externalImageIdentifier.startsWith('arn:') + ? props.externalImageIdentifier + : stack.formatArn({ + service: 'lambda', + resource: 'microvm-image', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: props.externalImageIdentifier, + }); + } else { + Annotations.of(this).addWarningV2( + 'abca:microvm-image-not-provisioned', + 'The lambda-microvm backend is enabled but no MicroVM image is configured, so the ' + + 'orchestrator will reject lambda-microvm tasks. This is the expected state of the FIRST ' + + 'deploy (the artifact bucket must exist before the artifact can be uploaded). Next: run ' + + 'cdk/scripts/package-microvm-artifact.sh to upload the zip+Dockerfile, then redeploy with ' + + '--context microvm_base_image_arn= --context microvm_base_image_version= ' + + '(or point at an image you built by hand with --context microvm_image_identifier=).', + ); + } + + if (this.imageIdentifier) { + // Emitted on EVERY deploy that configures an image, in both image states. + // Not a throw and not suppressible: P1 provisions a complete substrate and + // a buildable image, which looks indistinguishable from a working backend + // until a task silently fails to progress. ADR-021 sub-decision 3's + // hook-phasing table puts serving `/run` in P2 (agent-side), so an operator + // must be told that "deploy succeeded" is not "backend works". + Annotations.of(this).addWarningV2( + 'abca:microvm-image-p1-not-runnable', + 'A MicroVM image is configured, but the lambda-microvm backend is not yet runnable end to ' + + 'end (ADR-021 P1). The image advertises the /run lifecycle hook, and the agent does not ' + + 'serve it until P2 — so a lambda-microvm task will start a MicroVM and then fail to ' + + 'progress past session start. Keep production repos on compute_type=agentcore or ecs ' + + 'until P2 (smoke parity) lands. The /ready, /validate, /suspend, /resume and /terminate ' + + 'hooks are deliberately not declared yet: a hook the service calls but nothing answers ' + + 'fails the corresponding build or lifecycle transition.', + ); + } + + NagSuppressions.addResourceSuppressions([this.artifactBucket, this.payloadBucket], [ + { + id: 'AwsSolutions-S1', + reason: 'Artifact bucket holds a single build input (the agent zip+Dockerfile) read only by ' + + 'the Lambda MicroVMs build role; the payload bucket holds ephemeral per-task /run payloads ' + + `with a ${MICROVM_PAYLOAD_TTL_DAYS}-day TTL, written only by the orchestrator (grantPut) and ` + + 'read only by the MicroVM execution role, both scoped to the bucket. Object-level access ' + + 'logging (a second log bucket + CloudTrail data events) is not justified for a single ' + + 'build input or for transient boot payloads.', + }, + ], true); + + NagSuppressions.addResourceSuppressions([this.buildRole, this.executionRole], [ + { + id: 'AwsSolutions-IAM5', + reason: 'CloudWatch Logs wildcard is the service-owned ' + + `${MICROVM_LOG_GROUP_PREFIX}/* namespace (log stream names are minted per MicroVM, so no ` + + 'synth-time ARN exists); S3 object/* wildcard comes from CDK grantRead on the dedicated ' + + 'payload bucket (read-only, scoped to that bucket — ADR-021 sub-decision 3). The build ' + + 'role\'s s3:GetObject is scoped to a single object key, not a wildcard.', + }, + ], true); + } + + /** + * CloudWatch Logs writes scoped to the MicroVM log namespace. + * + * The service documents `logs:CreateLogGroup` + `CreateLogStream` + + * `PutLogEvents` for the build role; the execution role gets the same set so + * runtime logs land in the same, retention-managed group. `CreateLogGroup` is + * included even though this construct pre-creates the group: the service + * creates per-image/per-MicroVM groups under the prefix, and a missing + * `CreateLogGroup` silently costs you the build logs — the one artifact you + * need when a snapshot build fails. + */ + private grantMicrovmLogWrites(role: iam.IRole): void { + role.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'], + resources: [ + Stack.of(this).formatArn({ + service: 'logs', + resource: 'log-group', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: `${MICROVM_LOG_GROUP_PREFIX}/*`, + }), + Stack.of(this).formatArn({ + service: 'logs', + resource: 'log-group', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: `${MICROVM_LOG_GROUP_PREFIX}/*:log-stream:*`, + }), + ], + })); + } +} + +/** + * Add `sts:TagSession` alongside the `sts:AssumeRole` CDK's `assumedBy` emits. + * + * The MicroVM service needs BOTH actions (developer guide, "Trust policies"), + * but `iam.Role`'s `assumedBy` only renders `sts:AssumeRole`. Passing a second + * statement through `assumeRolePolicy` keeps the `aws:SourceAccount` condition + * identical on both actions — dropping it on the `TagSession` half would leave + * the confused-deputy hole half-open. + */ +function grantTagSession(role: iam.Role, principal: iam.ServicePrincipal): void { + role.assumeRolePolicy?.addStatements(new iam.PolicyStatement({ + actions: ['sts:TagSession'], + principals: [principal], + conditions: principal.policyFragment.conditions, + })); +} + +/** + * Reduce a candidate name to the character set MicroVM image / network + * connector names accept (alphanumerics, `-`, `_`) and cap its length. + * + * Stack names can contain characters these APIs reject, and an unresolved + * (token) stack name would otherwise produce a name containing `${Token[...]}`. + */ +function sanitizeImageName(candidate: string): string { + const MAX_NAME_LENGTH = 64; + return candidate + .replace(/[^A-Za-z0-9-_]/g, '-') + .slice(0, MAX_NAME_LENGTH) + .replace(/-+$/, ''); +} diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index 305285315..3ca475f80 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -189,6 +189,29 @@ export interface TaskApiProps { */ readonly ecsClusterArn?: string; + /** + * IAM resource ARN of the MicroVM image this deployment provisioned (ADR-021 + * sub-decision 4). When provided, the cancel Lambda gets + * `lambda:TerminateMicrovm` **scoped to that one image**, so a cancelled + * MicroVM-backed task actually stops billing — mirroring the conditional + * AgentCore `RUNTIME_ARN` / `ecsClusterArn` wiring above. + * + * An ARN rather than an on/off boolean so the grant is exactly scoped. `TaskApi` + * is constructed before `LambdaMicrovmCompute` (the cancel Lambda's ARN is + * needed earlier), so the stack passes a `Lazy.string` that resolves after the + * image exists — the same cycle-breaking pattern `agentCoreStopSessionRuntimeArn` + * uses for the AgentCore runtime ARN. + * + * Absent ⇒ no grant. That is the correct behaviour in the "backend enabled but + * no image configured yet" bootstrap state too: with no image there can be no + * MicroVM-backed task to cancel. + * + * No matching env var: `cancel-task.ts` reads the `microvmId` from the task + * row's `compute_metadata` and — unlike the AgentCore path — never falls back + * to a stack-level identifier. + */ + readonly lambdaMicrovmImageArn?: string; + /** * S3 bucket for task attachments. When provided, the create-task Lambda * gets PutObject/DeleteObject grants and the bucket name as env var. @@ -665,6 +688,35 @@ export class TaskApi extends Construct { })); } + // ADR-021: cancelling a `lambda-microvm` task must actively terminate the + // MicroVM — leaving it to the 8-hour `maximumDurationInSeconds` cap would + // keep billing 16 vCPU and would hold account memory quota that gates + // admission of new tasks. Conditional for the same reason the AgentCore and + // ECS grants above are: a deployment without the backend gets no grant. + // + // ONLY `lambda:TerminateMicrovm`. `cancel-task.ts` sends + // `TerminateMicrovmCommand` and nothing else — it does not read MicroVM + // state first — so `lambda:GetMicrovm` would be a permission with no caller. + // (The approve/deny Lambdas get `ResumeMicrovm` + `GetMicrovm` in P3, where + // a state read is genuinely needed for the resume reconciliation.) + // + // Resource is the MicroVM *image*, not the running instance: every MicroVM + // lifecycle action authorizes against `microvm-image:` (Service + // Authorization Reference), so the per-session `microvmId` never appears in + // IAM — which is what lets this be scoped to the one platform-created image + // rather than an account-wide `microvm-image:*`. The ARN arrives as a + // `Lazy.string` because TaskApi is built before the MicroVM construct. + // + // `:*` sibling: version-suffix hedge, same rationale as the + // orchestrator's lifecycle grant in `task-orchestrator.ts` — still pinned to + // this image's name, so it can never match a different image. + if (props.lambdaMicrovmImageArn) { + cancelTaskFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['lambda:TerminateMicrovm'], + resources: [props.lambdaMicrovmImageArn, `${props.lambdaMicrovmImageArn}:*`], + })); + } + // Repo table read for onboarding gate if (props.repoTable) { props.repoTable.grantReadData(createTaskFn); @@ -1296,7 +1348,7 @@ export class TaskApi extends Construct { }, { id: 'AwsSolutions-IAM5', - reason: 'DynamoDB index/* wildcards generated by CDK grantReadWriteData/grantReadData for GSI access', + reason: 'DynamoDB index/* wildcards generated by CDK grantReadWriteData/grantReadData for GSI access; ecs:StopTask is conditioned on the cluster ARN; lambda:TerminateMicrovm is scoped to the single platform MicroVM image ARN plus a :* version-suffix sibling (delivered as a Lazy.string because TaskApi is built before the MicroVM construct) — ADR-021', }, ], true); } diff --git a/cdk/src/constructs/task-orchestrator.ts b/cdk/src/constructs/task-orchestrator.ts index a638b369f..0b16ab3f3 100644 --- a/cdk/src/constructs/task-orchestrator.ts +++ b/cdk/src/constructs/task-orchestrator.ts @@ -174,6 +174,58 @@ export interface TaskOrchestratorProps { * ReadWrite grants for URL fetch/screen/upload during hydration. */ readonly attachmentsBucket?: s3.IBucket; + + /** + * AWS Lambda MicroVMs compute strategy configuration (ADR-021 sub-decision 4). + * When provided, the `MICROVM_*` env vars and the MicroVM lifecycle IAM + * statements are added to the orchestrator. + * + * Grouped into one all-or-nothing object for the same reason `ecsConfig` is: + * the strategy refuses to start a session unless `imageIdentifier`, + * `executionRoleArn`, `egressConnectorArns` and `payloadBucket` are ALL + * present, so the type makes a partial configuration unrepresentable instead + * of deferring the failure to the first task on the backend. + */ + readonly microvmConfig?: { + /** Image name or ARN passed as `imageIdentifier` on every `RunMicrovm`. */ + readonly imageIdentifier: string; + /** + * The image's IAM resource ARN, used to scope every MicroVM lifecycle grant + * to that one platform-created image (ADR-021). + * + * REQUIRED, and separate from {@link imageIdentifier} because that field may + * hold a bare image NAME (valid for `RunMicrovm`, not a valid IAM resource). + * Required rather than optional on purpose: an optional field here would + * silently re-introduce an account-wide `microvm-image:*` fallback, and no + * caller needs one — the `microvmImage` ARN shape is fully derivable from an + * image name plus the stack's partition/Region/account, which + * `LambdaMicrovmCompute` does. + */ + readonly imageArn: string; + /** + * Optional image version pin. Omitted ⇒ the service resolves the latest + * active version, which is what a rebuild-in-place flow wants. + */ + readonly imageVersion?: string; + /** Role the MicroVM assumes at runtime; passed on `RunMicrovm`. */ + readonly executionRoleArn: string; + /** Egress network connectors; comma-joined into the env var. */ + readonly egressConnectorArns: string[]; + /** + * Ingress network connectors. Empty in P1–P3: nothing dials into the + * MicroVM (no JWE tokens are minted at all), so no ingress is configured. + * The plumbing exists so #391 operator shell access can add one without a + * strategy change. + */ + readonly ingressConnectorArns?: string[]; + /** + * Bucket for `/run` payloads that exceed the 16 KB `runHookPayload` cap. + * The orchestrator gets **write only** — unlike the ECS payload bucket + * there is no finalize-time delete on this backend (the bucket's lifecycle + * rule is the reaper), so `grantDelete` would be an unused permission. + */ + readonly payloadBucket: s3.IBucket; + }; } /** @@ -276,6 +328,23 @@ export class TaskOrchestrator extends Construct { // #502: bucket the orchestrator writes the ECS payload to (and deletes // from at finalize); the ECS strategy reads this to build the S3 URI. ...(props.ecsPayloadBucket && { ECS_PAYLOAD_BUCKET: props.ecsPayloadBucket.bucketName }), + // ADR-021: the MicroVM substrate's deployment-time configuration. Names + // are the contract `lambda-microvm-strategy.ts` reads verbatim — do not + // rename one side without the other. + ...(props.microvmConfig && { + MICROVM_IMAGE_IDENTIFIER: props.microvmConfig.imageIdentifier, + MICROVM_EXECUTION_ROLE_ARN: props.microvmConfig.executionRoleArn, + MICROVM_EGRESS_CONNECTOR_ARNS: props.microvmConfig.egressConnectorArns.join(','), + MICROVM_PAYLOAD_BUCKET: props.microvmConfig.payloadBucket.bucketName, + ...(props.microvmConfig.imageVersion && { + MICROVM_IMAGE_VERSION: props.microvmConfig.imageVersion, + }), + // Omitted entirely when empty: the strategy treats an absent/blank + // value as "no ingress connectors" and omits the field on RunMicrovm. + ...((props.microvmConfig.ingressConnectorArns?.length ?? 0) > 0 && { + MICROVM_INGRESS_CONNECTOR_ARNS: props.microvmConfig.ingressConnectorArns!.join(','), + }), + }), ...(props.attachmentsBucket && { ATTACHMENTS_BUCKET_NAME: props.attachmentsBucket.bucketName }), }, bundling: orchestratorBundling, @@ -303,6 +372,16 @@ export class TaskOrchestrator extends Construct { props.ecsPayloadBucket.grantDelete(this.fn); } + // ADR-021: MicroVM payload bucket. WRITE only — the strategy uploads an + // oversized /run payload and never reads it back (the MicroVM execution + // role is the reader, with its own read-only grant), and it never deletes + // (the bucket's lifecycle rule reaps). No grantDelete, deliberately: the ECS + // path has one because the orchestrator deletes at finalize; this one does + // not, so the grant would be dead permission. + if (props.microvmConfig) { + props.microvmConfig.payloadBucket.grantPut(this.fn); + } + // Durable execution managed policy this.fn.role!.addManagedPolicy( iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicDurableExecutionRolePolicy'), @@ -364,6 +443,91 @@ export class TaskOrchestrator extends Construct { })); } + // Lambda MicroVMs compute strategy permissions (only when configured). + // + // EXACTLY the four control-plane actions the P1 strategy calls, per + // ADR-021's "only the MicroVM lifecycle actions it calls" requirement: + // RunMicrovm — startSession + // GetMicrovm — pollSession + // TerminateMicrovm — stopSession / finalize (the active cleanup path) + // PassNetworkConnector — required to attach egress connectors, even the + // AWS-managed ones + // + // NOT granted, deliberately: + // - lambda:SuspendMicrovm / lambda:ResumeMicrovm — the ADR's grant list + // names them, but P1 has no suspend/resume code path. They land with the + // P3 interface widening (mandatory suspendSession/resumeSession across + // all three strategies) together with the approve/deny Lambdas' + // conditional ResumeMicrovm + GetMicrovm. + // - lambda:CreateMicrovmAuthToken — granted to no role in any phase; no + // JWE consumer exists (ADR-021 sub-decision 3). + if (props.microvmConfig) { + // RESOURCE SCOPING — every MicroVM lifecycle action authorizes against the + // *image*, not the running instance: the Service Authorization Reference + // lists `microvmImage` + // (`arn::lambda:::microvm-image:`) as the + // required resource for RunMicrovm, GetMicrovm and TerminateMicrovm alike. + // That is what makes ADR-021's "scoped to platform-created images" + // achievable even though `microvmId` is minted per session — the + // per-session identifier never appears in IAM. + // + // There is NO account-wide fallback: `imageArn` is a required prop, and + // `LambdaMicrovmCompute` resolves a bare image name to its exact ARN, so + // this statement always names exactly one image. + // + // The `:*` sibling is a version-suffix hedge, not a widening. The SAR + // pattern ends at the image name, but the CLI and console surface + // version-qualified `…:microvm-image::` forms, and the docs + // are already inconsistent about MicroVM ARN separators. If the authorized + // resource turns out to carry the version, an exact-only policy would + // AccessDenied every task; if it does not, this entry is inert. Either way + // it stays pinned to this image's name (names cannot contain `:`), so it + // can never match a different image. + const { imageArn } = props.microvmConfig; + const microvmImageResources = [imageArn, `${imageArn}:*`]; + + this.fn.addToRolePolicy(new iam.PolicyStatement({ + sid: 'MicrovmLifecycle', + actions: [ + 'lambda:RunMicrovm', + 'lambda:GetMicrovm', + 'lambda:TerminateMicrovm', + ], + resources: microvmImageResources, + })); + + // `lambda:PassNetworkConnector` supports NO resource-level permissions + // (the Service Authorization Reference lists no resource type for it), so + // `Resource: '*'` is mandatory — a narrowed ARN would simply never match + // and RunMicrovm would fail with AccessDenied. It is also why the ADR + // notes the action is needed "even for the default connectors": the + // AWS-managed connectors live in the `aws` account, outside any ARN we + // could enumerate. The action only permits *passing* a connector to a + // service, not creating or reading one. + this.fn.addToRolePolicy(new iam.PolicyStatement({ + sid: 'MicrovmPassNetworkConnector', + actions: ['lambda:PassNetworkConnector'], + resources: ['*'], + })); + + // RunMicrovm hands the MicroVM its execution role, which is a PassRole in + // IAM's eyes (the Reference lists `iam:PassRole` as a documented dependent + // of RunMicrovm) — same shape as the ECS branch above passing the task and + // execution roles to ecs-tasks.amazonaws.com. ADR-021's grant list omits + // it because it enumerates MicroVM actions, not the IAM plumbing they + // imply; without it RunMicrovm fails on the role hand-off. + this.fn.addToRolePolicy(new iam.PolicyStatement({ + sid: 'MicrovmPassExecutionRole', + actions: ['iam:PassRole'], + resources: [props.microvmConfig.executionRoleArn], + conditions: { + StringEquals: { + 'iam:PassedToService': 'lambda.amazonaws.com', + }, + }, + })); + } + // Per-repo Secrets Manager grants (e.g. per-repo GitHub tokens from Blueprints) for (const [index, secretArn] of (props.additionalSecretArns ?? []).entries()) { const secret = secretsmanager.Secret.fromSecretCompleteArn( @@ -415,7 +579,7 @@ export class TaskOrchestrator extends Construct { }, { id: 'AwsSolutions-IAM5', - reason: 'DynamoDB index/* wildcards generated by CDK grantReadWriteData; AgentCore runtime/* required for sub-resource invocation; Secrets Manager wildcards generated by CDK grantRead; AgentCore Memory wildcards generated by CDK grantRead/grantWrite; ECS RunTask/DescribeTasks/StopTask conditioned on cluster ARN; iam:PassRole scoped to ECS task/execution roles and conditioned on ecs-tasks.amazonaws.com', + reason: 'DynamoDB index/* wildcards generated by CDK grantReadWriteData; AgentCore runtime/* required for sub-resource invocation; Secrets Manager wildcards generated by CDK grantRead; AgentCore Memory wildcards generated by CDK grantRead/grantWrite; ECS RunTask/DescribeTasks/StopTask conditioned on cluster ARN; iam:PassRole scoped to ECS task/execution roles and conditioned on ecs-tasks.amazonaws.com; S3 object/* wildcard from CDK grantPut on the dedicated MicroVM payload bucket; MicroVM lifecycle actions (RunMicrovm/GetMicrovm/TerminateMicrovm) are scoped to the single platform MicroVM image ARN plus a :* version-suffix sibling (every one of them authorizes against the image resource, not the per-session instance; no account-wide wildcard is used); lambda:PassNetworkConnector requires Resource:* because the action supports no resource-level permissions and the AWS-managed connectors live outside this account; iam:PassRole is scoped to the MicroVM execution role and conditioned on lambda.amazonaws.com', }, ], true); } diff --git a/cdk/src/handlers/cancel-task.ts b/cdk/src/handlers/cancel-task.ts index 72b3864fd..723bd165a 100644 --- a/cdk/src/handlers/cancel-task.ts +++ b/cdk/src/handlers/cancel-task.ts @@ -20,6 +20,7 @@ import { BedrockAgentCoreClient, StopRuntimeSessionCommand } from '@aws-sdk/client-bedrock-agentcore'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { ECSClient, StopTaskCommand } from '@aws-sdk/client-ecs'; +import { LambdaMicrovmsClient, TerminateMicrovmCommand } from '@aws-sdk/client-lambda-microvms'; import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; @@ -33,6 +34,7 @@ import { computeTtlEpoch } from './shared/validation'; const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); const agentCoreClient = new BedrockAgentCoreClient({}); const ecsClient = new ECSClient({}); +const microvmClient = new LambdaMicrovmsClient({}); const TABLE_NAME = process.env.TASK_TABLE_NAME!; const EVENTS_TABLE_NAME = process.env.TASK_EVENTS_TABLE_NAME!; const TASK_RETENTION_DAYS = Number(process.env.TASK_RETENTION_DAYS ?? '90'); @@ -144,6 +146,42 @@ export async function handler(event: APIGatewayProxyEvent): Promise = asyn ); autoRetried = retried; - // Build compute metadata for the task record so cancel-task can stop the right backend - const computeMetadata: Record = handle.strategyType === 'ecs' - ? { clusterArn: handle.clusterArn, taskArn: handle.taskArn } - : { runtimeArn: handle.runtimeArn }; + // Build compute metadata for the task record so cancel-task can stop the + // right backend (and, for lambda-microvm, so the P3 approve/deny Lambdas + // can load the handle they resume from — ADR-021 sub-decision 2). + const computeMetadata = buildComputeMetadata(handle); await transitionTask(taskId, TaskStatus.HYDRATING, TaskStatus.RUNNING, { session_id: handle.sessionId, @@ -237,6 +239,14 @@ const durableHandler: DurableExecutionHandler = asyn ? resolveComputeStrategy(blueprintConfig) : undefined; + // Kept as a SEPARATE local rather than widening `computeStrategy`'s condition: + // the ECS cross-check below is gated on `computeStrategy` truthiness, so + // reusing that local for lambda-microvm would route MicroVM polls through the + // ECS exit-code/patience logic. Two locals keep the ECS path byte-identical. + const microvmStrategy = blueprintConfig.compute_type === 'lambda-microvm' + ? resolveComputeStrategy(blueprintConfig) + : undefined; + // Step 5: Wait for agent to finish // Polls DynamoDB on each interval. The agent writes terminal status when done. // While RUNNING, the runtime updates `agent_heartbeat_at`; if that timestamp @@ -299,6 +309,46 @@ const durableHandler: DurableExecutionHandler = asyn } } + // Lambda MicroVMs substrate cross-check (ADR-021 sub-decision 1). Same + // division of labour as the ECS block above — the strategy reports raw + // substrate state and `reconcileMicrovmSubstrateState` interprets it + // against the DDB status — but the rules differ: a `suspended` VM is + // healthy during an approval wait, an anomaly (not a failure) otherwise, + // and a terminal VM with a non-terminal task row is a substrate failure. + if ( + ddbState.lastStatus + && !TERMINAL_STATUSES.includes(ddbState.lastStatus) + && microvmStrategy + && sessionHandle.strategyType === 'lambda-microvm' + ) { + try { + const substrateStatus = await microvmStrategy.pollSession(sessionHandle); + const { taskFailed } = await reconcileMicrovmSubstrateState({ + taskId, + ddbStatus: ddbState.lastStatus, + substrate: substrateStatus, + microvmId: sessionHandle.microvmId, + userId: task.user_id, + correlation, + log, + repo: task.repo, + }); + if (taskFailed) { + return { attempts: ddbState.attempts, lastStatus: TaskStatus.FAILED }; + } + } catch (err) { + // Non-fatal: a GetMicrovm hiccup must not abort the durable poll step. + // The task stays bounded by MAX_POLL_ATTEMPTS (~8.5 h) and, once the + // agent is RUNNING, by its own terminal write. A repeated-failure + // escalation counter (the ECS `MAX_CONSECUTIVE_ECS_POLL_FAILURES` + // analogue) is deliberately deferred — it would add PollState fields + // that P3's suspend policy will need to reshape anyway. + log.warn('MicroVM pollSession check failed (non-fatal)', { + error: err instanceof Error ? err.message : String(err), + }); + } + } + return { ...ddbState, consecutiveEcsPollFailures, consecutiveEcsCompletedPolls }; }, { @@ -338,6 +388,20 @@ const durableHandler: DurableExecutionHandler = asyn if (blueprintConfig.compute_type === 'ecs') { await deleteEcsPayload(taskId); } + // ADR-021: "When the orchestrator finalizes a `lambda-microvm` task, the + // orchestrator shall call terminate-microvm (termination shall not rely on + // any substrate timeout)." Without this the VM lingers until + // `maximumDurationInSeconds` (8 h) expires — with `idlePolicy` omitted there + // is no tighter substrate bound — so we would keep paying for a full 8-hour + // reservation after every task, and every SUSPENDED/RUNNING VM keeps counting + // against the account memory quota that gates admission. + // + // `stopSession` is internally best-effort (it swallows and level-differentiates + // every failure), so this cannot fail the finalize step or strand the task in + // a non-terminal state. + if (microvmStrategy && sessionHandle.strategyType === 'lambda-microvm') { + await microvmStrategy.stopSession(sessionHandle); + } }); }; diff --git a/cdk/src/handlers/shared/api-key.ts b/cdk/src/handlers/shared/api-key.ts index 777df8705..5c207119b 100644 --- a/cdk/src/handlers/shared/api-key.ts +++ b/cdk/src/handlers/shared/api-key.ts @@ -27,7 +27,7 @@ import { API_KEY_SCOPES, type ApiKeyScope } from './types'; export const API_KEY_PREFIX = 'bgak'; /** Secret entropy per key (bytes; 256-bit). */ -export const API_KEY_SECRET_BYTES = 32; +const API_KEY_SECRET_BYTES = 32; /** Number of underscore-delimited segments in a well-formed key. */ const KEY_PART_COUNT = 3; diff --git a/cdk/src/handlers/shared/comment-trigger.ts b/cdk/src/handlers/shared/comment-trigger.ts index 4182bed17..14a1589f2 100644 --- a/cdk/src/handlers/shared/comment-trigger.ts +++ b/cdk/src/handlers/shared/comment-trigger.ts @@ -17,9 +17,6 @@ * SOFTWARE. */ -/** The explicit mention that turns a channel comment into an instruction. */ -export const COMMENT_TRIGGER_MENTION = '@bgagent'; - export interface CommentTrigger { readonly triggered: boolean; readonly instruction: string; diff --git a/cdk/src/handlers/shared/compute-strategy.ts b/cdk/src/handlers/shared/compute-strategy.ts index c3de58869..76f262296 100644 --- a/cdk/src/handlers/shared/compute-strategy.ts +++ b/cdk/src/handlers/shared/compute-strategy.ts @@ -20,13 +20,47 @@ import type { BlueprintConfig, ComputeType } from './repo-config'; import { AgentCoreComputeStrategy } from './strategies/agentcore-strategy'; import { EcsComputeStrategy } from './strategies/ecs-strategy'; +import { LambdaMicrovmComputeStrategy } from './strategies/lambda-microvm-strategy'; +/** + * Per-session compute handle, discriminated on ``strategyType``. + * + * ``sessionId`` is the shared key across every variant: it is what the + * orchestrator persists as ``TaskRecord.session_id`` (and what + * ``cancel-task.ts`` / ``pollTaskStatus`` read back), so every variant must + * supply a non-empty, substrate-meaningful value. AgentCore uses a fresh UUID + * (its ``runtimeSessionId`` must be ≥ 33 chars); ECS uses the task ARN; the + * MicroVM backend uses the ``microvmId`` — see the note on the + * ``lambda-microvm`` variant below. + * + * ADR-021 sub-decision 1: the MicroVM variant carries ``microvmId`` (every + * lifecycle API — suspend/resume/terminate/get — takes only that identifier) + * and ``endpoint`` (minted per session by ``RunMicrovm``, required for any + * future orchestrator→agent HTTP interaction). The image ARN/version is + * deliberately NOT in the handle: like the ECS task-definition ARN it is + * deployment-time configuration consumed by ``startSession`` from the + * construct-injected environment and recorded in the session-start log entry + * for diagnostics, not per-session lifecycle state. + */ export type SessionHandle = | { readonly sessionId: string; readonly strategyType: 'agentcore'; readonly runtimeArn: string } - | { readonly sessionId: string; readonly strategyType: 'ecs'; readonly clusterArn: string; readonly taskArn: string }; + | { readonly sessionId: string; readonly strategyType: 'ecs'; readonly clusterArn: string; readonly taskArn: string } + | { readonly sessionId: string; readonly strategyType: 'lambda-microvm'; readonly microvmId: string; readonly endpoint: string }; +/** + * Substrate-observed session state. Deliberately mechanical: the strategy + * REPORTS, the orchestrator INTERPRETS (ADR-021 sub-decision 1 — "Poll + * semantics"). ``pollSession`` receives only the handle and cannot see the + * task's DynamoDB status, so no health rule may live in a strategy. + * + * ``suspended`` exists only for backends with an orchestrator-visible suspend + * API (today: ``lambda-microvm``). It is NOT a health verdict — a suspended + * MicroVM is healthy while the task is ``AWAITING_APPROVAL`` and an anomaly + * otherwise, and only the orchestrator can tell the two apart. + */ export type SessionStatus = | { readonly status: 'running' } + | { readonly status: 'suspended' } | { readonly status: 'completed' } | { readonly status: 'failed'; readonly error: string }; @@ -60,6 +94,8 @@ export function resolveComputeStrategy(blueprintConfig: BlueprintConfig): Comput return new AgentCoreComputeStrategy(); case 'ecs': return new EcsComputeStrategy(); + case 'lambda-microvm': + return new LambdaMicrovmComputeStrategy(); default: { const _exhaustive: never = computeType; throw new Error(`Unknown compute_type: '${_exhaustive}'`); diff --git a/cdk/src/handlers/shared/error-classifier.ts b/cdk/src/handlers/shared/error-classifier.ts index f68e15f46..b0953d8d7 100644 --- a/cdk/src/handlers/shared/error-classifier.ts +++ b/cdk/src/handlers/shared/error-classifier.ts @@ -17,6 +17,8 @@ * SOFTWARE. */ +import { LAMBDA_MICROVM_SUPPORTED_REGIONS } from './microvm-regions'; + /** * Error categories for runtime task errors. */ @@ -61,7 +63,7 @@ export const ErrorClass = { USER: 'user', } as const; -export type ErrorClassType = (typeof ErrorClass)[keyof typeof ErrorClass]; +type ErrorClassType = (typeof ErrorClass)[keyof typeof ErrorClass]; /** * Structured classification of a task error. @@ -189,6 +191,132 @@ const PATTERNS: readonly ErrorPattern[] = [ errorClass: ErrorClass.TRANSIENT, }, }, + { + // ADR-021 sub-decision 4, "Regional availability enforcement" (defense in + // depth): Lambda MicroVMs launched in 5 Regions, so a stack deployed + // elsewhere gets an endpoint-resolution failure from the SDK rather than a + // typed service error. Classify it as CONFIG + non-retryable so the task + // reply names the cause and the supported Regions instead of "temporary + // infrastructure issue — reply to retry" (which would loop forever). + // + // ORDERING: this entry MUST stay ABOVE the generic `Session start failed` + // pattern below, which would otherwise win on the persisted (wrapped) + // error_message and hand the user a retry remedy. Both alternatives are + // anchored on a `lambda`/`microvm` marker precisely so an AgentCore or ECS + // endpoint failure cannot be hijacked into MicroVM copy — their endpoint + // hosts are `bedrock-agentcore.*` / `ecs.*`. + pattern: /(?:UnknownEndpoint|Inaccessible host|Could not resolve endpoint).{0,120}lambda|(?:lambda[- ]?microvms?|microvms?).{0,60}(?:not available|not supported|unavailable)|(?:not available|not supported|unavailable).{0,60}(?:lambda[- ]?microvms?)/i, + classification: { + category: ErrorCategory.CONFIG, + title: 'Lambda MicroVMs is not available in this Region', + description: + 'The task\'s Blueprint selects the `lambda-microvm` compute backend, but the AWS Lambda MicroVMs service could not be reached in this stack\'s Region — the regional endpoint does not resolve.', + remedy: + 'Lambda MicroVMs is available in: ' + + LAMBDA_MICROVM_SUPPORTED_REGIONS.join(', ') + + '. Either redeploy the platform in a supported Region, or move this repo to a backend that is available here ' + + '(bgagent repo onboard --compute-type agentcore, or --compute-type ecs for large repos). ' + + 'If AWS has since launched MicroVMs in this Region, update the supported-Region list in ' + + 'cdk/src/handlers/shared/microvm-regions.ts. Retrying as-is will not help.', + retryable: false, + errorClass: ErrorClass.SERVICE, + }, + }, + // --- Lambda MicroVMs (ADR-021) --- + // + // SCOPING: the three SDK-exception entries below are anchored on the + // ``MicroVM failed`` marker that + // ``lambda-microvm-strategy.wrapMicrovmError`` puts on every error it lets + // escape (see ``MICROVM_ERROR_MARKER``). The anchor is mandatory, not + // decorative: ``ThrottlingException``, ``ServiceQuotaExceededException`` and + // ``ResourceNotFoundException`` are generic AWS exception names that AgentCore, + // ECS, DynamoDB and Secrets Manager all throw too. Matching them unscoped + // would retroactively re-classify every other backend's throttle — changing + // its ``errorClass`` and therefore whether ``startSessionWithRetry`` + // auto-retries it. Unmarked occurrences must keep falling through to whatever + // classified them before this section existed (a precise earlier pattern, or + // UNKNOWN). + // + // Both orders are accepted in each pattern so a future wrapper that puts the + // exception name ahead of the marker still matches; ``[\s\S]`` rather than + // ``.`` because SDK messages can span lines. + // + // ORDERING: this section sits immediately ABOVE the generic + // `Session start failed` catch-all. That is only safe BECAUSE of the marker: + // an unmarked `Session start failed: ThrottlingException` from AgentCore or ECS + // cannot match here and still reaches the generic entry with its original + // copy, while a MicroVM failure gets the precise remedy (quota-increase path, + // the MICROVM_* env vars to check) instead of "Check AgentCore Runtime or ECS + // cluster health" — advice that names the wrong substrate entirely. If the + // marker anchor is ever dropped, these MUST move back below the catch-all. + { + pattern: /MicroVM substrate terminated before the agent wrote a terminal status/i, + classification: { + category: ErrorCategory.COMPUTE, + title: 'The MicroVM stopped before the agent reported a result', + description: + 'The Lambda MicroVM running this task reached a terminal state (session duration cap, host fault, or an external terminate) while the task was still mid-flight, so no result was ever written.', + remedy: + 'This is a compute-substrate fault, not a problem with your request — reply here to try again. ' + + 'If it repeats, check the MicroVM logs for the session and whether the task is exceeding the 8-hour session cap; ' + + 'a long-running repo may belong on --compute-type ecs.', + retryable: true, + errorClass: ErrorClass.TRANSIENT, + }, + }, + { + // Account-level MicroVM memory quota. Deliberately TRANSIENT rather than + // SERVICE: this quota is capacity-shaped, not configuration-shaped — it + // frees as running/suspended MicroVMs terminate (AWS counts SUSPENDED VMs + // toward the quota), which is the same "wait and retry" character as the + // existing per-user `concurrency limit` entry above. The remedy still names + // the quota-increase path for the case where the ceiling is genuinely too + // low, so a persistently failing deployment is not left guessing. + pattern: /MicroVM [\w ]+failed[\s\S]*ServiceQuotaExceededException|ServiceQuotaExceededException[\s\S]*MicroVM [\w ]+failed/i, + classification: { + category: ErrorCategory.COMPUTE, + title: 'Couldn\'t start — the MicroVM compute quota is currently exhausted', + description: 'Starting the MicroVM was rejected because the account\'s Lambda MicroVMs quota (memory across running and suspended MicroVMs) is fully consumed.', + remedy: + 'Wait for in-flight tasks to finish and retry — the quota frees as MicroVMs terminate. ' + + 'If the platform hits this routinely, request a Lambda MicroVMs quota increase in Service Quotas, ' + + 'or lower the per-user concurrency cap so admission stops accepting tasks the substrate cannot start.', + retryable: true, + errorClass: ErrorClass.TRANSIENT, + }, + }, + { + pattern: /MicroVM [\w ]+failed[\s\S]*(?:ThrottlingException|TooManyRequestsException)|(?:ThrottlingException|TooManyRequestsException)[\s\S]*MicroVM [\w ]+failed/i, + classification: { + category: ErrorCategory.COMPUTE, + title: 'Couldn\'t start — the MicroVM control plane throttled the request', + description: 'The Lambda MicroVMs control plane returned a throttling error for this session\'s lifecycle call.', + remedy: 'This is a rate limit, not a problem with your request — reply here to try again. If it is constant, an admin should check the account\'s Lambda MicroVMs request-rate quotas.', + retryable: true, + errorClass: ErrorClass.TRANSIENT, + }, + }, + { + // A named resource the strategy was handed does not exist: a MicroVM image + // identifier/version, an execution role, or a network connector ARN. Always + // a deploy/config fault — retrying with the same ARNs cannot succeed. + // + // NOTE: `pollSession` maps a ResourceNotFoundException from `GetMicrovm` to + // `completed` (a reaped MicroVM) and never lets it reach here, so this entry + // only ever sees start-time resource faults. + pattern: /MicroVM [\w ]+failed[\s\S]*ResourceNotFoundException|ResourceNotFoundException[\s\S]*MicroVM [\w ]+failed/i, + classification: { + category: ErrorCategory.CONFIG, + title: 'A MicroVM resource referenced by this deployment does not exist', + description: 'The Lambda MicroVMs control plane rejected the session because a resource it was pointed at — MicroVM image, execution role, or network connector — could not be found.', + remedy: + 'Retrying won\'t help: an admin needs to re-deploy the MicroVM substrate so the image, execution role, and network connector ARNs wired into the orchestrator actually exist. ' + + 'Check the MICROVM_IMAGE_IDENTIFIER / MICROVM_EXECUTION_ROLE_ARN / MICROVM_*_CONNECTOR_ARNS values on the orchestrator function against the deployed resources.', + retryable: false, + errorClass: ErrorClass.SERVICE, + }, + }, + { pattern: /Session start failed/i, classification: { @@ -470,6 +598,7 @@ const PATTERNS: readonly ErrorPattern[] = [ errorClass: ErrorClass.TRANSIENT, }, }, + ]; /** diff --git a/cdk/src/handlers/shared/jira-adf.ts b/cdk/src/handlers/shared/jira-adf.ts index 62322525b..77287e89f 100644 --- a/cdk/src/handlers/shared/jira-adf.ts +++ b/cdk/src/handlers/shared/jira-adf.ts @@ -36,7 +36,7 @@ */ /** Deepest markdown heading level (`######`) ADF heading nodes are clamped to. */ -export const MAX_MARKDOWN_HEADING_LEVEL = 6; +const MAX_MARKDOWN_HEADING_LEVEL = 6; export interface AdfNode { readonly type?: string; diff --git a/cdk/src/handlers/shared/jira-attachments.ts b/cdk/src/handlers/shared/jira-attachments.ts index 4e7e47bd2..51b10936f 100644 --- a/cdk/src/handlers/shared/jira-attachments.ts +++ b/cdk/src/handlers/shared/jira-attachments.ts @@ -77,7 +77,7 @@ const ATTACHMENT_FETCH_TIMEOUT_MS = 10_000; const COMMENT_FETCH_TIMEOUT_MS = 5_000; /** Default cap on recent comments folded into the task context. */ -export const DEFAULT_MAX_COMMENTS = 10; +const DEFAULT_MAX_COMMENTS = 10; /** * Tenant-scoped context for a Jira REST call, resolved once per task by the diff --git a/cdk/src/handlers/shared/microvm-regions.ts b/cdk/src/handlers/shared/microvm-regions.ts new file mode 100644 index 000000000..06e926281 --- /dev/null +++ b/cdk/src/handlers/shared/microvm-regions.ts @@ -0,0 +1,72 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Regional availability of the AWS Lambda MicroVMs backend (ADR-021, + * sub-decision 4 — "Regional availability enforcement"). + * + * ABCA is a single-region deployment, so the constraint is binary per stack: + * either the stack's Region offers Lambda MicroVMs or the `lambda-microvm` + * backend does not exist there at all. Enforcement is layered, and THIS module + * is the one *static* layer: + * + * - **synth/deploy** (static, this constant) — offline determinism is + * required, so the CDK gate compares the stack Region against + * {@link LAMBDA_MICROVM_SUPPORTED_REGIONS}. + * - **repo onboarding / `platform doctor` / orchestration** (live) — a + * `ListManagedMicrovmImages` probe, so those layers **self-heal** the day + * AWS adds a Region, with no code change and no dependency on this list. + * + * ## Documented update path (this list rots by design) + * + * When AWS launches Lambda MicroVMs in a new Region: + * + * 1. Add the Region id to {@link LAMBDA_MICROVM_SUPPORTED_REGIONS} below + * (this is the ONLY place the list is declared — do not copy it). + * 2. Ship it; nothing else needs changing. The live probes already accepted + * the Region before this edit, so the edit only unblocks synth. + * + * Until step 1 lands, operators in a newly launched Region use the CDK + * context-flag escape hatch rather than waiting on a release — which is why a + * stale list is a friction bug, never an availability outage. + * + * Source: ADR-021 capability table ("Regions (launch)"); 5 Regions at the + * 2026-06-22 launch. + */ +export const LAMBDA_MICROVM_SUPPORTED_REGIONS: readonly string[] = [ + 'us-east-1', + 'us-east-2', + 'us-west-2', + 'eu-west-1', + 'ap-northeast-1', +]; + +/** + * True when `region` is in the statically documented + * {@link LAMBDA_MICROVM_SUPPORTED_REGIONS} list. + * + * A `false` result means "not known to be supported *by this build*", NOT + * "unsupported by AWS" — the live probes are authoritative. Never use this to + * reject at runtime what a probe already accepted. + * + * @param region - an AWS Region id (e.g. `us-east-1`); compared verbatim. + */ +export function isLambdaMicrovmRegionSupported(region: string | undefined): boolean { + return !!region && LAMBDA_MICROVM_SUPPORTED_REGIONS.includes(region); +} diff --git a/cdk/src/handlers/shared/orchestrator.ts b/cdk/src/handlers/shared/orchestrator.ts index 7308c4532..57f94d52a 100644 --- a/cdk/src/handlers/shared/orchestrator.ts +++ b/cdk/src/handlers/shared/orchestrator.ts @@ -21,6 +21,7 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { S3Client } from '@aws-sdk/client-s3'; import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import { ulid } from 'ulid'; +import type { SessionHandle, SessionStatus } from './compute-strategy'; import { AttachmentBudgetExceededError, AttachmentConfigurationError, AttachmentResolutionError, hydrateContext, resolveGitHubToken } from './context-hydration'; import { logger, type Logger } from './logger'; import { writeMinimalEpisode } from './memory'; @@ -228,6 +229,157 @@ const MIN_POLL_INTERVAL_MS = 5_000; /** Maximum allowed poll interval (5 minutes). */ const MAX_POLL_INTERVAL_MS = 300_000; +/** + * Build the ``compute_metadata`` map persisted on the task row at session start, + * so a later handler can act on the right backend without re-deriving anything: + * ``cancel-task.ts`` reads ``clusterArn``/``taskArn`` from it today, and ADR-021 + * sub-decision 2 has the approve/deny Lambdas read ``microvmId`` from it in P3. + * + * Kept as an exhaustive switch (not a ternary + spread) so a fourth backend is a + * compile error here rather than a silently empty metadata map — the field is + * load-bearing for compute shutdown, and a missing handle is exactly what + * ``task_cancel_compute_orphan`` exists to alarm on. + * + * DynamoDB stores this as ``Record`` (see + * ``TaskRecord.compute_metadata``), so every value must already be a string. + */ +export function buildComputeMetadata(handle: SessionHandle): Record { + switch (handle.strategyType) { + case 'ecs': + return { clusterArn: handle.clusterArn, taskArn: handle.taskArn }; + case 'agentcore': + return { runtimeArn: handle.runtimeArn }; + case 'lambda-microvm': + // ADR-021: `microvmId` keys every lifecycle API; `endpoint` is per-session + // state that becomes load-bearing the day an orchestrator→agent HTTP + // consumer appears (none exists in P1–P3). + return { microvmId: handle.microvmId, endpoint: handle.endpoint }; + default: { + const _exhaustive: never = handle; + throw new Error(`Unknown strategyType on session handle: ${JSON.stringify(_exhaustive)}`); + } + } +} + +/** Outcome of a MicroVM substrate cross-check. */ +export interface MicrovmReconcileResult { + /** + * True when this call drove the task to FAILED — the caller must stop polling + * and report ``lastStatus: FAILED``. False for every healthy or + * anomalous-but-not-fatal observation. + */ + readonly taskFailed: boolean; +} + +/** + * Cross-reference a MicroVM's substrate state against the task's DynamoDB status + * — the interpretation half of ADR-021's "the strategy reports, the orchestrator + * interprets" split. ``pollSession`` can only see the handle, so every rule that + * needs the task status lives here, exactly as ``finalPollState`` already does + * the substrate/DDB cross-check for ECS and ``pollTaskStatus`` does it for + * AgentCore heartbeats. + * + * Rules (all three from ADR-021 sub-decision 1's EARS requirements): + * - substrate ``suspended`` + task ``AWAITING_APPROVAL`` → HEALTHY. This is the + * orchestrator's own intended suspend (P3); say nothing. + * - substrate ``suspended`` + any other task status → ANOMALY, not a failure. + * Surface a task event and keep polling: a suspended VM preserves full + * memory/disk state and can be resumed, so failing the task would destroy + * recoverable work over a condition we cannot yet explain. + * - substrate terminal + non-terminal task status → FAIL the task with the + * substrate-failure reason (``error-classifier`` has the matching entry). + * + * The terminal branch RE-READS the task row before failing. The DynamoDB status + * handed in was read earlier in the same poll cycle, and the ordinary happy path + * is "agent writes terminal status, agent exits, VM terminates" — so a stale read + * plus a fast teardown would otherwise fail a task that actually succeeded. The + * re-read is the same "confirm before acting on a lost race" move ``finalizeTask`` + * makes after a failed transition. (ECS buys the same protection with a + * 5-consecutive-poll patience counter; one extra GetItem on a path that is about + * to end the task is cheaper and does not add state to ``PollState``.) + * + * @param taskId - the task being polled. + * @param ddbStatus - the task status observed by this poll cycle. + * @param substrate - what ``pollSession`` reported. + * @param microvmId - for event/log correlation. + * @param userId - owner, for ``failTask``. + * @param correlation - the #245 envelope for emitted events. + * @param log - the caller's child logger (already carries task/user/repo). + * @param repo - optional target repo for the correlation envelope. + */ +export async function reconcileMicrovmSubstrateState(args: { + taskId: string; + ddbStatus: TaskStatusType; + substrate: SessionStatus; + microvmId: string; + userId: string; + correlation: EventCorrelation; + log: Logger; + repo?: string; +}): Promise { + const { taskId, ddbStatus, substrate, microvmId, userId, correlation, log, repo } = args; + + if (substrate.status === 'running') { + return { taskFailed: false }; + } + + if (substrate.status === 'suspended') { + if (ddbStatus === TaskStatus.AWAITING_APPROVAL) { + // Orchestrator-intended suspend during an approval wait — the whole point + // of this backend. Nothing to report. + return { taskFailed: false }; + } + // Suspended outside an approval wait. Nothing in ABCA suspends a MicroVM + // except the orchestrator's (P3) approval-wait policy, so this means either + // an out-of-band SuspendMicrovm call or a substrate-side suspend we did not + // ask for. Surface it — do NOT fail-fast (ADR-021: "an anomaly to surface, + // not fail-fast"); the VM's state is intact and resumable. + log.warn('MicroVM is suspended while the task is not awaiting approval', { + microvm_id: microvmId, + task_status: ddbStatus, + }); + await emitTaskEvent(taskId, 'microvm_suspend_anomaly', { + microvm_id: microvmId, + task_status: ddbStatus, + reason: 'suspended_outside_approval_wait', + }, correlation); + return { taskFailed: false }; + } + + // Terminal substrate report (`completed` or `failed`). `pollSession` reports + // TERMINATING/TERMINATED/NotFound as `completed` because it cannot see an exit + // code; `failed` only reaches here if a future mapping adds one. + const detail = substrate.status === 'failed' ? substrate.error : `substrate state ${substrate.status}`; + + const reread = await loadTask(taskId); + if (TERMINAL_STATUSES.includes(reread.status)) { + // The agent wrote its terminal status between this cycle's status read and + // now — the normal shutdown ordering. Not a failure. + log.info('MicroVM terminated after the agent wrote a terminal status', { + microvm_id: microvmId, + task_status: reread.status, + }); + return { taskFailed: false }; + } + + log.error('MicroVM reached a terminal state before the agent wrote a terminal status', { + microvm_id: microvmId, + task_status: reread.status, + detail, + }); + // `releaseConcurrency: false` — the finalize step sees the now-terminal task + // and decrements, matching the ECS substrate-failure branch in orchestrate-task. + await failTask( + taskId, + reread.status, + `MicroVM substrate terminated before the agent wrote a terminal status: ${detail}`, + userId, + false, + repo, + ); + return { taskFailed: true }; +} + /** * Load blueprint configuration for a task's repository and merge with platform defaults. * @param task - the task record (needs task.repo). diff --git a/cdk/src/handlers/shared/repo-config.ts b/cdk/src/handlers/shared/repo-config.ts index a8303cd98..5eae15664 100644 --- a/cdk/src/handlers/shared/repo-config.ts +++ b/cdk/src/handlers/shared/repo-config.ts @@ -25,7 +25,7 @@ import { logger } from './logger'; * Per-repository configuration written by the Blueprint CDK construct * and read at runtime by the task API gate and the orchestrator. */ -export type ComputeType = 'agentcore' | 'ecs'; +export type ComputeType = 'agentcore' | 'ecs' | 'lambda-microvm'; export interface RepoConfig { readonly repo: string; diff --git a/cdk/src/handlers/shared/strategies/lambda-microvm-strategy.ts b/cdk/src/handlers/shared/strategies/lambda-microvm-strategy.ts new file mode 100644 index 000000000..7c2080bb9 --- /dev/null +++ b/cdk/src/handlers/shared/strategies/lambda-microvm-strategy.ts @@ -0,0 +1,448 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + GetMicrovmCommand, + LambdaMicrovmsClient, + MicrovmState, + RunMicrovmCommand, + TerminateMicrovmCommand, +} from '@aws-sdk/client-lambda-microvms'; +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import type { ComputeStrategy, SessionHandle, SessionStatus } from '../compute-strategy'; +import { logger } from '../logger'; +import type { BlueprintConfig } from '../repo-config'; + +let sharedClient: LambdaMicrovmsClient | undefined; +function getClient(): LambdaMicrovmsClient { + if (!sharedClient) { + sharedClient = new LambdaMicrovmsClient({}); + } + return sharedClient; +} + +let sharedS3Client: S3Client | undefined; +function getS3Client(): S3Client { + if (!sharedS3Client) { + sharedS3Client = new S3Client({}); + } + return sharedS3Client; +} + +const MICROVM_IMAGE_IDENTIFIER = process.env.MICROVM_IMAGE_IDENTIFIER; +const MICROVM_IMAGE_VERSION = process.env.MICROVM_IMAGE_VERSION; +const MICROVM_EXECUTION_ROLE_ARN = process.env.MICROVM_EXECUTION_ROLE_ARN; +const MICROVM_EGRESS_CONNECTOR_ARNS = process.env.MICROVM_EGRESS_CONNECTOR_ARNS; +const MICROVM_INGRESS_CONNECTOR_ARNS = process.env.MICROVM_INGRESS_CONNECTOR_ARNS; +const MICROVM_PAYLOAD_BUCKET = process.env.MICROVM_PAYLOAD_BUCKET; + +/** + * Session wall-clock ceiling passed on EVERY ``RunMicrovm`` call, pinned to the + * service maximum of 28 800 s / 8 h (ADR-021 sub-decision 1). + * + * Three reasons it is a constant and not a knob: it matches AgentCore's 8-hour + * session cap (backend parity), it sits inside the orchestrator's ~8.5 h + * safety-net poll window, and — because ``idlePolicy`` is omitted (see + * {@link RunMicrovmCommand} construction below) — it is the ONLY substrate-level + * bound on *suspended* time as well as running time. There is no wall-clock task + * budget in the platform today (budgets are ``max_turns`` / ``max_budget_usd``), + * so a Blueprint override would be policy without a driver; add one only if a + * real need appears. + */ +export const MICROVM_MAX_DURATION_SECONDS = 28_800; + +/** + * Hard service cap on ``runHookPayload`` (bytes). The SDK documents + * ``RunMicrovmRequest.runHookPayload`` as "Maximum: 16,384 bytes". + * + * This is the EXACT branch point for the inline/S3-pointer decision, with no + * safety margin — deliberately unlike ``ecs-strategy``, which keeps its inline + * warn line at 6 144 of ECS's 8 192-byte cap. That margin exists because ECS + * counts the *whole* ``containerOverrides`` blob (env vars, command, and payload + * share one budget), so the strategy cannot know how much of the 8 192 the + * payload actually gets. ``runHookPayload`` is a single standalone string, so + * the counted size is exactly what we measure and the boundary is computable: + * ADR-021's requirement is "if the task payload EXCEEDS the 16 KB limit, upload", + * and shipping a 13 KB payload to S3 would violate it. + */ +const RUN_HOOK_PAYLOAD_LIMIT_BYTES = 16_384; + +/** + * Stable marker prefixed onto every error this strategy lets escape, via + * {@link wrapMicrovmError}. Load-bearing, not cosmetic: ``error-classifier`` + * anchors its ``ThrottlingException`` / ``ServiceQuotaExceededException`` / + * ``ResourceNotFoundException`` entries on this marker so those bare AWS + * exception names classify as MicroVM faults ONLY when they came from this + * backend. Without the anchor an identically-named AgentCore or ECS throttle + * would silently inherit MicroVM copy and MicroVM retry semantics. + * + * Keep in lockstep with the MicroVM section of ``error-classifier.ts``. + */ +export const MICROVM_ERROR_MARKER = 'MicroVM'; + +/** + * Wrap an error escaping a MicroVM control-plane (or payload-upload) call so it + * carries {@link MICROVM_ERROR_MARKER} plus the originating operation. + * + * The AWS exception NAME is spliced into the message explicitly because + * ``err.message`` alone omits it (``String(err)`` would include it, but the + * classifier is handed the *wrapped* error) and the classifier keys on that + * name. ``cause`` retains the original for anyone who needs ``err.name``. + * + * The wrapper's own ``name`` is intentionally left as ``Error`` so + * ``String(wrapped)`` reads ``Error: MicroVM failed: : `` — + * marker first, which is the order the classifier patterns document. + */ +function wrapMicrovmError(operation: string, err: unknown): Error { + const name = err instanceof Error ? err.name : undefined; + const message = err instanceof Error ? err.message : String(err); + const detail = name && name !== 'Error' && !message.includes(name) + ? `${name}: ${message}` + : message; + return new Error(`${MICROVM_ERROR_MARKER} ${operation} failed: ${detail}`, { cause: err }); +} + +/** + * S3 object key for a task's MicroVM ``/run`` payload. Same key shape as the + * ECS payload bucket (``ecsPayloadKey``): one object per task under its own + * task-id prefix. The payload bucket's lifecycle-expiry rule (ADR-021 + * sub-decision 3) is the reaper — unlike the ECS path there is no orchestrator + * finalize-time delete on this backend yet. + */ +export function microvmPayloadKey(taskId: string): string { + return `${taskId}/payload.json`; +} + +/** Split a comma-separated env-var list into trimmed, non-empty entries. */ +function parseArnList(raw: string | undefined): string[] { + return (raw ?? '').split(',').map(s => s.trim()).filter(Boolean); +} + +/** + * AWS Lambda MicroVMs compute backend (ADR-021). + * + * A serverless Firecracker sandbox per session: snapshot-based launch, native + * disk that survives suspend/resume, and — unlike AgentCore — a real + * control-plane state machine the orchestrator can observe through + * {@link LambdaMicrovmComputeStrategy.pollSession}. + * + * P1 scope is start / poll / stop only. ``suspendSession`` / ``resumeSession`` + * (the interface widening across all three strategies) land in P3 — do NOT add + * them here piecemeal, ADR-021 sub-decision 1 requires them in one commit so + * the exhaustive-``never`` switch culture forces every backend to make a + * compile-checked decision about its suspend semantics. + */ +export class LambdaMicrovmComputeStrategy implements ComputeStrategy { + readonly type = 'lambda-microvm'; + + async startSession(input: { + taskId: string; + /** Accepted to satisfy the ComputeStrategy interface. MicroVMs have no + * workload-token-injecting runtime (they inherit the ECS env-var identity + * posture until #249 / ADR-016 redesign the seam), so this is unused. */ + userId: string; + payload: Record; + blueprintConfig: BlueprintConfig; + }): Promise { + if (!MICROVM_IMAGE_IDENTIFIER || !MICROVM_EXECUTION_ROLE_ARN || !MICROVM_EGRESS_CONNECTOR_ARNS || !MICROVM_PAYLOAD_BUCKET) { + // Config/deploy mismatch: this repo is compute_type=lambda-microvm but the + // stack was deployed WITHOUT the MicroVM substrate, so the orchestrator has + // no MICROVM_* env vars. Name the root cause + remedy so an admin doesn't + // have to reverse-engineer it from a bare env-var list — same posture as + // the ECS branch. (The CLI `repo onboard --compute-type lambda-microvm` + // availability probe normally prevents this; a repo onboarded before that + // guard, or edited directly, can still reach here.) + throw new Error( + 'This repository is configured compute_type=lambda-microvm, but this stack was deployed without the ' + + 'Lambda MicroVMs substrate (missing MICROVM_IMAGE_IDENTIFIER/MICROVM_EXECUTION_ROLE_ARN/' + + 'MICROVM_EGRESS_CONNECTOR_ARNS/MICROVM_PAYLOAD_BUCKET). Redeploy the stack with ' + + '`--context compute_type=lambda-microvm` to provision the MicroVM substrate, or set this repo to ' + + 'compute_type=agentcore (bgagent repo onboard --compute-type agentcore).', + ); + } + + const { taskId, payload } = input; + + // Payload delivery (ADR-021 sub-decision 3): the `/run` lifecycle hook + // receives `runHookPayload` as its request body, capped at 16 KB. The + // hydrated_context routinely blows that, so mirror the ECS S3-pointer + // pattern (#502): small payloads ride inline, large ones are uploaded to the + // platform payload bucket and only the S3 URI travels in the hook body. The + // MicroVM EXECUTION role holds the read grant, exactly as the ECS task role + // does today. + // + // Two keys, deliberately mirroring the ECS container env contract + // (AGENT_PAYLOAD / AGENT_PAYLOAD_S3_URI) so the agent's `/run` hook has one + // self-describing shape to branch on: + // { "agent_payload": {...} } — inline + // { "agent_payload_s3_uri": "s3://..." } — pointer + const inlineEnvelope = JSON.stringify({ agent_payload: payload }); + // Measure the SERIALIZED envelope, not the bare payload: the envelope is + // what the service counts against the 16 KB cap. Byte length (not + // String.length) because a multi-byte prompt/diff makes chars an undercount. + const inlineBytes = Buffer.byteLength(inlineEnvelope, 'utf8'); + + let runHookPayload: string; + let payloadS3Uri: string | undefined; + // EXACT boundary: `<= limit` inlines, `> limit` uploads. ADR-021 says upload + // only when the payload EXCEEDS 16 KB, so 16 384 bytes must still go inline. + if (inlineBytes <= RUN_HOOK_PAYLOAD_LIMIT_BYTES) { + runHookPayload = inlineEnvelope; + } else { + const key = microvmPayloadKey(taskId); + const payloadJson = JSON.stringify(payload); + try { + await getS3Client().send(new PutObjectCommand({ + Bucket: MICROVM_PAYLOAD_BUCKET, + Key: key, + Body: payloadJson, + ContentType: 'application/json', + })); + } catch (err) { + // Marked so the classifier attributes an upload failure to this backend + // rather than letting a bare S3 exception name fall through to UNKNOWN. + throw wrapMicrovmError('payload upload', err); + } + payloadS3Uri = `s3://${MICROVM_PAYLOAD_BUCKET}/${key}`; + runHookPayload = JSON.stringify({ agent_payload_s3_uri: payloadS3Uri }); + logger.info('Wrote MicroVM run-hook payload to S3', { + task_id: taskId, + bytes: Buffer.byteLength(payloadJson, 'utf8'), + inline_bytes: inlineBytes, + inline_limit_bytes: RUN_HOOK_PAYLOAD_LIMIT_BYTES, + uri: payloadS3Uri, + }); + } + + const ingressNetworkConnectors = parseArnList(MICROVM_INGRESS_CONNECTOR_ARNS); + + const command = new RunMicrovmCommand({ + imageIdentifier: MICROVM_IMAGE_IDENTIFIER, + ...(MICROVM_IMAGE_VERSION && { imageVersion: MICROVM_IMAGE_VERSION }), + executionRoleArn: MICROVM_EXECUTION_ROLE_ARN, + // Egress rides the platform VPC through an egress network connector so the + // DNS Firewall / security-group / flow-log stack applies unchanged + // (ADR-021 sub-decision 4). + egressNetworkConnectors: parseArnList(MICROVM_EGRESS_CONNECTOR_ARNS), + // NO ingress beyond the service defaults: nothing in P1–P3 dials into the + // MicroVM (no JWE tokens are minted at all), so SHELL_INGRESS and friends + // stay off. The env var exists so a deployment CAN pass connectors without + // a code change; absent ⇒ the field is omitted entirely. + ...(ingressNetworkConnectors.length > 0 && { ingressNetworkConnectors }), + runHookPayload, + maximumDurationInSeconds: MICROVM_MAX_DURATION_SECONDS, + // `idlePolicy` is OMITTED — never passed, in any phase (ADR-021 + // sub-decision 1, asserted by an invariant unit test). MicroVM idle + // policies measure idleness as *inbound traffic at the endpoint*, and the + // ABCA agent is outbound-only: "no inbound traffic" is its normal state, + // so a naive idle policy would suspend an agent mid-way through a + // 40-minute build. All three idlePolicy fields are required when the block + // is present, so omission is the unambiguous disabled state. Suspension is + // orchestrator-owned (P3) — do NOT reintroduce this field. + // + // `clientToken` is also deliberately omitted. It is an idempotency token, + // and the one place a MicroVM start is retried is `startSessionWithRetry`, + // which retries precisely BECAUSE the first attempt FAILED. Passing a + // task-derived token there would ask the service to dedupe against that + // failed attempt and could replay its outcome instead of genuinely + // retrying — turning the auto-retry into a no-op. Session start is already + // idempotent by construction at the ABCA level (no clone, commit, or PR has + // happened yet), so the token buys nothing and risks the retry. + }); + + let result; + try { + result = await getClient().send(command); + } catch (err) { + // Marker-scoped so `ThrottlingException` / `ServiceQuotaExceededException` + // / `ResourceNotFoundException` from THIS backend classify as MicroVM + // faults, while identically-named AgentCore/ECS errors keep their existing + // classification. See MICROVM_ERROR_MARKER. + throw wrapMicrovmError('RunMicrovm', err); + } + + const { microvmId, endpoint } = result; + if (!microvmId || !endpoint) { + throw new Error( + `RunMicrovm returned an incomplete response (microvmId=${microvmId ?? 'missing'}, ` + + `endpoint=${endpoint ? 'present' : 'missing'}, state=${result.state ?? 'unknown'})`, + ); + } + + // Image ARN/version is logged, NOT carried in the handle (ADR-021 + // sub-decision 1) — it is deployment-time config, and this line is the + // diagnostic record of which snapshot a given session actually booted. + logger.info('Lambda MicroVM session started', { + task_id: taskId, + microvm_id: microvmId, + state: result.state, + image_identifier: MICROVM_IMAGE_IDENTIFIER, + image_arn: result.imageArn, + image_version: result.imageVersion ?? MICROVM_IMAGE_VERSION, + maximum_duration_seconds: MICROVM_MAX_DURATION_SECONDS, + payload_delivery: payloadS3Uri ? 's3_pointer' : 'inline', + ...(payloadS3Uri && { payload_s3_uri: payloadS3Uri }), + }); + + return { + // sessionId = microvmId, mirroring the ECS variant's "sessionId = the + // substrate identifier" precedent (ECS uses the task ARN) rather than + // AgentCore's fresh UUID — AgentCore only needs a UUID because + // `runtimeSessionId` is a caller-minted value that must be ≥ 33 chars. + // Here the substrate mints the id, every lifecycle API keys on it, and it + // is what an operator needs to correlate `TaskRecord.session_id` with the + // MicroVM in logs/console. A second synthetic UUID would add a + // non-actionable identifier and leave `session_id` un-joinable. + sessionId: microvmId, + strategyType: 'lambda-microvm', + microvmId, + endpoint, + }; + } + + /** + * Report the substrate's view of the session — MECHANICALLY. No task-state + * interpretation happens here (ADR-021 sub-decision 1): this method sees only + * the handle, so the health rules that need the task's DynamoDB status live in + * the orchestrator (``reconcileMicrovmSubstrateState``). + * + * State mapping: + * - ``PENDING`` / ``RUNNING`` → ``running`` (PENDING is still booting, the + * same way ECS's PENDING/PROVISIONING map to ``running``). + * - ``SUSPENDING`` / ``SUSPENDED`` → ``suspended``. SUSPENDING is folded in + * because the VM is already on its way to frozen; reporting ``running`` + * would tell the orchestrator compute is still progressing when it is not. + * Both map to a state the orchestrator treats as benign-or-anomalous + * depending on the task status, never as a failure. + * - ``TERMINATING`` / ``TERMINATED`` → ``completed``. Both are terminal or + * terminal-bound and carry no exit code, so "the substrate is gone" is all + * the strategy can honestly say; whether that is success or failure is the + * orchestrator's call (it cross-references the DynamoDB status). + * - anything else (an unrecognized future state) → ``running``, so a service + * enum addition can never fail a healthy task. + */ + async pollSession(handle: SessionHandle): Promise { + if (handle.strategyType !== 'lambda-microvm') { + throw new Error('pollSession called with non-lambda-microvm handle'); + } + const { microvmId } = handle; + + let state: string | undefined; + try { + const result = await getClient().send(new GetMicrovmCommand({ + microvmIdentifier: microvmId, + })); + state = result.state; + } catch (err) { + // A MicroVM that the control plane no longer knows about is gone, not + // broken — treat NotFound as terminal (``completed``) rather than + // ``failed``. This deliberately DIVERGES from ecs-strategy's + // "DescribeTasks returned no task ⇒ failed": ECS keeps stopped tasks + // describable for ~1 h, so a missing task there really is anomalous, + // whereas a terminated MicroVM is reaped from the control plane by design + // and would otherwise fail every task that finished cleanly. The + // orchestrator still fails the task when this terminal report lands while + // the DynamoDB status is non-terminal, so a genuine mid-run disappearance + // is not swallowed — it just gets the substrate-failure classification + // instead of a misleading poll error. + if (err instanceof Error && err.name === 'ResourceNotFoundException') { + logger.info('MicroVM not found on poll — treating as terminal', { + microvm_id: microvmId, + }); + return { status: 'completed' }; + } + throw wrapMicrovmError('GetMicrovm', err); + } + + switch (state) { + case MicrovmState.PENDING: + case MicrovmState.RUNNING: + return { status: 'running' }; + case MicrovmState.SUSPENDING: + case MicrovmState.SUSPENDED: + return { status: 'suspended' }; + case MicrovmState.TERMINATING: + case MicrovmState.TERMINATED: + return { status: 'completed' }; + default: + logger.warn('Unrecognized MicroVM state — reporting running', { + microvm_id: microvmId, + state, + }); + return { status: 'running' }; + } + } + + /** + * Terminate the MicroVM. Best-effort with differentiated error handling + * matching ``agentcore-strategy.stopSession``: a stop that cannot happen must + * never fail the caller, but the log LEVEL has to distinguish "already gone" + * (expected) from "we were throttled / denied" (an operator signal that + * MicroVMs may be leaking) from "something else" (worth a warning). + * + * ADR-021: termination is the active cleanup path — it must not rely on + * ``maximumDurationInSeconds`` expiring, which would keep paying for an + * 8-hour reservation after the task is done. + */ + async stopSession(handle: SessionHandle): Promise { + if (handle.strategyType !== 'lambda-microvm') { + throw new Error('stopSession called with non-lambda-microvm handle'); + } + const { microvmId } = handle; + + try { + await getClient().send(new TerminateMicrovmCommand({ + microvmIdentifier: microvmId, + })); + logger.info('Lambda MicroVM terminated', { microvm_id: microvmId }); + } catch (err) { + const errName = err instanceof Error ? err.name : undefined; + if (errName === 'ResourceNotFoundException' || errName === 'ConflictException') { + // Already terminated (reaped) or already TERMINATING — the desired end + // state either way. ConflictException joins the info branch because a + // concurrent terminate (orchestrator finalize racing a user cancel) is + // routine here, and warning on it would train operators to ignore warns. + logger.info('MicroVM already terminated or terminating', { + microvm_id: microvmId, + error_type: errName, + }); + } else if (errName === 'ThrottlingException' || errName === 'AccessDeniedException') { + // A throttle or a missing lambda:TerminateMicrovm grant means the VM is + // probably STILL RUNNING and billing — escalate. + logger.error('Failed to terminate MicroVM', { + microvm_id: microvmId, + error_type: errName, + error: err instanceof Error ? err.message : String(err), + }); + } else { + logger.warn('Failed to terminate MicroVM (best-effort)', { + microvm_id: microvmId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } +} + +/** + * Re-exported so tests and future callers can assert the documented cap without + * duplicating the literal. This is BOTH the service's limit and our exact + * inline/S3-pointer branch point — there is no separate threshold. + */ +export const MICROVM_RUN_HOOK_PAYLOAD_LIMIT_BYTES = RUN_HOOK_PAYLOAD_LIMIT_BYTES; diff --git a/cdk/src/handlers/shared/validation.ts b/cdk/src/handlers/shared/validation.ts index 70dea4b52..4863bac4a 100644 --- a/cdk/src/handlers/shared/validation.ts +++ b/cdk/src/handlers/shared/validation.ts @@ -488,14 +488,9 @@ function filenameFromUrl(url: string, index: number): string { /** * Canonical MIME → file-extension map for the platform-allowed attachment types. - * This is the single source of truth for the type↔extension relationship: other - * modules that need the reverse (extension → MIME, e.g. to type a generic - * `application/octet-stream` download) derive it from {@link EXTENSION_TO_MIME} - * rather than re-listing the types — so adding a supported type is a one-line - * change here, inherited in both directions. Keep in step with - * ALLOWED_IMAGE_MIME_TYPES / ALLOWED_FILE_MIME_TYPES. + * Keep in step with ALLOWED_IMAGE_MIME_TYPES / ALLOWED_FILE_MIME_TYPES. */ -export const MIME_TO_EXTENSION: Record = { +const MIME_TO_EXTENSION: Record = { 'image/png': 'png', 'image/jpeg': 'jpg', 'text/plain': 'txt', @@ -506,28 +501,6 @@ export const MIME_TO_EXTENSION: Record = { 'text/x-log': 'log', }; -/** - * Reverse of {@link MIME_TO_EXTENSION}: file-extension → MIME, for typing a - * download whose HTTP content-type is generic. Derived (not hand-listed) so it - * can never drift from the canonical map. `jpg` → `image/jpeg` covers the common - * `.jpeg` alias too via the extra entry below. - */ -export const EXTENSION_TO_MIME: Readonly> = Object.freeze({ - ...Object.fromEntries(Object.entries(MIME_TO_EXTENSION).map(([mime, ext]) => [ext, mime])), - jpeg: 'image/jpeg', // `.jpeg` is a common alias MIME_TO_EXTENSION collapses to `jpg` -}); - -/** - * Human-friendly list of supported attachment file extensions, derived from - * {@link EXTENSION_TO_MIME}'s KEYS (deduped, upper-cased) — e.g. "PNG, JPG, JPEG, - * TXT, CSV, MD, JSON, PDF, LOG". Keyed off EXTENSION_TO_MIME (not MIME_TO_EXTENSION) - * so the accepted `.jpeg` alias is listed too — the label is exactly the set of - * extensions the type-inference will actually accept. For user-facing - * "unsupported file type" messages, so the list can never drift from the allowlist. - */ -export const SUPPORTED_ATTACHMENT_EXTENSIONS_LABEL: string = - [...new Set(Object.keys(EXTENSION_TO_MIME))].map((e) => e.toUpperCase()).join(', '); - export type AttachmentValidationResult = | { readonly valid: true; readonly parsed: ValidatedAttachment[] } | { readonly valid: false; readonly error: string }; diff --git a/cdk/src/main.ts b/cdk/src/main.ts index c8bc2cc88..0b4aa09e7 100644 --- a/cdk/src/main.ts +++ b/cdk/src/main.ts @@ -53,6 +53,15 @@ const excludeResourceTypes = [ 'AWS::Route53Resolver::ResolverQueryLoggingConfigAssociation', ]; +// TODO(#645): with three backends this single-valued tag is no longer an honest +// statement of what a stack runs — a `--context compute_type=lambda-microvm` +// deploy still provisions the AgentCore runtime, so every resource gets tagged +// `compute_type=lambda-microvm` including the AgentCore ones. ADR-021 +// sub-decision 4 flags revisiting the semantics (e.g. a `compute_types` list). +// Deliberately NOT changed here: retagging every resource in the stack is a +// replacement-risk change of its own, and MicroVM spend is already attributable +// through the per-resource `abca:compute-backend` tags the +// LambdaMicrovmCompute construct applies. Tags.of(stack).add('compute_type', computeType, { excludeResourceTypes }); const githubTagKeys = [ diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 06a98deb3..9607832fa 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -45,6 +45,11 @@ import { EcsPayloadBucket } from '../constructs/ecs-payload-bucket'; import { FanOutConsumer } from '../constructs/fanout-consumer'; import { GitHubScreenshotIntegration } from '../constructs/github-screenshot-integration'; import { JiraIntegration } from '../constructs/jira-integration'; +import { + LambdaMicrovmCompute, + isLambdaMicrovmImageConfigured, + type LambdaMicrovmImageInputs, +} from '../constructs/lambda-microvm-compute'; import { LinearIntegration } from '../constructs/linear-integration'; import { PendingUploadCleanup } from '../constructs/pending-upload-cleanup'; import { RepoTable } from '../constructs/repo-table'; @@ -202,6 +207,52 @@ export class AgentStack extends Stack { }, ]); + // --- Compute-backend deploy gate (read early) --- + // Which optional compute substrate this deploy provisions, from the + // ``compute_type`` deploy context (default 'agentcore' — the AgentCore + // runtime is always present, the other backends are additive). Read HERE, + // well above the constructs it gates, because TaskApi is instantiated + // before them and needs to know whether to wire the cancel Lambda's + // MicroVM termination grant (ADR-021 sub-decision 4). + const computeType = this.node.tryGetContext('compute_type') ?? 'agentcore'; + const lambdaMicrovmEnabled = computeType === 'lambda-microvm'; + + // The operator-supplied MicroVM image inputs, resolved HERE (pure context + // reads, no construct dependency) rather than at the construct's call site + // below, because TaskApi — created well before the MicroVM construct — needs + // to know whether an image will exist in order to decide whether the cancel + // Lambda gets a `lambda:TerminateMicrovm` grant at all. The same object is + // handed to the construct, and `isLambdaMicrovmImageConfigured` is the single + // shared predicate, so the two cannot drift. + // + // Base-image ARNs/versions are Region-scoped service data only discoverable + // through ``aws lambda-microvms list-managed-microvm-images``, and the + // artifact has to be uploaded to the bucket THIS stack creates — hence + // context values rather than defaults. See the construct's "three states" + // table and cdk/scripts/package-microvm-artifact.sh for the bootstrap + // sequence. + const microvmImageInputs: LambdaMicrovmImageInputs = { + baseImageArn: this.node.tryGetContext('microvm_base_image_arn'), + baseImageVersion: this.node.tryGetContext('microvm_base_image_version'), + externalImageIdentifier: this.node.tryGetContext('microvm_image_identifier'), + externalImageVersion: this.node.tryGetContext('microvm_image_version'), + }; + const microvmImageConfigured = lambdaMicrovmEnabled + && isLambdaMicrovmImageConfigured(microvmImageInputs); + + // MicroVM image ARN placeholder — the image is created AFTER TaskApi, but the + // cancel Lambda's grant must be scoped to it. Same Lazy.string cycle-break as + // the runtime / orchestrator / SessionRole ARNs below. + let microvmImageArnHolder: string | undefined; + const lazyMicrovmImageArn = Lazy.string({ + produce: () => { + if (!microvmImageArnHolder) { + throw new Error('MicroVM image ARN was accessed before LambdaMicrovmCompute was created'); + } + return microvmImageArnHolder; + }, + }); + // Network isolation — VPC with restricted egress const agentVpc = new AgentVpc(this, 'AgentVpc'); @@ -301,6 +352,11 @@ export class AgentStack extends Stack { traceArtifactsBucket: traceArtifactsBucket.bucket, attachmentsBucket: attachmentsBucket.bucket, userConcurrencyTable: userConcurrencyTable.table, + // ADR-021: gives the cancel Lambda `lambda:TerminateMicrovm`, scoped to the + // platform MicroVM image, so cancelling a MicroVM-backed task stops compute + // immediately. Omitted when no image is configured — there can be no + // MicroVM-backed task to cancel then. + ...(microvmImageConfigured && { lambdaMicrovmImageArn: lazyMicrovmImageArn }), }); // --- AgentCore Runtime (IAM-authed orchestrator path) --- @@ -594,7 +650,7 @@ export class AgentStack extends Stack { // ``--context compute_type=ecs``, so the default synth (and the // bootstrap-coverage test that synths with default context) stays // agentcore-only. Mirrors upstream #164 (gate ECS construct on context). - const computeType = this.node.tryGetContext('compute_type') ?? 'agentcore'; + // (``computeType`` is read near the top of the constructor — TaskApi needs it.) // #502: ephemeral bucket for ECS task payloads — the orchestrator writes the // payload here (it exceeds the 8 KB RunTask containerOverrides limit) and // passes only an S3 URI pointer; the container fetches it on boot, the @@ -643,18 +699,83 @@ export class AgentStack extends Stack { }) : undefined; + // --- AWS Lambda MicroVMs compute backend (CONTEXT-GATED) --- + // ADR-021 P1: a serverless Firecracker sandbox per session — VM-level + // isolation with no cluster to operate, and (from P3) suspend/resume so a + // task parked on a HITL approval gate stops billing compute while keeping + // its cloned repo and warm build caches in memory. + // + // Gated exactly like the ECS backend above: resources synthesize only under + // ``--context compute_type=lambda-microvm``, so the default synth — and the + // bootstrap-coverage test that synths with default context — stays + // agentcore-only. The construct itself enforces the ADR's Region gate, so a + // deploy into a Region without Lambda MicroVMs fails at synth rather than on + // the first task. + const lambdaMicrovm = lambdaMicrovmEnabled + ? new LambdaMicrovmCompute(this, 'LambdaMicrovmCompute', { + vpc: agentVpc.vpc, + // Per-session IAM scoping (#209): the MicroVM execution role is admitted + // to the same per-task SessionRole the AgentCore runtime and the Fargate + // task role use, so tenant-data access is tag-scoped on every substrate. + agentSessionRole, + // Resolved above TaskApi — see `microvmImageInputs`. + ...microvmImageInputs, + }) + : undefined; + + // Resolve the Lazy TaskApi's cancel grant is scoped by. The invariant the + // Lazy's `produce` guards: `microvmImageConfigured` (computed from the same + // inputs, via the same predicate) is true exactly when the construct sets + // `imageArn`, so a configured deployment always has an ARN to resolve and an + // unconfigured one never asks for it. + microvmImageArnHolder = lambdaMicrovm?.imageArn; + // Advertise which compute substrate this deploy actually provisioned, so the // CLI can refuse to onboard a repo as ``compute_type: ecs`` when the ECS gate // wasn't on (``--context compute_type=ecs``) — otherwise that mismatch only // surfaces per-task as "ECS compute strategy requires ECS_CLUSTER_ARN…" at // runtime. ``ecs`` implies the AgentCore runtime is ALSO available (the ECS - // gate is additive), so an agentcore repo works on either substrate. + // gate is additive), so an agentcore repo works on either substrate — and the + // same holds for ``lambda-microvm`` (ADR-021). new CfnOutput(this, 'ComputeSubstrate', { - value: ecsCluster ? 'ecs' : 'agentcore', - description: 'Compute substrate provisioned by this deploy: "agentcore" (default) or "ecs" ' - + '(deployed with --context compute_type=ecs; adds the Fargate substrate alongside AgentCore).', + value: ecsCluster ? 'ecs' : (lambdaMicrovm ? 'lambda-microvm' : 'agentcore'), + description: 'Compute substrate provisioned by this deploy: "agentcore" (default), "ecs" ' + + '(deployed with --context compute_type=ecs; adds the Fargate substrate alongside AgentCore) ' + + 'or "lambda-microvm" (--context compute_type=lambda-microvm; adds the Lambda MicroVMs ' + + 'substrate alongside AgentCore).', }); + if (lambdaMicrovm) { + // Emitted so the packaging helper (cdk/scripts/package-microvm-artifact.sh) + // can discover where to upload the zip+Dockerfile and which log group / + // build role to hand `create-microvm-image` — none of which have + // predictable physical names. + new CfnOutput(this, 'MicrovmArtifactBucketName', { + value: lambdaMicrovm.artifactBucket.bucketName, + description: 'S3 bucket the Lambda MicroVMs zip+Dockerfile artifact is uploaded to (ADR-021)', + }); + new CfnOutput(this, 'MicrovmArtifactObjectKey', { + value: lambdaMicrovm.artifactObjectKey, + description: 'S3 key the Lambda MicroVMs artifact must be uploaded to (matches the build role\'s s3:GetObject scope)', + }); + new CfnOutput(this, 'MicrovmBuildRoleArn', { + value: lambdaMicrovm.buildRole.roleArn, + description: 'IAM role for `aws lambda-microvms create-microvm-image --build-role-arn`', + }); + new CfnOutput(this, 'MicrovmExecutionRoleArn', { + value: lambdaMicrovm.executionRole.roleArn, + description: 'IAM role the running MicroVM assumes (`run-microvm --execution-role-arn`)', + }); + new CfnOutput(this, 'MicrovmEgressConnectorArns', { + value: lambdaMicrovm.egressConnectorArns.join(','), + description: 'Lambda network connector ARNs routing MicroVM egress through the platform VPC', + }); + new CfnOutput(this, 'MicrovmLogGroupName', { + value: lambdaMicrovm.logGroup.logGroupName, + description: 'CloudWatch log group for MicroVM build- and run-time logs', + }); + } + // --- Task Orchestrator (durable Lambda function) --- const orchestrator = new TaskOrchestrator(this, 'TaskOrchestrator', { taskTable: taskTable.table, @@ -683,6 +804,26 @@ export class AgentStack extends Stack { // #502: pass the payload bucket so the orchestrator writes/deletes the // out-of-band payload and the ECS strategy builds the S3 URI pointer. ...(ecsPayloadBucket && { ecsPayloadBucket: ecsPayloadBucket.bucket }), + // ADR-021: route ``compute_type: 'lambda-microvm'`` repos to the MicroVM + // substrate. Wired only when an image is actually configured — without one + // the strategy has nothing to run, and injecting a partial MICROVM_* env + // block would trade the strategy's precise "deployed without the MicroVM + // substrate" error for an opaque service-side failure. The construct sets + // ``imageIdentifier`` and ``imageArn`` together (a bare image name is + // resolved to its exact ARN), so testing both keeps the all-or-nothing + // contract compile-checked rather than assumed. + ...(lambdaMicrovm?.imageIdentifier && lambdaMicrovm.imageArn && { + microvmConfig: { + imageIdentifier: lambdaMicrovm.imageIdentifier, + imageArn: lambdaMicrovm.imageArn, + imageVersion: lambdaMicrovm.imageVersion, + executionRoleArn: lambdaMicrovm.executionRole.roleArn, + egressConnectorArns: lambdaMicrovm.egressConnectorArns, + // No ingress connectors in P1–P3: nothing dials into the MicroVM and + // no JWE auth tokens are minted (ADR-021 sub-decision 3). + payloadBucket: lambdaMicrovm.payloadBucket, + }, + }), }); // Now that the orchestrator exists, resolve the Lazy used by TaskApi at synth. diff --git a/cdk/test/bootstrap/__snapshots__/version.test.ts.snap b/cdk/test/bootstrap/__snapshots__/version.test.ts.snap index 71788609a..f8b1a2e7e 100644 --- a/cdk/test/bootstrap/__snapshots__/version.test.ts.snap +++ b/cdk/test/bootstrap/__snapshots__/version.test.ts.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`bootstrap version module hash is stable 1`] = `"b0501c8a57f20e4b5bf50d6fe8bbb934310adc4b64480228565d8392a19d1503"`; +exports[`bootstrap version module hash is stable 1`] = `"40d0a8b2343663084f614423fc3e6210761377dedf0bc079956ba7d4cea84c5e"`; diff --git a/cdk/test/bootstrap/artifact-sync.test.ts b/cdk/test/bootstrap/artifact-sync.test.ts index 95fb9a9c5..b0aae2c95 100644 --- a/cdk/test/bootstrap/artifact-sync.test.ts +++ b/cdk/test/bootstrap/artifact-sync.test.ts @@ -19,7 +19,14 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { infrastructurePolicy, applicationPolicy, observabilityPolicy } from '../../src/bootstrap/policies'; +import { + infrastructurePolicy, + applicationPolicy, + observabilityPolicy, + computeAgentcorePolicy, + computeEcsPolicy, + computeLambdaMicrovmPolicy, +} from '../../src/bootstrap/policies'; import { BOOTSTRAP_VERSION, computeBootstrapHash } from '../../src/bootstrap/version'; const artifactsDir = join(__dirname, '..', '..', 'bootstrap'); @@ -40,6 +47,9 @@ describe('Bootstrap artifact sync', () => { { name: 'infrastructure', fn: infrastructurePolicy }, { name: 'application', fn: applicationPolicy }, { name: 'observability', fn: observabilityPolicy }, + { name: 'compute-agentcore', fn: computeAgentcorePolicy }, + { name: 'compute-ecs', fn: computeEcsPolicy }, + { name: 'compute-lambda-microvm', fn: computeLambdaMicrovmPolicy }, ] as const; for (const { name, fn } of cases) { diff --git a/cdk/test/bootstrap/bootstrap-template.test.ts b/cdk/test/bootstrap/bootstrap-template.test.ts index d4bae4965..f028501a1 100644 --- a/cdk/test/bootstrap/bootstrap-template.test.ts +++ b/cdk/test/bootstrap/bootstrap-template.test.ts @@ -48,6 +48,15 @@ describe('Bootstrap template', () => { expect(template.Conditions.IncludeComputeEcs).toBeDefined(); expect(template.Conditions.IncludeComputeEcs['Fn::Not']).toBeDefined(); }); + + it('has IncludeComputeLambdaMicrovms condition matching the full backend token', () => { + const condition = template.Conditions.IncludeComputeLambdaMicrovms; + expect(condition).toBeDefined(); + expect(condition['Fn::Not']).toBeDefined(); + // Split on the WHOLE `lambda-microvm` token: splitting on a `lambda` + // prefix would also fire for any future `lambda*` backend name. + expect(JSON.stringify(condition)).toContain('lambda-microvm'); + }); }); describe('Managed policy resources', () => { @@ -57,6 +66,7 @@ describe('Bootstrap template', () => { 'IaCRoleABCAObservability', 'IaCRoleABCAComputeAgentcore', 'IaCRoleABCAComputeEcs', + 'IaCRoleABCAComputeLambdaMicrovms', ]; for (const logicalId of expectedPolicies) { @@ -75,9 +85,16 @@ describe('Bootstrap template', () => { expect(template.Resources.IaCRoleABCAComputeEcs.Condition).toBe('IncludeComputeEcs'); }); - it('non-ECS policies do not have a condition', () => { - const nonEcs = expectedPolicies.filter((p) => p !== 'IaCRoleABCAComputeEcs'); - for (const logicalId of nonEcs) { + it('IaCRoleABCAComputeLambdaMicrovms has IncludeComputeLambdaMicrovms condition', () => { + expect(template.Resources.IaCRoleABCAComputeLambdaMicrovms.Condition) + .toBe('IncludeComputeLambdaMicrovms'); + }); + + it('non-optional-compute policies do not have a condition', () => { + const unconditional = expectedPolicies.filter( + (p) => p !== 'IaCRoleABCAComputeEcs' && p !== 'IaCRoleABCAComputeLambdaMicrovms', + ); + for (const logicalId of unconditional) { expect(template.Resources[logicalId].Condition).toBeUndefined(); } }); @@ -122,6 +139,15 @@ describe('Bootstrap template', () => { expect(ecsEntry).toBeDefined(); expect(ecsEntry['Fn::If'][1]).toEqual({ Ref: 'IaCRoleABCAComputeEcs' }); expect(ecsEntry['Fn::If'][2]).toEqual({ Ref: 'AWS::NoValue' }); + + // ...and so should Lambda MicroVMs (ADR-021). + const microvmEntry = fallback.find( + + (item: any) => item['Fn::If'] && item['Fn::If'][0] === 'IncludeComputeLambdaMicrovms', + ); + expect(microvmEntry).toBeDefined(); + expect(microvmEntry['Fn::If'][1]).toEqual({ Ref: 'IaCRoleABCAComputeLambdaMicrovms' }); + expect(microvmEntry['Fn::If'][2]).toEqual({ Ref: 'AWS::NoValue' }); }); it('does not reference AdministratorAccess', () => { @@ -157,10 +183,13 @@ describe('Bootstrap template', () => { // ECS should be conditional - const ecsItem = items.find((item: any) => item['Fn::If']); - expect(ecsItem).toBeDefined(); - expect(ecsItem['Fn::If'][0]).toBe('IncludeComputeEcs'); - expect(ecsItem['Fn::If'][1]).toBe('Compute-ECS'); + const conditionalItems = items.filter((item: any) => item['Fn::If']); + expect(conditionalItems.map((item: any) => item['Fn::If'][0])).toEqual([ + 'IncludeComputeEcs', + 'IncludeComputeLambdaMicrovms', + ]); + expect(conditionalItems[0]['Fn::If'][1]).toBe('Compute-ECS'); + expect(conditionalItems[1]['Fn::If'][1]).toBe('Compute-LambdaMicrovms'); }); }); diff --git a/cdk/test/bootstrap/golden-baseline.test.ts b/cdk/test/bootstrap/golden-baseline.test.ts index ead2b9734..2283618ca 100644 --- a/cdk/test/bootstrap/golden-baseline.test.ts +++ b/cdk/test/bootstrap/golden-baseline.test.ts @@ -26,6 +26,7 @@ import { applicationPolicy, computeAgentcorePolicy, computeEcsPolicy, + computeLambdaMicrovmPolicy, infrastructurePolicy, observabilityPolicy, } from '../../src/bootstrap/policies'; @@ -73,12 +74,13 @@ describe('Golden-file parity: TypeScript policies match DEPLOYMENT_ROLES.md', () const markdownPath = join(__dirname, '..', '..', '..', 'docs', 'design', 'DEPLOYMENT_ROLES.md'); const markdown = readFileSync(markdownPath, 'utf-8'); - // Extract JSON blocks: [0]=trust, [1]=infrastructure, [2]=application, [3]=observability, [4]=ECS + // Extract JSON blocks: [0]=trust, [1]=infrastructure, [2]=application, [3]=observability, [4]=ECS, [5]=Lambda MicroVMs const jsonBlocks = extractJsonBlocks(markdown); const goldenInfra = JSON.parse(jsonBlocks[1]); const goldenApp = JSON.parse(jsonBlocks[2]); const goldenObs = JSON.parse(jsonBlocks[3]); const goldenEcs = JSON.parse(jsonBlocks[4]); + const goldenLambdaMicrovm = JSON.parse(jsonBlocks[5]); // Resolve TypeScript policies via CDK Stack const tsInfra = stack.resolve(infrastructurePolicy()); @@ -86,6 +88,7 @@ describe('Golden-file parity: TypeScript policies match DEPLOYMENT_ROLES.md', () const tsObs = stack.resolve(observabilityPolicy()); const tsComputeAgentcore = stack.resolve(computeAgentcorePolicy()); const tsComputeEcs = stack.resolve(computeEcsPolicy()); + const tsComputeLambdaMicrovm = stack.resolve(computeLambdaMicrovmPolicy()); // --- Infrastructure and Application: direct parity --- const directTestCases: Array<{ @@ -212,4 +215,28 @@ describe('Golden-file parity: TypeScript policies match DEPLOYMENT_ROLES.md', () } }); }); + + // --- Compute-LambdaMicrovms: matches block 5 from markdown --- + describe('Compute-LambdaMicrovms policy', () => { + const goldenNorm = normalizeStatements(goldenLambdaMicrovm.Statement); + const tsNorm = normalizeStatements( + (tsComputeLambdaMicrovm as { Statement: Array<{ Sid?: string; Action?: string | string[]; Resource?: string | string[] }> }).Statement, + ); + + it('has the same SIDs', () => { + expect(tsNorm.map((s) => s.sid)).toEqual(goldenNorm.map((s) => s.sid)); + }); + + it('has identical actions (sorted)', () => { + for (let i = 0; i < goldenNorm.length; i++) { + expect(tsNorm[i].actions).toEqual(goldenNorm[i].actions); + } + }); + + it('has identical resources (sorted)', () => { + for (let i = 0; i < goldenNorm.length; i++) { + expect(tsNorm[i].resources).toEqual(goldenNorm[i].resources); + } + }); + }); }); diff --git a/cdk/test/bootstrap/policies.test.ts b/cdk/test/bootstrap/policies.test.ts index 2ee788c3a..e3cdae579 100644 --- a/cdk/test/bootstrap/policies.test.ts +++ b/cdk/test/bootstrap/policies.test.ts @@ -22,6 +22,7 @@ import { allPolicies } from '../../src/bootstrap/policies'; import { applicationPolicy } from '../../src/bootstrap/policies/application'; import { computeAgentcorePolicy } from '../../src/bootstrap/policies/compute-agentcore'; import { computeEcsPolicy } from '../../src/bootstrap/policies/compute-ecs'; +import { computeLambdaMicrovmPolicy } from '../../src/bootstrap/policies/compute-lambda-microvm'; import { infrastructurePolicy } from '../../src/bootstrap/policies/infrastructure'; import { observabilityPolicy } from '../../src/bootstrap/policies/observability'; @@ -399,6 +400,79 @@ describe('IaCRole-ABCA-Compute-ECS', () => { }); }); +describe('computeLambdaMicrovmPolicy', () => { + const stack = new Stack(); + const doc = computeLambdaMicrovmPolicy(); + const json = doc.toJSON(); + const rendered = JSON.stringify(json); + + it('produces valid JSON', () => { + expect(() => JSON.parse(rendered)).not.toThrow(); + }); + + it('is under 6144 characters when serialized', () => { + // AWS managed policy size limit + expect(rendered.length).toBeLessThan(6144); + }); + + it('contains the expected SIDs', () => { + const resolvedDoc = stack.resolve(doc); + const statements = resolvedDoc.Statement as Array<{ Sid: string }>; + + expect(statements.map((s) => s.Sid)).toEqual(['LambdaMicrovms']); + }); + + it('covers the expected service prefixes', () => { + const resolvedDoc = stack.resolve(doc); + const statements = resolvedDoc.Statement as Array<{ Action: string | string[] }>; + const allActions = statements.flatMap((s) => + Array.isArray(s.Action) ? s.Action : [s.Action], + ); + const prefixes = new Set(allActions.map((a) => a.split(':')[0])); + + expect(prefixes).toEqual(new Set(['lambda'])); + }); + + it('covers both CFN resource types the construct synthesizes', () => { + const resolvedDoc = stack.resolve(doc); + const statements = resolvedDoc.Statement as Array<{ Action: string | string[] }>; + const allActions = statements.flatMap((s) => + Array.isArray(s.Action) ? s.Action : [s.Action], + ); + + // AWS::Lambda::MicrovmImage + AWS::Lambda::NetworkConnector, plus the + // documented dependent of CreateMicrovmImage. + expect(allActions).toContain('lambda:CreateMicrovmImage'); + expect(allActions).toContain('lambda:CreateNetworkConnector'); + expect(allActions).toContain('lambda:PassNetworkConnector'); + }); + + it('grants NO runtime session lifecycle actions (those belong to the orchestrator role)', () => { + // A deploy role that can start, suspend, or mint tokens for MicroVMs is a + // privilege escalation — the orchestrator gets those, per-deployment. + // Compared as exact actions, not substrings: `lambda:GetMicrovmImage` (which + // the deploy role legitimately needs) contains `lambda:GetMicrovm`. + const resolvedDoc = stack.resolve(doc); + const statements = resolvedDoc.Statement as Array<{ Action: string | string[] }>; + const allActions = new Set(statements.flatMap((s) => + Array.isArray(s.Action) ? s.Action : [s.Action], + )); + + for (const action of [ + 'lambda:RunMicrovm', + 'lambda:GetMicrovm', + 'lambda:TerminateMicrovm', + 'lambda:SuspendMicrovm', + 'lambda:ResumeMicrovm', + 'lambda:CreateMicrovmAuthToken', + 'lambda:CreateMicrovmShellAuthToken', + 'lambda:ConnectMicrovm', + ]) { + expect(allActions.has(action)).toBe(false); + } + }); +}); + describe('Cross-policy validation', () => { const stack = new Stack(); const policies = allPolicies(); @@ -416,8 +490,10 @@ describe('Cross-policy validation', () => { expect(unique.size).toBe(allSids.length); }); - it('returns exactly 5 policies', () => { - expect(policies).toHaveLength(5); + it('returns exactly 6 policies', () => { + // infrastructure, application, observability, compute-agentcore, + // compute-ecs, compute-lambda-microvm (ADR-021). + expect(policies).toHaveLength(6); }); it('every policy is under 6144 character limit', () => { diff --git a/cdk/test/constructs/lambda-microvm-compute.test.ts b/cdk/test/constructs/lambda-microvm-compute.test.ts new file mode 100644 index 000000000..455de8db7 --- /dev/null +++ b/cdk/test/constructs/lambda-microvm-compute.test.ts @@ -0,0 +1,594 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, Stack } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import { AgentSessionRole } from '../../src/constructs/agent-session-role'; +import { + LambdaMicrovmCompute, + MICROVM_ARTIFACT_OBJECT_KEY, + MICROVM_BACKEND_TAG_KEY, + MICROVM_BACKEND_TAG_VALUE, + MICROVM_LOG_GROUP_PREFIX, + MICROVM_PAYLOAD_TTL_DAYS, + MICROVM_REGION_OVERRIDE_CONTEXT, + assertLambdaMicrovmRegionSupported, + isLambdaMicrovmImageConfigured, +} from '../../src/constructs/lambda-microvm-compute'; +import { LAMBDA_MICROVM_SUPPORTED_REGIONS } from '../../src/handlers/shared/microvm-regions'; + +const BASE_IMAGE_ARN = 'arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1'; + +interface BuildOptions { + readonly region?: string; + readonly context?: Record; + readonly withImage?: boolean; + readonly externalImageIdentifier?: string; + readonly externalImageVersion?: string; + readonly withSessionRole?: boolean; + readonly regionAgnostic?: boolean; +} + +interface Built { + readonly stack: Stack; + readonly construct: LambdaMicrovmCompute; + readonly template: Template; +} + +/** + * Construct a minimal stack around the construct WITHOUT synthesizing it. + * + * Deliberately NOT the full `AgentStack`: this file asserts the construct's own + * contract, so a bare `Stack` + VPC keeps each template small and makes the + * resource counts below unambiguous. Stack-level wiring is covered in + * `test/stacks/agent.test.ts`. + * + * Separate from {@link build} because the Region gate throws inside the + * construct's constructor — those cases must never reach a synth, and skipping + * it keeps the one describe that cannot cache a `Template` cheap. + */ +function instantiate(options: BuildOptions = {}): Omit { + const app = new App({ context: options.context }); + const stack = new Stack(app, 'TestStack', { + env: options.regionAgnostic + ? undefined + : { account: '123456789012', region: options.region ?? 'us-east-1' }, + }); + const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2 }); + + let agentSessionRole: AgentSessionRole | undefined; + if (options.withSessionRole) { + // AgentSessionRole requires at least one assuming role; a stand-in for the + // AgentCore runtime role keeps this test focused on the MicroVM execution + // role being admitted *in addition* to it. + const runtimeRole = new iam.Role(stack, 'RuntimeRole', { + assumedBy: new iam.ServicePrincipal('bedrock-agentcore.amazonaws.com'), + }); + agentSessionRole = new AgentSessionRole(stack, 'AgentSessionRole', { + assumingRoles: [runtimeRole], + taskScopedTables: [ + new dynamodb.Table(stack, 'TaskTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + }), + ], + traceArtifactsBucket: new s3.Bucket(stack, 'TraceBucket'), + attachmentsBucket: new s3.Bucket(stack, 'AttachmentsBucket'), + }); + } + + const construct = new LambdaMicrovmCompute(stack, 'LambdaMicrovmCompute', { + vpc, + agentSessionRole, + ...(options.withImage && { + baseImageArn: BASE_IMAGE_ARN, + baseImageVersion: '1', + }), + externalImageIdentifier: options.externalImageIdentifier, + externalImageVersion: options.externalImageVersion, + }); + + return { stack, construct }; +} + +/** {@link instantiate} plus one synth, for assertions against the template. */ +function build(options: BuildOptions = {}): Built { + const { stack, construct } = instantiate(options); + return { stack, construct, template: Template.fromStack(stack) }; +} + +describe('LambdaMicrovmCompute — image provisioned from a managed base image', () => { + let built: Built; + let template: Template; + + beforeAll(() => { + built = build({ withImage: true, withSessionRole: true }); + template = built.template; + }); + + test('synthesizes exactly one MicroVM image from the artifact bucket object', () => { + template.resourceCountIs('AWS::Lambda::MicrovmImage', 1); + template.hasResourceProperties('AWS::Lambda::MicrovmImage', { + BaseImageArn: BASE_IMAGE_ARN, + BaseImageVersion: '1', + CodeArtifact: { + Uri: { + 'Fn::Join': ['', [ + 's3://', + { Ref: Match.stringLikeRegexp('LambdaMicrovmComputeArtifactBucket') }, + `/${MICROVM_ARTIFACT_OBJECT_KEY}`, + ]], + }, + }, + }); + }); + + test('builds an ARM64 image sized at the 32 GB service ceiling', () => { + template.hasResourceProperties('AWS::Lambda::MicrovmImage', { + CpuConfigurations: [{ Architecture: 'arm64' }], + Resources: [{ MinimumMemoryInMiB: 32768 }], + }); + }); + + test('configures ONLY the /run hook — suspend/resume/terminate are P3', () => { + const images = template.findResources('AWS::Lambda::MicrovmImage'); + const hooks = Object.values(images)[0]!.Properties.Hooks; + expect(hooks.Port).toBe(8080); + expect(hooks.MicrovmHooks).toEqual({ Run: '/run', RunTimeoutInSeconds: 60 }); + // A hook the service calls but the agent does not serve fails the lifecycle + // transition, so P1 must not advertise hooks it has not implemented. + expect(hooks.MicrovmHooks.Suspend).toBeUndefined(); + expect(hooks.MicrovmHooks.Resume).toBeUndefined(); + expect(hooks.MicrovmHooks.Terminate).toBeUndefined(); + expect(hooks.MicrovmImageHooks).toBeUndefined(); + }); + + test('bakes NO environment variables into the snapshot (ADR-021: no secrets in the image)', () => { + template.hasResourceProperties('AWS::Lambda::MicrovmImage', { + EnvironmentVariables: [], + }); + }); + + test('routes image build-time egress through the platform VPC connector', () => { + template.hasResourceProperties('AWS::Lambda::MicrovmImage', { + EgressNetworkConnectors: [ + { 'Fn::GetAtt': [Match.stringLikeRegexp('LambdaMicrovmComputeEgressConnector'), 'Arn'] }, + ], + }); + }); + + test('exposes the image ARN as both the identifier and the IAM grant scope', () => { + expect(built.construct.imageIdentifier).toBeDefined(); + expect(built.construct.imageIdentifier).toBe(built.construct.imageArn); + // Version deliberately unpinned so the service resolves the latest ACTIVE + // version after a rebuild without a stack update. + expect(built.construct.imageVersion).toBeUndefined(); + }); + + test('creates an egress-only network connector on the private subnets', () => { + template.resourceCountIs('AWS::Lambda::NetworkConnector', 1); + template.hasResourceProperties('AWS::Lambda::NetworkConnector', { + Configuration: { + VpcEgressConfiguration: { + // The only value the service accepts today. + AssociatedComputeResourceTypes: ['MicroVm'], + NetworkProtocol: 'IPv4', + SubnetIds: Match.anyValue(), + SecurityGroupIds: [ + { 'Fn::GetAtt': [Match.stringLikeRegexp('LambdaMicrovmComputeMicrovmSG'), 'GroupId'] }, + ], + }, + }, + }); + }); + + test('security group allows TCP 443 egress only', () => { + template.hasResourceProperties('AWS::EC2::SecurityGroup', { + GroupDescription: 'Lambda MicroVMs agent sessions - egress TCP 443 only', + SecurityGroupEgress: [ + Match.objectLike({ IpProtocol: 'tcp', FromPort: 443, ToPort: 443, CidrIp: '0.0.0.0/0' }), + ], + }); + }); + + test('creates a retention-managed log group under the service log namespace', () => { + template.hasResourceProperties('AWS::Logs::LogGroup', { + LogGroupName: `${MICROVM_LOG_GROUP_PREFIX}/TestStack-abca-agent`, + RetentionInDays: 90, + }); + }); + + test('creates the artifact and payload buckets, both private + TLS-only + encrypted', () => { + // artifact + payload, plus the trace/attachments buckets the test's + // AgentSessionRole fixture needs. + template.resourceCountIs('AWS::S3::Bucket', 4); + const buckets = template.findResources('AWS::S3::Bucket'); + const microvmBuckets = Object.entries(buckets).filter(([id]) => id.includes('LambdaMicrovmCompute')); + expect(microvmBuckets).toHaveLength(2); + for (const [, bucket] of microvmBuckets) { + expect(bucket.Properties.PublicAccessBlockConfiguration).toEqual({ + BlockPublicAcls: true, + BlockPublicPolicy: true, + IgnorePublicAcls: true, + RestrictPublicBuckets: true, + }); + expect(bucket.Properties.BucketEncryption).toEqual({ + ServerSideEncryptionConfiguration: [ + { ServerSideEncryptionByDefault: { SSEAlgorithm: 'AES256' } }, + ], + }); + } + }); + + test('payload bucket expires objects (the ONLY reaper on this backend)', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + LifecycleConfiguration: { + Rules: Match.arrayWith([ + Match.objectLike({ + Id: 'microvm-payload-ttl', + Status: 'Enabled', + ExpirationInDays: MICROVM_PAYLOAD_TTL_DAYS, + AbortIncompleteMultipartUpload: { DaysAfterInitiation: 1 }, + }), + ]), + }, + }); + }); + + test('artifact bucket has NO object expiry (the snapshot may be rebuilt from it)', () => { + const buckets = template.findResources('AWS::S3::Bucket'); + const [, artifactBucket] = Object.entries(buckets) + .find(([id]) => id.includes('LambdaMicrovmComputeArtifactBucket'))!; + const rules = artifactBucket.Properties.LifecycleConfiguration.Rules; + expect(rules).toEqual([ + expect.objectContaining({ + Id: 'microvm-artifact-mpu-abort', + AbortIncompleteMultipartUpload: { DaysAfterInitiation: 1 }, + }), + ]); + expect(rules[0].ExpirationInDays).toBeUndefined(); + }); + + test('both roles are trusted by lambda.amazonaws.com for AssumeRole AND TagSession, pinned to this account', () => { + const roles = Object.entries(template.findResources('AWS::IAM::Role')) + .filter(([id]) => id.includes('LambdaMicrovmComputeBuildRole') || id.includes('LambdaMicrovmComputeExecutionRole')); + expect(roles).toHaveLength(2); + + for (const [, role] of roles) { + const statements = role.Properties.AssumeRolePolicyDocument.Statement; + expect(statements.map((s: { Action: string }) => s.Action).sort()) + .toEqual(['sts:AssumeRole', 'sts:TagSession']); + for (const statement of statements) { + // `microvms.lambda.amazonaws.com` does not exist — using it is rejected + // with MalformedPolicyDocument. + expect(statement.Principal).toEqual({ Service: 'lambda.amazonaws.com' }); + // Confused-deputy: lambda.amazonaws.com is shared with every other + // Lambda feature, so aws:SourceAccount must be on BOTH actions. + expect(statement.Condition).toEqual({ + StringEquals: { 'aws:SourceAccount': '123456789012' }, + }); + expect(JSON.stringify(statement.Condition)).not.toContain('aws:SourceArn'); + } + } + }); + + test('build role reads exactly the one artifact object and writes MicroVM logs', () => { + const policies = Object.entries(template.findResources('AWS::IAM::Policy')) + .filter(([id]) => id.includes('LambdaMicrovmComputeBuildRole')); + expect(policies).toHaveLength(1); + const statements = policies[0]![1].Properties.PolicyDocument.Statement; + const actions = statements.flatMap((s: { Action: string | string[] }) => + Array.isArray(s.Action) ? s.Action : [s.Action]); + expect(actions.sort()).toEqual([ + 'logs:CreateLogGroup', + 'logs:CreateLogStream', + 'logs:PutLogEvents', + 's3:GetObject', + ]); + // Object-scoped, not bucket-scoped. + const s3Statement = statements.find((s: { Action: string }) => s.Action === 's3:GetObject'); + expect(JSON.stringify(s3Statement.Resource)).toContain(MICROVM_ARTIFACT_OBJECT_KEY); + }); + + test('execution role gets READ-ONLY on the payload bucket and no write/delete', () => { + const policies = Object.entries(template.findResources('AWS::IAM::Policy')) + .filter(([id]) => id.includes('LambdaMicrovmComputeExecutionRole')); + const statements = policies.flatMap(([, p]) => p.Properties.PolicyDocument.Statement); + const actions: string[] = statements.flatMap((s: { Action: string | string[] }) => + Array.isArray(s.Action) ? s.Action : [s.Action]); + + // CDK's grantRead renders the Get*/List* read set. + expect(actions).toContain('s3:GetObject*'); + // The MicroVM runs untrusted repo code — it must not be able to clobber + // another task's payload, so nothing mutating may appear. + const s3Actions = actions.filter(a => a.startsWith('s3:')); + expect(s3Actions).toEqual(['s3:GetObject*', 's3:GetBucket*', 's3:List*']); + for (const action of s3Actions) { + expect(action).not.toMatch(/Put|Delete|Abort|Write|^s3:\*$/); + } + }); + + test('execution role has NO Bedrock / Secrets Manager / DynamoDB grants (P2 scope)', () => { + const policies = Object.entries(template.findResources('AWS::IAM::Policy')) + .filter(([id]) => id.includes('LambdaMicrovmComputeExecutionRole')); + const rendered = JSON.stringify(policies); + expect(rendered).not.toContain('bedrock:'); + expect(rendered).not.toContain('secretsmanager:'); + expect(rendered).not.toContain('dynamodb:'); + expect(rendered).not.toContain('bedrock-agentcore:'); + }); + + test('execution role is admitted to the per-task SessionRole (tenant-data delegation)', () => { + const sessionRoles = Object.entries(template.findResources('AWS::IAM::Role')) + .filter(([id]) => id.includes('AgentSessionRole')); + expect(sessionRoles).toHaveLength(1); + const trust = JSON.stringify(sessionRoles[0]![1].Properties.AssumeRolePolicyDocument); + expect(trust).toContain('sts:TagSession'); + expect(trust).toContain('LambdaMicrovmComputeExecutionRole'); + + // ...and the other half of admitComputeRole: the execution role may assume it. + const execPolicies = JSON.stringify( + Object.entries(template.findResources('AWS::IAM::Policy')) + .filter(([id]) => id.includes('LambdaMicrovmComputeExecutionRole')), + ); + expect(execPolicies).toContain('sts:AssumeRole'); + }); + + test('never grants lambda:CreateMicrovmAuthToken to anything (no JWE consumer in P1–P3)', () => { + expect(JSON.stringify(template.toJSON())).not.toContain('CreateMicrovmAuthToken'); + }); + + test('warns that a P1 image is not runnable end to end (hook phasing)', () => { + // ADR-021 sub-decision 3: P1 DECLARES /run, the agent SERVES it in P2. A + // provisioned substrate plus a buildable image is indistinguishable from a + // working backend until a task silently stalls, so the warning is emitted on + // every deploy that configures an image. + const warnings = built.construct.node.metadata.filter(m => m.type === 'aws:cdk:warning'); + const message = warnings.map(w => String(w.data)).join('\n'); + expect(JSON.stringify(built.construct.node.metadata)) + .toContain('abca:microvm-image-p1-not-runnable'); + expect(message).toContain('/run'); + expect(message).toContain('P2'); + }); + + test('declares no hook the agent does not serve yet', () => { + const images = template.findResources('AWS::Lambda::MicrovmImage'); + const rendered = JSON.stringify(Object.values(images)[0]!.Properties.Hooks); + for (const hook of ['Suspend', 'Resume', 'Terminate', 'Ready', 'Validate']) { + expect(rendered).not.toContain(hook); + } + expect(rendered).toContain('"Run":"/run"'); + }); + + test('tags every MicroVM resource with the backend cost-allocation tag', () => { + const json = template.toJSON(); + const taggedTypes = [ + 'AWS::Lambda::MicrovmImage', + 'AWS::Lambda::NetworkConnector', + 'AWS::S3::Bucket', + 'AWS::IAM::Role', + 'AWS::EC2::SecurityGroup', + ]; + for (const type of taggedTypes) { + const allResources = json.Resources as Record }; + }>; + const resources = Object.entries(allResources) + .filter(([id, r]) => r.Type === type && id.includes('LambdaMicrovmCompute')); + expect(resources.length).toBeGreaterThan(0); + for (const [id, resource] of resources) { + expect(resource.Properties?.Tags).toEqual( + expect.arrayContaining([ + { Key: MICROVM_BACKEND_TAG_KEY, Value: MICROVM_BACKEND_TAG_VALUE }, + ]), + ); + expect(id).toContain('LambdaMicrovmCompute'); + } + } + }); +}); + +describe('LambdaMicrovmCompute — image built out of band', () => { + const EXTERNAL_IMAGE_ARN = 'arn:aws:lambda:us-east-1:123456789012:microvm-image:my-agent'; + + // Two distinct configurations (identifier given as an ARN vs as a bare name), + // so two cached fixtures — synthesized once each, per cdk/AGENTS.md. + let byArn: Built; + let byName: Built; + let byNameVersioned: Built; + + beforeAll(() => { + byArn = build({ + externalImageIdentifier: EXTERNAL_IMAGE_ARN, + externalImageVersion: '7', + }); + byName = build({ externalImageIdentifier: 'my-agent' }); + byNameVersioned = build({ externalImageIdentifier: 'my-agent', externalImageVersion: '3' }); + }); + + test('uses the supplied identifier and synthesizes no image resource', () => { + byArn.template.resourceCountIs('AWS::Lambda::MicrovmImage', 0); + // The buckets/roles/connector still exist — the substrate is provisioned. + byArn.template.resourceCountIs('AWS::Lambda::NetworkConnector', 1); + expect(byArn.construct.imageIdentifier).toBe(EXTERNAL_IMAGE_ARN); + expect(byArn.construct.imageVersion).toBe('7'); + expect(byArn.construct.imageArn).toBe(EXTERNAL_IMAGE_ARN); + }); + + test('resolves a bare image NAME to its exact IAM resource ARN', () => { + expect(byName.construct.imageIdentifier).toBe('my-agent'); + // Load-bearing for ADR-021's "scoped to platform-created images": a bare + // name is not an IAM resource, but the `microvmImage` ARN shape is fully + // derivable from it, so the grant stays pinned to THIS image instead of + // widening to an account/Region-wide `microvm-image:*`. + // The partition stays the Aws.PARTITION pseudo-parameter (unresolved until + // deploy) so the same code is correct in aws-cn / aws-us-gov — hence the + // suffix assertion rather than a literal `arn:aws:` comparison. + expect(byName.construct.imageArn) + .toMatch(/^arn:.+:lambda:us-east-1:123456789012:microvm-image:my-agent$/); + expect(byName.construct.imageArn).not.toContain('microvm-image:*'); + }); + + test('warns that a P1 image is not runnable, out-of-band path included', () => { + // The warning is keyed on "an image is configured", not on which of the two + // image states produced it — an out-of-band build is just as un-runnable. + for (const fixture of [byArn, byName]) { + expect(JSON.stringify(fixture.construct.node.metadata)) + .toContain('abca:microvm-image-p1-not-runnable'); + } + }); + + test('an image version never changes the IAM scope', () => { + // The SAR `microvmImage` pattern ends at the name; `imageVersion` travels as + // a separate RunMicrovm request field. + expect(byNameVersioned.construct.imageArn).toBe(byName.construct.imageArn); + expect(byNameVersioned.construct.imageVersion).toBe('3'); + }); +}); + +describe('LambdaMicrovmCompute — first deploy, no image configured', () => { + let built: Built; + + beforeAll(() => { + built = build(); + }); + + test('provisions the substrate but no image, and reports no identifier', () => { + built.template.resourceCountIs('AWS::Lambda::MicrovmImage', 0); + built.template.resourceCountIs('AWS::Lambda::NetworkConnector', 1); + expect(built.construct.imageIdentifier).toBeUndefined(); + }); + + test('warns (does not throw) so the artifact bucket can be created before the upload', () => { + const warnings = built.construct.node.metadata.filter(m => m.type === 'aws:cdk:warning'); + expect(warnings).toHaveLength(1); + const message = String(warnings[0]!.data); + // The warning has to be actionable: it names the bootstrap remedy and the + // context flags that move the deployment out of this state. + expect(message).toContain('package-microvm-artifact.sh'); + expect(message).toContain('microvm_base_image_arn'); + expect(message).toContain('microvm_image_identifier'); + expect(JSON.stringify(built.construct.node.metadata)) + .toContain('abca:microvm-image-not-provisioned'); + // ...and NOT the "configured but not runnable" warning — there is no image + // to be un-runnable, and stacking both would blur two different remedies. + expect(JSON.stringify(built.construct.node.metadata)) + .not.toContain('abca:microvm-image-p1-not-runnable'); + }); +}); + +describe('assertLambdaMicrovmRegionSupported — Region gate', () => { + // TEST-CONVENTION EXEMPTION (cdk/AGENTS.md "synth once in beforeAll"): + // these cases assert that the construct's CONSTRUCTOR throws, so there is no + // template to cache — a fixture built in `beforeAll` would fail the hook + // rather than the test. They stay per-case but use `instantiate()`, which + // skips `Template.fromStack()` entirely: the gate runs as the construct's + // first statement, so no synth is needed to observe it. The one case that + // DOES need a template (the escape hatch succeeding) is cached below. + let overridden: Built; + + beforeAll(() => { + overridden = build({ + region: 'eu-central-1', + withImage: true, + context: { [MICROVM_REGION_OVERRIDE_CONTEXT]: true }, + }); + }); + + test.each(LAMBDA_MICROVM_SUPPORTED_REGIONS)('accepts %s', (region) => { + expect(() => instantiate({ region, withImage: true })).not.toThrow(); + }); + + test('fails synth in an unsupported Region, naming the list and the escape hatch', () => { + let error: Error | undefined; + try { + instantiate({ region: 'eu-central-1', withImage: true }); + } catch (err) { + error = err as Error; + } + + expect(error).toBeDefined(); + // One instantiation, three assertions on its message — the remedy has to + // name the Region, the supported list, and the escape hatch, or an operator + // in a just-launched Region is stuck. + expect(error!.message).toMatch(/AWS Lambda MicroVMs are not available in eu-central-1/); + expect(error!.message).toContain(MICROVM_REGION_OVERRIDE_CONTEXT); + expect(error!.message).toContain('us-east-1, us-east-2, us-west-2, eu-west-1, ap-northeast-1'); + }); + + test('the context escape hatch unblocks an unsupported Region', () => { + overridden.template.resourceCountIs('AWS::Lambda::MicrovmImage', 1); + // Still surfaced as a warning — the operator is ahead of the constant. + expect(JSON.stringify(overridden.construct.node.metadata)) + .toContain('abca:microvm-region-override'); + }); + + test('skips the check for a region-agnostic (token) Region rather than rejecting it', () => { + // `cdk synth` with no CDK_DEFAULT_REGION resolves Stack.region to the + // AWS::Region pseudo-parameter; comparing that to a Region list would fail + // every region-agnostic synth. The live probes cover this case instead. + expect(() => instantiate({ regionAgnostic: true, withImage: true })).not.toThrow(); + }); + + test('is callable standalone on any construct scope', () => { + const supported = new Stack(new App(), 'S', { env: { account: '1', region: 'us-west-2' } }); + expect(() => assertLambdaMicrovmRegionSupported(supported)).not.toThrow(); + + const unsupported = new Stack(new App(), 'S', { env: { account: '1', region: 'sa-east-1' } }); + expect(() => assertLambdaMicrovmRegionSupported(unsupported)).toThrow(/not available in sa-east-1/); + }); +}); + +describe('isLambdaMicrovmImageConfigured — the shared three-state predicate', () => { + // Public because `stacks/agent.ts` must answer "will an image exist?" BEFORE + // TaskApi is constructed (the cancel grant's Lazy ARN depends on it), while the + // construct answers the same question later. One predicate, so they cannot + // drift — a drift means either a missing cancel grant or a grant scoped to an + // image that was never created. + test('true for a complete managed-base-image build', () => { + expect(isLambdaMicrovmImageConfigured({ + baseImageArn: BASE_IMAGE_ARN, + baseImageVersion: '1', + })).toBe(true); + }); + + test('true for an out-of-band image, by ARN or by bare name', () => { + expect(isLambdaMicrovmImageConfigured({ externalImageIdentifier: 'abca-agent' })).toBe(true); + expect(isLambdaMicrovmImageConfigured({ + externalImageIdentifier: 'arn:aws:lambda:us-east-1:123456789012:microvm-image:abca-agent', + })).toBe(true); + }); + + test('false for a PARTIAL base-image config (CFN requires both fields)', () => { + // The construct falls through to its no-image branch here, so the predicate + // must agree — otherwise the stack would wire a cancel grant whose Lazy ARN + // has nothing to resolve to. + expect(isLambdaMicrovmImageConfigured({ baseImageArn: BASE_IMAGE_ARN })).toBe(false); + expect(isLambdaMicrovmImageConfigured({ baseImageVersion: '1' })).toBe(false); + }); + + test('false when nothing is configured, and a version alone never counts', () => { + expect(isLambdaMicrovmImageConfigured({})).toBe(false); + expect(isLambdaMicrovmImageConfigured({ externalImageVersion: '7' })).toBe(false); + }); +}); diff --git a/cdk/test/constructs/task-api.test.ts b/cdk/test/constructs/task-api.test.ts index 79fd7fa9a..2ec00826f 100644 --- a/cdk/test/constructs/task-api.test.ts +++ b/cdk/test/constructs/task-api.test.ts @@ -373,6 +373,60 @@ describe('TaskApi construct', () => { expect(vars).not.toHaveProperty('ECS_CLUSTER_ARN'); } }); + + describe('Lambda MicroVMs cancel wiring (ADR-021)', () => { + const MICROVM_IMAGE_ARN = 'arn:aws:lambda:us-east-1:123456789012:microvm-image:abca-agent'; + + // One synth per configuration, cached (cdk/AGENTS.md): the backend enabled, + // and — via the outer describe's `baseTemplate` — the backend disabled. + let microvmTemplate: Template; + + beforeAll(() => { + microvmTemplate = createStack({ lambdaMicrovmImageArn: MICROVM_IMAGE_ARN }).template; + }); + + /** Every MicroVM-related IAM action granted anywhere in the template. */ + function microvmActions(template: Template): string[] { + return Object.values(template.findResources('AWS::IAM::Policy')) + .flatMap((p) => (p as any).Properties.PolicyDocument.Statement as Array<{ Action: string | string[] }>) + .flatMap((stmt) => (Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action])) + .filter((action) => action.includes('Microvm')); + } + + /** MICROVM_* env keys across every Lambda in the template. */ + function microvmEnvKeys(template: Template): string[] { + return Object.values(template.findResources('AWS::Lambda::Function')) + .flatMap((fn) => Object.keys(((fn as any).Properties?.Environment?.Variables ?? {}) as Record)) + .filter((key) => key.startsWith('MICROVM_')); + } + + test('grants ONLY lambda:TerminateMicrovm when the backend is enabled', () => { + // cancel-task.ts sends TerminateMicrovmCommand and nothing else — it never + // reads MicroVM state — so lambda:GetMicrovm would be an unused grant. + expect(microvmActions(microvmTemplate)).toEqual(['lambda:TerminateMicrovm']); + }); + + test('scopes the grant to the one platform image, never an account wildcard', () => { + const statement = Object.values(microvmTemplate.findResources('AWS::IAM::Policy')) + .flatMap((p) => (p as any).Properties.PolicyDocument.Statement as Array<{ Action: string | string[]; Resource: unknown }>) + .find((stmt) => stmt.Action === 'lambda:TerminateMicrovm')!; + + // Every MicroVM lifecycle action authorizes against the image resource, so + // the per-session microvmId never appears in IAM and this can be exact. + expect(statement.Resource).toEqual([MICROVM_IMAGE_ARN, `${MICROVM_IMAGE_ARN}:*`]); + expect(statement.Resource).not.toBe('*'); + expect(JSON.stringify(statement.Resource)).not.toContain('microvm-image:*'); + }); + + test('adds no MicroVM grant when no image is configured', () => { + expect(microvmActions(baseTemplate)).toEqual([]); + expect(microvmEnvKeys(baseTemplate)).toEqual([]); + }); + + test('needs no env var — the handler reads microvmId from the task row', () => { + expect(microvmEnvKeys(microvmTemplate)).toEqual([]); + }); + }); }); describe('TaskApi construct with webhooks', () => { diff --git a/cdk/test/constructs/task-orchestrator.test.ts b/cdk/test/constructs/task-orchestrator.test.ts index ebcba02e8..159ed0b15 100644 --- a/cdk/test/constructs/task-orchestrator.test.ts +++ b/cdk/test/constructs/task-orchestrator.test.ts @@ -20,6 +20,7 @@ import { App, Stack } from 'aws-cdk-lib'; import { Template, Match } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as s3 from 'aws-cdk-lib/aws-s3'; import { TaskOrchestrator } from '../../src/constructs/task-orchestrator'; interface StackOverrides { @@ -522,3 +523,194 @@ describe('TaskOrchestrator construct', () => { }); }); }); + +describe('TaskOrchestrator with the Lambda MicroVMs backend (ADR-021)', () => { + const IMAGE_ARN = 'arn:aws:lambda:us-east-1:123456789012:microvm-image:abca-agent'; + /** What the construct derives from the bare image name `abca-agent`. */ + const NAME_DERIVED_IMAGE_ARN = 'arn:aws:lambda:us-east-1:123456789012:microvm-image:abca-agent'; + const EXECUTION_ROLE_ARN = 'arn:aws:iam::123456789012:role/MicrovmExecutionRole'; + const CONNECTOR_ARN = 'arn:aws:lambda:us-east-1:123456789012:network-connector:nc-123'; + + /** + * Build a stack with `microvmConfig` wired. Separate from `createStack` above + * because the payload bucket has to be a real construct (the prop takes an + * `s3.IBucket` so the grant can be rendered), which the shared helper's + * override shape does not model. + */ + function createMicrovmStack(config?: { + imageIdentifier?: string; + imageArn?: string; + imageVersion?: string; + ingressConnectorArns?: string[]; + }): { template: Template } { + const app = new App(); + const stack = new Stack(app, 'TestStack', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + const mkTable = (id: string, sortKey?: string) => new dynamodb.Table(stack, id, { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + ...(sortKey ? { sortKey: { name: sortKey, type: dynamodb.AttributeType.STRING } } : {}), + }); + + new TaskOrchestrator(stack, 'TaskOrchestrator', { + taskTable: mkTable('TaskTable'), + taskEventsTable: mkTable('TaskEventsTable', 'event_id'), + userConcurrencyTable: new dynamodb.Table(stack, 'UserConcurrencyTable', { + partitionKey: { name: 'user_id', type: dynamodb.AttributeType.STRING }, + }), + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/test-runtime', + microvmConfig: { + imageIdentifier: config?.imageIdentifier ?? IMAGE_ARN, + imageArn: config?.imageArn ?? IMAGE_ARN, + imageVersion: config?.imageVersion, + executionRoleArn: EXECUTION_ROLE_ARN, + egressConnectorArns: [CONNECTOR_ARN], + ingressConnectorArns: config?.ingressConnectorArns, + payloadBucket: new s3.Bucket(stack, 'MicrovmPayloadBucket'), + }, + }); + + return { template: Template.fromStack(stack) }; + } + + function orchestratorEnv(template: Template): Record { + const [, fn] = Object.entries(template.findResources('AWS::Lambda::Function')) + .find(([id]) => id.includes('OrchestratorFn'))!; + return fn.Properties.Environment.Variables as Record; + } + + function microvmStatements(template: Template): Array<{ + Sid?: string; + Action: string | string[]; + Resource: unknown; + Condition?: unknown; + }> { + return Object.values(template.findResources('AWS::IAM::Policy')) + .flatMap(p => p.Properties.PolicyDocument.Statement) + .filter((s: { Sid?: string }) => (s.Sid ?? '').startsWith('Microvm')); + } + + // Three distinct configurations, synthesized once each in beforeAll per + // cdk/AGENTS.md: the default wiring, the same wiring with an image version + + // ingress connectors pinned, and a name-derived (not operator-supplied) image + // ARN. `noMicrovmTemplate` is the negative control. + let template: Template; + let pinnedTemplate: Template; + let nameDerivedTemplate: Template; + let noMicrovmTemplate: Template; + + beforeAll(() => { + template = createMicrovmStack().template; + pinnedTemplate = createMicrovmStack({ + imageVersion: '4', + ingressConnectorArns: ['arn:aws:lambda:us-east-1:aws:network-connector:x', 'arn:y'], + }).template; + // What LambdaMicrovmCompute passes when the operator gave a bare image NAME: + // the exact ARN it derived, never a wildcard. + nameDerivedTemplate = createMicrovmStack({ + imageIdentifier: 'abca-agent', + imageArn: NAME_DERIVED_IMAGE_ARN, + }).template; + noMicrovmTemplate = createStack().template; + }); + + test('injects the required MICROVM_* env vars verbatim (the strategy contract)', () => { + const env = orchestratorEnv(template); + expect(env.MICROVM_IMAGE_IDENTIFIER).toBe(IMAGE_ARN); + expect(env.MICROVM_EXECUTION_ROLE_ARN).toBe(EXECUTION_ROLE_ARN); + expect(env.MICROVM_EGRESS_CONNECTOR_ARNS).toBe(CONNECTOR_ARN); + expect(env.MICROVM_PAYLOAD_BUCKET).toBeDefined(); + }); + + test('omits the optional env vars when not configured', () => { + const env = orchestratorEnv(template); + // Absent (not empty-string) so the strategy's "no ingress connectors" branch + // omits the field on RunMicrovm entirely. + expect(env.MICROVM_IMAGE_VERSION).toBeUndefined(); + expect(env.MICROVM_INGRESS_CONNECTOR_ARNS).toBeUndefined(); + }); + + test('pins the image version and ingress connectors when supplied', () => { + const env = orchestratorEnv(pinnedTemplate); + expect(env.MICROVM_IMAGE_VERSION).toBe('4'); + expect(env.MICROVM_INGRESS_CONNECTOR_ARNS).toBe('arn:aws:lambda:us-east-1:aws:network-connector:x,arn:y'); + }); + + test('grants exactly the four P1 lifecycle actions and nothing more', () => { + const actions = microvmStatements(template) + .flatMap(s => Array.isArray(s.Action) ? s.Action : [s.Action]) + .filter(a => a.startsWith('lambda:')); + expect(actions.sort()).toEqual([ + 'lambda:GetMicrovm', + 'lambda:PassNetworkConnector', + 'lambda:RunMicrovm', + 'lambda:TerminateMicrovm', + ]); + }); + + test('scopes the lifecycle statement to the one platform image ARN', () => { + const lifecycle = microvmStatements(template).find(s => s.Sid === 'MicrovmLifecycle')!; + // Exact ARN plus the `:*` version-suffix hedge — both pinned to this + // image's name, so neither can match another image. + expect(lifecycle.Resource).toEqual([IMAGE_ARN, `${IMAGE_ARN}:*`]); + }); + + test('a name-derived image ARN is scoped to that exact name, never a wildcard', () => { + // An out-of-band image referenced by bare NAME is a valid RunMicrovm + // identifier but not an IAM resource. LambdaMicrovmCompute resolves it to the + // exact `microvmImage` ARN, so ADR-021's "scoped to platform-created images" + // holds here too — the account/Region-wide `microvm-image:*` widening this + // test previously accepted is a compliance violation, not a fallback. + const lifecycle = microvmStatements(nameDerivedTemplate).find(s => s.Sid === 'MicrovmLifecycle')!; + expect(lifecycle.Resource).toEqual([ + NAME_DERIVED_IMAGE_ARN, + `${NAME_DERIVED_IMAGE_ARN}:*`, + ]); + expect(JSON.stringify(lifecycle.Resource)).not.toContain('microvm-image:*'); + expect(JSON.stringify(lifecycle.Resource)).toContain('microvm-image:abca-agent'); + }); + + test('PassNetworkConnector must be Resource:* (the action has no resource type)', () => { + const pass = microvmStatements(template).find(s => s.Sid === 'MicrovmPassNetworkConnector')!; + expect(pass.Resource).toBe('*'); + }); + + test('passes the execution role to lambda.amazonaws.com only', () => { + const passRole = microvmStatements(template).find(s => s.Sid === 'MicrovmPassExecutionRole')!; + expect(passRole.Action).toBe('iam:PassRole'); + expect(passRole.Resource).toBe(EXECUTION_ROLE_ARN); + expect(passRole.Condition).toEqual({ + StringEquals: { 'iam:PassedToService': 'lambda.amazonaws.com' }, + }); + }); + + test('grants NO suspend/resume (P3) and NO auth-token minting (never)', () => { + const actions = new Set( + Object.values(template.findResources('AWS::IAM::Policy')) + .flatMap(p => p.Properties.PolicyDocument.Statement as Array<{ Action: string | string[] }>) + .flatMap(s => Array.isArray(s.Action) ? s.Action : [s.Action]), + ); + expect(actions.has('lambda:SuspendMicrovm')).toBe(false); + expect(actions.has('lambda:ResumeMicrovm')).toBe(false); + expect(actions.has('lambda:CreateMicrovmAuthToken')).toBe(false); + expect(actions.has('lambda:CreateMicrovmShellAuthToken')).toBe(false); + }); + + test('gets write on the payload bucket but NOT delete (lifecycle rule is the reaper)', () => { + const payloadStatements = Object.values(template.findResources('AWS::IAM::Policy')) + .flatMap(p => p.Properties.PolicyDocument.Statement as Array<{ + Action: string | string[]; + Resource: unknown; + }>) + .filter(s => JSON.stringify(s.Resource).includes('MicrovmPayloadBucket')); + + const actions = payloadStatements.flatMap(s => Array.isArray(s.Action) ? s.Action : [s.Action]); + expect(actions).toContain('s3:PutObject'); + expect(actions).not.toContain('s3:DeleteObject'); + }); + + test('adds no MicroVM statements when microvmConfig is omitted', () => { + expect(microvmStatements(noMicrovmTemplate)).toEqual([]); + expect(orchestratorEnv(noMicrovmTemplate).MICROVM_IMAGE_IDENTIFIER).toBeUndefined(); + }); +}); diff --git a/cdk/test/handlers/cancel-task.test.ts b/cdk/test/handlers/cancel-task.test.ts index 68e7b1de0..b64465766 100644 --- a/cdk/test/handlers/cancel-task.test.ts +++ b/cdk/test/handlers/cancel-task.test.ts @@ -31,6 +31,11 @@ jest.mock('@aws-sdk/client-ecs', () => ({ ECSClient: jest.fn(() => ({ send: mockEcsSend })), StopTaskCommand: jest.fn((input: unknown) => ({ _type: 'StopTask', input })), })); +const mockMicrovmSend = jest.fn(); +jest.mock('@aws-sdk/client-lambda-microvms', () => ({ + LambdaMicrovmsClient: jest.fn(() => ({ send: mockMicrovmSend })), + TerminateMicrovmCommand: jest.fn((input: unknown) => ({ _type: 'TerminateMicrovm', input })), +})); jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); jest.mock('@aws-sdk/lib-dynamodb', () => ({ DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockSend })) }, @@ -327,6 +332,70 @@ describe('cancel-task handler', () => { expect(mockAgentCoreSend).not.toHaveBeenCalled(); }); + test('cancels a lambda-microvm task via TerminateMicrovm, not AgentCore or ECS', async () => { + mockSend.mockReset(); + // NOTE: RUNNING_TASK carries `agent_runtime_arn`, and RUNTIME_ARN is also set + // in this suite's env — exactly the mixed-deployment shape that would send a + // MicroVM task down the AgentCore branch if the microvm check did not come + // first. This test is the regression guard for that branch ordering. + const microvmTask = { + ...RUNNING_TASK, + compute_type: 'lambda-microvm', + session_id: 'mvm-0123456789abcdef', + compute_metadata: { + microvmId: 'mvm-0123456789abcdef', + endpoint: 'https://mvm-0123456789abcdef.microvm.lambda.us-east-1.amazonaws.com', + }, + }; + mockSend + .mockResolvedValueOnce({ Item: microvmTask }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(200); + expect(mockMicrovmSend).toHaveBeenCalledTimes(1); + const cmd = mockMicrovmSend.mock.calls[0][0]; + expect(cmd._type).toBe('TerminateMicrovm'); + // The SDK request key is `microvmIdentifier`, not `microvmId`. + expect(cmd.input).toEqual({ microvmIdentifier: 'mvm-0123456789abcdef' }); + expect(mockAgentCoreSend).not.toHaveBeenCalled(); + expect(mockEcsSend).not.toHaveBeenCalled(); + }); + + test('skips TerminateMicrovm when compute_metadata.microvmId is missing', async () => { + mockSend.mockReset(); + const microvmTask = { ...RUNNING_TASK, compute_type: 'lambda-microvm', compute_metadata: {} }; + mockSend + .mockResolvedValueOnce({ Item: microvmTask }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(200); + expect(mockMicrovmSend).not.toHaveBeenCalled(); + // Must NOT silently fall through to stopping an unrelated AgentCore runtime. + expect(mockAgentCoreSend).not.toHaveBeenCalled(); + }); + + test('a TerminateMicrovm failure still returns 200 (the CANCELLED write stands)', async () => { + mockSend.mockReset(); + const microvmTask = { + ...RUNNING_TASK, + compute_type: 'lambda-microvm', + compute_metadata: { microvmId: 'mvm-0123456789abcdef', endpoint: 'https://x' }, + }; + mockSend + .mockResolvedValueOnce({ Item: microvmTask }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + mockMicrovmSend.mockRejectedValueOnce(new Error('ResourceNotFoundException')); + + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(200); + expect(mockMicrovmSend).toHaveBeenCalledTimes(1); + }); + test('falls back to AgentCore stop when compute_type is unrecognized but RUNTIME_ARN is available', async () => { mockSend.mockReset(); const unknownTask = { diff --git a/cdk/test/handlers/orchestrate-task-microvm.test.ts b/cdk/test/handlers/orchestrate-task-microvm.test.ts new file mode 100644 index 000000000..ead329189 --- /dev/null +++ b/cdk/test/handlers/orchestrate-task-microvm.test.ts @@ -0,0 +1,290 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Drives the FULL orchestrate-task durable handler for a `lambda-microvm` task + * with a fake durable-execution context, so the wiring the unit tests can't see + * is covered end to end: strategy resolution → RunMicrovm → compute_metadata + * persistence → substrate poll cross-check → **TerminateMicrovm on finalize**. + * + * The finalize terminate is an ADR-021 normative requirement ("When the + * orchestrator finalizes a `lambda-microvm` task, the orchestrator shall call + * terminate-microvm") and is invisible to strategy-level unit tests, which is + * exactly how it was missed the first time. + */ + +// `withDurableExecution` wraps the handler at import time; unwrap it so the raw +// (event, context) function is testable (same trick as orchestrate-task-feedback). +jest.mock('@aws/durable-execution-sdk-js', () => ({ + withDurableExecution: (fn: unknown) => fn, +})); + +const MICROVM_ID = 'mvm-0123456789abcdef'; +const ENDPOINT = 'https://mvm-0123456789abcdef.microvm.lambda.us-east-1.amazonaws.com'; + +const mockMicrovmSend = jest.fn(); +jest.mock('@aws-sdk/client-lambda-microvms', () => ({ + LambdaMicrovmsClient: jest.fn(() => ({ send: mockMicrovmSend })), + RunMicrovmCommand: jest.fn((input: unknown) => ({ _type: 'RunMicrovm', input })), + GetMicrovmCommand: jest.fn((input: unknown) => ({ _type: 'GetMicrovm', input })), + TerminateMicrovmCommand: jest.fn((input: unknown) => ({ _type: 'TerminateMicrovm', input })), + MicrovmState: { + PENDING: 'PENDING', + RUNNING: 'RUNNING', + SUSPENDED: 'SUSPENDED', + SUSPENDING: 'SUSPENDING', + TERMINATED: 'TERMINATED', + TERMINATING: 'TERMINATING', + }, +})); + +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn(() => ({ send: jest.fn().mockResolvedValue({}) })), + PutObjectCommand: jest.fn((input: unknown) => ({ _type: 'PutObject', input })), + DeleteObjectCommand: jest.fn((input: unknown) => ({ _type: 'DeleteObject', input })), +})); + +// Real orchestrator helpers would talk to DynamoDB; stub them and assert on the +// calls. `buildComputeMetadata` is kept REAL (re-exported from the actual module) +// so the persisted metadata shape is genuinely exercised, not mirrored. +const realOrchestrator = jest.requireActual('../../src/handlers/shared/orchestrator'); +const mockTransitionTask = jest.fn(); +const mockEmitTaskEvent = jest.fn(); +const mockFinalizeTask = jest.fn(); +const mockPollTaskStatus = jest.fn(); +const mockReconcile = jest.fn(); +const mockFailTask = jest.fn(); +jest.mock('../../src/handlers/shared/orchestrator', () => ({ + admissionControl: jest.fn().mockResolvedValue(true), + emitTaskEvent: (...a: unknown[]) => mockEmitTaskEvent(...a), + envelopeFor: () => ({ + log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + correlation: { user_id: 'user-1', repo: 'org/repo' }, + }), + failTask: (...a: unknown[]) => mockFailTask(...a), + finalizeTask: (...a: unknown[]) => mockFinalizeTask(...a), + hydrateAndTransition: jest.fn().mockResolvedValue({ repo_url: 'org/repo', task_id: 'TASK001' }), + loadBlueprintConfig: jest.fn().mockResolvedValue({ compute_type: 'lambda-microvm', runtime_arn: '' }), + loadTask: jest.fn().mockResolvedValue({ + task_id: 'TASK001', user_id: 'user-1', status: 'SUBMITTED', repo: 'org/repo', + }), + pollTaskStatus: (...a: unknown[]) => mockPollTaskStatus(...a), + reconcileMicrovmSubstrateState: (...a: unknown[]) => mockReconcile(...a), + transitionTask: (...a: unknown[]) => mockTransitionTask(...a), + buildComputeMetadata: realOrchestrator.buildComputeMetadata, +})); + +jest.mock('../../src/handlers/shared/preflight', () => ({ + runPreflightChecks: jest.fn().mockResolvedValue({ passed: true, checks: {} }), +})); + +const mockDeleteEcsPayload = jest.fn(); +jest.mock('../../src/handlers/shared/strategies/ecs-strategy', () => ({ + deleteEcsPayload: (...a: unknown[]) => mockDeleteEcsPayload(...a), + EcsComputeStrategy: jest.fn(), +})); + +// MICROVM_* env must be set before the strategy module is imported. +process.env.MICROVM_IMAGE_IDENTIFIER = 'arn:aws:lambda:us-east-1:123456789012:microvm-image/abca-agent'; +process.env.MICROVM_EXECUTION_ROLE_ARN = 'arn:aws:iam::123456789012:role/AbcaMicrovmExecution'; +process.env.MICROVM_EGRESS_CONNECTOR_ARNS = 'arn:aws:lambda:us-east-1:123456789012:network-connector/egress-1'; +process.env.MICROVM_PAYLOAD_BUCKET = 'test-microvm-payload-bucket'; +process.env.TASK_TABLE_NAME = 'Tasks'; +process.env.TASK_EVENTS_TABLE_NAME = 'TaskEvents'; +process.env.USER_CONCURRENCY_TABLE_NAME = 'UserConcurrency'; +process.env.TASK_RETENTION_DAYS = '90'; + +import { TaskStatus } from '../../src/constructs/task-status'; +import { handler } from '../../src/handlers/orchestrate-task'; + +/** + * Minimal stand-in for the durable-execution context: `step` runs its body + * inline, `waitForCondition` runs the poll body ONCE and returns the resulting + * state (enough to exercise the substrate cross-check without looping). + */ +function fakeContext(opts: { pollOnce?: boolean } = {}) { + const steps: string[] = []; + return { + steps, + ctx: { + step: async (name: string, fn: () => Promise) => { + steps.push(name); + return fn(); + }, + waitForCondition: async ( + name: string, + fn: (state: { attempts: number }) => Promise, + cfg: { initialState: { attempts: number } }, + ) => { + steps.push(name); + if (opts.pollOnce === false) return cfg.initialState; + return fn(cfg.initialState); + }, + }, + }; +} + +function runMicrovmOk() { + mockMicrovmSend.mockResolvedValueOnce({ + microvmId: MICROVM_ID, + endpoint: ENDPOINT, + state: 'RUNNING', + imageArn: 'arn:image', + imageVersion: '7', + }); +} + +function commandsOfType(type: string) { + return mockMicrovmSend.mock.calls.map(c => c[0]).filter(c => c._type === type); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockMicrovmSend.mockReset(); + mockPollTaskStatus.mockResolvedValue({ attempts: 1, lastStatus: TaskStatus.COMPLETED }); + mockReconcile.mockResolvedValue({ taskFailed: false }); +}); + +describe('orchestrate-task for a lambda-microvm task', () => { + test('persists microvmId and endpoint in compute_metadata on the RUNNING transition', async () => { + runMicrovmOk(); + const { ctx } = fakeContext(); + + await handler({ task_id: 'TASK001' }, ctx as never); + + // RunMicrovm actually went out. + expect(commandsOfType('RunMicrovm')).toHaveLength(1); + + expect(mockTransitionTask).toHaveBeenCalledTimes(1); + const [, from, to, attrs] = mockTransitionTask.mock.calls[0]; + expect(from).toBe(TaskStatus.HYDRATING); + expect(to).toBe(TaskStatus.RUNNING); + expect(attrs.compute_type).toBe('lambda-microvm'); + // ADR-021: the P3 approve/deny Lambdas resume from exactly these two keys. + expect(attrs.compute_metadata).toEqual({ microvmId: MICROVM_ID, endpoint: ENDPOINT }); + // sessionId is the microvmId (substrate identifier, mirroring ECS). + expect(attrs.session_id).toBe(MICROVM_ID); + // agent_runtime_arn is an AgentCore-only attribute and must not appear. + expect(attrs.agent_runtime_arn).toBeUndefined(); + }); + + test('sends TerminateMicrovm on finalize (ADR-021 active-cleanup requirement)', async () => { + runMicrovmOk(); + const { ctx, steps } = fakeContext(); + + await handler({ task_id: 'TASK001' }, ctx as never); + + expect(steps).toContain('finalize'); + expect(mockFinalizeTask).toHaveBeenCalledTimes(1); + + const terminates = commandsOfType('TerminateMicrovm'); + expect(terminates).toHaveLength(1); + expect(terminates[0].input).toEqual({ microvmIdentifier: MICROVM_ID }); + }); + + test('terminates AFTER finalizeTask, so the task row is already terminal', async () => { + const order: string[] = []; + mockFinalizeTask.mockImplementation(async () => { order.push('finalizeTask'); }); + mockMicrovmSend.mockImplementation(async (cmd: { _type: string }) => { + if (cmd._type === 'RunMicrovm') { + return { microvmId: MICROVM_ID, endpoint: ENDPOINT, state: 'RUNNING' }; + } + if (cmd._type === 'TerminateMicrovm') order.push('terminate'); + return {}; + }); + + await handler({ task_id: 'TASK001' }, fakeContext().ctx as never); + + expect(order).toEqual(['finalizeTask', 'terminate']); + }); + + test('a TerminateMicrovm failure does not fail the finalize step (best-effort)', async () => { + runMicrovmOk(); + const err = new Error('boom'); + err.name = 'InternalServerException'; + mockMicrovmSend.mockRejectedValueOnce(err); + + // stopSession swallows every failure internally, so the handler resolves. + await expect(handler({ task_id: 'TASK001' }, fakeContext().ctx as never)).resolves.toBeUndefined(); + expect(mockFinalizeTask).toHaveBeenCalledTimes(1); + }); + + test('does not call deleteEcsPayload for a microvm task', async () => { + runMicrovmOk(); + + await handler({ task_id: 'TASK001' }, fakeContext().ctx as never); + + expect(mockDeleteEcsPayload).not.toHaveBeenCalled(); + }); + + test('cross-checks the substrate through reconcileMicrovmSubstrateState while non-terminal', async () => { + runMicrovmOk(); + mockPollTaskStatus.mockResolvedValue({ attempts: 1, lastStatus: TaskStatus.RUNNING }); + // GetMicrovm during the poll, then TerminateMicrovm on finalize. + mockMicrovmSend.mockResolvedValueOnce({ microvmId: MICROVM_ID, state: 'SUSPENDED' }); + + await handler({ task_id: 'TASK001' }, fakeContext().ctx as never); + + expect(commandsOfType('GetMicrovm')).toHaveLength(1); + expect(mockReconcile).toHaveBeenCalledTimes(1); + const args = mockReconcile.mock.calls[0][0]; + expect(args.microvmId).toBe(MICROVM_ID); + expect(args.ddbStatus).toBe(TaskStatus.RUNNING); + // The strategy's mechanical mapping is what the orchestrator interprets. + expect(args.substrate).toEqual({ status: 'suspended' }); + }); + + test('returns a failed poll state when reconciliation fails the task', async () => { + runMicrovmOk(); + mockPollTaskStatus.mockResolvedValue({ attempts: 3, lastStatus: TaskStatus.RUNNING }); + mockMicrovmSend.mockResolvedValueOnce({ microvmId: MICROVM_ID, state: 'TERMINATED' }); + mockReconcile.mockResolvedValue({ taskFailed: true }); + + await handler({ task_id: 'TASK001' }, fakeContext().ctx as never); + + expect(mockFinalizeTask).toHaveBeenCalledWith( + 'TASK001', + { attempts: 3, lastStatus: TaskStatus.FAILED }, + 'user-1', + ); + }); + + test('skips the substrate cross-check once the DDB status is terminal', async () => { + runMicrovmOk(); + mockPollTaskStatus.mockResolvedValue({ attempts: 1, lastStatus: TaskStatus.COMPLETED }); + + await handler({ task_id: 'TASK001' }, fakeContext().ctx as never); + + expect(commandsOfType('GetMicrovm')).toHaveLength(0); + expect(mockReconcile).not.toHaveBeenCalled(); + // Finalize still terminates. + expect(commandsOfType('TerminateMicrovm')).toHaveLength(1); + }); + + test('a GetMicrovm poll failure is non-fatal and finalize still terminates', async () => { + runMicrovmOk(); + mockPollTaskStatus.mockResolvedValue({ attempts: 1, lastStatus: TaskStatus.RUNNING }); + mockMicrovmSend.mockRejectedValueOnce(new Error('transient')); + + await expect(handler({ task_id: 'TASK001' }, fakeContext().ctx as never)).resolves.toBeUndefined(); + + expect(mockReconcile).not.toHaveBeenCalled(); + expect(commandsOfType('TerminateMicrovm')).toHaveLength(1); + }); +}); diff --git a/cdk/test/handlers/shared/compute-strategy.test.ts b/cdk/test/handlers/shared/compute-strategy.test.ts index 1fac73d7b..d00dea2ea 100644 --- a/cdk/test/handlers/shared/compute-strategy.test.ts +++ b/cdk/test/handlers/shared/compute-strategy.test.ts @@ -30,9 +30,26 @@ jest.mock('@aws-sdk/client-ecs', () => ({ StopTaskCommand: jest.fn(), })); +jest.mock('@aws-sdk/client-lambda-microvms', () => ({ + LambdaMicrovmsClient: jest.fn(() => ({ send: jest.fn() })), + RunMicrovmCommand: jest.fn(), + GetMicrovmCommand: jest.fn(), + TerminateMicrovmCommand: jest.fn(), + MicrovmState: { + PENDING: 'PENDING', + RUNNING: 'RUNNING', + SUSPENDED: 'SUSPENDED', + SUSPENDING: 'SUSPENDING', + TERMINATED: 'TERMINATED', + TERMINATING: 'TERMINATING', + }, +})); + import { resolveComputeStrategy } from '../../../src/handlers/shared/compute-strategy'; +import type { ComputeType } from '../../../src/handlers/shared/repo-config'; import { AgentCoreComputeStrategy } from '../../../src/handlers/shared/strategies/agentcore-strategy'; import { EcsComputeStrategy } from '../../../src/handlers/shared/strategies/ecs-strategy'; +import { LambdaMicrovmComputeStrategy } from '../../../src/handlers/shared/strategies/lambda-microvm-strategy'; describe('resolveComputeStrategy', () => { test('returns AgentCoreComputeStrategy for compute_type agentcore', () => { @@ -52,4 +69,34 @@ describe('resolveComputeStrategy', () => { expect(strategy).toBeInstanceOf(EcsComputeStrategy); expect(strategy.type).toBe('ecs'); }); + + test('returns LambdaMicrovmComputeStrategy for compute_type lambda-microvm', () => { + const strategy = resolveComputeStrategy({ + compute_type: 'lambda-microvm', + runtime_arn: 'arn:test', + }); + expect(strategy).toBeInstanceOf(LambdaMicrovmComputeStrategy); + expect(strategy.type).toBe('lambda-microvm'); + }); + + test('throws a named error for an unknown compute_type (exhaustive-never default)', () => { + expect(() => + resolveComputeStrategy({ + // Cast past the exhaustive union to exercise the runtime default arm — + // a hand-edited RepoTable row can carry a value the type system forbids. + compute_type: 'quantum-annealer' as ComputeType, + runtime_arn: 'arn:test', + }), + ).toThrow("Unknown compute_type: 'quantum-annealer'"); + }); + + test('every ComputeType member resolves to a strategy whose type field round-trips', () => { + // Guards the pairing itself: a new backend added to ComputeType but wired to + // the wrong class would still be instanceof-correct in the tests above. + const all: ComputeType[] = ['agentcore', 'ecs', 'lambda-microvm']; + for (const computeType of all) { + const strategy = resolveComputeStrategy({ compute_type: computeType, runtime_arn: 'arn:test' }); + expect(strategy.type).toBe(computeType); + } + }); }); diff --git a/cdk/test/handlers/shared/error-classifier.test.ts b/cdk/test/handlers/shared/error-classifier.test.ts index 43d0a315d..aae72bc9c 100644 --- a/cdk/test/handlers/shared/error-classifier.test.ts +++ b/cdk/test/handlers/shared/error-classifier.test.ts @@ -18,6 +18,7 @@ */ import { classifyError, ErrorCategory, ErrorClass, isTransientError, retryGuidance, type ErrorClassification } from '../../../src/handlers/shared/error-classifier'; +import { LAMBDA_MICROVM_SUPPORTED_REGIONS } from '../../../src/handlers/shared/microvm-regions'; import { toTaskDetail, type TaskRecord } from '../../../src/handlers/shared/types'; describe('classifyError', () => { @@ -425,6 +426,212 @@ describe('classifyError', () => { }); }); + // --- Lambda MicroVMs (ADR-021) --- + + describe('Lambda MicroVMs errors', () => { + test('classifies regional unavailability as a non-retryable CONFIG fault with the supported-Region list', () => { + // ADR-021: "If startSession fails because the MicroVM service is unavailable + // in the stack region, then the orchestrator shall classify the failure with + // a configuration remedy and shall not retry." + const result = classifyError( + 'Session start failed: UnknownEndpoint: Inaccessible host: `lambda.eu-central-1.amazonaws.com\'. ' + + 'This service may not be available in the `eu-central-1\' region.', + )!; + expect(result.category).toBe(ErrorCategory.CONFIG); + expect(result.title).toBe('Lambda MicroVMs is not available in this Region'); + expect(result.retryable).toBe(false); + expect(result.errorClass).toBe(ErrorClass.SERVICE); + expect(isTransientError(result)).toBe(false); + // The remedy must name the supported Regions AND the alternative backends. + for (const region of LAMBDA_MICROVM_SUPPORTED_REGIONS) { + expect(result.remedy).toContain(region); + } + expect(result.remedy).toContain('--compute-type agentcore'); + expect(result.remedy).toContain('microvm-regions.ts'); + }); + + test('classifies regional unavailability from the raw (unwrapped) SDK error too', () => { + // startSessionWithRetry classifies String(err) — no "Session start failed" + // wrapper — so the no-retry verdict must hold on the raw string as well. + const result = classifyError('Could not resolve endpoint for lambda in region ap-south-1')!; + expect(result.title).toBe('Lambda MicroVMs is not available in this Region'); + expect(isTransientError(result)).toBe(false); + }); + + test('regional unavailability wins over the generic "Session start failed" copy (ordering)', () => { + // The generic compute entry would tell the user "reply to retry", which + // loops forever against a Region that will never have the service. + const generic = classifyError('Session start failed: boom')!; + expect(generic.title).toBe('Agent session failed to start'); + const regional = classifyError('Session start failed: MicroVMs not available in this region')!; + expect(regional.title).toBe('Lambda MicroVMs is not available in this Region'); + }); + + test('does NOT hijack an AgentCore or ECS endpoint failure (marker-anchored pattern)', () => { + // Their endpoint hosts are bedrock-agentcore.* / ecs.*, so the MicroVM + // entry must not match — otherwise a transient AgentCore blip would be + // reported as a permanent regional misconfiguration. + const agentcore = classifyError( + 'Session start failed: UnknownEndpoint: Inaccessible host: `bedrock-agentcore.us-east-1.amazonaws.com\'.', + )!; + expect(agentcore.title).toBe('Agent session failed to start'); + const ecs = classifyError( + 'Session start failed: UnknownEndpoint: Inaccessible host: `ecs.us-east-1.amazonaws.com\'.', + )!; + expect(ecs.title).toBe('Agent session failed to start'); + }); + + test('classifies ServiceQuotaExceededException as a retryable capacity fault', () => { + const result = classifyError( + 'Session start failed: Error: MicroVM RunMicrovm failed: ServiceQuotaExceededException: MicroVM memory quota exceeded', + )!; + expect(result.category).toBe(ErrorCategory.COMPUTE); + expect(result.retryable).toBe(true); + // Capacity-shaped, not configuration-shaped: it frees as MicroVMs + // terminate, so the session-start auto-retry is allowed to try once. + expect(result.errorClass).toBe(ErrorClass.TRANSIENT); + expect(isTransientError(result)).toBe(true); + expect(result.remedy).toMatch(/quota increase/i); + }); + + test('classifies ThrottlingException as retryable/transient', () => { + const result = classifyError('MicroVM RunMicrovm failed: ThrottlingException: Rate exceeded')!; + expect(result.category).toBe(ErrorCategory.COMPUTE); + expect(result.retryable).toBe(true); + expect(result.errorClass).toBe(ErrorClass.TRANSIENT); + expect(isTransientError(result)).toBe(true); + }); + + test('classifies TooManyRequestsException alongside ThrottlingException', () => { + const result = classifyError('MicroVM GetMicrovm failed: TooManyRequestsException: slow down')!; + expect(result.errorClass).toBe(ErrorClass.TRANSIENT); + }); + + test('classifies a marked ResourceNotFoundException as a non-retryable deploy fault', () => { + const result = classifyError('MicroVM RunMicrovm failed: ResourceNotFoundException: MicroVM image not found')!; + expect(result.category).toBe(ErrorCategory.CONFIG); + expect(result.retryable).toBe(false); + expect(result.errorClass).toBe(ErrorClass.SERVICE); + // A retry with the same ARNs cannot succeed — no auto-retry. + expect(isTransientError(result)).toBe(false); + expect(result.remedy).toContain('MICROVM_IMAGE_IDENTIFIER'); + }); + + test('classifies a marked payload-upload failure', () => { + const result = classifyError('MicroVM payload upload failed: ThrottlingException: SlowDown')!; + // The marker admits multi-word operation names ("payload upload"). + expect(result.errorClass).toBe(ErrorClass.TRANSIENT); + }); + + test('classifies a MicroVM substrate-failure reason written by the orchestrator', () => { + // Must stay in lockstep with the reason string + // `reconcileMicrovmSubstrateState` persists. + const result = classifyError( + 'MicroVM substrate terminated before the agent wrote a terminal status: substrate state completed', + )!; + expect(result.category).toBe(ErrorCategory.COMPUTE); + expect(result.retryable).toBe(true); + expect(result.errorClass).toBe(ErrorClass.TRANSIENT); + expect(result.title).not.toMatch(/Unexpected error/i); + }); + + test('every new MicroVM classification carries a full, non-empty guidance shape', () => { + const messages = [ + 'Session start failed: UnknownEndpoint: Inaccessible host: `lambda.eu-central-1.amazonaws.com\'', + 'MicroVM RunMicrovm failed: ServiceQuotaExceededException: quota exceeded', + 'MicroVM RunMicrovm failed: ThrottlingException: Rate exceeded', + 'MicroVM RunMicrovm failed: ResourceNotFoundException: image not found', + 'MicroVM substrate terminated before the agent wrote a terminal status: substrate state completed', + ]; + for (const msg of messages) { + const result = classifyError(msg) as ErrorClassification; + expect(result.title.length).toBeGreaterThan(0); + expect(result.description.length).toBeGreaterThan(0); + expect(result.remedy.length).toBeGreaterThan(0); + expect(typeof result.retryable).toBe('boolean'); + expect([ErrorClass.TRANSIENT, ErrorClass.SERVICE, ErrorClass.USER]).toContain(result.errorClass); + expect(result.category).not.toBe(ErrorCategory.UNKNOWN); + } + }); + }); + + // --- Cross-backend scoping regression (ADR-021) --- + + describe('MicroVM exception patterns must NOT change other backends', () => { + /** + * `ThrottlingException`, `ServiceQuotaExceededException` and + * `ResourceNotFoundException` are generic AWS exception names thrown by + * AgentCore, ECS, DynamoDB and Secrets Manager too. The MicroVM entries are + * therefore anchored on the `MicroVM failed` marker that + * `lambda-microvm-strategy.wrapMicrovmError` adds. + * + * These cases pin the PRE-ADR-021 behaviour for UNMARKED occurrences: on + * `main` the classifier contained no pattern for any of these names, so a + * bare occurrence fell through to UNKNOWN — category `unknown`, title + * "Unexpected error", `retryable: false`, `errorClass: USER`, and therefore + * NOT auto-retried by `startSessionWithRetry`. If a future edit drops the + * marker anchor, these fail instead of silently flipping every other + * backend's retry semantics. + */ + const PRE_CHANGE_UNKNOWN = { + category: ErrorCategory.UNKNOWN, + title: 'Unexpected error', + retryable: false, + errorClass: ErrorClass.USER, + }; + + test.each([ + ['ThrottlingException: Rate exceeded'], + ['ServiceQuotaExceededException: quota exceeded'], + ['ResourceNotFoundException: Requested resource not found'], + ['TooManyRequestsException: slow down'], + ])('an unmarked "%s" still classifies as UNKNOWN, exactly as before', (message) => { + const result = classifyError(message)!; + expect(result.category).toBe(PRE_CHANGE_UNKNOWN.category); + expect(result.title).toBe(PRE_CHANGE_UNKNOWN.title); + expect(result.retryable).toBe(PRE_CHANGE_UNKNOWN.retryable); + expect(result.errorClass).toBe(PRE_CHANGE_UNKNOWN.errorClass); + // The retry gate must be unchanged for other backends. + expect(isTransientError(result)).toBe(false); + }); + + test('an AgentCore StopRuntimeSession throttle is not re-classified as a MicroVM fault', () => { + const result = classifyError( + 'ThrottlingException: Too many requests for agentRuntimeArn arn:aws:bedrock-agentcore:us-east-1:1:runtime/r', + )!; + expect(result.title).toBe('Unexpected error'); + expect(result.title).not.toMatch(/MicroVM/i); + }); + + test('an ECS RunTask quota error is not re-classified as a MicroVM fault', () => { + const result = classifyError( + 'ECS RunTask returned no task: arn:test: ServiceQuotaExceededException', + )!; + expect(result.title).not.toMatch(/MicroVM/i); + expect(result.category).toBe(ErrorCategory.UNKNOWN); + }); + + test('the generic "Session start failed" wrapper still wins for agentcore/ECS quota + throttle', () => { + // Pinned by the pre-existing compute-errors test too; restated here so the + // scoping intent is explicit at the regression site. + expect(classifyError('Session start failed: ServiceQuotaExceededException')!.title) + .toBe('Agent session failed to start'); + expect(classifyError('Session start failed: ThrottlingException: Rate exceeded')!.title) + .toBe('Agent session failed to start'); + expect(classifyError('Session start failed: ResourceNotFoundException')!.title) + .toBe('Agent session failed to start'); + }); + + test('Blueprint / hydration ResourceNotFoundException keep their precise CONFIG copy', () => { + expect( + classifyError('Blueprint config load failed: ResourceNotFoundException: Requested resource not found')!.title, + ).toBe('Blueprint configuration error'); + expect( + classifyError('Hydration failed: ResourceNotFoundException: secret missing')!.title, + ).toBe('Context hydration failed'); + }); + }); + // --- Environmental blockers (#251) --- describe('blocker errors (canonical BLOCKED[] prefix)', () => { diff --git a/cdk/test/handlers/shared/microvm-regions.test.ts b/cdk/test/handlers/shared/microvm-regions.test.ts new file mode 100644 index 000000000..020858cba --- /dev/null +++ b/cdk/test/handlers/shared/microvm-regions.test.ts @@ -0,0 +1,63 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + isLambdaMicrovmRegionSupported, + LAMBDA_MICROVM_SUPPORTED_REGIONS, +} from '../../../src/handlers/shared/microvm-regions'; + +describe('LAMBDA_MICROVM_SUPPORTED_REGIONS', () => { + test('lists exactly the 5 launch Regions from ADR-021', () => { + expect([...LAMBDA_MICROVM_SUPPORTED_REGIONS]).toEqual([ + 'us-east-1', + 'us-east-2', + 'us-west-2', + 'eu-west-1', + 'ap-northeast-1', + ]); + }); + + test('has no duplicate entries', () => { + expect(new Set(LAMBDA_MICROVM_SUPPORTED_REGIONS).size).toBe(LAMBDA_MICROVM_SUPPORTED_REGIONS.length); + }); +}); + +describe('isLambdaMicrovmRegionSupported', () => { + test.each([...LAMBDA_MICROVM_SUPPORTED_REGIONS])('accepts the supported Region %s', (region) => { + expect(isLambdaMicrovmRegionSupported(region)).toBe(true); + }); + + test('rejects a Region that has not launched the backend', () => { + expect(isLambdaMicrovmRegionSupported('eu-central-1')).toBe(false); + expect(isLambdaMicrovmRegionSupported('ap-southeast-2')).toBe(false); + }); + + test('rejects undefined and empty input rather than defaulting to supported', () => { + // A missing AWS_REGION must never read as "supported" — the static gate is a + // deny-by-default check, and the live probes are what self-heal false denials. + expect(isLambdaMicrovmRegionSupported(undefined)).toBe(false); + expect(isLambdaMicrovmRegionSupported('')).toBe(false); + }); + + test('compares Region ids verbatim (no case folding, no prefix matching)', () => { + expect(isLambdaMicrovmRegionSupported('US-EAST-1')).toBe(false); + expect(isLambdaMicrovmRegionSupported('us-east-1a')).toBe(false); + expect(isLambdaMicrovmRegionSupported('us-east')).toBe(false); + }); +}); diff --git a/cdk/test/handlers/shared/orchestrator.test.ts b/cdk/test/handlers/shared/orchestrator.test.ts new file mode 100644 index 000000000..7db385f7a --- /dev/null +++ b/cdk/test/handlers/shared/orchestrator.test.ts @@ -0,0 +1,324 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// --- Mocks --- +const mockDdbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ + DynamoDBClient: jest.fn(() => ({})), +})); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockDdbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), + UpdateCommand: jest.fn((input: unknown) => ({ _type: 'Update', input })), +})); +jest.mock('@aws-sdk/client-s3', () => ({ S3Client: jest.fn(() => ({ send: jest.fn() })) })); + +const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), child: jest.fn() }; +jest.mock('../../../src/handlers/shared/logger', () => ({ logger: mockLogger })); + +process.env.TASK_TABLE_NAME = 'Tasks'; +process.env.TASK_EVENTS_TABLE_NAME = 'TaskEvents'; +process.env.USER_CONCURRENCY_TABLE_NAME = 'Concurrency'; +process.env.TASK_RETENTION_DAYS = '90'; + +import { TaskStatus } from '../../../src/constructs/task-status'; +import type { SessionHandle, SessionStatus } from '../../../src/handlers/shared/compute-strategy'; +import { buildComputeMetadata, reconcileMicrovmSubstrateState } from '../../../src/handlers/shared/orchestrator'; + +const MICROVM_ID = 'mvm-0123456789abcdef'; +const ENDPOINT = 'https://mvm-0123456789abcdef.microvm.lambda.us-east-1.amazonaws.com'; + +/** Commands the mocked document client received, in order. */ +function sentCommands(): Array<{ _type: string; input: Record }> { + return mockDdbSend.mock.calls.map(c => c[0]); +} + +function commandsOfType(type: string): Array<{ _type: string; input: Record }> { + return sentCommands().filter(c => c._type === type); +} + +/** + * Prime the mocked doc client: the FIRST Get returns a task row with + * ``rereadStatus``; every Put/Update resolves empty. Mirrors the single re-read + * `reconcileMicrovmSubstrateState` performs before failing a task. + */ +function primeReread(rereadStatus: string): void { + mockDdbSend.mockImplementation((cmd: { _type: string }) => { + if (cmd._type === 'Get') { + return Promise.resolve({ + Item: { task_id: 'TASK001', user_id: 'user-1', repo: 'org/repo', status: rereadStatus }, + }); + } + return Promise.resolve({}); + }); +} + +const CORRELATION = { user_id: 'user-1', repo: 'org/repo' }; + +function reconcile(substrate: SessionStatus, ddbStatus: string) { + return reconcileMicrovmSubstrateState({ + taskId: 'TASK001', + ddbStatus: ddbStatus as never, + substrate, + microvmId: MICROVM_ID, + userId: 'user-1', + correlation: CORRELATION, + log: mockLogger, + repo: 'org/repo', + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockDdbSend.mockReset(); + mockDdbSend.mockResolvedValue({}); +}); + +describe('buildComputeMetadata', () => { + test('persists clusterArn and taskArn for an ECS handle (unchanged behaviour)', () => { + const handle: SessionHandle = { + sessionId: 'arn:aws:ecs:us-east-1:123456789012:task/c/abc', + strategyType: 'ecs', + clusterArn: 'arn:aws:ecs:us-east-1:123456789012:cluster/c', + taskArn: 'arn:aws:ecs:us-east-1:123456789012:task/c/abc', + }; + // cancel-task.ts reads exactly these two keys — do not rename them. + expect(buildComputeMetadata(handle)).toEqual({ + clusterArn: 'arn:aws:ecs:us-east-1:123456789012:cluster/c', + taskArn: 'arn:aws:ecs:us-east-1:123456789012:task/c/abc', + }); + }); + + test('persists runtimeArn for an AgentCore handle (unchanged behaviour)', () => { + const handle: SessionHandle = { + sessionId: 'a-uuid', + strategyType: 'agentcore', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/r', + }; + expect(buildComputeMetadata(handle)).toEqual({ + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/r', + }); + }); + + test('persists microvmId and endpoint for a lambda-microvm handle', () => { + const handle: SessionHandle = { + sessionId: MICROVM_ID, + strategyType: 'lambda-microvm', + microvmId: MICROVM_ID, + endpoint: ENDPOINT, + }; + // ADR-021: the P3 approve/deny Lambdas load the resume handle from these keys. + expect(buildComputeMetadata(handle)).toEqual({ microvmId: MICROVM_ID, endpoint: ENDPOINT }); + }); + + test('never carries the MicroVM image ARN (deployment config, not session state)', () => { + const metadata = buildComputeMetadata({ + sessionId: MICROVM_ID, + strategyType: 'lambda-microvm', + microvmId: MICROVM_ID, + endpoint: ENDPOINT, + }); + expect(Object.keys(metadata).sort()).toEqual(['endpoint', 'microvmId']); + }); + + test('produces only string values (compute_metadata is Record in DDB)', () => { + for (const handle of [ + { sessionId: 's', strategyType: 'agentcore', runtimeArn: 'a' }, + { sessionId: 's', strategyType: 'ecs', clusterArn: 'c', taskArn: 't' }, + { sessionId: 's', strategyType: 'lambda-microvm', microvmId: 'm', endpoint: 'e' }, + ] as SessionHandle[]) { + for (const value of Object.values(buildComputeMetadata(handle))) { + expect(typeof value).toBe('string'); + } + } + }); + + test('throws for an unrecognized strategyType (exhaustive-never guard)', () => { + expect(() => + buildComputeMetadata({ sessionId: 's', strategyType: 'firecracker-v2' } as unknown as SessionHandle), + ).toThrow(/Unknown strategyType on session handle/); + }); +}); + +describe('reconcileMicrovmSubstrateState', () => { + describe('running substrate', () => { + test('is a no-op: no DDB reads, no events, task not failed', async () => { + const result = await reconcile({ status: 'running' }, TaskStatus.RUNNING); + + expect(result).toEqual({ taskFailed: false }); + expect(mockDdbSend).not.toHaveBeenCalled(); + }); + }); + + describe('suspended substrate', () => { + test('is healthy while the task is AWAITING_APPROVAL — no event, no failure', async () => { + const result = await reconcile({ status: 'suspended' }, TaskStatus.AWAITING_APPROVAL); + + // The orchestrator-intended suspend during an approval wait is the whole + // economic point of the backend: it must be silent. + expect(result).toEqual({ taskFailed: false }); + expect(mockDdbSend).not.toHaveBeenCalled(); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + test('writes an anomaly event and does NOT fail the task when the status is RUNNING', async () => { + const result = await reconcile({ status: 'suspended' }, TaskStatus.RUNNING); + + expect(result).toEqual({ taskFailed: false }); + + const puts = commandsOfType('Put'); + expect(puts).toHaveLength(1); + expect(puts[0].input.TableName).toBe('TaskEvents'); + const item = puts[0].input.Item as Record; + expect(item.event_type).toBe('microvm_suspend_anomaly'); + expect(item.task_id).toBe('TASK001'); + // Correlation envelope (#245) stamped as top-level fields. + expect(item.user_id).toBe('user-1'); + expect(item.repo).toBe('org/repo'); + expect(item.metadata).toEqual({ + microvm_id: MICROVM_ID, + task_status: TaskStatus.RUNNING, + reason: 'suspended_outside_approval_wait', + }); + + // Crucially: no status transition — a suspended VM is resumable, so + // failing the task would destroy recoverable work. + expect(commandsOfType('Update')).toHaveLength(0); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + test.each([ + TaskStatus.HYDRATING, + TaskStatus.RUNNING, + TaskStatus.FINALIZING, + ])('treats suspended + %s as an anomaly rather than a failure', async (status) => { + const result = await reconcile({ status: 'suspended' }, status); + + expect(result).toEqual({ taskFailed: false }); + expect(commandsOfType('Put')[0].input.Item).toMatchObject({ + event_type: 'microvm_suspend_anomaly', + metadata: { task_status: status }, + }); + }); + }); + + describe('terminal substrate', () => { + test('fails the task when the re-read status is still non-terminal', async () => { + primeReread(TaskStatus.RUNNING); + + const result = await reconcile({ status: 'completed' }, TaskStatus.RUNNING); + + expect(result).toEqual({ taskFailed: true }); + + // Re-read before acting (guards the "agent wrote terminal, VM torn down" + // race), then the FAILED transition. + expect(commandsOfType('Get')).toHaveLength(1); + const updates = commandsOfType('Update'); + expect(updates).toHaveLength(1); + expect(updates[0].input.TableName).toBe('Tasks'); + const values = updates[0].input.ExpressionAttributeValues as Record; + expect(values[':toStatus']).toBe(TaskStatus.FAILED); + expect(values[':fromStatus']).toBe(TaskStatus.RUNNING); + // The reason string is what error-classifier keys the substrate-failure + // classification on — keep the two in lockstep. + expect(values[':attr_error_message']).toBe( + 'MicroVM substrate terminated before the agent wrote a terminal status: substrate state completed', + ); + + // Plus the task_failed audit event. + const puts = commandsOfType('Put'); + expect(puts).toHaveLength(1); + expect((puts[0].input.Item as Record).event_type).toBe('task_failed'); + }); + + test('does NOT fail the task when the re-read shows the agent already wrote a terminal status', async () => { + primeReread(TaskStatus.COMPLETED); + + const result = await reconcile({ status: 'completed' }, TaskStatus.RUNNING); + + // Normal shutdown ordering: agent writes COMPLETED, exits, VM terminates. + // Without the re-read this would have failed a successful task. + expect(result).toEqual({ taskFailed: false }); + expect(commandsOfType('Update')).toHaveLength(0); + expect(commandsOfType('Put')).toHaveLength(0); + }); + + test.each([ + TaskStatus.COMPLETED, + TaskStatus.FAILED, + TaskStatus.CANCELLED, + TaskStatus.TIMED_OUT, + ])('accepts a re-read terminal status of %s without failing the task', async (status) => { + primeReread(status); + + const result = await reconcile({ status: 'completed' }, TaskStatus.RUNNING); + + expect(result).toEqual({ taskFailed: false }); + expect(commandsOfType('Update')).toHaveLength(0); + }); + + test('carries the substrate error detail into the failure reason', async () => { + primeReread(TaskStatus.RUNNING); + + const result = await reconcile({ status: 'failed', error: 'host fault' }, TaskStatus.RUNNING); + + expect(result).toEqual({ taskFailed: true }); + const values = commandsOfType('Update')[0].input.ExpressionAttributeValues as Record; + expect(values[':attr_error_message']).toBe( + 'MicroVM substrate terminated before the agent wrote a terminal status: host fault', + ); + }); + + test('fails from AWAITING_APPROVAL too — a terminated VM cannot resume the gate', async () => { + primeReread(TaskStatus.AWAITING_APPROVAL); + + const result = await reconcile({ status: 'completed' }, TaskStatus.AWAITING_APPROVAL); + + expect(result).toEqual({ taskFailed: true }); + const values = commandsOfType('Update')[0].input.ExpressionAttributeValues as Record; + expect(values[':fromStatus']).toBe(TaskStatus.AWAITING_APPROVAL); + expect(values[':toStatus']).toBe(TaskStatus.FAILED); + }); + + test('transitions from the RE-READ status, not the stale polled status', async () => { + // Task moved HYDRATING → RUNNING between the poll read and the re-read; the + // conditional transition must use the fresh value or it fails its own + // ConditionExpression and the task is left stuck. + primeReread(TaskStatus.RUNNING); + + await reconcile({ status: 'completed' }, TaskStatus.HYDRATING); + + const values = commandsOfType('Update')[0].input.ExpressionAttributeValues as Record; + expect(values[':fromStatus']).toBe(TaskStatus.RUNNING); + }); + + test('does not decrement concurrency — the finalize step owns the release', async () => { + primeReread(TaskStatus.RUNNING); + + await reconcile({ status: 'completed' }, TaskStatus.RUNNING); + + // Matches the ECS substrate-failure branch: failTask(..., releaseConcurrency=false). + const concurrencyWrites = commandsOfType('Update').filter( + c => c.input.TableName === 'Concurrency', + ); + expect(concurrencyWrites).toHaveLength(0); + }); + }); +}); diff --git a/cdk/test/handlers/shared/session-start-retry.test.ts b/cdk/test/handlers/shared/session-start-retry.test.ts index f6227278e..ee27474c1 100644 --- a/cdk/test/handlers/shared/session-start-retry.test.ts +++ b/cdk/test/handlers/shared/session-start-retry.test.ts @@ -128,3 +128,129 @@ describe('startSessionWithRetry — retry-event emit is best-effort (#599 B1)', expect(warns.some((w) => w.message.includes('event emit failed'))).toBe(true); }); }); + +describe('startSessionWithRetry — Lambda MicroVM start failures (ADR-021)', () => { + /** + * The retry gate is strategy-AGNOSTIC by construction: it takes + * `Pick` and asks `classifyError` about the + * RAW error string, so a new backend needs no change here — only correct + * classifier entries. These cases pin that contract for the MicroVM errors + * ADR-021 calls out, so a future classifier edit can't silently turn a + * non-retryable regional misconfiguration into a wasted retry (or drop the + * retry on a throttle). + */ + const MICROVM_HANDLE: SessionHandle = { + sessionId: 'mvm-0123456789abcdef', + strategyType: 'lambda-microvm', + microvmId: 'mvm-0123456789abcdef', + endpoint: 'https://mvm-0123456789abcdef.microvm.lambda.us-east-1.amazonaws.com', + }; + + it('auto-retries a ThrottlingException once and returns the MicroVM handle', async () => { + // The realistic shape: the strategy wraps escaping errors with the + // `MicroVM failed` marker (wrapMicrovmError), and the retry gate + // classifies String(err) — so the marker is present when it is classified. + const throttle = new Error('MicroVM RunMicrovm failed: ThrottlingException: Rate exceeded'); + const startSession = jest + .fn() + .mockRejectedValueOnce(throttle) + .mockResolvedValueOnce(MICROVM_HANDLE); + const { d, emitReasons } = deps(); + + const res = await startSessionWithRetry({ startSession }, {} as never, d); + + expect(res).toEqual({ handle: MICROVM_HANDLE, autoRetried: true }); + expect(startSession).toHaveBeenCalledTimes(2); + expect(emitReasons).toHaveLength(1); + }); + + it('auto-retries a ServiceQuotaExceededException (capacity frees as MicroVMs terminate)', async () => { + const quota = new Error( + 'MicroVM RunMicrovm failed: ServiceQuotaExceededException: MicroVM memory quota exceeded', + ); + const startSession = jest + .fn() + .mockRejectedValueOnce(quota) + .mockResolvedValueOnce(MICROVM_HANDLE); + const { d } = deps(); + + const res = await startSessionWithRetry({ startSession }, {} as never, d); + + expect(res).toEqual({ handle: MICROVM_HANDLE, autoRetried: true }); + expect(startSession).toHaveBeenCalledTimes(2); + }); + + it('does NOT retry a regional-unavailability failure — the Region will never gain the service', async () => { + const regional = new Error( + "UnknownEndpoint: Inaccessible host: `lambda.eu-central-1.amazonaws.com'. " + + "This service may not be available in the `eu-central-1' region.", + ); + const startSession = jest.fn().mockRejectedValueOnce(regional); + const { d, emitReasons } = deps(); + + await expect(startSessionWithRetry({ startSession }, {} as never, d)).rejects.toBe(regional); + + expect(startSession).toHaveBeenCalledTimes(1); + expect(emitReasons).toEqual([]); + }); + + it('does NOT retry a missing-substrate config error (no MICROVM_* env vars)', async () => { + const configErr = new Error( + 'This repository is configured compute_type=lambda-microvm, but this stack was deployed without the ' + + 'Lambda MicroVMs substrate (missing MICROVM_IMAGE_IDENTIFIER/...).', + ); + const startSession = jest.fn().mockRejectedValueOnce(configErr); + const { d } = deps(); + + await expect(startSessionWithRetry({ startSession }, {} as never, d)).rejects.toBe(configErr); + expect(startSession).toHaveBeenCalledTimes(1); + }); + + it('does NOT retry a ResourceNotFoundException (bad image/role/connector ARN)', async () => { + const notFound = new Error('MicroVM RunMicrovm failed: ResourceNotFoundException: MicroVM image not found'); + const startSession = jest.fn().mockRejectedValueOnce(notFound); + const { d } = deps(); + + await expect(startSessionWithRetry({ startSession }, {} as never, d)).rejects.toBe(notFound); + expect(startSession).toHaveBeenCalledTimes(1); + }); +}); + +describe('startSessionWithRetry — other backends keep their pre-ADR-021 retry behaviour', () => { + /** + * Companion to the classifier scoping regression: the marker anchor means an + * AgentCore or ECS error carrying the SAME AWS exception name is still + * classified as before (UNKNOWN ⇒ not transient ⇒ no auto-retry). If the + * anchor is dropped, these flip to two attempts and this fails. + */ + it('does NOT auto-retry an unmarked ThrottlingException (agentcore/ECS path)', async () => { + const throttle = new Error('ThrottlingException: Rate exceeded'); + const startSession = jest.fn().mockRejectedValueOnce(throttle); + const { d, emitReasons } = deps(); + + await expect(startSessionWithRetry({ startSession }, {} as never, d)).rejects.toBe(throttle); + expect(startSession).toHaveBeenCalledTimes(1); + expect(emitReasons).toEqual([]); + }); + + it('does NOT auto-retry an unmarked ServiceQuotaExceededException (agentcore/ECS path)', async () => { + const quota = new Error('ServiceQuotaExceededException: quota exceeded'); + const startSession = jest.fn().mockRejectedValueOnce(quota); + const { d } = deps(); + + await expect(startSessionWithRetry({ startSession }, {} as never, d)).rejects.toBe(quota); + expect(startSession).toHaveBeenCalledTimes(1); + }); + + it('still auto-retries the ECS deploy-race, proving the gate itself is unchanged', async () => { + const startSession = jest + .fn() + .mockRejectedValueOnce(new Error('TaskDefinition is inactive')) + .mockResolvedValueOnce(HANDLE); + const { d } = deps(); + + const res = await startSessionWithRetry({ startSession }, {} as never, d); + expect(res).toEqual({ handle: HANDLE, autoRetried: true }); + expect(startSession).toHaveBeenCalledTimes(2); + }); +}); diff --git a/cdk/test/handlers/shared/strategies/lambda-microvm-strategy.test.ts b/cdk/test/handlers/shared/strategies/lambda-microvm-strategy.test.ts new file mode 100644 index 000000000..36584dcb9 --- /dev/null +++ b/cdk/test/handlers/shared/strategies/lambda-microvm-strategy.test.ts @@ -0,0 +1,638 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const IMAGE_IDENTIFIER = 'arn:aws:lambda:us-east-1:123456789012:microvm-image/abca-agent'; +const IMAGE_VERSION = '7'; +const EXECUTION_ROLE_ARN = 'arn:aws:iam::123456789012:role/AbcaMicrovmExecution'; +const EGRESS_CONNECTOR_ARN = 'arn:aws:lambda:us-east-1:123456789012:network-connector/egress-1'; +const INGRESS_CONNECTOR_ARN = 'arn:aws:lambda:us-east-1:123456789012:network-connector/ingress-1'; +const PAYLOAD_BUCKET = 'test-microvm-payload-bucket'; +const MICROVM_ID = 'mvm-0123456789abcdef'; +const ENDPOINT = 'https://mvm-0123456789abcdef.microvm.lambda.us-east-1.amazonaws.com'; + +// Set env vars BEFORE import — LambdaMicrovmComputeStrategy reads them as +// module-level constants (same pattern as ecs-strategy). The top-of-file import +// is the FULLY-CONFIGURED substrate; the missing-config describe block below +// re-imports under jest.isolateModules with vars deleted, and the ingress block +// re-imports with the OPTIONAL ingress var set. Ingress is deliberately absent +// here so the default (no-ingress) assertions are hermetic. +process.env.MICROVM_IMAGE_IDENTIFIER = IMAGE_IDENTIFIER; +process.env.MICROVM_IMAGE_VERSION = IMAGE_VERSION; +process.env.MICROVM_EXECUTION_ROLE_ARN = EXECUTION_ROLE_ARN; +process.env.MICROVM_EGRESS_CONNECTOR_ARNS = EGRESS_CONNECTOR_ARN; +process.env.MICROVM_PAYLOAD_BUCKET = PAYLOAD_BUCKET; +delete process.env.MICROVM_INGRESS_CONNECTOR_ARNS; + +const mockSend = jest.fn(); +jest.mock('@aws-sdk/client-lambda-microvms', () => ({ + LambdaMicrovmsClient: jest.fn(() => ({ send: mockSend })), + RunMicrovmCommand: jest.fn((input: unknown) => ({ _type: 'RunMicrovm', input })), + GetMicrovmCommand: jest.fn((input: unknown) => ({ _type: 'GetMicrovm', input })), + TerminateMicrovmCommand: jest.fn((input: unknown) => ({ _type: 'TerminateMicrovm', input })), + // Mirrors the real SDK's const-object enum so the strategy's switch keys on + // the same literals the service returns. + MicrovmState: { + PENDING: 'PENDING', + RUNNING: 'RUNNING', + SUSPENDED: 'SUSPENDED', + SUSPENDING: 'SUSPENDING', + TERMINATED: 'TERMINATED', + TERMINATING: 'TERMINATING', + }, +})); + +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn(() => ({ send: mockS3Send })), + PutObjectCommand: jest.fn((input: unknown) => ({ _type: 'PutObject', input })), + DeleteObjectCommand: jest.fn((input: unknown) => ({ _type: 'DeleteObject', input })), +})); + +// The real logger writes JSON to process.stdout/stderr, so level assertions need +// the module mocked rather than console spied (see repo-config.test.ts). +const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), child: jest.fn() }; +jest.mock('../../../../src/handlers/shared/logger', () => ({ logger: mockLogger })); + +import type { BlueprintConfig } from '../../../../src/handlers/shared/repo-config'; +import { + LambdaMicrovmComputeStrategy, + MICROVM_ERROR_MARKER, + MICROVM_MAX_DURATION_SECONDS, + MICROVM_RUN_HOOK_PAYLOAD_LIMIT_BYTES, + microvmPayloadKey, +} from '../../../../src/handlers/shared/strategies/lambda-microvm-strategy'; + +const BLUEPRINT: BlueprintConfig = { compute_type: 'lambda-microvm', runtime_arn: '' }; + +/** + * Build a payload whose serialized `{"agent_payload": …}` envelope is EXACTLY + * `targetBytes` long, so the 16 KB boundary can be probed on both sides. Asserts + * its own arithmetic — if the envelope shape ever changes, this fails loudly + * rather than silently testing the wrong boundary. + */ +function payloadWithEnvelopeBytes(targetBytes: number): Record { + const overhead = Buffer.byteLength(JSON.stringify({ agent_payload: { p: '' } }), 'utf8'); + const payload = { p: 'x'.repeat(targetBytes - overhead) }; + expect(Buffer.byteLength(JSON.stringify({ agent_payload: payload }), 'utf8')).toBe(targetBytes); + return payload; +} + +function runMicrovmOk() { + mockSend.mockResolvedValueOnce({ + microvmId: MICROVM_ID, + endpoint: ENDPOINT, + state: 'RUNNING', + imageArn: IMAGE_IDENTIFIER, + imageVersion: IMAGE_VERSION, + }); +} + +/** The handle shape pollSession/stopSession expect. */ +const makeHandle = () => ({ + sessionId: MICROVM_ID, + strategyType: 'lambda-microvm' as const, + microvmId: MICROVM_ID, + endpoint: ENDPOINT, +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('LambdaMicrovmComputeStrategy', () => { + test('type is lambda-microvm', () => { + expect(new LambdaMicrovmComputeStrategy().type).toBe('lambda-microvm'); + }); + + describe('startSession', () => { + test('sends RunMicrovm with the configured image, execution role and egress connector', async () => { + runMicrovmOk(); + + const handle = await new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo', prompt: 'Fix the bug' }, + blueprintConfig: BLUEPRINT, + }); + + expect(mockSend).toHaveBeenCalledTimes(1); + const call = mockSend.mock.calls[0][0]; + expect(call._type).toBe('RunMicrovm'); + expect(call.input.imageIdentifier).toBe(IMAGE_IDENTIFIER); + expect(call.input.imageVersion).toBe(IMAGE_VERSION); + expect(call.input.executionRoleArn).toBe(EXECUTION_ROLE_ARN); + expect(call.input.egressNetworkConnectors).toEqual([EGRESS_CONNECTOR_ARN]); + + expect(handle).toEqual({ + sessionId: MICROVM_ID, + strategyType: 'lambda-microvm', + microvmId: MICROVM_ID, + endpoint: ENDPOINT, + }); + }); + + test('sets maximumDurationInSeconds to the 28800s service maximum', async () => { + runMicrovmOk(); + + await new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: BLUEPRINT, + }); + + expect(MICROVM_MAX_DURATION_SECONDS).toBe(28_800); + expect(mockSend.mock.calls[0][0].input.maximumDurationInSeconds).toBe(28_800); + }); + + test('OMITS idlePolicy entirely — the field must be absent, not disabled (ADR-021 invariant)', async () => { + runMicrovmOk(); + + await new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: BLUEPRINT, + }); + + const input = mockSend.mock.calls[0][0].input; + // Absence, not `undefined`/falsy: all three idlePolicy fields are required + // when the block is present, so omission is the unambiguous disabled state. + // Traffic-based auto-suspend would freeze the outbound-only agent mid-build. + expect(Object.keys(input)).not.toContain('idlePolicy'); + expect('idlePolicy' in input).toBe(false); + }); + + test('passes NO ingress connectors by default (no orchestrator to agent path in P1)', async () => { + runMicrovmOk(); + + await new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: BLUEPRINT, + }); + + const input = mockSend.mock.calls[0][0].input; + expect('ingressNetworkConnectors' in input).toBe(false); + }); + + test('inlines a small payload in runHookPayload and never touches S3', async () => { + runMicrovmOk(); + + await new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo', prompt: 'Fix the bug', max_turns: 50 }, + blueprintConfig: BLUEPRINT, + }); + + expect(mockS3Send).not.toHaveBeenCalled(); + const envelope = JSON.parse(mockSend.mock.calls[0][0].input.runHookPayload); + expect(envelope.agent_payload).toEqual({ repo_url: 'org/repo', prompt: 'Fix the bug', max_turns: 50 }); + expect(envelope.agent_payload_s3_uri).toBeUndefined(); + }); + + test('uploads an oversized payload to S3 and inlines only the pointer', async () => { + mockS3Send.mockResolvedValueOnce({}); + runMicrovmOk(); + + const big = { repo_url: 'org/repo', hydrated_context: { blob: 'x'.repeat(20_000) } }; + await new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: big, + blueprintConfig: BLUEPRINT, + }); + + // Same key shape as the ECS payload bucket: /payload.json + expect(mockS3Send).toHaveBeenCalledTimes(1); + const put = mockS3Send.mock.calls[0][0]; + expect(put._type).toBe('PutObject'); + expect(put.input.Bucket).toBe(PAYLOAD_BUCKET); + expect(put.input.Key).toBe('TASK001/payload.json'); + expect(put.input.ContentType).toBe('application/json'); + expect(JSON.parse(put.input.Body)).toEqual(big); + + const runHookPayload = mockSend.mock.calls[0][0].input.runHookPayload; + const envelope = JSON.parse(runHookPayload); + expect(envelope.agent_payload_s3_uri).toBe(`s3://${PAYLOAD_BUCKET}/TASK001/payload.json`); + expect(envelope.agent_payload).toBeUndefined(); + // The whole point: the hook body must sit far under the 16 KB cap. + expect(Buffer.byteLength(runHookPayload, 'utf8')).toBeLessThan(MICROVM_RUN_HOOK_PAYLOAD_LIMIT_BYTES); + }); + + test('the inline/S3 branch point IS the 16KB service cap, with no headroom', () => { + // Unlike ECS (whose 8192-byte cap is shared with env vars + command, so it + // needs a margin), runHookPayload is the entire counted string — ADR-021 + // says upload only when the payload EXCEEDS 16 KB, so shipping a 13 KB + // payload to S3 would violate the requirement. + expect(MICROVM_RUN_HOOK_PAYLOAD_LIMIT_BYTES).toBe(16_384); + }); + + test('a payload whose envelope is EXACTLY 16384 bytes stays inline', async () => { + runMicrovmOk(); + + await new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: payloadWithEnvelopeBytes(16_384), + blueprintConfig: BLUEPRINT, + }); + + // Boundary is `<=`: 16384 is at the cap, not over it. + expect(mockS3Send).not.toHaveBeenCalled(); + const runHookPayload = mockSend.mock.calls[0][0].input.runHookPayload; + expect(Buffer.byteLength(runHookPayload, 'utf8')).toBe(16_384); + expect(JSON.parse(runHookPayload).agent_payload).toBeDefined(); + }); + + test('a payload whose envelope is 16385 bytes — one over — goes to S3', async () => { + mockS3Send.mockResolvedValueOnce({}); + runMicrovmOk(); + + await new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: payloadWithEnvelopeBytes(16_385), + blueprintConfig: BLUEPRINT, + }); + + expect(mockS3Send).toHaveBeenCalledTimes(1); + const envelope = JSON.parse(mockSend.mock.calls[0][0].input.runHookPayload); + expect(envelope.agent_payload_s3_uri).toBe(`s3://${PAYLOAD_BUCKET}/TASK001/payload.json`); + expect(envelope.agent_payload).toBeUndefined(); + }); + + test('a mid-sized payload the old 12KB threshold would have offloaded stays inline', async () => { + runMicrovmOk(); + + // Regression guard for the compliance fix: 13 KB is under the 16 KB cap, so + // it must NOT incur an S3 round-trip. + await new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: payloadWithEnvelopeBytes(13_000), + blueprintConfig: BLUEPRINT, + }); + + expect(mockS3Send).not.toHaveBeenCalled(); + expect(JSON.parse(mockSend.mock.calls[0][0].input.runHookPayload).agent_payload).toBeDefined(); + }); + + test('measures the serialized envelope in BYTES, so a multi-byte payload still goes to S3', async () => { + mockS3Send.mockResolvedValueOnce({}); + runMicrovmOk(); + + // 7000 chars of 3-byte UTF-8 → ~21 KB of bytes but only 7 KB of chars. + // Measuring String.length would have wrongly inlined this. + const payload = { repo_url: 'org/repo', prompt: '\u4f60'.repeat(7_000) }; + await new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload, + blueprintConfig: BLUEPRINT, + }); + + expect(mockS3Send).toHaveBeenCalledTimes(1); + expect(JSON.parse(mockSend.mock.calls[0][0].input.runHookPayload).agent_payload_s3_uri).toBeDefined(); + }); + + test('throws when RunMicrovm returns no microvmId', async () => { + mockSend.mockResolvedValueOnce({ endpoint: ENDPOINT, state: 'PENDING' }); + + await expect( + new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: BLUEPRINT, + }), + ).rejects.toThrow('RunMicrovm returned an incomplete response'); + }); + + test('throws when RunMicrovm returns no endpoint', async () => { + mockSend.mockResolvedValueOnce({ microvmId: MICROVM_ID, state: 'PENDING' }); + + await expect( + new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: BLUEPRINT, + }), + ).rejects.toThrow('RunMicrovm returned an incomplete response'); + }); + + test('microvmPayloadKey matches the ECS payload key shape', () => { + expect(microvmPayloadKey('TASK001')).toBe('TASK001/payload.json'); + }); + }); + + describe('pollSession — mechanical state mapping, no task-state interpretation', () => { + test.each([ + ['PENDING', 'running'], + ['RUNNING', 'running'], + ['SUSPENDING', 'suspended'], + ['SUSPENDED', 'suspended'], + ['TERMINATING', 'completed'], + ['TERMINATED', 'completed'], + ])('maps MicroVM state %s to session status %s', async (state, expected) => { + mockSend.mockResolvedValueOnce({ microvmId: MICROVM_ID, state }); + + const result = await new LambdaMicrovmComputeStrategy().pollSession(makeHandle()); + expect(result).toEqual({ status: expected }); + }); + + test('sends GetMicrovm keyed on microvmIdentifier', async () => { + mockSend.mockResolvedValueOnce({ microvmId: MICROVM_ID, state: 'RUNNING' }); + + await new LambdaMicrovmComputeStrategy().pollSession(makeHandle()); + + const call = mockSend.mock.calls[0][0]; + expect(call._type).toBe('GetMicrovm'); + expect(call.input).toEqual({ microvmIdentifier: MICROVM_ID }); + }); + + test('reports SUSPENDED as suspended without inspecting task state', async () => { + mockSend.mockResolvedValueOnce({ microvmId: MICROVM_ID, state: 'SUSPENDED' }); + + // No task id, no DDB read — the strategy cannot see the task row at all, + // which is exactly why the health rules live in the orchestrator. + const result = await new LambdaMicrovmComputeStrategy().pollSession(makeHandle()); + expect(result).toEqual({ status: 'suspended' }); + }); + + test('treats ResourceNotFoundException as completed (a reaped MicroVM is gone, not broken)', async () => { + const err = new Error('MicroVM mvm-0123456789abcdef not found'); + err.name = 'ResourceNotFoundException'; + mockSend.mockRejectedValueOnce(err); + + const result = await new LambdaMicrovmComputeStrategy().pollSession(makeHandle()); + expect(result).toEqual({ status: 'completed' }); + }); + + test('rethrows non-NotFound errors so the caller can count poll failures', async () => { + const err = new Error('Rate exceeded'); + err.name = 'ThrottlingException'; + mockSend.mockRejectedValueOnce(err); + + await expect(new LambdaMicrovmComputeStrategy().pollSession(makeHandle())).rejects.toThrow('Rate exceeded'); + }); + test('reports running for an unrecognized future state rather than failing the task', async () => { + mockSend.mockResolvedValueOnce({ microvmId: MICROVM_ID, state: 'HIBERNATING_SOMEDAY' }); + + const result = await new LambdaMicrovmComputeStrategy().pollSession(makeHandle()); + expect(result).toEqual({ status: 'running' }); + }); + + test('throws when the handle is not a lambda-microvm handle', async () => { + await expect( + new LambdaMicrovmComputeStrategy().pollSession({ + sessionId: 'test', + strategyType: 'agentcore', + runtimeArn: 'arn:test', + }), + ).rejects.toThrow('pollSession called with non-lambda-microvm handle'); + }); + }); + + describe('stopSession — best-effort with differentiated error handling', () => { + test('sends TerminateMicrovm keyed on microvmIdentifier', async () => { + mockSend.mockResolvedValueOnce({}); + + await new LambdaMicrovmComputeStrategy().stopSession(makeHandle()); + + expect(mockSend).toHaveBeenCalledTimes(1); + const call = mockSend.mock.calls[0][0]; + expect(call._type).toBe('TerminateMicrovm'); + expect(call.input).toEqual({ microvmIdentifier: MICROVM_ID }); + }); + + test.each([ + ['ResourceNotFoundException', 'info'], + ['ConflictException', 'info'], + ['ThrottlingException', 'error'], + ['AccessDeniedException', 'error'], + ['InternalServerException', 'warn'], + ])('logs %s at %s level and never throws', async (errName, expectedLevel) => { + const err = new Error('boom'); + err.name = errName; + mockSend.mockRejectedValueOnce(err); + + await expect(new LambdaMicrovmComputeStrategy().stopSession(makeHandle())).resolves.toBeUndefined(); + + const byLevel: Record = { + info: mockLogger.info, + warn: mockLogger.warn, + error: mockLogger.error, + }; + expect(byLevel[expectedLevel]).toHaveBeenCalledTimes(1); + for (const [level, spy] of Object.entries(byLevel)) { + if (level !== expectedLevel) expect(spy).not.toHaveBeenCalled(); + } + }); + + test('throws when the handle is not a lambda-microvm handle', async () => { + await expect( + new LambdaMicrovmComputeStrategy().stopSession({ + sessionId: 'test', + strategyType: 'ecs', + clusterArn: 'arn:cluster', + taskArn: 'arn:task', + }), + ).rejects.toThrow('stopSession called with non-lambda-microvm handle'); + }); + }); + + describe('error marking — every escaping error carries the MicroVM marker', () => { + // The marker is what lets error-classifier scope its bare AWS exception-name + // patterns to THIS backend, so an unmarked escape is a silent classification + // hole: the error would fall through to UNKNOWN ("Unexpected error"). + const markerRe = new RegExp(`${MICROVM_ERROR_MARKER} [\\w ]+failed`); + + test('the marker constant matches the pattern shape the classifier anchors on', () => { + expect(MICROVM_ERROR_MARKER).toBe('MicroVM'); + expect(`${MICROVM_ERROR_MARKER} RunMicrovm failed: x`).toMatch(markerRe); + }); + + test('marks a RunMicrovm failure and splices in the SDK exception name', async () => { + const err = new Error('Rate exceeded'); + err.name = 'ThrottlingException'; + mockSend.mockRejectedValueOnce(err); + + const start = new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: BLUEPRINT, + }); + + await expect(start).rejects.toThrow(markerRe); + // The exception NAME must appear: err.message alone omits it, and the + // classifier keys on ``. + await expect(start).rejects.toThrow('MicroVM RunMicrovm failed: ThrottlingException: Rate exceeded'); + }); + + test('preserves the original error as `cause` so err.name stays inspectable', async () => { + const err = new Error('quota'); + err.name = 'ServiceQuotaExceededException'; + mockSend.mockRejectedValueOnce(err); + + await new LambdaMicrovmComputeStrategy() + .startSession({ taskId: 'TASK001', userId: 'u', payload: {}, blueprintConfig: BLUEPRINT }) + .catch((thrown: Error) => { + expect(thrown.cause).toBe(err); + expect((thrown.cause as Error).name).toBe('ServiceQuotaExceededException'); + }); + expect.assertions(2); + }); + + test('marks a GetMicrovm failure', async () => { + const err = new Error('Rate exceeded'); + err.name = 'ThrottlingException'; + mockSend.mockRejectedValueOnce(err); + + await expect(new LambdaMicrovmComputeStrategy().pollSession(makeHandle())) + .rejects.toThrow('MicroVM GetMicrovm failed: ThrottlingException: Rate exceeded'); + }); + + test('marks a payload-upload failure so an S3 fault is attributed to this backend', async () => { + const err = new Error('Access Denied'); + err.name = 'AccessDenied'; + mockS3Send.mockRejectedValueOnce(err); + + const start = new LambdaMicrovmComputeStrategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: payloadWithEnvelopeBytes(20_000), + blueprintConfig: BLUEPRINT, + }); + + await expect(start).rejects.toThrow('MicroVM payload upload failed: AccessDenied: Access Denied'); + // Never reaches RunMicrovm — no half-started MicroVM on an upload fault. + expect(mockSend).not.toHaveBeenCalled(); + }); + + test('does not double-prefix when the message already contains the exception name', async () => { + const err = new Error('ThrottlingException: Rate exceeded'); + err.name = 'ThrottlingException'; + mockSend.mockRejectedValueOnce(err); + + await expect(new LambdaMicrovmComputeStrategy().pollSession(makeHandle())) + .rejects.toThrow('MicroVM GetMicrovm failed: ThrottlingException: Rate exceeded'); + }); + + test('marks a non-Error rejection too', async () => { + mockSend.mockRejectedValueOnce('a bare string failure'); + + await expect(new LambdaMicrovmComputeStrategy().pollSession(makeHandle())) + .rejects.toThrow('MicroVM GetMicrovm failed: a bare string failure'); + }); + }); +}); + +// The env-var guard reads module-level constants, so the missing-config case +// needs a fresh module graph with the vars deleted (same isolateModules pattern +// ecs-strategy's #502 tests use). +describe('LambdaMicrovmComputeStrategy without the MicroVM substrate deployed', () => { + function loadStrategyWithout(missing: string[]): typeof LambdaMicrovmComputeStrategy { + let Strategy!: typeof LambdaMicrovmComputeStrategy; + const saved: Record = {}; + jest.isolateModules(() => { + for (const key of missing) { + saved[key] = process.env[key]; + delete process.env[key]; + } + // eslint-disable-next-line @typescript-eslint/no-require-imports + Strategy = require('../../../../src/handlers/shared/strategies/lambda-microvm-strategy').LambdaMicrovmComputeStrategy; + }); + for (const [key, value] of Object.entries(saved)) { + if (value !== undefined) process.env[key] = value; + } + return Strategy; + } + + test.each([ + ['MICROVM_IMAGE_IDENTIFIER'], + ['MICROVM_EXECUTION_ROLE_ARN'], + ['MICROVM_EGRESS_CONNECTOR_ARNS'], + ['MICROVM_PAYLOAD_BUCKET'], + ])('throws a descriptive config/deploy-mismatch error when %s is missing', async (envVar) => { + const Strategy = loadStrategyWithout([envVar]); + + const start = new Strategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: BLUEPRINT, + }); + + // Names the root cause AND both remedies — an admin must not have to + // reverse-engineer this from a bare env-var list. + await expect(start).rejects.toThrow(/compute_type=lambda-microvm/); + await expect(start).rejects.toThrow(/deployed without the Lambda MicroVMs substrate/); + await expect(start).rejects.toThrow(/--context compute_type=lambda-microvm/); + await expect(start).rejects.toThrow(/--compute-type agentcore/); + await expect(start).rejects.toThrow(new RegExp(envVar)); + // Fails BEFORE any AWS call — no half-started MicroVM, no orphan S3 object. + expect(mockSend).not.toHaveBeenCalled(); + expect(mockS3Send).not.toHaveBeenCalled(); + }); + + test('MICROVM_IMAGE_VERSION is optional — the field is omitted so the service picks the default', async () => { + const Strategy = loadStrategyWithout(['MICROVM_IMAGE_VERSION']); + runMicrovmOk(); + + await new Strategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: BLUEPRINT, + }); + + expect('imageVersion' in mockSend.mock.calls[0][0].input).toBe(false); + }); +}); + +describe('LambdaMicrovmComputeStrategy with ingress connectors configured', () => { + test('passes the configured ingress connectors when the env var is set', async () => { + let Strategy!: typeof LambdaMicrovmComputeStrategy; + jest.isolateModules(() => { + process.env.MICROVM_INGRESS_CONNECTOR_ARNS = `${INGRESS_CONNECTOR_ARN}, ${INGRESS_CONNECTOR_ARN}-b`; + // eslint-disable-next-line @typescript-eslint/no-require-imports + Strategy = require('../../../../src/handlers/shared/strategies/lambda-microvm-strategy').LambdaMicrovmComputeStrategy; + }); + delete process.env.MICROVM_INGRESS_CONNECTOR_ARNS; + + runMicrovmOk(); + await new Strategy().startSession({ + taskId: 'TASK001', + userId: 'cognito-test', + payload: { repo_url: 'org/repo' }, + blueprintConfig: BLUEPRINT, + }); + + // Comma-separated, whitespace-trimmed — same parsing as ECS_SUBNETS. + expect(mockSend.mock.calls[0][0].input.ingressNetworkConnectors).toEqual([ + INGRESS_CONNECTOR_ARN, + `${INGRESS_CONNECTOR_ARN}-b`, + ]); + }); +}); diff --git a/cdk/test/handlers/start-session-composition.test.ts b/cdk/test/handlers/start-session-composition.test.ts index 1fe29fbd6..43f7e146a 100644 --- a/cdk/test/handlers/start-session-composition.test.ts +++ b/cdk/test/handlers/start-session-composition.test.ts @@ -40,6 +40,28 @@ jest.mock('@aws-sdk/client-bedrock-agentcore', () => ({ StopRuntimeSessionCommand: jest.fn((input: unknown) => ({ _type: 'StopRuntimeSession', input })), })); +const mockMicrovmSend = jest.fn(); +jest.mock('@aws-sdk/client-lambda-microvms', () => ({ + LambdaMicrovmsClient: jest.fn(() => ({ send: mockMicrovmSend })), + RunMicrovmCommand: jest.fn((input: unknown) => ({ _type: 'RunMicrovm', input })), + GetMicrovmCommand: jest.fn((input: unknown) => ({ _type: 'GetMicrovm', input })), + TerminateMicrovmCommand: jest.fn((input: unknown) => ({ _type: 'TerminateMicrovm', input })), + MicrovmState: { + PENDING: 'PENDING', + RUNNING: 'RUNNING', + SUSPENDED: 'SUSPENDED', + SUSPENDING: 'SUSPENDING', + TERMINATED: 'TERMINATED', + TERMINATING: 'TERMINATING', + }, +})); + +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn(() => ({ send: jest.fn().mockResolvedValue({}) })), + PutObjectCommand: jest.fn((input: unknown) => ({ _type: 'PutObject', input })), + DeleteObjectCommand: jest.fn((input: unknown) => ({ _type: 'DeleteObject', input })), +})); + jest.mock('../../src/handlers/shared/repo-config', () => ({ loadRepoConfig: jest.fn(), checkRepoOnboarded: jest.fn(), @@ -66,10 +88,14 @@ process.env.USER_CONCURRENCY_TABLE_NAME = 'UserConcurrency'; process.env.RUNTIME_ARN = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/test'; process.env.MAX_CONCURRENT_TASKS_PER_USER = '3'; process.env.TASK_RETENTION_DAYS = '90'; +process.env.MICROVM_IMAGE_IDENTIFIER = 'arn:aws:lambda:us-east-1:123456789012:microvm-image/abca-agent'; +process.env.MICROVM_EXECUTION_ROLE_ARN = 'arn:aws:iam::123456789012:role/AbcaMicrovmExecution'; +process.env.MICROVM_EGRESS_CONNECTOR_ARNS = 'arn:aws:lambda:us-east-1:123456789012:network-connector/egress-1'; +process.env.MICROVM_PAYLOAD_BUCKET = 'test-microvm-payload-bucket'; import { TaskStatus } from '../../src/constructs/task-status'; import { resolveComputeStrategy } from '../../src/handlers/shared/compute-strategy'; -import { transitionTask, emitTaskEvent, failTask } from '../../src/handlers/shared/orchestrator'; +import { transitionTask, emitTaskEvent, failTask, buildComputeMetadata } from '../../src/handlers/shared/orchestrator'; import type { BlueprintConfig } from '../../src/handlers/shared/repo-config'; beforeEach(() => { @@ -156,3 +182,79 @@ describe('start-session step composition', () => { expect(mockDdbSend).toHaveBeenCalledTimes(4); }); }); + +describe('start-session step composition — lambda-microvm (ADR-021)', () => { + const taskId = 'TASK001'; + const MICROVM_ID = 'mvm-0123456789abcdef'; + const ENDPOINT = 'https://mvm-0123456789abcdef.microvm.lambda.us-east-1.amazonaws.com'; + const blueprintConfig: BlueprintConfig = { compute_type: 'lambda-microvm', runtime_arn: '' }; + const payload = { repo_url: 'org/repo', task_id: taskId }; + + test('startSession → buildComputeMetadata → transitionTask persists microvmId and endpoint', async () => { + mockMicrovmSend.mockResolvedValueOnce({ + microvmId: MICROVM_ID, + endpoint: ENDPOINT, + state: 'RUNNING', + imageArn: 'arn:image', + imageVersion: '7', + }); + mockDdbSend.mockResolvedValue({}); + + const strategy = resolveComputeStrategy(blueprintConfig); + const handle = await strategy.startSession({ taskId, userId: 'cognito-test', payload, blueprintConfig }); + + await transitionTask(taskId, TaskStatus.HYDRATING, TaskStatus.RUNNING, { + session_id: handle.sessionId, + started_at: new Date().toISOString(), + compute_type: handle.strategyType, + compute_metadata: buildComputeMetadata(handle), + }); + + // RunMicrovm actually went out. + expect(mockMicrovmSend).toHaveBeenCalledTimes(1); + expect(mockMicrovmSend.mock.calls[0][0]._type).toBe('RunMicrovm'); + + // The persisted attributes are what cancel-task (and P3's approve/deny + // resume) read back, so assert them on the real UpdateCommand input. + const update = mockDdbSend.mock.calls.find(c => c[0]._type === 'Update')![0]; + const values = update.input.ExpressionAttributeValues as Record; + expect(values[':attr_compute_type']).toBe('lambda-microvm'); + expect(values[':attr_compute_metadata']).toEqual({ microvmId: MICROVM_ID, endpoint: ENDPOINT }); + expect(values[':attr_session_id']).toBe(MICROVM_ID); + expect(values[':toStatus']).toBe(TaskStatus.RUNNING); + }); + + test('compute_metadata carries ONLY the two lifecycle keys (no image ARN)', async () => { + mockMicrovmSend.mockResolvedValueOnce({ + microvmId: MICROVM_ID, + endpoint: ENDPOINT, + state: 'RUNNING', + imageArn: 'arn:aws:lambda:us-east-1:123456789012:microvm-image/abca-agent', + imageVersion: '7', + }); + + const strategy = resolveComputeStrategy(blueprintConfig); + const handle = await strategy.startSession({ taskId, userId: 'cognito-test', payload, blueprintConfig }); + const metadata = buildComputeMetadata(handle); + + // ADR-021: the image ARN is deployment-time config, logged not persisted. + expect(Object.keys(metadata).sort()).toEqual(['endpoint', 'microvmId']); + expect(JSON.stringify(metadata)).not.toContain('microvm-image'); + }); + + test('error path: a marked RunMicrovm failure flows into failTask', async () => { + const err = new Error('Rate exceeded'); + err.name = 'ThrottlingException'; + mockMicrovmSend.mockRejectedValueOnce(err); + mockDdbSend.mockResolvedValue({}); + + const strategy = resolveComputeStrategy(blueprintConfig); + + await expect( + strategy.startSession({ taskId, userId: 'cognito-test', payload, blueprintConfig }), + ).rejects.toThrow('MicroVM RunMicrovm failed: ThrottlingException: Rate exceeded'); + + await failTask(taskId, TaskStatus.HYDRATING, 'Session start failed: boom', 'user-123', true); + expect(mockDdbSend).toHaveBeenCalled(); + }); +}); diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index 9319c3f53..f32def2b3 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -19,6 +19,7 @@ import { App } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; +import * as lambdaMicrovmCompute from '../../src/constructs/lambda-microvm-compute'; import { AgentStack } from '../../src/stacks/agent'; describe('AgentStack', () => { @@ -532,3 +533,296 @@ describe('AgentStack with the ECS substrate gate (--context compute_type=ecs)', template.hasOutput('ComputeSubstrate', { Value: 'ecs' }); }); }); + +describe('AgentStack with the Lambda MicroVMs substrate gate (--context compute_type=lambda-microvm)', () => { + const BASE_IMAGE_ARN = 'arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1'; + + let template: Template; + + beforeAll(() => { + // Gate ON *and* an image configured — the steady state. The intermediate + // "gate on, no image yet" state is covered in the construct test; here the + // point is the stack-level wiring (env vars + IAM + outputs) that only + // exists once an image identifier is available. + const app = new App({ + context: { + compute_type: 'lambda-microvm', + microvm_base_image_arn: BASE_IMAGE_ARN, + microvm_base_image_version: '1', + }, + }); + const stack = new AgentStack(app, 'TestAgentStackMicrovm', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + template = Template.fromStack(stack); + }); + + test('provisions the MicroVM image + egress network connector', () => { + template.resourceCountIs('AWS::Lambda::MicrovmImage', 1); + template.resourceCountIs('AWS::Lambda::NetworkConnector', 1); + }); + + test('does NOT provision the ECS substrate (the gates are mutually exclusive)', () => { + template.resourceCountIs('AWS::ECS::Cluster', 0); + template.resourceCountIs('AWS::ECS::TaskDefinition', 0); + }); + + test('outputs ComputeSubstrate=lambda-microvm so the CLI allows that onboarding', () => { + template.hasOutput('ComputeSubstrate', { Value: 'lambda-microvm' }); + }); + + test('outputs everything the packaging script needs to find (no predictable physical names)', () => { + for (const output of [ + 'MicrovmArtifactBucketName', + 'MicrovmArtifactObjectKey', + 'MicrovmBuildRoleArn', + 'MicrovmExecutionRoleArn', + 'MicrovmEgressConnectorArns', + 'MicrovmLogGroupName', + ]) { + template.hasOutput(output, {}); + } + }); + + test('injects the four required MICROVM_* env vars the strategy reads', () => { + const fns = template.findResources('AWS::Lambda::Function'); + const [, orchestrator] = Object.entries(fns) + .find(([id]) => id.includes('TaskOrchestratorOrchestratorFn'))!; + const env = orchestrator.Properties.Environment.Variables as Record; + + expect(Object.keys(env).filter(k => k.startsWith('MICROVM_')).sort()).toEqual([ + 'MICROVM_EGRESS_CONNECTOR_ARNS', + 'MICROVM_EXECUTION_ROLE_ARN', + 'MICROVM_IMAGE_IDENTIFIER', + 'MICROVM_PAYLOAD_BUCKET', + ]); + // Image version is deliberately unpinned, and no ingress connectors exist + // in P1–P3 (nothing dials into the MicroVM, no JWE tokens are minted). + expect(env.MICROVM_IMAGE_VERSION).toBeUndefined(); + expect(env.MICROVM_INGRESS_CONNECTOR_ARNS).toBeUndefined(); + }); + + test('grants the orchestrator exactly the P1 lifecycle actions, image-scoped', () => { + const policies = Object.entries(template.findResources('AWS::IAM::Policy')) + .filter(([id]) => id.includes('TaskOrchestrator')); + const statements = policies.flatMap(([, p]) => p.Properties.PolicyDocument.Statement as Array<{ + Sid?: string; + Action: string | string[]; + Resource: unknown; + }>); + + const lifecycle = statements.find(s => s.Sid === 'MicrovmLifecycle')!; + expect(lifecycle.Action).toEqual([ + 'lambda:RunMicrovm', + 'lambda:GetMicrovm', + 'lambda:TerminateMicrovm', + ]); + // Every MicroVM lifecycle action authorizes against the *image* resource, + // which is why "scoped to platform-created images" is achievable at all. + expect(JSON.stringify(lifecycle.Resource)).toMatch( + /"Fn::GetAtt":\["LambdaMicrovmComputeImage[^"]*","ImageArn"\]/, + ); + + // PassNetworkConnector supports no resource-level permissions. + const pass = statements.find(s => s.Sid === 'MicrovmPassNetworkConnector')!; + expect(pass.Action).toBe('lambda:PassNetworkConnector'); + expect(pass.Resource).toBe('*'); + + // iam:PassRole for the execution role hand-off, service-conditioned. + const passRole = statements.find(s => s.Sid === 'MicrovmPassExecutionRole')!; + expect(passRole.Action).toBe('iam:PassRole'); + }); + + test('does NOT grant suspend/resume (P3) or auth-token minting (never)', () => { + const rendered = JSON.stringify(template.toJSON()); + expect(rendered).not.toContain('lambda:SuspendMicrovm'); + expect(rendered).not.toContain('lambda:ResumeMicrovm'); + expect(rendered).not.toContain('lambda:CreateMicrovmAuthToken'); + expect(rendered).not.toContain('lambda:CreateMicrovmShellAuthToken'); + expect(rendered).not.toContain('lambda:ConnectMicrovm'); + }); + + test('orchestrator may WRITE the payload bucket; nothing grants it delete', () => { + const policies = Object.entries(template.findResources('AWS::IAM::Policy')) + .filter(([id]) => id.includes('TaskOrchestrator')); + const statements = policies.flatMap(([, p]) => p.Properties.PolicyDocument.Statement as Array<{ + Action: string | string[]; + Resource: unknown; + }>); + const payloadStatements = statements.filter(s => + JSON.stringify(s.Resource).includes('LambdaMicrovmComputePayloadBucket')); + + const actions = payloadStatements.flatMap(s => Array.isArray(s.Action) ? s.Action : [s.Action]); + expect(actions).toContain('s3:PutObject'); + // The bucket's lifecycle rule is the reaper on this backend — unlike the ECS + // path the orchestrator never deletes, so the grant must not exist. + expect(actions).not.toContain('s3:DeleteObject'); + }); + + test('cancel Lambda may terminate a MicroVM (and only terminate), image-scoped', () => { + const policies = Object.entries(template.findResources('AWS::IAM::Policy')) + .filter(([id]) => id.includes('CancelTaskFn')); + const statements = policies.flatMap(([, p]) => p.Properties.PolicyDocument.Statement as Array<{ + Action: string | string[]; + Resource: unknown; + }>); + const microvmStatements = statements.filter(s => + JSON.stringify(s.Action).includes('Microvm')); + + expect(microvmStatements).toHaveLength(1); + expect(microvmStatements[0]!.Action).toBe('lambda:TerminateMicrovm'); + // Resolved through the stack's Lazy.string to the actual image resource + // (TaskApi is built before the MicroVM construct), so the grant names ONE + // image instead of an account/Region-wide `microvm-image:*`. + const rendered = JSON.stringify(microvmStatements[0]!.Resource); + expect(rendered).toMatch(/LambdaMicrovmComputeImage[^"]*","ImageArn"/); + expect(rendered).not.toContain('microvm-image:*'); + }); + + test('MicroVM resources carry the backend cost-allocation tag', () => { + template.hasResourceProperties('AWS::Lambda::MicrovmImage', { + Tags: Match.arrayWith([{ Key: 'abca:compute-backend', Value: 'lambda-microvm' }]), + }); + template.hasResourceProperties('AWS::Lambda::NetworkConnector', { + Tags: Match.arrayWith([{ Key: 'abca:compute-backend', Value: 'lambda-microvm' }]), + }); + }); + + describe('Region gate', () => { + // TEST-CONVENTION EXEMPTION (cdk/AGENTS.md "synth once in beforeAll"): the + // failure case asserts the STACK CONSTRUCTOR throws, so there is no template + // to cache. It is also cheap — the gate runs inside the MicroVM construct + // before any resource is created, and no `Template.fromStack()` is called. + // The success case (escape hatch) does need a template, so it is cached here. + let overriddenTemplate: Template; + + beforeAll(() => { + const app = new App({ + context: { + compute_type: 'lambda-microvm', + microvm_region_override: true, + microvm_base_image_arn: BASE_IMAGE_ARN, + microvm_base_image_version: '1', + }, + }); + overriddenTemplate = Template.fromStack(new AgentStack(app, 'TestAgentStackMicrovmOverride', { + env: { account: '123456789012', region: 'eu-central-1' }, + })); + }); + + test('fails synth when the stack Region has no Lambda MicroVMs', () => { + const app = new App({ context: { compute_type: 'lambda-microvm' } }); + expect(() => new AgentStack(app, 'TestAgentStackMicrovmBadRegion', { + env: { account: '123456789012', region: 'eu-central-1' }, + })).toThrow(/AWS Lambda MicroVMs are not available in eu-central-1/); + }); + + test('the microvm_region_override context flag unblocks an unsupported Region', () => { + overriddenTemplate.resourceCountIs('AWS::Lambda::MicrovmImage', 1); + }); + }); +}); + +describe('AgentStack default (agentcore) deploy — MicroVM substrate absent', () => { + let template: Template; + + beforeAll(() => { + const app = new App(); + const stack = new AgentStack(app, 'TestAgentStackNoMicrovm', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + template = Template.fromStack(stack); + }); + + test('synthesizes no MicroVM resources', () => { + template.resourceCountIs('AWS::Lambda::MicrovmImage', 0); + template.resourceCountIs('AWS::Lambda::NetworkConnector', 0); + }); + + test('injects no MICROVM_* env vars', () => { + const fns = Object.values(template.findResources('AWS::Lambda::Function')); + for (const fn of fns) { + const env = (fn.Properties.Environment?.Variables ?? {}) as Record; + expect(Object.keys(env).filter(k => k.startsWith('MICROVM_'))).toEqual([]); + } + }); + + test('grants no MicroVM IAM actions anywhere', () => { + // Scoped to policy documents rather than the whole template: cdk-nag + // suppression *reasons* legitimately mention the actions in prose. + const statements = Object.values(template.findResources('AWS::IAM::Policy')) + .flatMap(p => p.Properties.PolicyDocument.Statement as Array<{ Action: string | string[] }>); + const actions = statements.flatMap(s => Array.isArray(s.Action) ? s.Action : [s.Action]); + expect(actions.filter(a => a.includes('Microvm'))).toEqual([]); + }); +}); + +describe('AgentStack with the MicroVM gate on but no image configured (first deploy)', () => { + let template: Template; + + beforeAll(() => { + // The bootstrap state: substrate provisioned so the artifact bucket exists, + // but no image yet. Exercises the false branch of the shared + // `isLambdaMicrovmImageConfigured` predicate that gates BOTH the + // orchestrator's MICROVM_* wiring and the cancel Lambda's grant. + const app = new App({ context: { compute_type: 'lambda-microvm' } }); + const stack = new AgentStack(app, 'TestAgentStackMicrovmNoImage', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + template = Template.fromStack(stack); + }); + + test('provisions the substrate (buckets, roles, connector) but no image', () => { + template.resourceCountIs('AWS::Lambda::NetworkConnector', 1); + template.resourceCountIs('AWS::Lambda::MicrovmImage', 0); + template.hasOutput('MicrovmArtifactBucketName', {}); + }); + + test('grants no MicroVM IAM actions at all — nothing to run or cancel yet', () => { + // Notably this also proves the stack never resolves the image-ARN Lazy in + // this state: doing so would throw "accessed before LambdaMicrovmCompute was + // created"-class errors rather than synthesize. + const actions = Object.values(template.findResources('AWS::IAM::Policy')) + .flatMap(p => p.Properties.PolicyDocument.Statement as Array<{ Action: string | string[] }>) + .flatMap(s => Array.isArray(s.Action) ? s.Action : [s.Action]); + expect(actions.filter(a => a.includes('Microvm'))).toEqual([]); + }); + + test('injects no MICROVM_* env vars (the strategy fails fast with its own remedy)', () => { + const fns = Object.values(template.findResources('AWS::Lambda::Function')); + const keys = fns.flatMap(fn => + Object.keys((fn.Properties.Environment?.Variables ?? {}) as Record)); + expect(keys.filter(k => k.startsWith('MICROVM_'))).toEqual([]); + }); +}); + +describe('AgentStack MicroVM image ARN invariant', () => { + let synthError: unknown; + + beforeAll(() => { + // Force the stack-side gate true while the real construct remains in its + // no-image bootstrap state. This is the only way to exercise the Lazy's + // defensive invariant without changing production behavior. + const configuredSpy = jest.spyOn(lambdaMicrovmCompute, 'isLambdaMicrovmImageConfigured') + .mockReturnValue(true); + try { + const app = new App({ context: { compute_type: 'lambda-microvm' } }); + const stack = new AgentStack(app, 'TestAgentStackMicrovmInvariant', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + Template.fromStack(stack); + } catch (err) { + synthError = err; + } finally { + configuredSpy.mockRestore(); + } + }); + + test('fails synth if a configured deployment has no image ARN', () => { + expect(synthError).toEqual(expect.objectContaining({ + message: expect.stringContaining( + 'MicroVM image ARN was accessed before LambdaMicrovmCompute was created', + ), + })); + }); +}); diff --git a/cli/package.json b/cli/package.json index 204d749e4..fa076d20a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -41,6 +41,7 @@ "@aws-sdk/client-cloudformation": "3.1078.0", "@aws-sdk/client-cognito-identity-provider": "3.1078.0", "@aws-sdk/client-dynamodb": "3.1078.0", + "@aws-sdk/client-lambda-microvms": "^3.1098.0", "@aws-sdk/client-secrets-manager": "3.1078.0", "@aws-sdk/lib-dynamodb": "3.1078.0", "commander": "^14.0.3" diff --git a/cli/src/commands/repo.ts b/cli/src/commands/repo.ts index 94a58f3d3..b57fc818d 100644 --- a/cli/src/commands/repo.ts +++ b/cli/src/commands/repo.ts @@ -152,7 +152,7 @@ export function makeRepoCommand(): Command { .argument('', 'Repository identifier') .option('--region ', 'AWS region (defaults to configured region or AWS_REGION)') .option('--stack-name ', 'CloudFormation stack name', DEFAULT_STACK_NAME) - .option('--compute-type ', 'Compute substrate: agentcore or ecs') + .option('--compute-type ', 'Compute substrate: agentcore, ecs, or lambda-microvm') .option('--runtime-arn ', 'Override AgentCore runtime ARN (agentcore only)') .option('--model ', 'Foundation model ID override') .option('--token-secret-arn ', 'Per-repo GitHub token Secrets Manager ARN') @@ -161,8 +161,11 @@ export function makeRepoCommand(): Command { .option('--output ', 'Output format: text or json', 'text') .action(async (repoId: string, opts) => { assertRepoFormat(repoId); - if (opts.computeType && opts.computeType !== 'agentcore' && opts.computeType !== 'ecs') { - throw new CliError("--compute-type must be 'agentcore' or 'ecs'."); + if (opts.computeType + && opts.computeType !== 'agentcore' + && opts.computeType !== 'ecs' + && opts.computeType !== 'lambda-microvm') { + throw new CliError("--compute-type must be 'agentcore', 'ecs', or 'lambda-microvm'."); } const { region, stackName } = resolveOperatorContext(opts); diff --git a/cli/src/commands/runtime.ts b/cli/src/commands/runtime.ts index 61795a26f..78088cb85 100644 --- a/cli/src/commands/runtime.ts +++ b/cli/src/commands/runtime.ts @@ -82,7 +82,9 @@ export function makeRuntimeCommand(): Command { ? `${b.runtime_arn} (${b.runtime_arn_source})` : b.compute_type === 'ecs' ? '(n/a — ECS uses platform cluster)' - : '(missing)'; + : b.compute_type === 'lambda-microvm' + ? '(n/a — Lambda MicroVMs are platform-managed)' + : '(missing)'; console.log( `${b.repo.padEnd(REPO_WIDTH)} ${b.status.padEnd(10)} ` + `${b.compute_type.padEnd(COMPUTE_WIDTH)} ${runtimeLabel}`, @@ -112,6 +114,14 @@ export function makeRuntimeCommand(): Command { console.log(` note: ${ecs.note}`); } } + + if ((report.lambda_microvm_substrates ?? []).length > 0) { + console.log('\nLambda MicroVM compute substrates:'); + for (const microvm of report.lambda_microvm_substrates ?? []) { + console.log(` repos: ${microvm.used_by_repos.join(', ')}`); + console.log(` note: ${microvm.note}`); + } + } }), ); diff --git a/cli/src/lambda-microvm-availability.ts b/cli/src/lambda-microvm-availability.ts new file mode 100644 index 000000000..0c268a048 --- /dev/null +++ b/cli/src/lambda-microvm-availability.ts @@ -0,0 +1,68 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + LambdaMicrovmsClient, + ListManagedMicrovmImagesCommand, +} from '@aws-sdk/client-lambda-microvms'; +import { CliError } from './errors'; + +/** Informational copy; keep in sync with cdk/src/handlers/shared/microvm-regions.ts. */ +const LAMBDA_MICROVM_LAUNCH_REGIONS: readonly string[] = [ + 'us-east-1', + 'us-east-2', + 'us-west-2', + 'eu-west-1', + 'ap-northeast-1', +]; + +interface LambdaMicrovmProbeClient { + send(command: ListManagedMicrovmImagesCommand): Promise; +} + +export type LambdaMicrovmProbeClientFactory = (region: string) => LambdaMicrovmProbeClient; + +export const LAMBDA_MICROVM_REMEDY = + `Launch regions: ${LAMBDA_MICROVM_LAUNCH_REGIONS.join(', ')}. ` + + 'Use --compute-type agentcore or --compute-type ecs in other regions.'; + +/** Probe the read-only image catalog used to detect regional service availability. */ +export async function probeLambdaMicrovmAvailability( + region: string, + clientFactory: LambdaMicrovmProbeClientFactory = + (clientRegion) => new LambdaMicrovmsClient({ region: clientRegion }), +): Promise { + const client = clientFactory(region); + await client.send(new ListManagedMicrovmImagesCommand({})); +} + +/** Probe and convert any SDK/endpoint failure into an operator-actionable error. */ +export async function requireLambdaMicrovmAvailability( + region: string, + clientFactory?: LambdaMicrovmProbeClientFactory, +): Promise { + try { + await probeLambdaMicrovmAvailability(region, clientFactory); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new CliError( + `Lambda MicroVMs availability probe failed in ${region}: ${message}. ${LAMBDA_MICROVM_REMEDY}`, + ); + } +} diff --git a/cli/src/platform-doctor.ts b/cli/src/platform-doctor.ts index 2d9611601..e9cd503f0 100644 --- a/cli/src/platform-doctor.ts +++ b/cli/src/platform-doctor.ts @@ -24,8 +24,13 @@ import { DescribeUserPoolCommand, } from '@aws-sdk/client-cognito-identity-provider'; import { isGithubTokenConfigured } from './github-token'; +import { + LAMBDA_MICROVM_REMEDY, + LambdaMicrovmProbeClientFactory, + probeLambdaMicrovmAvailability, +} from './lambda-microvm-availability'; import { PLATFORM_REPO_DEFAULTS } from './repo-display'; -import { countActiveRepos } from './repo-lookup'; +import { listRepoConfigs, RepoConfigRow } from './repo-lookup'; import { getStackOutput } from './stack-outputs'; /** @@ -36,7 +41,7 @@ import { getStackOutput } from './stack-outputs'; * (`us.anthropic.…`) used at invoke time, while `GetFoundationModel` requires * the bare foundation-model id, so we strip the regional inference prefix. */ -export const DEFAULT_BEDROCK_MODEL_ID = +const DEFAULT_BEDROCK_MODEL_ID = PLATFORM_REPO_DEFAULTS.model_id.replace(/^(us|eu|apac)\./, ''); export type DoctorCheckStatus = 'pass' | 'fail' | 'warn'; @@ -51,6 +56,8 @@ export interface DoctorCheckResult { export interface RunPlatformDoctorOptions { readonly region: string; readonly stackName: string; + /** Override for deterministic/offline tests. */ + readonly lambdaMicrovmClientFactory?: LambdaMicrovmProbeClientFactory; } /** Smoke-check deployed platform readiness (operator AWS credentials). */ @@ -77,8 +84,15 @@ export async function runPlatformDoctor( checks.push(await checkApiReachable(apiUrl)); checks.push(await checkCognitoConfig(region, userPoolId, appClientId)); checks.push(await checkGithubToken(region, githubTokenSecretArn)); - checks.push(await checkActiveRepos(region, repoTableName)); + const activeRepoResult = await loadActiveRepos(region, repoTableName); + checks.push(checkActiveRepos(repoTableName, activeRepoResult)); checks.push(await checkBedrockModel(region, DEFAULT_BEDROCK_MODEL_ID)); + if (activeRepoResult.repos.some((repo) => repo.compute_type === 'lambda-microvm')) { + checks.push(await checkLambdaMicrovmAvailability( + region, + options.lambdaMicrovmClientFactory, + )); + } return checks; } @@ -177,33 +191,73 @@ async function checkGithubToken( }; } -async function checkActiveRepos( +async function loadActiveRepos( region: string, repoTableName: string | null, -): Promise { +): Promise<{ readonly repos: RepoConfigRow[]; readonly error?: string }> { + if (!repoTableName) return { repos: [] }; + try { + return { + repos: (await listRepoConfigs(region, repoTableName)) + .filter((repo) => repo.status === 'active'), + }; + } catch (err) { + return { repos: [], error: err instanceof Error ? err.message : String(err) }; + } +} + +function checkActiveRepos( + repoTableName: string | null, + result: { readonly repos: readonly RepoConfigRow[]; readonly error?: string }, +): DoctorCheckResult { const id = 'active_repos'; const label = 'At least one active onboarded repo'; if (!repoTableName) { return { id, label, status: 'fail', detail: 'Stack output RepoTableName is missing.' }; } + if (result.error) { + return { id, label, status: 'fail', detail: result.error }; + } + + const count = result.repos.length; + if (count >= 1) { + return { id, label, status: 'pass', detail: `${count} active repo(s) in ${repoTableName}.` }; + } + return { + id, + label, + status: 'fail', + detail: 'No active repos in RepoTable. Register a Blueprint and redeploy.', + }; +} + +async function checkLambdaMicrovmAvailability( + region: string, + clientFactory?: LambdaMicrovmProbeClientFactory, +): Promise { + const id = 'lambda_microvm_availability'; + const label = `Lambda MicroVMs service (${region})`; try { - const count = await countActiveRepos(region, repoTableName); - if (count >= 1) { - return { id, label, status: 'pass', detail: `${count} active repo(s) in ${repoTableName}.` }; - } + await probeLambdaMicrovmAvailability(region, clientFactory); return { id, label, - status: 'fail', - detail: 'No active repos in RepoTable. Register a Blueprint and redeploy.', + status: 'pass', + detail: `Managed MicroVM images are available in ${region}.`, }; } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const errorName = err instanceof Error ? err.name : ''; + const accessDenied = /AccessDenied|Unauthorized|not authorized/i.test(`${errorName} ${message}`); return { id, label, - status: 'fail', - detail: err instanceof Error ? err.message : String(err), + status: accessDenied ? 'warn' : 'fail', + detail: accessDenied + ? `${message}. Cannot verify Lambda MicroVM availability in ${region}; check IAM permissions for ` + + 'lambda-microvms List* actions.' + : `${message}. ${LAMBDA_MICROVM_REMEDY}`, }; } } diff --git a/cli/src/repo-lookup.ts b/cli/src/repo-lookup.ts index 529997c30..7301d5fdc 100644 --- a/cli/src/repo-lookup.ts +++ b/cli/src/repo-lookup.ts @@ -149,9 +149,3 @@ export async function listRepoConfigs( return items.sort((a, b) => a.repo.localeCompare(b.repo)); } - -/** Count active repos in RepoTable. */ -export async function countActiveRepos(region: string, tableName: string): Promise { - const repos = await listRepoConfigs(region, tableName); - return repos.filter((r) => r.status === 'active').length; -} diff --git a/cli/src/repo-onboard.ts b/cli/src/repo-onboard.ts index 40825fd4c..4e30009fd 100644 --- a/cli/src/repo-onboard.ts +++ b/cli/src/repo-onboard.ts @@ -19,6 +19,10 @@ import { PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import { documentClient } from './dynamo-clients'; +import { + LambdaMicrovmProbeClientFactory, + requireLambdaMicrovmAvailability, +} from './lambda-microvm-availability'; import { assertRepoFormat, loadRepoConfig, @@ -31,7 +35,7 @@ import { export const REMOVED_REPO_TTL_DAYS = 30; export interface OnboardRepoOptions { - readonly computeType?: 'agentcore' | 'ecs'; + readonly computeType?: 'agentcore' | 'ecs' | 'lambda-microvm'; readonly runtimeArn?: string; readonly modelId?: string; readonly maxTurns?: number; @@ -39,12 +43,18 @@ export interface OnboardRepoOptions { readonly pollIntervalMs?: number; } +export interface OnboardRepoDependencies { + /** Override for deterministic/offline tests; production uses the regional AWS client. */ + readonly lambdaMicrovmClientFactory?: LambdaMicrovmProbeClientFactory; +} + /** Register or re-activate a repository in RepoTable (operator path). */ export async function onboardRepo( region: string, tableName: string, repo: string, options: OnboardRepoOptions = {}, + dependencies: OnboardRepoDependencies = {}, ): Promise { assertRepoFormat(repo); const now = new Date().toISOString(); @@ -63,6 +73,11 @@ export async function onboardRepo( existing = undefined; } + const effectiveComputeType = options.computeType ?? existing?.compute_type ?? 'agentcore'; + if (effectiveComputeType === 'lambda-microvm') { + await requireLambdaMicrovmAvailability(region, dependencies.lambdaMicrovmClientFactory); + } + const item: Record = { repo, status: 'active', diff --git a/cli/src/runtime-status.ts b/cli/src/runtime-status.ts index e005df2c6..704dfcff1 100644 --- a/cli/src/runtime-status.ts +++ b/cli/src/runtime-status.ts @@ -24,7 +24,7 @@ import { import { PLATFORM_REPO_DEFAULTS } from './repo-display'; import { listRepoConfigs, RepoConfigRow } from './repo-lookup'; -export interface BlueprintRuntimeBinding { +interface BlueprintRuntimeBinding { readonly repo: string; readonly status: RepoConfigRow['status']; readonly compute_type: string; @@ -44,7 +44,7 @@ interface RuntimeProbeBase { * carries the GetAgentRuntime response, an `'error'` probe carries only the * failure message. */ -export type RuntimeProbeResult = +type RuntimeProbeResult = | (RuntimeProbeBase & { readonly probe_status: 'ok'; readonly agent_runtime_id?: string; @@ -58,17 +58,24 @@ export type RuntimeProbeResult = readonly error: string; }); -export interface EcsSubstrateSummary { +interface EcsSubstrateSummary { readonly compute_type: 'ecs'; readonly used_by_repos: readonly string[]; readonly note: string; } +interface LambdaMicrovmSubstrateSummary { + readonly compute_type: 'lambda-microvm'; + readonly used_by_repos: readonly string[]; + readonly note: string; +} + export interface RuntimeStatusReport { readonly platform_default_runtime_arn: string | null; readonly blueprints: readonly BlueprintRuntimeBinding[]; readonly agentcore_runtimes: readonly RuntimeProbeResult[]; readonly ecs_substrates: readonly EcsSubstrateSummary[]; + readonly lambda_microvm_substrates: readonly LambdaMicrovmSubstrateSummary[]; } /** Parse ``agentRuntimeId`` (and optional version) from an AgentCore runtime ARN. */ @@ -90,7 +97,9 @@ function bindingForRepo( ): BlueprintRuntimeBinding { const computeType = config.compute_type ?? PLATFORM_REPO_DEFAULTS.compute_type; const hasBlueprintRuntime = config.runtime_arn !== undefined; - const runtimeArn = hasBlueprintRuntime ? config.runtime_arn : platformRuntimeArn ?? undefined; + const runtimeArn = computeType === 'agentcore' + ? hasBlueprintRuntime ? config.runtime_arn : platformRuntimeArn ?? undefined + : undefined; return { repo: config.repo, @@ -154,6 +163,7 @@ export async function buildRuntimeStatusReport( const agentcoreMap = new Map(); const ecsRepos: string[] = []; + const lambdaMicrovmRepos: string[] = []; for (const binding of blueprints) { if (binding.status !== 'active') continue; @@ -161,6 +171,10 @@ export async function buildRuntimeStatusReport( ecsRepos.push(binding.repo); continue; } + if (binding.compute_type === 'lambda-microvm') { + lambdaMicrovmRepos.push(binding.repo); + continue; + } if (binding.runtime_arn) { const list = agentcoreMap.get(binding.runtime_arn) ?? []; list.push(binding.repo); @@ -182,10 +196,20 @@ export async function buildRuntimeStatusReport( }] : []; + const lambda_microvm_substrates: LambdaMicrovmSubstrateSummary[] = lambdaMicrovmRepos.length > 0 + ? [{ + compute_type: 'lambda-microvm', + used_by_repos: lambdaMicrovmRepos, + note: 'Lambda MicroVM sessions use platform-managed images and per-session MicroVMs. ' + + 'Per-blueprint runtime_arn overrides do not apply to Lambda MicroVM compute.', + }] + : []; + return { platform_default_runtime_arn: platformRuntimeArn, blueprints, agentcore_runtimes, ecs_substrates, + lambda_microvm_substrates, }; } diff --git a/cli/test/commands/platform.test.ts b/cli/test/commands/platform.test.ts index f362e07e2..abee5db29 100644 --- a/cli/test/commands/platform.test.ts +++ b/cli/test/commands/platform.test.ts @@ -25,6 +25,7 @@ import * as stackOutputs from '../../src/stack-outputs'; const cognitoSend = jest.fn(); const bedrockSend = jest.fn(); +const microvmSend = jest.fn(); jest.mock('@aws-sdk/client-cognito-identity-provider', () => { const actual = jest.requireActual('@aws-sdk/client-cognito-identity-provider'); @@ -42,6 +43,14 @@ jest.mock('@aws-sdk/client-bedrock', () => { }; }); +jest.mock('@aws-sdk/client-lambda-microvms', () => { + const actual = jest.requireActual('@aws-sdk/client-lambda-microvms'); + return { + ...actual, + LambdaMicrovmsClient: jest.fn(() => ({ send: microvmSend })), + }; +}); + jest.mock('../../src/github-token', () => { const actual = jest.requireActual('../../src/github-token'); return { @@ -54,13 +63,13 @@ jest.mock('../../src/repo-lookup', () => { const actual = jest.requireActual('../../src/repo-lookup'); return { ...actual, - countActiveRepos: jest.fn(), + listRepoConfigs: jest.fn(), }; }); const getStackOutputSpy = jest.spyOn(stackOutputs, 'getStackOutput'); const isGithubTokenConfiguredMock = githubToken.isGithubTokenConfigured as jest.Mock; -const countActiveReposMock = repoLookup.countActiveRepos as jest.Mock; +const listRepoConfigsMock = repoLookup.listRepoConfigs as jest.Mock; const originalFetch = global.fetch; function mockStackOutputs(): void { @@ -80,9 +89,10 @@ describe('runPlatformDoctor', () => { beforeEach(() => { getStackOutputSpy.mockReset(); isGithubTokenConfiguredMock.mockReset(); - countActiveReposMock.mockReset(); + listRepoConfigsMock.mockReset(); cognitoSend.mockReset().mockResolvedValue({}); bedrockSend.mockReset().mockResolvedValue({}); + microvmSend.mockReset().mockResolvedValue({ images: [] }); global.fetch = jest.fn().mockResolvedValue({ status: 401, ok: false }); }); @@ -93,7 +103,10 @@ describe('runPlatformDoctor', () => { test('returns pass when all checks succeed', async () => { mockStackOutputs(); isGithubTokenConfiguredMock.mockResolvedValue(true); - countActiveReposMock.mockResolvedValue(2); + listRepoConfigsMock.mockResolvedValue([ + { repo: 'acme/a', status: 'active' }, + { repo: 'acme/b', status: 'active' }, + ]); const results = await runPlatformDoctor({ region: 'us-east-1', stackName: 'dev' }); expect(doctorChecksPassed(results)).toBe(true); @@ -107,7 +120,7 @@ describe('runPlatformDoctor', () => { test('fails when github token is not configured', async () => { mockStackOutputs(); isGithubTokenConfiguredMock.mockResolvedValue(false); - countActiveReposMock.mockResolvedValue(1); + listRepoConfigsMock.mockResolvedValue([{ repo: 'acme/a', status: 'active' }]); const results = await runPlatformDoctor({ region: 'us-east-1', stackName: 'dev' }); expect(doctorChecksPassed(results)).toBe(false); @@ -117,13 +130,138 @@ describe('runPlatformDoctor', () => { test('warns when API returns an unexpected status code', async () => { mockStackOutputs(); isGithubTokenConfiguredMock.mockResolvedValue(true); - countActiveReposMock.mockResolvedValue(1); + listRepoConfigsMock.mockResolvedValue([{ repo: 'acme/a', status: 'active' }]); (global.fetch as jest.Mock).mockResolvedValue({ status: 500, ok: false }); const results = await runPlatformDoctor({ region: 'us-east-1', stackName: 'dev' }); expect(results.find((r) => r.id === 'api_reachable')?.status).toBe('warn'); expect(doctorChecksPassed(results)).toBe(true); }); + + test('fails the active-repo check when the repo table output is missing', async () => { + mockStackOutputs(); + getStackOutputSpy.mockImplementation(async (_region, _stack, key) => + key === 'RepoTableName' ? null : ({ + ApiUrl: 'https://api.example/v1/', + UserPoolId: 'us-east-1_pool', + AppClientId: 'client123', + GitHubTokenSecretArn: 'arn:token', + }[key] ?? null)); + isGithubTokenConfiguredMock.mockResolvedValue(true); + + const results = await runPlatformDoctor({ region: 'us-east-1', stackName: 'dev' }); + + expect(results.find((r) => r.id === 'active_repos')).toEqual(expect.objectContaining({ + status: 'fail', + detail: 'Stack output RepoTableName is missing.', + })); + expect(listRepoConfigsMock).not.toHaveBeenCalled(); + }); + + test('fails the active-repo check when the repo table has no active rows', async () => { + mockStackOutputs(); + isGithubTokenConfiguredMock.mockResolvedValue(true); + listRepoConfigsMock.mockResolvedValue([{ repo: 'acme/removed', status: 'removed' }]); + + const results = await runPlatformDoctor({ region: 'us-east-1', stackName: 'dev' }); + + expect(results.find((r) => r.id === 'active_repos')).toEqual(expect.objectContaining({ + status: 'fail', + detail: 'No active repos in RepoTable. Register a Blueprint and redeploy.', + })); + }); + + test('reports a non-Error repo lookup failure', async () => { + mockStackOutputs(); + isGithubTokenConfiguredMock.mockResolvedValue(true); + listRepoConfigsMock.mockRejectedValue('DynamoDB unavailable'); + + const results = await runPlatformDoctor({ region: 'us-east-1', stackName: 'dev' }); + + expect(results.find((r) => r.id === 'active_repos')).toEqual(expect.objectContaining({ + status: 'fail', + detail: 'DynamoDB unavailable', + })); + }); + + test('probes Lambda MicroVMs only when an active blueprint uses it', async () => { + mockStackOutputs(); + isGithubTokenConfiguredMock.mockResolvedValue(true); + listRepoConfigsMock.mockResolvedValue([ + { repo: 'acme/a', status: 'active', compute_type: 'lambda-microvm' }, + { repo: 'acme/removed', status: 'removed', compute_type: 'lambda-microvm' }, + ]); + + const results = await runPlatformDoctor({ region: 'us-east-1', stackName: 'dev' }); + + expect(results.find((r) => r.id === 'lambda_microvm_availability')?.status).toBe('pass'); + expect(microvmSend).toHaveBeenCalledTimes(1); + }); + + test('omits Lambda MicroVM check when no active blueprint uses it', async () => { + mockStackOutputs(); + isGithubTokenConfiguredMock.mockResolvedValue(true); + listRepoConfigsMock.mockResolvedValue([ + { repo: 'acme/a', status: 'active', compute_type: 'agentcore' }, + { repo: 'acme/removed', status: 'removed', compute_type: 'lambda-microvm' }, + ]); + + const results = await runPlatformDoctor({ region: 'us-east-1', stackName: 'dev' }); + + expect(results.some((r) => r.id === 'lambda_microvm_availability')).toBe(false); + expect(microvmSend).not.toHaveBeenCalled(); + }); + + test('reports Lambda MicroVM probe failure with remedy', async () => { + mockStackOutputs(); + isGithubTokenConfiguredMock.mockResolvedValue(true); + listRepoConfigsMock.mockResolvedValue([ + { repo: 'acme/a', status: 'active', compute_type: 'lambda-microvm' }, + ]); + microvmSend.mockRejectedValue(new Error('endpoint not found')); + + const results = await runPlatformDoctor({ region: 'eu-central-1', stackName: 'dev' }); + const check = results.find((r) => r.id === 'lambda_microvm_availability'); + + expect(check?.status).toBe('fail'); + expect(check?.detail).toContain('Launch regions: us-east-1'); + expect(check?.detail).toContain('--compute-type agentcore'); + }); + + test('warns when IAM prevents the Lambda MicroVM availability check', async () => { + mockStackOutputs(); + isGithubTokenConfiguredMock.mockResolvedValue(true); + listRepoConfigsMock.mockResolvedValue([ + { repo: 'acme/a', status: 'active', compute_type: 'lambda-microvm' }, + ]); + microvmSend.mockRejectedValue(Object.assign( + new Error('User is not authorized to perform lambda-microvms:ListManagedMicrovmImages'), + { name: 'AccessDeniedException' }, + )); + + const results = await runPlatformDoctor({ region: 'us-east-1', stackName: 'dev' }); + const check = results.find((r) => r.id === 'lambda_microvm_availability'); + + expect(check?.status).toBe('warn'); + expect(check?.detail).toContain('Cannot verify Lambda MicroVM availability'); + expect(check?.detail).toContain('lambda-microvms List* actions'); + expect(doctorChecksPassed(results)).toBe(true); + }); + + test('formats a non-Error Lambda MicroVM probe failure', async () => { + mockStackOutputs(); + isGithubTokenConfiguredMock.mockResolvedValue(true); + listRepoConfigsMock.mockResolvedValue([ + { repo: 'acme/a', status: 'active', compute_type: 'lambda-microvm' }, + ]); + microvmSend.mockRejectedValue('service unavailable'); + + const results = await runPlatformDoctor({ region: 'us-east-1', stackName: 'dev' }); + const check = results.find((r) => r.id === 'lambda_microvm_availability'); + + expect(check?.status).toBe('fail'); + expect(check?.detail).toContain('service unavailable'); + }); }); describe('doctorChecksPassed', () => { diff --git a/cli/test/commands/repo-onboard-command.test.ts b/cli/test/commands/repo-onboard-command.test.ts index db0a9ac0b..56524c510 100644 --- a/cli/test/commands/repo-onboard-command.test.ts +++ b/cli/test/commands/repo-onboard-command.test.ts @@ -104,7 +104,20 @@ describe('repo onboard/offboard commands', () => { 'node', 'test', 'onboard', 'acme/a', '--region', 'us-east-1', '--compute-type', 'lambda', - ])).rejects.toThrow("compute-type must be 'agentcore' or 'ecs'"); + ])).rejects.toThrow("compute-type must be 'agentcore', 'ecs', or 'lambda-microvm'"); + }); + + test('repo onboard accepts lambda-microvm compute type', async () => { + const cmd = makeRepoCommand(); + await cmd.parseAsync([ + 'node', 'test', 'onboard', 'acme/a', + '--region', 'us-east-1', + '--compute-type', 'lambda-microvm', + ]); + + expect((onboardRepo as jest.Mock).mock.calls[0][3]).toEqual( + expect.objectContaining({ computeType: 'lambda-microvm' }), + ); }); test('repo offboard invokes offboardRepo', async () => { diff --git a/cli/test/commands/repo-onboard.test.ts b/cli/test/commands/repo-onboard.test.ts index d91dc85da..d6114e3a7 100644 --- a/cli/test/commands/repo-onboard.test.ts +++ b/cli/test/commands/repo-onboard.test.ts @@ -65,6 +65,73 @@ describe('repo onboard/offboard', () => { expect(put.input.Item?.compute_type).toBe('agentcore'); }); + test('onboardRepo probes when an existing repo is switched to lambda-microvm', async () => { + const { loadRepoConfig } = jest.requireMock('../../src/repo-lookup') as { + loadRepoConfig: jest.Mock; + }; + loadRepoConfig.mockResolvedValueOnce({ + repo: 'acme/a', + status: 'active', + compute_type: 'agentcore', + }); + const microvmSend = jest.fn().mockResolvedValue({ images: [] }); + + await onboardRepo( + 'us-east-1', + 'RepoTable', + 'acme/a', + { computeType: 'lambda-microvm' }, + { lambdaMicrovmClientFactory: () => ({ send: microvmSend }) }, + ); + + expect(microvmSend).toHaveBeenCalledTimes(1); + const put = ddbSend.mock.calls[0][0] as PutCommand; + expect(put.input.Item?.compute_type).toBe('lambda-microvm'); + }); + + test('onboardRepo probes when reactivating an existing lambda-microvm repo without a flag', async () => { + const { loadRepoConfig } = jest.requireMock('../../src/repo-lookup') as { + loadRepoConfig: jest.Mock; + }; + loadRepoConfig.mockResolvedValueOnce({ + repo: 'acme/a', + status: 'removed', + compute_type: 'lambda-microvm', + }); + const microvmSend = jest.fn().mockResolvedValue({ images: [] }); + + await onboardRepo( + 'us-east-1', + 'RepoTable', + 'acme/a', + {}, + { lambdaMicrovmClientFactory: () => ({ send: microvmSend }) }, + ); + + expect(microvmSend).toHaveBeenCalledTimes(1); + const put = ddbSend.mock.calls[0][0] as PutCommand; + expect(put.input.Item?.compute_type).toBe('lambda-microvm'); + }); + + test('onboardRepo rejects lambda-microvm when its availability probe fails', async () => { + const microvmSend = jest.fn().mockRejectedValue( + Object.assign(new Error('unknown service'), { name: 'UnrecognizedClientException' }), + ); + + await expect(onboardRepo( + 'eu-central-1', + 'RepoTable', + 'acme/a', + { computeType: 'lambda-microvm' }, + { lambdaMicrovmClientFactory: () => ({ send: microvmSend }) }, + )).rejects.toThrow( + 'Launch regions: us-east-1, us-east-2, us-west-2, eu-west-1, ap-northeast-1. ' + + 'Use --compute-type agentcore or --compute-type ecs', + ); + + expect(ddbSend).not.toHaveBeenCalled(); + }); + test('offboardRepo sets removed status and TTL', async () => { await offboardRepo('us-east-1', 'RepoTable', 'acme/a'); diff --git a/cli/test/commands/runtime-status.test.ts b/cli/test/commands/runtime-status.test.ts index c3e925946..bae2c7d55 100644 --- a/cli/test/commands/runtime-status.test.ts +++ b/cli/test/commands/runtime-status.test.ts @@ -57,6 +57,11 @@ describe('buildRuntimeStatusReport', () => { status: 'active', compute_type: 'ecs', }, + { + repo: 'acme/microvm', + status: 'active', + compute_type: 'lambda-microvm', + }, ]); const report = await buildRuntimeStatusReport( @@ -67,6 +72,12 @@ describe('buildRuntimeStatusReport', () => { expect(report.agentcore_runtimes).toHaveLength(2); expect(report.ecs_substrates).toHaveLength(1); + expect(report.lambda_microvm_substrates).toEqual([expect.objectContaining({ + compute_type: 'lambda-microvm', + used_by_repos: ['acme/microvm'], + })]); + expect(report.blueprints.find((blueprint) => blueprint.repo === 'acme/microvm')?.runtime_arn) + .toBeUndefined(); expect(report.blueprints[0].runtime_arn_source).toBe('platform'); expect(report.blueprints[1].runtime_arn_source).toBe('blueprint'); expect(controlPlaneSend).toHaveBeenCalledTimes(2); diff --git a/cli/test/commands/runtime.test.ts b/cli/test/commands/runtime.test.ts index ffd19ef2f..11cb69627 100644 --- a/cli/test/commands/runtime.test.ts +++ b/cli/test/commands/runtime.test.ts @@ -51,6 +51,7 @@ describe('runtime status command', () => { control_plane_status: 'READY', }], ecs_substrates: [], + lambda_microvm_substrates: [], }); }); @@ -84,6 +85,7 @@ describe('runtime status command', () => { used_by_repos: ['acme/ecs'], note: 'ECS note', }], + lambda_microvm_substrates: [], }); const cmd = makeRuntimeCommand(); @@ -109,6 +111,7 @@ describe('runtime status command', () => { used_by_repos: ['acme/ecs'], note: 'ECS note', }], + lambda_microvm_substrates: [], }); const cmd = makeRuntimeCommand(); @@ -119,6 +122,34 @@ describe('runtime status command', () => { expect(output).toContain('ECS note'); }); + test('prints Lambda MicroVM substrate note without runtime probing', async () => { + (buildRuntimeStatusReport as jest.Mock).mockResolvedValue({ + platform_default_runtime_arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/platform', + blueprints: [{ + repo: 'acme/microvm', + status: 'active', + compute_type: 'lambda-microvm', + runtime_arn: undefined, + runtime_arn_source: 'platform', + }], + agentcore_runtimes: [], + ecs_substrates: [], + lambda_microvm_substrates: [{ + compute_type: 'lambda-microvm', + used_by_repos: ['acme/microvm'], + note: 'Lambda MicroVM sessions are platform-managed.', + }], + }); + + const cmd = makeRuntimeCommand(); + await cmd.parseAsync(['node', 'test', 'status', '--region', 'us-east-1']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('n/a — Lambda MicroVMs are platform-managed'); + expect(output).toContain('Lambda MicroVM compute substrates'); + expect(output).toContain('Lambda MicroVM sessions are platform-managed.'); + }); + test('fails when RepoTable output is missing', async () => { (getStackOutput as jest.Mock).mockImplementation(async (_r: string, _s: string, key: string) => { if (key === 'RepoTableName') return null; @@ -136,6 +167,7 @@ describe('runtime status command', () => { blueprints: [], agentcore_runtimes: [], ecs_substrates: [], + lambda_microvm_substrates: [], }); const cmd = makeRuntimeCommand(); diff --git a/docs/decisions/ADR-021-lambda-microvms-compute-backend.md b/docs/decisions/ADR-021-lambda-microvms-compute-backend.md index 8e41bbe1f..dfc371fa3 100644 --- a/docs/decisions/ADR-021-lambda-microvms-compute-backend.md +++ b/docs/decisions/ADR-021-lambda-microvms-compute-backend.md @@ -47,11 +47,27 @@ Adopt **AWS Lambda MicroVMs as a third, opt-in `ComputeStrategy` backend** named ### 1. Strategy shape: extend the interface with mandatory suspend/resume -`ComputeType` widens to `'agentcore' | 'ecs' | 'lambda-microvm'` (mirrored in `cli/src/types.ts` and the CLI's inline unions). `SessionHandle` gains a `{ strategyType: 'lambda-microvm', microvmId, endpoint }` variant — `microvmId` because every lifecycle API (`suspend-microvm`, `resume-microvm`, `terminate-microvm`, and `create-microvm-auth-token` — the latter not called in P1–P3, see sub-decision 3) takes only the MicroVM identifier, and `endpoint` because it is per-session (minted by `RunMicrovm`) and required for any orchestrator→agent HTTP interaction. The image ARN is deliberately **not** in the handle: like the ECS task definition ARN, it is deployment-time configuration consumed by `startSession` (from construct-injected environment) and recorded in the session-start log entry for diagnostics, not per-session lifecycle state. +`ComputeType` widens to `'agentcore' | 'ecs' | 'lambda-microvm'` (mirrored in `cli/src/types.ts` and the CLI's inline unions). `SessionHandle` gains a `{ strategyType: 'lambda-microvm', microvmId, endpoint }` variant — `microvmId` because every lifecycle API (`suspend-microvm`, `resume-microvm`, `terminate-microvm`, `get-microvm`, and `create-microvm-auth-token` — the latter not called in P1–P3, see sub-decision 3) takes only the MicroVM identifier, and `endpoint` because it is per-session (minted by `RunMicrovm`) and required for any orchestrator→agent HTTP interaction. Note the naming seam: the handle field is `microvmId` (matching `RunMicrovmResponse.microvmId`), while the request key on every lifecycle command is `microvmIdentifier` — the strategy is the only place that translates between the two. The image ARN is deliberately **not** in the handle: like the ECS task definition ARN, it is deployment-time configuration consumed by `startSession` (from construct-injected environment) and recorded in the session-start log entry for diagnostics, not per-session lifecycle state. The `ComputeStrategy` interface gains **mandatory** `suspendSession(handle)` / `resumeSession(handle)` methods returning a typed result (`{ supported: false } | { supported: true }`-shaped, exact type at implementation time); the widening lands in P3 (see sub-decision 5), in one commit across all three strategies. Mandatory-with-explicit-stub is the codebase idiom, not optional methods: no behavioral interface in the codebase has an optional method, `AgentCoreComputeStrategy.pollSession` is already a mandatory explicit stub rather than an optional member, and the exhaustive-`never` switch culture means a fourth backend must make a compile-checked decision about its suspend semantics instead of silently falling through a `strategy.suspendSession?.()` feature-detection. The agentcore and ecs strategies return `unsupported` (not a silent success — a suspend that silently no-ops would let the orchestrator believe compute billing stopped when it did not); the orchestrator gates its suspend policy on the typed response, consistent with how `pollTaskStatus` already branches explicitly on `computeType`. -**Poll semantics — the strategy reports, the orchestrator interprets.** `pollSession(handle)` receives only the session handle and cannot see task state, so the health rules must live where the DynamoDB status lives. `SessionStatus` gains a `'suspended'` variant; the strategy maps `GetMicrovm` state mechanically (`RUNNING` → `running`, `SUSPENDED` → `suspended`, `TERMINATED` → `completed`) and the **orchestrator** cross-references against the task row — the same division of labor `finalPollState` already uses for ECS (substrate stopped + non-terminal DynamoDB status → failed) and `pollTaskStatus` uses for agentcore heartbeats: substrate `suspended` + task `AWAITING_APPROVAL` is healthy (orchestrator-intended suspend); `suspended` with any other task status is an anomaly to surface, not fail-fast; substrate terminal + non-terminal task status → classify failed. +**Poll semantics — the strategy reports, the orchestrator interprets.** `pollSession(handle)` receives only the session handle and cannot see task state, so the health rules must live where the DynamoDB status lives. `SessionStatus` gains a `'suspended'` variant; the strategy maps `GetMicrovm` state mechanically and the **orchestrator** cross-references against the task row — the same division of labor `finalPollState` already uses for ECS (substrate stopped + non-terminal DynamoDB status → failed) and `pollTaskStatus` uses for agentcore heartbeats: substrate `suspended` + task `AWAITING_APPROVAL` is healthy (orchestrator-intended suspend); `suspended` with any other task status is an anomaly to surface, not fail-fast; substrate terminal + non-terminal task status → classify failed. + +The service's `MicrovmState` enum has **six** members, not three, so the mapping is stated exhaustively (one line of rationale each, mirrored in the strategy's doc comment): + +| `MicrovmState` | `SessionStatus` | Why | +|---|---|---| +| `PENDING` | `running` | Still booting; the same way ECS's `PENDING`/`PROVISIONING` map to `running`. | +| `RUNNING` | `running` | — | +| `SUSPENDING` | `suspended` | Already on its way to frozen; reporting `running` would tell the orchestrator compute is still progressing when it is not. Both suspend states land on a report the orchestrator treats as benign-or-anomalous depending on task status, never as failure. | +| `SUSPENDED` | `suspended` | — | +| `TERMINATING` | `completed` | Terminal-bound and carries no exit code, so "the substrate is gone" is all the strategy can honestly say. | +| `TERMINATED` | `completed` | Success vs failure is the orchestrator's call — it cross-references the DynamoDB status. | +| *unrecognized* | `running` | A future service enum addition must never fail a healthy task; the strategy warns and keeps polling. | + +**`GetMicrovm` `ResourceNotFoundException` → `completed`.** This deliberately diverges from `ecs-strategy`, where `DescribeTasks` returning no task maps to `failed`. ECS keeps stopped tasks describable for roughly an hour, so a missing task there really is anomalous; a terminated MicroVM is reaped from the control plane **by design**, so `failed` would fail every task that finished cleanly. The divergence is safe because it does not weaken detection: the orchestrator still fails the task when a terminal report lands while the DynamoDB status is non-terminal, so a genuine mid-run disappearance is caught — it simply receives the substrate-failure classification instead of a misleading poll error. Because that cross-check acts on a status read earlier in the same poll cycle, the orchestrator **re-reads the task row before failing** (the normal shutdown order is "agent writes terminal status → agent exits → VM terminates", which a stale read would otherwise turn into a spurious failure); ECS buys the same protection with a five-consecutive-poll patience counter instead. + +Neither the mapping nor the NotFound rule is a health decision: both are mechanical restatements of substrate state, which is what keeps the "strategy reports, orchestrator interprets" split intact. Normative requirements (EARS, per [ADR-020](./ADR-020-ears-requirements-syntax.md)): @@ -60,8 +76,10 @@ Normative requirements (EARS, per [ADR-020](./ADR-020-ears-requirements-syntax.m - When `startSession` returns, the orchestrator shall persist the MicroVM handle (`microvmId`, `endpoint`) in the task row's `compute_metadata` (the field `cancel-task.ts` already reads ECS handles from). - The strategy shall omit `idlePolicy` on every `RunMicrovm` call, in every phase. - The orchestrator shall be the sole initiator of suspension, via `suspendSession`. -- When `pollSession` observes MicroVM state `SUSPENDED`, the strategy shall report `suspended` without interpreting task state. -- If the strategy reports a terminal substrate state while the task's DynamoDB status is non-terminal, then the orchestrator shall classify the task as failed with a substrate-failure remedy. +- When `pollSession` observes MicroVM state `SUSPENDED` or `SUSPENDING`, the strategy shall report `suspended` without interpreting task state. +- When `pollSession` observes a MicroVM state it does not recognize, the strategy shall report `running`. +- If `GetMicrovm` reports that the MicroVM does not exist, then the strategy shall report `completed`. +- If the strategy reports a terminal substrate state while the task's DynamoDB status is non-terminal, then the orchestrator shall re-read the task row and, if it is still non-terminal, classify the task as failed with a substrate-failure remedy. - If the strategy reports `suspended` while the task's DynamoDB status is not `AWAITING_APPROVAL`, then the orchestrator shall surface an anomaly event and shall not fail-fast the task. - If `suspendSession` or `resumeSession` is invoked on a strategy that does not support suspension, then the strategy shall return an explicit unsupported result. - When the agent process reaches a terminal state, the agent shall exit. @@ -97,18 +115,32 @@ Normative requirements (EARS): ### 3. Packaging: same agent image source, new build path -The existing agent container (`agent/` Dockerfile, already ARM64) is repackaged as a zip + Dockerfile artifact in S3 and built into a versioned `MicrovmImage` via `CreateMicrovmImage` (with `/ready` and `/validate` build hooks for snapshot quality). The agent runs its existing FastAPI server (`agent/src/server.py`) — the MicroVM path uses the HTTP entrypoint like AgentCore, not ECS's batch bypass — plus the four runtime lifecycle hooks on a sidecar port. Runtime hooks are fast-notification only (1–60 s): `/run` validates the payload and starts the pipeline **asynchronously**, mirroring how the agent loop already runs in a background thread behind `/ping` on AgentCore. +The existing agent container (`agent/` Dockerfile, already ARM64) is repackaged as a zip + Dockerfile artifact in S3 and built into a versioned `MicrovmImage` via `CreateMicrovmImage`. The agent runs its existing FastAPI server (`agent/src/server.py`) — the MicroVM path uses the HTTP entrypoint like AgentCore, not ECS's batch bypass — plus the four runtime lifecycle hooks (`/run`, `/suspend`, `/resume`, `/terminate`) on a sidecar port, and the `/ready` + `/validate` build hooks for snapshot quality. Runtime hooks are fast-notification only (1–60 s): `/run` validates the payload and starts the pipeline **asynchronously**, mirroring how the agent loop already runs in a background thread behind `/ping` on AgentCore. + +**Hook phasing — P1 provisions the packaging plane, P2 makes it runnable.** Serving the hooks is agent-side work, so the hooks land in a different phase from the infrastructure that declares them: + +| Hook | Declared by | Served by the agent | Notes | +|---|---|---|---| +| `/run` | P1 (construct sets `hooks.microvmHooks.run`) | **P2** | The payload-delivery channel; declared in P1 so the image shape and IAM are reviewed once, but nothing answers it until P2. | +| `/ready`, `/validate` | **P2** | **P2** | Build-time snapshot-quality hooks. Deliberately NOT declared in P1: a `/validate` endpoint that 404s fails every image build. | +| `/suspend`, `/resume`, `/terminate` | **P3** (suspend/resume), P2 (`/terminate`) | P3 / P2 | Declaring a runtime hook the agent does not serve fails the corresponding lifecycle transition, so each is declared only in the phase that implements it. P1 termination is the orchestrator's `TerminateMicrovm`, which needs no in-guest cooperation. | + +Consequence to state plainly: **a P1-built MicroVM image is not runnable end to end.** P1 delivers the strategy, the construct, the roles/buckets/connector, the image resource, and the packaging script — an image built from a P1 deployment boots the existing FastAPI server but does not answer `/run`, so a `lambda-microvm` task started against it will not progress. P2 ("smoke parity") is the phase that makes clone → change → PR work on this backend. The construct and the packaging script both surface this at synth/run time so an operator cannot mistake a provisioned substrate for a working one. **Payload delivery** reuses the ECS strategy's S3-pointer pattern, adapted to `runHookPayload` (≤ 16 KB): small payloads inline; larger ones uploaded by the strategy to a platform payload bucket (the ECS payload bucket pattern in `ecs-agent-cluster.ts`: orchestrator write access, compute-role read-only scoped to the bucket, lifecycle expiry on objects) with only the S3 URI in `runHookPayload` — the MicroVM **execution role** holds the read grant, exactly as the ECS task role does today. **No orchestrator→agent HTTP path exists in P1–P3**: payload arrives through the `/run` hook, all agent work is outbound, and therefore **no JWE auth tokens are minted at all** — token minting (and its ≤ 60 min TTL refresh problem) is deferred until a real consumer exists (e.g. operator shell access, [#391](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/391)). The `endpoint` stays in the `SessionHandle` because it is genuinely per-session state that becomes load-bearing the day such a consumer appears. **Constraint accepted:** 32 GB RAM / 32 GB disk (disk quota to be confirmed against current service quotas when COMPUTE.md is updated) means repos that motivated the 120 GB ECS sizing stay on `ecs`; MicroVMs target the default-sized workload with suspend economics, not the heavy-build niche. -- The image build shall not embed secrets, tokens, or per-task identity in the snapshot. -- The agent shall resolve credentials at `/run` time. -- When the `/run` hook receives the task payload, the agent shall validate it, start the pipeline asynchronously, and return HTTP 200 within the hook budget. -- The agent shall not execute the clone→verify→PR pipeline on the hook path. -- If the task payload exceeds the 16 KB `runHookPayload` limit, then the strategy shall upload the payload to the platform payload bucket and pass only its S3 URI in `runHookPayload`. -- The MicroVM execution role shall hold read-only access to the payload bucket, scoped to that bucket. +Normative requirements (EARS). The requirements that constrain what the *strategy* and the *image build* do are P1; the ones that require the *agent* to serve a hook are P2, per the phasing table above: + +- (P1) The image build shall not embed secrets, tokens, or per-task identity in the snapshot. +- (P1) If the task payload exceeds the 16 KB `runHookPayload` limit, then the strategy shall upload the payload to the platform payload bucket and pass only its S3 URI in `runHookPayload`. +- (P1) The MicroVM execution role shall hold read-only access to the payload bucket, scoped to that bucket. +- (P1) Where a deployment configures a MicroVM image before the agent serves the runtime hooks, the platform shall warn that the image is not yet runnable end to end. +- (P2) The agent shall resolve credentials at `/run` time. +- (P2) When the `/run` hook receives the task payload, the agent shall validate it, start the pipeline asynchronously, and return HTTP 200 within the hook budget. +- (P2) The agent shall not execute the clone→verify→PR pipeline on the hook path. +- (P2) Where the image declares the `/ready` and `/validate` build hooks, the agent shall serve them. ### 4. Infra and IAM: conditional resources behind bootstrap `ComputeTypes` @@ -154,8 +186,8 @@ Lambda MicroVMs launched in 5 regions (us-east-1/2, us-west-2, eu-west-1, ap-nor ### 5. Rollout: phased, default unchanged -- **P1 — strategy + infra:** `LambdaMicrovmComputeStrategy` (start/poll/stop), CDK construct, bootstrap policy, types sync, unit + CDK assertion tests. No suspend yet. -- **P2 — smoke parity:** agent completes clone → change → PR on the backend with progress visible to `bgagent watch`; failure classification entries in `error-classifier.ts`; **AgentCore Memory parity** (IAM grant + `MEMORY_ID` delivery, following the `EcsAgentCluster` pattern — Memory is a standalone service already consumed cross-substrate, and omitting the grant silently no-ops cross-session learning). +- **P1 — strategy + infra:** `LambdaMicrovmComputeStrategy` (start/poll/stop), CDK construct, bootstrap policy, types sync, unit + CDK assertion tests. No suspend yet, and **no runnable image yet** — the construct declares the `/run` hook but the agent does not serve it until P2 (sub-decision 3's phasing table). +- **P2 — smoke parity:** the agent serves the runtime and build hooks (`/run`, `/terminate`, `/ready`, `/validate`), making a MicroVM image runnable end to end; agent completes clone → change → PR on the backend with progress visible to `bgagent watch`; failure classification entries in `error-classifier.ts`; **AgentCore Memory parity** (IAM grant + `MEMORY_ID` delivery, following the `EcsAgentCluster` pattern — Memory is a standalone service already consumed cross-substrate, and omitting the grant silently no-ops cross-session learning). - **P3 — suspend/resume:** the interface widening from sub-decision 1 (mandatory methods, all three strategies in one commit), HITL-wait suspend policy, inline resume in the approve/deny Lambda with orchestrator-poll reconciliation (sub-decision 2), timeout-under-freeze wall-clock handling; coordinate with [#491](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/491)'s unified liveness model and update Cedar decision #7's rationale note. - **Out of scope:** replacing AgentCore as default; classic Lambda functions as a runtime; GPU; the Runtime-coupled workload-access-token injection path (delivery mechanism exists only on AgentCore Runtime; MicroVMs adopt the ECS env-var posture until [#249](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/249)/ADR-016 redesign the seam). Gateway integration is orthogonal: ADR-019/[#641](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/641) is substrate-portable by design and applies to this backend when it lands. diff --git a/docs/design/DEPLOYMENT_ROLES.md b/docs/design/DEPLOYMENT_ROLES.md index 9f2f0baca..8a279edc8 100644 --- a/docs/design/DEPLOYMENT_ROLES.md +++ b/docs/design/DEPLOYMENT_ROLES.md @@ -2,7 +2,7 @@ This document defines least-privilege IAM policies for the CloudFormation execution role used during `cdk deploy`. The default CDK bootstrap grants `AdministratorAccess` to this role; the policies below scope it to only what ABCA needs. -> **Origin**: These IAM policies were derived from a thorough review of the repository's CDK constructs, stacks, and handler code, then **validated against a live deployment** in `us-east-1` (create, update, task execution, and destroy). CloudTrail analysis identified 36 additional actions beyond the initial code review, and 7 deployment iterations refined the policies to their current form. The policies are split into five managed policies — three always-applied (Infrastructure, Application, Observability) plus two compute-variant policies (Compute-AgentCore, Compute-ECS) — to stay under the IAM 6,144-character limit and to support per-compute-backend bootstrap selection. The policy sources live in `cdk/src/bootstrap/policies/*.ts` and are compiled to `cdk/bootstrap/policies/*.json`. +> **Origin**: These IAM policies were derived from a thorough review of the repository's CDK constructs, stacks, and handler code, then **validated against a live deployment** in `us-east-1` (create, update, task execution, and destroy). CloudTrail analysis identified 36 additional actions beyond the initial code review, and 7 deployment iterations refined the policies to their current form. The policies are split into six managed policies — three always-applied (Infrastructure, Application, Observability) plus three compute-variant policies (Compute-AgentCore, Compute-ECS, Compute-LambdaMicrovms) — to stay under the IAM 6,144-character limit and to support per-compute-backend bootstrap selection. The policy sources live in `cdk/src/bootstrap/policies/*.ts` and are compiled to `cdk/bootstrap/policies/*.json`. ## How CDK deployment roles work @@ -17,7 +17,7 @@ The policy below is a **CloudFormation Execution Role** replacement. The other t ## Using these policies -The policies are split into five IAM managed policies (each under the 6,144-character limit): +The policies are split into six IAM managed policies (each under the 6,144-character limit): | Policy Name | Scope | When applied | |-------------|-------|--------------| @@ -26,10 +26,11 @@ The policies are split into five IAM managed policies (each under the 6,144-char | `IaCRole-ABCA-Observability` | Bedrock Guardrails, CloudWatch, X-Ray, S3, ECR, KMS, SSM, STS | Always | | `IaCRole-ABCA-Compute-Agentcore` | Bedrock AgentCore (`bedrock-agentcore:*`) | Always (default compute backend) | | `IaCRole-ABCA-Compute-ECS` | ECS cluster + task-definition operations | Only when `ecs` is in `ComputeTypes` | +| `IaCRole-ABCA-Compute-LambdaMicrovms` | Lambda MicroVM image + network-connector operations | Only when `lambda-microvm` is in `ComputeTypes` | > **Placeholder substitution**: Replace `ACCOUNT_ID` with your 12-digit AWS account ID and `REGION` with your deployment region (e.g., `us-east-1`) throughout this document. -These policies are not created or attached manually. The repository generates them — and a custom bootstrap template that wires all five into the CloudFormation execution role — from the TypeScript sources, then bootstraps with that template: +These policies are not created or attached manually. The repository generates them — and a custom bootstrap template that wires all six into the CloudFormation execution role — from the TypeScript sources, then bootstraps with that template: ```bash # Regenerate artifacts (policies JSON + template YAML) and bootstrap. @@ -47,7 +48,7 @@ aws cloudformation update-stack --stack-name CDKToolkit --use-previous-template aws cloudformation describe-stacks --stack-name CDKToolkit --query 'Stacks[0].Parameters' ``` -Under the hood, `mise //cdk:bootstrap` runs `npx cdk bootstrap --template bootstrap/bootstrap-template.yaml` (see `cdk/mise.toml`). The generated template defines five inline `AWS::IAM::ManagedPolicy` resources that **replace** the default `AdministratorAccess` on the CloudFormation execution role; the `IaCRole-ABCA-Compute-ECS` policy is conditional on the `ComputeTypes` parameter including `ecs`. The policy sources are `cdk/src/bootstrap/policies/{infrastructure,application,observability,compute-agentcore,compute-ecs}.ts`, compiled to `cdk/bootstrap/policies/*.json` by `cdk/scripts/generate-bootstrap-artifacts.ts`. +Under the hood, `mise //cdk:bootstrap` runs `npx cdk bootstrap --template bootstrap/bootstrap-template.yaml` (see `cdk/mise.toml`). The generated template defines six inline `AWS::IAM::ManagedPolicy` resources that **replace** the default `AdministratorAccess` on the CloudFormation execution role; the `IaCRole-ABCA-Compute-ECS` and `IaCRole-ABCA-Compute-LambdaMicrovms` policies are conditional on the `ComputeTypes` parameter including their respective backend. The policy sources are `cdk/src/bootstrap/policies/{infrastructure,application,observability,compute-agentcore,compute-ecs,compute-lambda-microvm}.ts`, compiled to `cdk/bootstrap/policies/*.json` by `cdk/scripts/generate-bootstrap-artifacts.ts`. ## Trust policy @@ -82,7 +83,7 @@ Under the hood, `mise //cdk:bootstrap` runs `npx cdk bootstrap --template bootst For deploying the `backgroundagent-dev` stack. This single stack contains all platform resources including the AgentCore runtime, ECS compute (when enabled), API Gateway, Cognito, DynamoDB tables, VPC, DNS Firewall, and observability infrastructure. -> **IAM managed policy size limit**: A single managed policy cannot exceed 6,144 characters. The permissions below are split into five policies to stay under this limit (three always-applied, plus two compute-variant policies). They are wired into the CloudFormation execution role by the generated bootstrap template; see [Using these policies](#using-these-policies). +> **IAM managed policy size limit**: A single managed policy cannot exceed 6,144 characters. The permissions below are split into six policies to stay under this limit (three always-applied, plus three compute-variant policies). They are wired into the CloudFormation execution role by the generated bootstrap template; see [Using these policies](#using-these-policies). ### IaCRole-ABCA-Infrastructure @@ -738,6 +739,44 @@ When the ECS Fargate compute backend is enabled (set the `ComputeTypes` CFN para } ``` +### IaCRole-ABCA-Compute-LambdaMicrovms + +When the Lambda MicroVM compute backend is enabled (include `lambda-microvm` in the `ComputeTypes` CFN parameter on the `CDKToolkit` stack), the generated template conditionally attaches this policy to the CloudFormation execution role. It permits CloudFormation to manage MicroVM images and network connectors; runtime session lifecycle permissions remain on the orchestrator role. + +```json +{ + "Statement": [ + { + "Action": [ + "lambda:CreateMicrovmImage", + "lambda:GetMicrovmImage", + "lambda:UpdateMicrovmImage", + "lambda:DeleteMicrovmImage", + "lambda:ListMicrovmImages", + "lambda:GetMicrovmImageVersion", + "lambda:UpdateMicrovmImageVersion", + "lambda:DeleteMicrovmImageVersion", + "lambda:ListMicrovmImageVersions", + "lambda:GetMicrovmImageBuild", + "lambda:ListMicrovmImageBuilds", + "lambda:ListManagedMicrovmImages", + "lambda:ListManagedMicrovmImageVersions", + "lambda:CreateNetworkConnector", + "lambda:GetNetworkConnector", + "lambda:UpdateNetworkConnector", + "lambda:DeleteNetworkConnector", + "lambda:ListNetworkConnectors", + "lambda:PassNetworkConnector" + ], + "Effect": "Allow", + "Resource": "*", + "Sid": "LambdaMicrovms" + } + ], + "Version": "2012-10-17" +} +``` + ## Runtime IAM roles (created by the stack) These roles are created inside the CloudFormation stack at deploy time, not by the deployer. They are documented here for a complete picture of the IAM footprint. diff --git a/docs/src/content/docs/architecture/Deployment-roles.md b/docs/src/content/docs/architecture/Deployment-roles.md index a852a9b67..cdce53038 100644 --- a/docs/src/content/docs/architecture/Deployment-roles.md +++ b/docs/src/content/docs/architecture/Deployment-roles.md @@ -6,7 +6,7 @@ title: Deployment roles This document defines least-privilege IAM policies for the CloudFormation execution role used during `cdk deploy`. The default CDK bootstrap grants `AdministratorAccess` to this role; the policies below scope it to only what ABCA needs. -> **Origin**: These IAM policies were derived from a thorough review of the repository's CDK constructs, stacks, and handler code, then **validated against a live deployment** in `us-east-1` (create, update, task execution, and destroy). CloudTrail analysis identified 36 additional actions beyond the initial code review, and 7 deployment iterations refined the policies to their current form. The policies are split into five managed policies — three always-applied (Infrastructure, Application, Observability) plus two compute-variant policies (Compute-AgentCore, Compute-ECS) — to stay under the IAM 6,144-character limit and to support per-compute-backend bootstrap selection. The policy sources live in `cdk/src/bootstrap/policies/*.ts` and are compiled to `cdk/bootstrap/policies/*.json`. +> **Origin**: These IAM policies were derived from a thorough review of the repository's CDK constructs, stacks, and handler code, then **validated against a live deployment** in `us-east-1` (create, update, task execution, and destroy). CloudTrail analysis identified 36 additional actions beyond the initial code review, and 7 deployment iterations refined the policies to their current form. The policies are split into six managed policies — three always-applied (Infrastructure, Application, Observability) plus three compute-variant policies (Compute-AgentCore, Compute-ECS, Compute-LambdaMicrovms) — to stay under the IAM 6,144-character limit and to support per-compute-backend bootstrap selection. The policy sources live in `cdk/src/bootstrap/policies/*.ts` and are compiled to `cdk/bootstrap/policies/*.json`. ## How CDK deployment roles work @@ -21,7 +21,7 @@ The policy below is a **CloudFormation Execution Role** replacement. The other t ## Using these policies -The policies are split into five IAM managed policies (each under the 6,144-character limit): +The policies are split into six IAM managed policies (each under the 6,144-character limit): | Policy Name | Scope | When applied | |-------------|-------|--------------| @@ -30,10 +30,11 @@ The policies are split into five IAM managed policies (each under the 6,144-char | `IaCRole-ABCA-Observability` | Bedrock Guardrails, CloudWatch, X-Ray, S3, ECR, KMS, SSM, STS | Always | | `IaCRole-ABCA-Compute-Agentcore` | Bedrock AgentCore (`bedrock-agentcore:*`) | Always (default compute backend) | | `IaCRole-ABCA-Compute-ECS` | ECS cluster + task-definition operations | Only when `ecs` is in `ComputeTypes` | +| `IaCRole-ABCA-Compute-LambdaMicrovms` | Lambda MicroVM image + network-connector operations | Only when `lambda-microvm` is in `ComputeTypes` | > **Placeholder substitution**: Replace `ACCOUNT_ID` with your 12-digit AWS account ID and `REGION` with your deployment region (e.g., `us-east-1`) throughout this document. -These policies are not created or attached manually. The repository generates them — and a custom bootstrap template that wires all five into the CloudFormation execution role — from the TypeScript sources, then bootstraps with that template: +These policies are not created or attached manually. The repository generates them — and a custom bootstrap template that wires all six into the CloudFormation execution role — from the TypeScript sources, then bootstraps with that template: ```bash # Regenerate artifacts (policies JSON + template YAML) and bootstrap. @@ -51,7 +52,7 @@ aws cloudformation update-stack --stack-name CDKToolkit --use-previous-template aws cloudformation describe-stacks --stack-name CDKToolkit --query 'Stacks[0].Parameters' ``` -Under the hood, `mise //cdk:bootstrap` runs `npx cdk bootstrap --template bootstrap/bootstrap-template.yaml` (see `cdk/mise.toml`). The generated template defines five inline `AWS::IAM::ManagedPolicy` resources that **replace** the default `AdministratorAccess` on the CloudFormation execution role; the `IaCRole-ABCA-Compute-ECS` policy is conditional on the `ComputeTypes` parameter including `ecs`. The policy sources are `cdk/src/bootstrap/policies/{infrastructure,application,observability,compute-agentcore,compute-ecs}.ts`, compiled to `cdk/bootstrap/policies/*.json` by `cdk/scripts/generate-bootstrap-artifacts.ts`. +Under the hood, `mise //cdk:bootstrap` runs `npx cdk bootstrap --template bootstrap/bootstrap-template.yaml` (see `cdk/mise.toml`). The generated template defines six inline `AWS::IAM::ManagedPolicy` resources that **replace** the default `AdministratorAccess` on the CloudFormation execution role; the `IaCRole-ABCA-Compute-ECS` and `IaCRole-ABCA-Compute-LambdaMicrovms` policies are conditional on the `ComputeTypes` parameter including their respective backend. The policy sources are `cdk/src/bootstrap/policies/{infrastructure,application,observability,compute-agentcore,compute-ecs,compute-lambda-microvm}.ts`, compiled to `cdk/bootstrap/policies/*.json` by `cdk/scripts/generate-bootstrap-artifacts.ts`. ## Trust policy @@ -86,7 +87,7 @@ Under the hood, `mise //cdk:bootstrap` runs `npx cdk bootstrap --template bootst For deploying the `backgroundagent-dev` stack. This single stack contains all platform resources including the AgentCore runtime, ECS compute (when enabled), API Gateway, Cognito, DynamoDB tables, VPC, DNS Firewall, and observability infrastructure. -> **IAM managed policy size limit**: A single managed policy cannot exceed 6,144 characters. The permissions below are split into five policies to stay under this limit (three always-applied, plus two compute-variant policies). They are wired into the CloudFormation execution role by the generated bootstrap template; see [Using these policies](#using-these-policies). +> **IAM managed policy size limit**: A single managed policy cannot exceed 6,144 characters. The permissions below are split into six policies to stay under this limit (three always-applied, plus three compute-variant policies). They are wired into the CloudFormation execution role by the generated bootstrap template; see [Using these policies](#using-these-policies). ### IaCRole-ABCA-Infrastructure @@ -742,6 +743,44 @@ When the ECS Fargate compute backend is enabled (set the `ComputeTypes` CFN para } ``` +### IaCRole-ABCA-Compute-LambdaMicrovms + +When the Lambda MicroVM compute backend is enabled (include `lambda-microvm` in the `ComputeTypes` CFN parameter on the `CDKToolkit` stack), the generated template conditionally attaches this policy to the CloudFormation execution role. It permits CloudFormation to manage MicroVM images and network connectors; runtime session lifecycle permissions remain on the orchestrator role. + +```json +{ + "Statement": [ + { + "Action": [ + "lambda:CreateMicrovmImage", + "lambda:GetMicrovmImage", + "lambda:UpdateMicrovmImage", + "lambda:DeleteMicrovmImage", + "lambda:ListMicrovmImages", + "lambda:GetMicrovmImageVersion", + "lambda:UpdateMicrovmImageVersion", + "lambda:DeleteMicrovmImageVersion", + "lambda:ListMicrovmImageVersions", + "lambda:GetMicrovmImageBuild", + "lambda:ListMicrovmImageBuilds", + "lambda:ListManagedMicrovmImages", + "lambda:ListManagedMicrovmImageVersions", + "lambda:CreateNetworkConnector", + "lambda:GetNetworkConnector", + "lambda:UpdateNetworkConnector", + "lambda:DeleteNetworkConnector", + "lambda:ListNetworkConnectors", + "lambda:PassNetworkConnector" + ], + "Effect": "Allow", + "Resource": "*", + "Sid": "LambdaMicrovms" + } + ], + "Version": "2012-10-17" +} +``` + ## Runtime IAM roles (created by the stack) These roles are created inside the CloudFormation stack at deploy time, not by the deployer. They are documented here for a complete picture of the IAM footprint. diff --git a/docs/src/content/docs/decisions/Adr-021-lambda-microvms-compute-backend.md b/docs/src/content/docs/decisions/Adr-021-lambda-microvms-compute-backend.md index d94584952..98999eda0 100644 --- a/docs/src/content/docs/decisions/Adr-021-lambda-microvms-compute-backend.md +++ b/docs/src/content/docs/decisions/Adr-021-lambda-microvms-compute-backend.md @@ -51,11 +51,27 @@ Adopt **AWS Lambda MicroVMs as a third, opt-in `ComputeStrategy` backend** named ### 1. Strategy shape: extend the interface with mandatory suspend/resume -`ComputeType` widens to `'agentcore' | 'ecs' | 'lambda-microvm'` (mirrored in `cli/src/types.ts` and the CLI's inline unions). `SessionHandle` gains a `{ strategyType: 'lambda-microvm', microvmId, endpoint }` variant — `microvmId` because every lifecycle API (`suspend-microvm`, `resume-microvm`, `terminate-microvm`, and `create-microvm-auth-token` — the latter not called in P1–P3, see sub-decision 3) takes only the MicroVM identifier, and `endpoint` because it is per-session (minted by `RunMicrovm`) and required for any orchestrator→agent HTTP interaction. The image ARN is deliberately **not** in the handle: like the ECS task definition ARN, it is deployment-time configuration consumed by `startSession` (from construct-injected environment) and recorded in the session-start log entry for diagnostics, not per-session lifecycle state. +`ComputeType` widens to `'agentcore' | 'ecs' | 'lambda-microvm'` (mirrored in `cli/src/types.ts` and the CLI's inline unions). `SessionHandle` gains a `{ strategyType: 'lambda-microvm', microvmId, endpoint }` variant — `microvmId` because every lifecycle API (`suspend-microvm`, `resume-microvm`, `terminate-microvm`, `get-microvm`, and `create-microvm-auth-token` — the latter not called in P1–P3, see sub-decision 3) takes only the MicroVM identifier, and `endpoint` because it is per-session (minted by `RunMicrovm`) and required for any orchestrator→agent HTTP interaction. Note the naming seam: the handle field is `microvmId` (matching `RunMicrovmResponse.microvmId`), while the request key on every lifecycle command is `microvmIdentifier` — the strategy is the only place that translates between the two. The image ARN is deliberately **not** in the handle: like the ECS task definition ARN, it is deployment-time configuration consumed by `startSession` (from construct-injected environment) and recorded in the session-start log entry for diagnostics, not per-session lifecycle state. The `ComputeStrategy` interface gains **mandatory** `suspendSession(handle)` / `resumeSession(handle)` methods returning a typed result (`{ supported: false } | { supported: true }`-shaped, exact type at implementation time); the widening lands in P3 (see sub-decision 5), in one commit across all three strategies. Mandatory-with-explicit-stub is the codebase idiom, not optional methods: no behavioral interface in the codebase has an optional method, `AgentCoreComputeStrategy.pollSession` is already a mandatory explicit stub rather than an optional member, and the exhaustive-`never` switch culture means a fourth backend must make a compile-checked decision about its suspend semantics instead of silently falling through a `strategy.suspendSession?.()` feature-detection. The agentcore and ecs strategies return `unsupported` (not a silent success — a suspend that silently no-ops would let the orchestrator believe compute billing stopped when it did not); the orchestrator gates its suspend policy on the typed response, consistent with how `pollTaskStatus` already branches explicitly on `computeType`. -**Poll semantics — the strategy reports, the orchestrator interprets.** `pollSession(handle)` receives only the session handle and cannot see task state, so the health rules must live where the DynamoDB status lives. `SessionStatus` gains a `'suspended'` variant; the strategy maps `GetMicrovm` state mechanically (`RUNNING` → `running`, `SUSPENDED` → `suspended`, `TERMINATED` → `completed`) and the **orchestrator** cross-references against the task row — the same division of labor `finalPollState` already uses for ECS (substrate stopped + non-terminal DynamoDB status → failed) and `pollTaskStatus` uses for agentcore heartbeats: substrate `suspended` + task `AWAITING_APPROVAL` is healthy (orchestrator-intended suspend); `suspended` with any other task status is an anomaly to surface, not fail-fast; substrate terminal + non-terminal task status → classify failed. +**Poll semantics — the strategy reports, the orchestrator interprets.** `pollSession(handle)` receives only the session handle and cannot see task state, so the health rules must live where the DynamoDB status lives. `SessionStatus` gains a `'suspended'` variant; the strategy maps `GetMicrovm` state mechanically and the **orchestrator** cross-references against the task row — the same division of labor `finalPollState` already uses for ECS (substrate stopped + non-terminal DynamoDB status → failed) and `pollTaskStatus` uses for agentcore heartbeats: substrate `suspended` + task `AWAITING_APPROVAL` is healthy (orchestrator-intended suspend); `suspended` with any other task status is an anomaly to surface, not fail-fast; substrate terminal + non-terminal task status → classify failed. + +The service's `MicrovmState` enum has **six** members, not three, so the mapping is stated exhaustively (one line of rationale each, mirrored in the strategy's doc comment): + +| `MicrovmState` | `SessionStatus` | Why | +|---|---|---| +| `PENDING` | `running` | Still booting; the same way ECS's `PENDING`/`PROVISIONING` map to `running`. | +| `RUNNING` | `running` | — | +| `SUSPENDING` | `suspended` | Already on its way to frozen; reporting `running` would tell the orchestrator compute is still progressing when it is not. Both suspend states land on a report the orchestrator treats as benign-or-anomalous depending on task status, never as failure. | +| `SUSPENDED` | `suspended` | — | +| `TERMINATING` | `completed` | Terminal-bound and carries no exit code, so "the substrate is gone" is all the strategy can honestly say. | +| `TERMINATED` | `completed` | Success vs failure is the orchestrator's call — it cross-references the DynamoDB status. | +| *unrecognized* | `running` | A future service enum addition must never fail a healthy task; the strategy warns and keeps polling. | + +**`GetMicrovm` `ResourceNotFoundException` → `completed`.** This deliberately diverges from `ecs-strategy`, where `DescribeTasks` returning no task maps to `failed`. ECS keeps stopped tasks describable for roughly an hour, so a missing task there really is anomalous; a terminated MicroVM is reaped from the control plane **by design**, so `failed` would fail every task that finished cleanly. The divergence is safe because it does not weaken detection: the orchestrator still fails the task when a terminal report lands while the DynamoDB status is non-terminal, so a genuine mid-run disappearance is caught — it simply receives the substrate-failure classification instead of a misleading poll error. Because that cross-check acts on a status read earlier in the same poll cycle, the orchestrator **re-reads the task row before failing** (the normal shutdown order is "agent writes terminal status → agent exits → VM terminates", which a stale read would otherwise turn into a spurious failure); ECS buys the same protection with a five-consecutive-poll patience counter instead. + +Neither the mapping nor the NotFound rule is a health decision: both are mechanical restatements of substrate state, which is what keeps the "strategy reports, orchestrator interprets" split intact. Normative requirements (EARS, per [ADR-020](/sample-autonomous-cloud-coding-agents/architecture/adr-020-ears-requirements-syntax)): @@ -64,8 +80,10 @@ Normative requirements (EARS, per [ADR-020](/sample-autonomous-cloud-coding-agen - When `startSession` returns, the orchestrator shall persist the MicroVM handle (`microvmId`, `endpoint`) in the task row's `compute_metadata` (the field `cancel-task.ts` already reads ECS handles from). - The strategy shall omit `idlePolicy` on every `RunMicrovm` call, in every phase. - The orchestrator shall be the sole initiator of suspension, via `suspendSession`. -- When `pollSession` observes MicroVM state `SUSPENDED`, the strategy shall report `suspended` without interpreting task state. -- If the strategy reports a terminal substrate state while the task's DynamoDB status is non-terminal, then the orchestrator shall classify the task as failed with a substrate-failure remedy. +- When `pollSession` observes MicroVM state `SUSPENDED` or `SUSPENDING`, the strategy shall report `suspended` without interpreting task state. +- When `pollSession` observes a MicroVM state it does not recognize, the strategy shall report `running`. +- If `GetMicrovm` reports that the MicroVM does not exist, then the strategy shall report `completed`. +- If the strategy reports a terminal substrate state while the task's DynamoDB status is non-terminal, then the orchestrator shall re-read the task row and, if it is still non-terminal, classify the task as failed with a substrate-failure remedy. - If the strategy reports `suspended` while the task's DynamoDB status is not `AWAITING_APPROVAL`, then the orchestrator shall surface an anomaly event and shall not fail-fast the task. - If `suspendSession` or `resumeSession` is invoked on a strategy that does not support suspension, then the strategy shall return an explicit unsupported result. - When the agent process reaches a terminal state, the agent shall exit. @@ -101,18 +119,32 @@ Normative requirements (EARS): ### 3. Packaging: same agent image source, new build path -The existing agent container (`agent/` Dockerfile, already ARM64) is repackaged as a zip + Dockerfile artifact in S3 and built into a versioned `MicrovmImage` via `CreateMicrovmImage` (with `/ready` and `/validate` build hooks for snapshot quality). The agent runs its existing FastAPI server (`agent/src/server.py`) — the MicroVM path uses the HTTP entrypoint like AgentCore, not ECS's batch bypass — plus the four runtime lifecycle hooks on a sidecar port. Runtime hooks are fast-notification only (1–60 s): `/run` validates the payload and starts the pipeline **asynchronously**, mirroring how the agent loop already runs in a background thread behind `/ping` on AgentCore. +The existing agent container (`agent/` Dockerfile, already ARM64) is repackaged as a zip + Dockerfile artifact in S3 and built into a versioned `MicrovmImage` via `CreateMicrovmImage`. The agent runs its existing FastAPI server (`agent/src/server.py`) — the MicroVM path uses the HTTP entrypoint like AgentCore, not ECS's batch bypass — plus the four runtime lifecycle hooks (`/run`, `/suspend`, `/resume`, `/terminate`) on a sidecar port, and the `/ready` + `/validate` build hooks for snapshot quality. Runtime hooks are fast-notification only (1–60 s): `/run` validates the payload and starts the pipeline **asynchronously**, mirroring how the agent loop already runs in a background thread behind `/ping` on AgentCore. + +**Hook phasing — P1 provisions the packaging plane, P2 makes it runnable.** Serving the hooks is agent-side work, so the hooks land in a different phase from the infrastructure that declares them: + +| Hook | Declared by | Served by the agent | Notes | +|---|---|---|---| +| `/run` | P1 (construct sets `hooks.microvmHooks.run`) | **P2** | The payload-delivery channel; declared in P1 so the image shape and IAM are reviewed once, but nothing answers it until P2. | +| `/ready`, `/validate` | **P2** | **P2** | Build-time snapshot-quality hooks. Deliberately NOT declared in P1: a `/validate` endpoint that 404s fails every image build. | +| `/suspend`, `/resume`, `/terminate` | **P3** (suspend/resume), P2 (`/terminate`) | P3 / P2 | Declaring a runtime hook the agent does not serve fails the corresponding lifecycle transition, so each is declared only in the phase that implements it. P1 termination is the orchestrator's `TerminateMicrovm`, which needs no in-guest cooperation. | + +Consequence to state plainly: **a P1-built MicroVM image is not runnable end to end.** P1 delivers the strategy, the construct, the roles/buckets/connector, the image resource, and the packaging script — an image built from a P1 deployment boots the existing FastAPI server but does not answer `/run`, so a `lambda-microvm` task started against it will not progress. P2 ("smoke parity") is the phase that makes clone → change → PR work on this backend. The construct and the packaging script both surface this at synth/run time so an operator cannot mistake a provisioned substrate for a working one. **Payload delivery** reuses the ECS strategy's S3-pointer pattern, adapted to `runHookPayload` (≤ 16 KB): small payloads inline; larger ones uploaded by the strategy to a platform payload bucket (the ECS payload bucket pattern in `ecs-agent-cluster.ts`: orchestrator write access, compute-role read-only scoped to the bucket, lifecycle expiry on objects) with only the S3 URI in `runHookPayload` — the MicroVM **execution role** holds the read grant, exactly as the ECS task role does today. **No orchestrator→agent HTTP path exists in P1–P3**: payload arrives through the `/run` hook, all agent work is outbound, and therefore **no JWE auth tokens are minted at all** — token minting (and its ≤ 60 min TTL refresh problem) is deferred until a real consumer exists (e.g. operator shell access, [#391](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/391)). The `endpoint` stays in the `SessionHandle` because it is genuinely per-session state that becomes load-bearing the day such a consumer appears. **Constraint accepted:** 32 GB RAM / 32 GB disk (disk quota to be confirmed against current service quotas when COMPUTE.md is updated) means repos that motivated the 120 GB ECS sizing stay on `ecs`; MicroVMs target the default-sized workload with suspend economics, not the heavy-build niche. -- The image build shall not embed secrets, tokens, or per-task identity in the snapshot. -- The agent shall resolve credentials at `/run` time. -- When the `/run` hook receives the task payload, the agent shall validate it, start the pipeline asynchronously, and return HTTP 200 within the hook budget. -- The agent shall not execute the clone→verify→PR pipeline on the hook path. -- If the task payload exceeds the 16 KB `runHookPayload` limit, then the strategy shall upload the payload to the platform payload bucket and pass only its S3 URI in `runHookPayload`. -- The MicroVM execution role shall hold read-only access to the payload bucket, scoped to that bucket. +Normative requirements (EARS). The requirements that constrain what the *strategy* and the *image build* do are P1; the ones that require the *agent* to serve a hook are P2, per the phasing table above: + +- (P1) The image build shall not embed secrets, tokens, or per-task identity in the snapshot. +- (P1) If the task payload exceeds the 16 KB `runHookPayload` limit, then the strategy shall upload the payload to the platform payload bucket and pass only its S3 URI in `runHookPayload`. +- (P1) The MicroVM execution role shall hold read-only access to the payload bucket, scoped to that bucket. +- (P1) Where a deployment configures a MicroVM image before the agent serves the runtime hooks, the platform shall warn that the image is not yet runnable end to end. +- (P2) The agent shall resolve credentials at `/run` time. +- (P2) When the `/run` hook receives the task payload, the agent shall validate it, start the pipeline asynchronously, and return HTTP 200 within the hook budget. +- (P2) The agent shall not execute the clone→verify→PR pipeline on the hook path. +- (P2) Where the image declares the `/ready` and `/validate` build hooks, the agent shall serve them. ### 4. Infra and IAM: conditional resources behind bootstrap `ComputeTypes` @@ -158,8 +190,8 @@ Lambda MicroVMs launched in 5 regions (us-east-1/2, us-west-2, eu-west-1, ap-nor ### 5. Rollout: phased, default unchanged -- **P1 — strategy + infra:** `LambdaMicrovmComputeStrategy` (start/poll/stop), CDK construct, bootstrap policy, types sync, unit + CDK assertion tests. No suspend yet. -- **P2 — smoke parity:** agent completes clone → change → PR on the backend with progress visible to `bgagent watch`; failure classification entries in `error-classifier.ts`; **AgentCore Memory parity** (IAM grant + `MEMORY_ID` delivery, following the `EcsAgentCluster` pattern — Memory is a standalone service already consumed cross-substrate, and omitting the grant silently no-ops cross-session learning). +- **P1 — strategy + infra:** `LambdaMicrovmComputeStrategy` (start/poll/stop), CDK construct, bootstrap policy, types sync, unit + CDK assertion tests. No suspend yet, and **no runnable image yet** — the construct declares the `/run` hook but the agent does not serve it until P2 (sub-decision 3's phasing table). +- **P2 — smoke parity:** the agent serves the runtime and build hooks (`/run`, `/terminate`, `/ready`, `/validate`), making a MicroVM image runnable end to end; agent completes clone → change → PR on the backend with progress visible to `bgagent watch`; failure classification entries in `error-classifier.ts`; **AgentCore Memory parity** (IAM grant + `MEMORY_ID` delivery, following the `EcsAgentCluster` pattern — Memory is a standalone service already consumed cross-substrate, and omitting the grant silently no-ops cross-session learning). - **P3 — suspend/resume:** the interface widening from sub-decision 1 (mandatory methods, all three strategies in one commit), HITL-wait suspend policy, inline resume in the approve/deny Lambda with orchestrator-poll reconciliation (sub-decision 2), timeout-under-freeze wall-clock handling; coordinate with [#491](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/491)'s unified liveness model and update Cedar decision #7's rationale note. - **Out of scope:** replacing AgentCore as default; classic Lambda functions as a runtime; GPU; the Runtime-coupled workload-access-token injection path (delivery mechanism exists only on AgentCore Runtime; MicroVMs adopt the ECS env-var posture until [#249](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/249)/ADR-016 redesign the seam). Gateway integration is orthogonal: ADR-019/[#641](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/641) is substrate-portable by design and applies to this backend when it lands. diff --git a/yarn.lock b/yarn.lock index f87bb4e67..f63469ceb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -492,6 +492,20 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/client-lambda-microvms@^3.1098.0": + version "3.1098.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-lambda-microvms/-/client-lambda-microvms-3.1098.0.tgz#ec8e9e6503a062c5f55436d78dff1e6fbea96f6d" + integrity sha512-V/6Kio2wjE5JmY4cb3gSdTxOTxW+3FZ9WL55CjIHLqYSZbSTxmdgOcw0i3yTDIgNOsCJ7IR55BwP4g4pEjwikA== + dependencies: + "@aws-sdk/core" "^3.977.3" + "@aws-sdk/credential-provider-node" "^3.972.75" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.31.1" + "@smithy/fetch-http-handler" "^5.6.13" + "@smithy/node-http-handler" "^4.9.13" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/client-lambda@^3.1014.0", "@aws-sdk/client-lambda@^3.1078.0": version "3.1081.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-lambda/-/client-lambda-3.1081.0.tgz#eed0c8ac8bb51fc1fad355cfdf33c62e9a4db2af" @@ -565,6 +579,20 @@ bowser "^2.11.0" tslib "^2.6.2" +"@aws-sdk/core@^3.977.3": + version "3.977.3" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.977.3.tgz#7e64190cdd146611d88201c53d68179309cbc273" + integrity sha512-6YZTpF5Zzl0KdSrztM0l6ErKdnXrnnodIDYfayHE9SFfZU8Ubxls7H2XnfWT121jgwiG+WP1z5kyfVDsH5V4qA== + dependencies: + "@aws-sdk/types" "^3.974.2" + "@aws-sdk/xml-builder" "^3.972.37" + "@aws/lambda-invoke-store" "^0.3.0" + "@smithy/core" "^3.31.1" + "@smithy/signature-v4" "^5.6.12" + "@smithy/types" "^4.16.1" + bowser "^2.11.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-env@^3.972.55": version "3.972.55" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.55.tgz#1129acf8860db362a30a02a531387fd399448268" @@ -576,6 +604,17 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-env@^3.972.64": + version "3.972.64" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.64.tgz#abdaeac8e25acb0bfd9162b5f136d16948aa7318" + integrity sha512-14kkR5aj1c7D+cYCrEcG2W3xw87wMYAEUeB/tMiQ2j0RVKzzdewd4mJw1+Q0JVLr4IFU1JHGDCyj0WqLrtIixg== + dependencies: + "@aws-sdk/core" "^3.977.3" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-http@^3.972.57": version "3.972.57" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.57.tgz#4f73bb2f0e03525ab11e001ac18c39cb120dd14c" @@ -589,6 +628,19 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-http@^3.972.66": + version "3.972.66" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.66.tgz#a9e06af4ee3af44025dd11163066eddb28a91c3d" + integrity sha512-BrZxoXScBZ+nTOYy388zHkz1n5cS8C+uGFMozD4w9OKEWMq39Cu/9l/k385Hb54dtv0VIeh12f8h67o3xDNH9g== + dependencies: + "@aws-sdk/core" "^3.977.3" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.31.1" + "@smithy/fetch-http-handler" "^5.6.13" + "@smithy/node-http-handler" "^4.9.13" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-ini@^3.972.62": version "3.972.62" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.62.tgz#a2ca7fc7a1899c0a4a38e501baa666cf1449f2e9" @@ -608,6 +660,25 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-ini@^3.973.9": + version "3.973.9" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.9.tgz#0eeb3f4c3859ae1abbdc709172108be1b9ede2b3" + integrity sha512-d/mMvS+sQjSWNA6+JCldK1YB/jQmtoWM8Izj10mrOZdRCeWNVb8IYQuuyHHKKKXcpGo5Pd0LIK6onHbLvlhuVQ== + dependencies: + "@aws-sdk/core" "^3.977.3" + "@aws-sdk/credential-provider-env" "^3.972.64" + "@aws-sdk/credential-provider-http" "^3.972.66" + "@aws-sdk/credential-provider-login" "^3.972.71" + "@aws-sdk/credential-provider-process" "^3.972.64" + "@aws-sdk/credential-provider-sso" "^3.973.8" + "@aws-sdk/credential-provider-web-identity" "^3.972.70" + "@aws-sdk/nested-clients" "^3.997.38" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.31.1" + "@smithy/credential-provider-imds" "^4.4.16" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-login@^3.972.61": version "3.972.61" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.61.tgz#f5ba696bae9af3505ae5f3e2a70d7e216d0d37f6" @@ -620,6 +691,18 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-login@^3.972.71": + version "3.972.71" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.71.tgz#7d7b56a40a6ab1db22a842872f2f0824d1087128" + integrity sha512-l93922lXnTs5RjM3QrMCXSmXiEgLFLaQ0Lco9F3tJ08o0mzGdAj6m2nAXDuTMHoUuL0u7RKmrhm+Z8/7GCilyg== + dependencies: + "@aws-sdk/core" "^3.977.3" + "@aws-sdk/nested-clients" "^3.997.38" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-node@^3.972.61", "@aws-sdk/credential-provider-node@^3.972.64": version "3.972.64" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.64.tgz#282456c24bad616faefe2a6c68701913f8289c10" @@ -637,6 +720,23 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-node@^3.972.75": + version "3.972.75" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.75.tgz#e4f7495bd8e3146d60f7346f9e1634060ba112e2" + integrity sha512-sdFhR4E3HWZefGL6OsBhxqXdqtFvxGOh8VLfiu9yttPgVNcpEozEDPmrTYmXA3CIf6npUETVLveQPGQ7GBzVhg== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.64" + "@aws-sdk/credential-provider-http" "^3.972.66" + "@aws-sdk/credential-provider-ini" "^3.973.9" + "@aws-sdk/credential-provider-process" "^3.972.64" + "@aws-sdk/credential-provider-sso" "^3.973.8" + "@aws-sdk/credential-provider-web-identity" "^3.972.70" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.31.1" + "@smithy/credential-provider-imds" "^4.4.16" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-process@^3.972.55": version "3.972.55" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.55.tgz#bebd30d0065ca34f64e14a870f54a09f23cf11e2" @@ -648,6 +748,17 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-process@^3.972.64": + version "3.972.64" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.64.tgz#c594eb5586fd14e0faa7b48dd015d85ef4d6f8dc" + integrity sha512-Q5noG5vfFo++bUaLGcjj8OHX5tmwa1O/5nDVMeaSSZgjzgvWbW6tgu/gSa+aENwcJXjoXDQh+8TRZR2zHds/aw== + dependencies: + "@aws-sdk/core" "^3.977.3" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-sso@^3.972.61": version "3.972.61" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.61.tgz#8c15846c65d2f03bfca3347626e1fcd1a0f40726" @@ -661,6 +772,19 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-sso@^3.973.8": + version "3.973.8" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.8.tgz#7e21e6dc876ea63f8546f5b49c9019e494e3d8c1" + integrity sha512-37okeZyRdVkGrU6nyKBPGqQvyl/7CEiMpUVOb0+BCNCIR0VAuBxNgjMb0mnsR1EXm8dqQFfQZOJ5YW/0sBfa1w== + dependencies: + "@aws-sdk/core" "^3.977.3" + "@aws-sdk/nested-clients" "^3.997.38" + "@aws-sdk/token-providers" "3.1098.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-web-identity@^3.972.61": version "3.972.61" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.61.tgz#1938cc425e6156acd673484f10b437c5c4c85158" @@ -673,6 +797,18 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-web-identity@^3.972.70": + version "3.972.70" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.70.tgz#21c4496c2c442bf68a1927bf1313084a3ab0fbc2" + integrity sha512-Ps/f9USafScb6CSQTRPNMzdKL5x5XRwDIRgFvnuFWfClGZe7/fI7iCFqXdxKjc9LBxP28E7iUgNloGgfgw406w== + dependencies: + "@aws-sdk/core" "^3.977.3" + "@aws-sdk/nested-clients" "^3.997.38" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/dynamodb-codec@^3.973.26", "@aws-sdk/dynamodb-codec@^3.973.29": version "3.973.29" resolved "https://registry.yarnpkg.com/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.29.tgz#ea72996b2e1f2c687254c69c357fa4278e42f1da" @@ -783,6 +919,20 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/nested-clients@^3.997.38": + version "3.997.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.38.tgz#eacb358e6203bd531e50387bfaa49f344377f4bc" + integrity sha512-jQDs+nxnmgo4+WPOmY373HIC8jx4KyYYMYDI74FWrzX3Ba9fYsV0dSA+7PNYNkhRbbRCRLre83tQ31F1ACCHkA== + dependencies: + "@aws-sdk/core" "^3.977.3" + "@aws-sdk/signature-v4-multi-region" "^3.996.43" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.31.1" + "@smithy/fetch-http-handler" "^5.6.13" + "@smithy/node-http-handler" "^4.9.13" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/s3-presigned-post@^3.1078.0": version "3.1081.0" resolved "https://registry.yarnpkg.com/@aws-sdk/s3-presigned-post/-/s3-presigned-post-3.1081.0.tgz#ed27961af8e772e23745e7ff34f34083bc69c291" @@ -818,6 +968,16 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/signature-v4-multi-region@^3.996.43": + version "3.996.43" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz#f23113e6481741a110bb233a43d48aa40556d62b" + integrity sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA== + dependencies: + "@aws-sdk/types" "^3.974.2" + "@smithy/signature-v4" "^5.6.12" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/token-providers@3.1078.0": version "3.1078.0" resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1078.0.tgz#554be2f2c42f21191f31aead718bcedf2047926d" @@ -842,6 +1002,18 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/token-providers@3.1098.0": + version "3.1098.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1098.0.tgz#d3c625084b5fec004c526bc645b802557344bb1d" + integrity sha512-6cFAviffeqYGjrXn4FqGbWcTxB3nbpBpQXUm6MvnrWZThJaVBTHHnespHgSUh4yVLfzhipSh9H/XjzkHQd9P4g== + dependencies: + "@aws-sdk/core" "^3.977.3" + "@aws-sdk/nested-clients" "^3.997.38" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.15": version "3.973.15" resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.15.tgz#98a4860bed33c32c7088924d0ab52f9eabbdf7c3" @@ -850,6 +1022,14 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/types@^3.974.2": + version "3.974.2" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.974.2.tgz#05e7ccac417735e0786d430d2d5cc139047a08da" + integrity sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/util-dynamodb@^3.996.5": version "3.996.5" resolved "https://registry.yarnpkg.com/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.5.tgz#2ad647e1532c20c76570a878e49f058a8d21e012" @@ -865,6 +1045,14 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/xml-builder@^3.972.37": + version "3.972.37" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz#669363721702d46a500c6ea485a44591e511f280" + integrity sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws/durable-execution-sdk-js@^2.0.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@aws/durable-execution-sdk-js/-/durable-execution-sdk-js-2.1.0.tgz#a020f8f3eae0fd3b577ad55397e454241a9e3029" @@ -2744,6 +2932,23 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@smithy/core@^3.31.1": + version "3.31.1" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.31.1.tgz#a57090db8997917d2f99eeb39db50b6283e60d3c" + integrity sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + +"@smithy/credential-provider-imds@^4.4.16": + version "4.4.16" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz#5197900c258a7c0b6fd2b9dbc11d5410f296d7c5" + integrity sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w== + dependencies: + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/credential-provider-imds@^4.4.5": version "4.4.6" resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.6.tgz#048ad961282016fc2d46d457f43beac8ca8f0e24" @@ -2753,6 +2958,15 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@smithy/fetch-http-handler@^5.6.13": + version "5.6.13" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz#688830a2d128aa95db5233d4ab827222a0bf4baa" + integrity sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A== + dependencies: + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/fetch-http-handler@^5.6.2": version "5.6.3" resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.3.tgz#7b4aec28f89bd1e3aa00dfae0d0bd30079c58341" @@ -2769,6 +2983,15 @@ dependencies: tslib "^2.6.2" +"@smithy/node-http-handler@^4.9.13": + version "4.9.13" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz#6c0811df72deb4890d07bf81a9a8bcdf782ba824" + integrity sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw== + dependencies: + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/node-http-handler@^4.9.2": version "4.9.3" resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.9.3.tgz#f464b7f4ac242e8d6642dd8a602078bc88b0c53d" @@ -2795,6 +3018,15 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@smithy/signature-v4@^5.6.12": + version "5.6.12" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.6.12.tgz#ee7a37e299de30e4d5c299b25485141b5b5d31c9" + integrity sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ== + dependencies: + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/types@^4.15.1": version "4.15.1" resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.15.1.tgz#cdad0fdf4c5d1f5788dc21a3f1fb6af523d79da0" @@ -2802,6 +3034,13 @@ dependencies: tslib "^2.6.2" +"@smithy/types@^4.16.1": + version "4.16.1" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.16.1.tgz#19e199c234829a51c085caf63f0bb17bb80187e4" + integrity sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg== + dependencies: + tslib "^2.6.2" + "@smithy/util-buffer-from@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz#6fc88585165ec73f8681d426d96de5d402021e4b" @@ -3716,9 +3955,9 @@ bowser@^2.11.0: integrity sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg== brace-expansion@^1.1.7, brace-expansion@^2.0.2, brace-expansion@^5.0.5, brace-expansion@^5.0.8: - version "5.0.8" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.8.tgz#135ad0d8d808eb18eb5e0ec9a21f3a0b92ef18cf" - integrity sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg== + version "5.0.9" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf" + integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg== dependencies: balanced-match "^4.0.2" @@ -7285,9 +7524,9 @@ postcss-selector-parser@^6.1.1: util-deprecate "^1.0.2" postcss@^8.4.38, postcss@^8.5.16, postcss@^8.5.18: - version "8.5.23" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.23.tgz#3493550116f478487298301d2c2e8dc5a56e6594" - integrity sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg== + version "8.5.25" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.25.tgz#5012a598eaaa897f21bbe8553be3cb7bd2bd78cb" + integrity sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw== dependencies: nanoid "^3.3.16" picocolors "^1.1.1"