diff --git a/.gitignore b/.gitignore index a0b0be3a..260d9d1b 100644 --- a/.gitignore +++ b/.gitignore @@ -37,10 +37,12 @@ build/ ### Kiro ### .kiro/debug/ +semantic-review/ ### Other infrastructure/cdk/output* dependency-reduced-pom.xml +infra/scripts/ws-test/reports/ .env diff --git a/apps/java-spring-ai-agents/aiagent/pom.xml b/apps/java-spring-ai-agents/aiagent/pom.xml index 9041aa59..7f9cbe9a 100644 --- a/apps/java-spring-ai-agents/aiagent/pom.xml +++ b/apps/java-spring-ai-agents/aiagent/pom.xml @@ -28,7 +28,7 @@ 25 - 2.0.0 + 2.0.1 @@ -134,7 +134,7 @@ org.springaicommunity spring-ai-agentcore-bom - 1.0.0 + 2.1.0 pom import diff --git a/apps/java-spring-ai-agents/backoffice/tools/pom.xml b/apps/java-spring-ai-agents/backoffice/tools/pom.xml index 6021ac28..a7e87bf6 100644 --- a/apps/java-spring-ai-agents/backoffice/tools/pom.xml +++ b/apps/java-spring-ai-agents/backoffice/tools/pom.xml @@ -31,7 +31,7 @@ org.springframework.ai spring-ai-bom - 2.0.0 + 2.0.1 pom import diff --git a/apps/java-spring-ai-agents/currency/src/main/java/com/example/currency/CurrencyHandler.java b/apps/java-spring-ai-agents/currency/src/main/java/com/example/currency/CurrencyHandler.java index 21f117ee..d7d2471a 100644 --- a/apps/java-spring-ai-agents/currency/src/main/java/com/example/currency/CurrencyHandler.java +++ b/apps/java-spring-ai-agents/currency/src/main/java/com/example/currency/CurrencyHandler.java @@ -18,7 +18,7 @@ */ public class CurrencyHandler implements RequestHandler, Map> { - private static final String FRANKFURTER_API = "https://api.frankfurter.app"; + private static final String FRANKFURTER_API = "https://api.frankfurter.dev/v1"; private static final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); diff --git a/apps/java-spring-ai-agents/demo-scripts/01-create.sh b/apps/java-spring-ai-agents/demo-scripts/01-create.sh index a1249b8b..edfc0032 100755 --- a/apps/java-spring-ai-agents/demo-scripts/01-create.sh +++ b/apps/java-spring-ai-agents/demo-scripts/01-create.sh @@ -46,7 +46,7 @@ cd ~/environment/aiagent if ! grep -q "spring-ai-agentcore-bom" pom.xml; then sed -i '/spring-ai-bom<\/artifactId>/,/<\/dependency>/{ /<\/dependency>/a \ -\t\t\t\n\t\t\t\torg.springaicommunity\n\t\t\t\tspring-ai-agentcore-bom\n\t\t\t\t1.0.0\n\t\t\t\tpom\n\t\t\t\timport\n\t\t\t +\t\t\t\n\t\t\t\torg.springaicommunity\n\t\t\t\tspring-ai-agentcore-bom\n\t\t\t\t2.1.0\n\t\t\t\tpom\n\t\t\t\timport\n\t\t\t }' pom.xml fi diff --git a/apps/java-spring-ai-agents/demo-scripts/02-memory.sh b/apps/java-spring-ai-agents/demo-scripts/02-memory.sh index 010c2b9f..07055660 100755 --- a/apps/java-spring-ai-agents/demo-scripts/02-memory.sh +++ b/apps/java-spring-ai-agents/demo-scripts/02-memory.sh @@ -28,6 +28,17 @@ if ! grep -q "spring-ai-agentcore-memory" pom.xml; then }' pom.xml fi +# AgentCore Memory 2.1.0 and Spring AI 2.0.1 otherwise resolve +# incompatible AWS SDK modules (2.49.4 and 2.51.2 respectively). +# Use the same coherent AWS SDK version as the full deployed application. +if ! grep -A2 'software.amazon.awssdk' pom.xml \ + | grep -q 'bom'; then + sed -i '/spring-ai-agentcore-bom<\/artifactId>/,/<\/dependency>/{ + /<\/dependency>/a \ +\t\t\t\n\t\t\t\tsoftware.amazon.awssdk\n\t\t\t\tbom\n\t\t\t\t2.46.20\n\t\t\t\tpom\n\t\t\t\timport\n\t\t\t + }' pom.xml +fi + # --- Add memory properties --- if ! grep -q "agentcore.memory.memory-id" src/main/resources/application.properties; then diff --git a/apps/java-spring-ai-agents/demo-scripts/README.md b/apps/java-spring-ai-agents/demo-scripts/README.md index 53c4f2ed..7b3df646 100644 --- a/apps/java-spring-ai-agents/demo-scripts/README.md +++ b/apps/java-spring-ai-agents/demo-scripts/README.md @@ -73,7 +73,7 @@ Follow **STYLE.md** for all content formatting decisions. ## Technology Stack - Spring Boot 4.1.0, Java 25 -- Spring AI 2.0.0 +- Spring AI 2.0.1 - Amazon Bedrock (Claude Sonnet 4.6, Claude Opus 4.6, Nova 2 Lite) - Amazon Bedrock AgentCore (Runtime, Memory, Browser, Code Interpreter, Gateway) - Amazon Cognito (JWT authentication) diff --git a/apps/java-spring-ai-agents/scripts/06-mcp-gateway.sh b/apps/java-spring-ai-agents/scripts/06-mcp-gateway.sh index 6a04a8aa..f85951a1 100755 --- a/apps/java-spring-ai-agents/scripts/06-mcp-gateway.sh +++ b/apps/java-spring-ai-agents/scripts/06-mcp-gateway.sh @@ -294,9 +294,9 @@ fi if [ "${EXISTING_HOLIDAYS_TARGET}" = "None" ] || [ -z "${EXISTING_HOLIDAYS_TARGET}" ]; then echo "Creating holidays target" - OPENAPI_SPEC=$(curl -s "https://date.nager.at/openapi/v3.json" | jq -c ' + OPENAPI_SPEC=$(curl -fsSL "https://nagerholidays.com/openapi/community-v4.json" | jq -ce ' .openapi = "3.0.0" | - . + {servers: [{url: "https://date.nager.at"}]} | + . + {servers: [{url: "https://nagerholidays.com"}]} | .paths |= with_entries( .value |= with_entries( .value.operationId = (.value.tags[0] // "api") + "_" + (.key | ascii_upcase) + "_" + (.value.summary | gsub("[^a-zA-Z0-9]"; "_") | .[0:30]) @@ -308,6 +308,11 @@ if [ "${EXISTING_HOLIDAYS_TARGET}" = "None" ] || [ -z "${EXISTING_HOLIDAYS_TARGE else . end) ') + if ! jq -e '.openapi == "3.0.0" and (.paths | type == "object" and length > 0)' >/dev/null <<<"${OPENAPI_SPEC}"; then + echo "Failed to download a valid holidays OpenAPI schema" >&2 + exit 1 + fi + TARGET_CONFIG=$(jq -n --arg spec "${OPENAPI_SPEC}" \ '{mcp: {openApiSchema: {inlinePayload: $spec}}}') diff --git a/infra/cdk/src/main/java/sample/com/WorkshopStack.java b/infra/cdk/src/main/java/sample/com/WorkshopStack.java index 16d54557..9dfd285b 100644 --- a/infra/cdk/src/main/java/sample/com/WorkshopStack.java +++ b/infra/cdk/src/main/java/sample/com/WorkshopStack.java @@ -3,11 +3,15 @@ import software.amazon.awscdk.RemovalPolicy; import software.amazon.awscdk.Stack; import software.amazon.awscdk.StackProps; +import software.amazon.awscdk.Tags; import software.amazon.awscdk.services.ecr.Repository; +import software.amazon.awscdk.services.iam.Effect; import software.amazon.awscdk.services.iam.ManagedPolicy; +import software.amazon.awscdk.services.iam.PolicyStatement; import software.constructs.Construct; import sample.com.constructs.*; import sample.com.constructs.Ide.IdeProps; +import java.util.List; import java.util.Map; public class WorkshopStack extends Stack { @@ -46,6 +50,11 @@ public WorkshopStack(final Construct scope, final String id, final StackProps pr templateType = "base"; // default } + String workshopId = templateType; + Tags.of(this).add("WorkshopId", workshopId); + Tags.of(this).add("WorkshopDeploymentId", this.getStackId()); + Tags.of(this).add("WorkshopOwner", "cloudformation"); + // Configuration values - get current git branch from CDK context String gitBranch = (String) this.getNode().tryGetContext("git.branch"); if (gitBranch == null) { @@ -72,6 +81,7 @@ public WorkshopStack(final Construct scope, final String id, final StackProps pr .vpc(vpc.getVpc()) .gitBranch(gitBranch) .templateType(templateType) + .workshopId(workshopId) .ideArch((isAiAgents || isAiAgentsAdvanced) ? Ide.IdeArch.ARM64 : Ide.IdeArch.X86_64_AMD) .build(); Ide ide = new Ide(this, "Ide", ideProps); @@ -84,6 +94,17 @@ public WorkshopStack(final Construct scope, final String id, final StackProps pr .environmentVariables(Map.of( "TEMPLATE_TYPE", templateType, "GIT_BRANCH", gitBranch)) + .rolePolicyStatements(List.of(PolicyStatement.Builder.create() + .effect(Effect.ALLOW) + .actions(List.of("iam:CreateServiceLinkedRole")) + .resources(List.of("arn:aws:iam::*:role/aws-service-role/*")) + .conditions(Map.of("StringEquals", Map.of("iam:AWSServiceName", List.of( + "ecs.amazonaws.com", + "elasticloadbalancing.amazonaws.com", + "network.bedrock-agentcore.amazonaws.com", + "runtime-identity.bedrock-agentcore.amazonaws.com" + )))) + .build())) .buildSpec(buildSpec) .build()); @@ -94,9 +115,18 @@ public WorkshopStack(final Construct scope, final String id, final StackProps pr .build()); // ECR Registry settings (Repository Creation Template for create-on-push) + List ecrRepositoryNames = (isJavaOnAws || isEks) + ? List.of("ai-jvm-analyzer", "perf-analyzer", "perf-collector") + : isSpringAi + ? List.of("aiagent", "mcpserver") + : (isAiAgents || isAiAgentsAdvanced) + ? List.of("aiagent", "backoffice") + : List.of(); EcrRegistry ecrRegistry = new EcrRegistry(this, "EcrRegistry", EcrRegistry.EcrRegistryProps.builder() .prefix(prefix) + .workshopId(workshopId) + .repositoryNames(ecrRepositoryNames) .build()); // Bedrock logging role (for model invocation logging to CloudWatch) @@ -200,18 +230,101 @@ public WorkshopStack(final Construct scope, final String id, final StackProps pr .statements(java.util.List.of( software.amazon.awscdk.services.iam.PolicyStatement.Builder.create() .effect(software.amazon.awscdk.services.iam.Effect.ALLOW) - .actions(java.util.List.of("bedrock:*", "bedrock-agentcore:*")) + .actions(java.util.List.of( + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + )) + .resources(java.util.List.of( + "arn:aws:bedrock:*::foundation-model/*", + "arn:aws:bedrock:*:" + this.getAccount() + ":inference-profile/*" + )) + .build(), + software.amazon.awscdk.services.iam.PolicyStatement.Builder.create() + .effect(software.amazon.awscdk.services.iam.Effect.ALLOW) + .actions(java.util.List.of("bedrock:Retrieve", "bedrock:RetrieveAndGenerate")) + .resources(java.util.List.of("arn:aws:bedrock:*:" + this.getAccount() + ":knowledge-base/*")) + .build(), + software.amazon.awscdk.services.iam.PolicyStatement.Builder.create() + .effect(software.amazon.awscdk.services.iam.Effect.ALLOW) + .actions(java.util.List.of( + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:GetEvent", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:RetrieveMemoryRecords", + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "bedrock-agentcore:InvokeAgentRuntime", + "bedrock-agentcore:InvokeGateway" + )) + .resources(java.util.List.of("arn:aws:bedrock-agentcore:*:" + this.getAccount() + ":*")) + .build(), + software.amazon.awscdk.services.iam.PolicyStatement.Builder.create() + .effect(software.amazon.awscdk.services.iam.Effect.ALLOW) + .actions(java.util.List.of( + "bedrock-agentcore:ConnectBrowserAutomationStream", + "bedrock-agentcore:ConnectBrowserLiveViewStream" + )) .resources(java.util.List.of("*")) .build(), software.amazon.awscdk.services.iam.PolicyStatement.Builder.create() .effect(software.amazon.awscdk.services.iam.Effect.ALLOW) - .actions(java.util.List.of("ecr:*", "logs:*", "xray:*", "cloudwatch:*")) + .actions(java.util.List.of( + "bedrock-agentcore:GetBrowserSession", + "bedrock-agentcore:StartBrowserSession", + "bedrock-agentcore:StopBrowserSession", + "bedrock-agentcore:UpdateBrowserStream" + )) + .resources(java.util.List.of("arn:aws:bedrock-agentcore:*:aws:browser/aws.browser.v1")) + .build(), + software.amazon.awscdk.services.iam.PolicyStatement.Builder.create() + .effect(software.amazon.awscdk.services.iam.Effect.ALLOW) + .actions(java.util.List.of( + "bedrock-agentcore:GetCodeInterpreterSession", + "bedrock-agentcore:InvokeCodeInterpreter", + "bedrock-agentcore:StartCodeInterpreterSession", + "bedrock-agentcore:StopCodeInterpreterSession" + )) + .resources(java.util.List.of("arn:aws:bedrock-agentcore:*:aws:code-interpreter/aws.codeinterpreter.v1")) + .build(), + software.amazon.awscdk.services.iam.PolicyStatement.Builder.create() + .effect(software.amazon.awscdk.services.iam.Effect.ALLOW) + .actions(java.util.List.of("ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer")) + .resources(java.util.List.of("arn:aws:ecr:*:" + this.getAccount() + ":repository/aiagent")) + .build(), + software.amazon.awscdk.services.iam.PolicyStatement.Builder.create() + .effect(software.amazon.awscdk.services.iam.Effect.ALLOW) + .actions(java.util.List.of("ecr:GetAuthorizationToken")) .resources(java.util.List.of("*")) .build(), software.amazon.awscdk.services.iam.PolicyStatement.Builder.create() .effect(software.amazon.awscdk.services.iam.Effect.ALLOW) - .actions(java.util.List.of("aws-marketplace:Subscribe", "aws-marketplace:Unsubscribe", "aws-marketplace:ViewSubscriptions")) + .actions(java.util.List.of( + "logs:DescribeLogStreams", + "logs:CreateLogGroup", + "logs:PutResourcePolicy", + "logs:CreateLogStream", + "logs:PutLogEvents" + )) + .resources(java.util.List.of("arn:aws:logs:*:" + this.getAccount() + ":log-group:/aws/bedrock-agentcore/runtimes/*")) + .build(), + software.amazon.awscdk.services.iam.PolicyStatement.Builder.create() + .effect(software.amazon.awscdk.services.iam.Effect.ALLOW) + .actions(java.util.List.of( + "logs:DescribeLogGroups", + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets" + )) + .resources(java.util.List.of("*")) + .build(), + software.amazon.awscdk.services.iam.PolicyStatement.Builder.create() + .effect(software.amazon.awscdk.services.iam.Effect.ALLOW) + .actions(java.util.List.of("cloudwatch:PutMetricData")) .resources(java.util.List.of("*")) + .conditions(java.util.Map.of("StringEquals", java.util.Map.of( + "cloudwatch:namespace", "bedrock-agentcore"))) .build() )) .build() @@ -302,6 +415,25 @@ public WorkshopStack(final Construct scope, final String id, final StackProps pr .privilegedMode(true) .environmentVariables(Map.of( "TEMPLATE_TYPE", templateType)) + .rolePolicyStatements(List.of( + PolicyStatement.Builder.create() + .effect(Effect.ALLOW) + .actions(List.of("sts:GetCallerIdentity", "ecr:GetAuthorizationToken")) + .resources(List.of("*")) + .build(), + PolicyStatement.Builder.create() + .effect(Effect.ALLOW) + .actions(List.of( + "ecr:BatchCheckLayerAvailability", + "ecr:CompleteLayerUpload", + "ecr:GetDownloadUrlForLayer", + "ecr:InitiateLayerUpload", + "ecr:PutImage", + "ecr:UploadLayerPart" + )) + .resources(List.of("arn:aws:ecr:" + this.getRegion() + ":" + this.getAccount() + ":repository/aiagent")) + .build() + )) .buildSpec(placeholderBuildSpec) .dependencies(java.util.List.of( vpc.getConcreteVpc(), // Ensures NAT Gateway is ready @@ -328,6 +460,7 @@ public WorkshopStack(final Construct scope, final String id, final StackProps pr CfnPreDeleteCleanup.CfnPreDeleteCleanupProps.builder() .prefix(prefix) .vpc(vpc.getVpc()) + .buckets(java.util.List.of(workshopBucket.getBucket(), workshopBucket.getAccessLogBucket())) .build()); } } \ No newline at end of file diff --git a/infra/cdk/src/main/java/sample/com/constructs/CfnPreDeleteCleanup.java b/infra/cdk/src/main/java/sample/com/constructs/CfnPreDeleteCleanup.java index 5e001243..065b91ff 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/CfnPreDeleteCleanup.java +++ b/infra/cdk/src/main/java/sample/com/constructs/CfnPreDeleteCleanup.java @@ -1,12 +1,15 @@ package sample.com.constructs; +import software.amazon.awscdk.ArnComponents; import software.amazon.awscdk.CustomResource; import software.amazon.awscdk.Duration; +import software.amazon.awscdk.Stack; import software.amazon.awscdk.services.ec2.IVpc; import software.amazon.awscdk.services.iam.*; import software.amazon.awscdk.services.lambda.Code; import software.amazon.awscdk.services.lambda.Function; import software.amazon.awscdk.services.lambda.Runtime; +import software.amazon.awscdk.services.s3.IBucket; import software.constructs.Construct; import java.io.IOException; @@ -26,6 +29,7 @@ public class CfnPreDeleteCleanup extends Construct { public static class CfnPreDeleteCleanupProps { private String prefix = "workshop"; private IVpc vpc; + private List buckets = List.of(); public static Builder builder() { return new Builder(); } @@ -34,11 +38,13 @@ public static class Builder { public Builder prefix(String prefix) { props.prefix = prefix; return this; } public Builder vpc(IVpc vpc) { props.vpc = vpc; return this; } + public Builder buckets(List buckets) { props.buckets = List.copyOf(buckets); return this; } public CfnPreDeleteCleanupProps build() { return props; } } public String getPrefix() { return prefix; } public IVpc getVpc() { return vpc; } + public List getBuckets() { return buckets; } } public CfnPreDeleteCleanup(final Construct scope, final String id, final CfnPreDeleteCleanupProps props) { @@ -59,26 +65,53 @@ public CfnPreDeleteCleanup(final Construct scope, final String id, final CfnPreD .effect(Effect.ALLOW) .actions(List.of( "ec2:DescribeVpcEndpoints", - "ec2:DeleteVpcEndpoints", - "ec2:DescribeSecurityGroups", - "ec2:DeleteSecurityGroup" + "ec2:DescribeSecurityGroups" )) .resources(List.of("*")) .build()); - // Add S3 permissions for bucket cleanup + String vpcArn = Stack.of(this).formatArn(ArnComponents.builder() + .service("ec2") + .resource("vpc") + .resourceName(props.getVpc().getVpcId()) + .build()); + lambdaRole.addToPolicy(PolicyStatement.Builder.create() .effect(Effect.ALLOW) - .actions(List.of( - "s3:ListAllMyBuckets", - "s3:ListBucket", - "s3:ListBucketVersions", - "s3:DeleteObject", - "s3:DeleteObjectVersion" - )) - .resources(List.of("*")) + .actions(List.of("ec2:DeleteVpcEndpoints")) + .resources(List.of(Stack.of(this).formatArn(ArnComponents.builder() + .service("ec2") + .resource("vpc-endpoint") + .resourceName("*") + .build()))) + .conditions(Map.of("StringEquals", Map.of("ec2:Vpc", vpcArn))) .build()); + lambdaRole.addToPolicy(PolicyStatement.Builder.create() + .effect(Effect.ALLOW) + .actions(List.of("ec2:DeleteSecurityGroup")) + .resources(List.of(Stack.of(this).formatArn(ArnComponents.builder() + .service("ec2") + .resource("security-group") + .resourceName("*") + .build()))) + .conditions(Map.of("StringEquals", Map.of("ec2:Vpc", vpcArn))) + .build()); + + // Add S3 permissions for bucket cleanup + for (IBucket bucket : props.getBuckets()) { + lambdaRole.addToPolicy(PolicyStatement.Builder.create() + .effect(Effect.ALLOW) + .actions(List.of("s3:DeleteBucket", "s3:ListBucket", "s3:ListBucketVersions")) + .resources(List.of(bucket.getBucketArn())) + .build()); + lambdaRole.addToPolicy(PolicyStatement.Builder.create() + .effect(Effect.ALLOW) + .actions(List.of("s3:DeleteObject", "s3:DeleteObjectVersion")) + .resources(List.of(bucket.arnForObjects("*"))) + .build()); + } + // Create cleanup Lambda function Function cleanupFunction = Function.Builder.create(this, "Function") .functionName(prefix + "-cfn-pre-delete-cleanup") @@ -94,7 +127,8 @@ public CfnPreDeleteCleanup(final Construct scope, final String id, final CfnPreD CustomResource.Builder.create(this, "Resource") .serviceToken(cleanupFunction.getFunctionArn()) .properties(Map.of( - "VpcId", props.getVpc().getVpcId() + "VpcId", props.getVpc().getVpcId(), + "BucketNames", props.getBuckets().stream().map(IBucket::getBucketName).toList() )) .build(); } diff --git a/infra/cdk/src/main/java/sample/com/constructs/CodeBuild.java b/infra/cdk/src/main/java/sample/com/constructs/CodeBuild.java index 235d3ae2..d7052cc2 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/CodeBuild.java +++ b/infra/cdk/src/main/java/sample/com/constructs/CodeBuild.java @@ -1,8 +1,14 @@ package sample.com.constructs; +import io.github.cdklabs.cdknag.NagPackSuppression; +import io.github.cdklabs.cdknag.NagSuppressions; +import software.amazon.awscdk.ArnComponents; import software.amazon.awscdk.CustomResource; import software.amazon.awscdk.Duration; +import software.amazon.awscdk.RemovalPolicy; +import software.amazon.awscdk.Stack; import software.amazon.awscdk.services.codebuild.*; +import software.amazon.awscdk.services.dynamodb.*; import software.amazon.awscdk.services.events.*; import software.amazon.awscdk.services.events.targets.LambdaFunction; import software.amazon.awscdk.services.iam.*; @@ -12,9 +18,9 @@ import software.amazon.awscdk.services.ec2.SubnetType; import software.constructs.Construct; +import java.util.ArrayList; import java.util.Map; import java.util.List; -import java.util.Arrays; import org.yaml.snakeyaml.Yaml; public class CodeBuild extends Construct { @@ -33,6 +39,7 @@ public static class CodeBuildProps { private Map environmentVariables; private String buildSpec; private List dependencies; + private List rolePolicyStatements = List.of(); public static CodeBuildProps.Builder builder() { return new Builder(); } @@ -48,6 +55,7 @@ public static class Builder { public Builder environmentVariables(Map environmentVariables) { props.environmentVariables = environmentVariables; return this; } public Builder buildSpec(String buildSpec) { props.buildSpec = buildSpec; return this; } public Builder dependencies(List dependencies) { props.dependencies = dependencies; return this; } + public Builder rolePolicyStatements(List rolePolicyStatements) { props.rolePolicyStatements = List.copyOf(rolePolicyStatements); return this; } public CodeBuildProps build() { return props; } } @@ -62,6 +70,7 @@ public static class Builder { public Map getEnvironmentVariables() { return environmentVariables; } public String getBuildSpec() { return buildSpec; } public List getDependencies() { return dependencies; } + public List getRolePolicyStatements() { return rolePolicyStatements; } } public CodeBuild(final Construct scope, final String id, final IVpc vpc, final Map environmentVariables, final String buildSpec) { @@ -78,10 +87,8 @@ public CodeBuild(final Construct scope, final String id, final CodeBuildProps pr // Create CodeBuild service role this.codeBuildRole = Role.Builder.create(this, "Role") .assumedBy(ServicePrincipal.Builder.create("codebuild.amazonaws.com").build()) - .managedPolicies(List.of( - ManagedPolicy.fromAwsManagedPolicyName("PowerUserAccess") - )) .build(); + props.getRolePolicyStatements().forEach(codeBuildRole::addToPolicy); // Create Lambda role for CodeBuild Lambda functions this.lambdaRole = Role.Builder.create(this, "LambdaRole") @@ -91,18 +98,6 @@ public CodeBuild(final Construct scope, final String id, final CodeBuildProps pr )) .build(); - // Add CodeBuild permissions for Lambda functions - PolicyStatement codeBuildPermissions = PolicyStatement.Builder.create() - .effect(Effect.ALLOW) - .actions(List.of( - "codebuild:StartBuild", - "codebuild:BatchGetBuilds" - )) - .resources(List.of("*")) - .build(); - - lambdaRole.addToPolicy(codeBuildPermissions); - // Convert environment variables to CodeBuild format Map codeBuildEnvVars = props.getEnvironmentVariables().entrySet().stream() .collect(java.util.stream.Collectors.toMap( @@ -131,41 +126,135 @@ public CodeBuild(final Construct scope, final String id, final CodeBuildProps pr .timeout(props.getTimeout()) .build(); + String networkInterfaceArn = Stack.of(this).formatArn(ArnComponents.builder() + .service("ec2") + .resource("network-interface") + .resourceName("*") + .build()); + List subnetArns = props.getVpc().getPrivateSubnets().stream() + .map(subnet -> Stack.of(this).formatArn(ArnComponents.builder() + .service("ec2") + .resource("subnet") + .resourceName(subnet.getSubnetId()) + .build())) + .toList(); + List createNetworkInterfaceResources = new ArrayList<>(subnetArns); + createNetworkInterfaceResources.addAll(codebuildProject.getConnections().getSecurityGroups().stream() + .map(securityGroup -> Stack.of(this).formatArn(ArnComponents.builder() + .service("ec2") + .resource("security-group") + .resourceName(securityGroup.getSecurityGroupId()) + .build())) + .toList()); + createNetworkInterfaceResources.add(networkInterfaceArn); + + CfnPolicy vpcPolicy = (CfnPolicy) codebuildProject.getNode() + .findChild("PolicyDocument").getNode().getDefaultChild(); + vpcPolicy.addPropertyOverride("PolicyDocument.Statement", List.of( + Map.of( + "Effect", "Allow", + "Action", List.of("ec2:CreateNetworkInterface"), + "Resource", createNetworkInterfaceResources + ), + Map.of( + "Effect", "Allow", + "Action", List.of("ec2:CreateNetworkInterfacePermission"), + "Resource", networkInterfaceArn, + "Condition", Map.of( + "StringEquals", Map.of("ec2:AuthorizedService", "codebuild.amazonaws.com"), + "ArnEquals", Map.of("ec2:Subnet", subnetArns) + ) + ), + Map.of( + "Effect", "Allow", + "Action", List.of("ec2:DeleteNetworkInterface"), + "Resource", "*" + ), + Map.of( + "Effect", "Allow", + "Action", List.of( + "ec2:DescribeDhcpOptions", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeVpcs" + ), + "Resource", "*" + ) + )); + vpcPolicy.addMetadata("checkov", Map.of( + "skip", List.of(Map.of( + "id", "CKV_AWS_111", + "comment", "CodeBuild requires ec2:DeleteNetworkInterface on wildcard resources because the API authorizes deletion against arn:aws:ec2:region:account:*/*." + )) + )); + + lambdaRole.addToPolicy(PolicyStatement.Builder.create() + .effect(Effect.ALLOW) + .actions(List.of("codebuild:StartBuild", "codebuild:BatchGetBuilds")) + .resources(List.of(codebuildProject.getProjectArn())) + .build()); + // Create start build Lambda function var startLambda = new Lambda(this, "StartLambda", "/lambda/codebuild-start.py", props.getProjectName() + "-start", Duration.minutes(2), lambdaRole); Function startBuildFunction = startLambda.getFunction(); - // Create report build Lambda function + // Persist the CloudFormation callback while CodeBuild runs. The start Lambda + // intentionally does not answer Create/Update requests; the report Lambda + // sends the response only after a terminal CodeBuild event. + Table pendingBuilds = Table.Builder.create(this, "PendingBuilds") + .partitionKey(Attribute.builder() + .name("BuildId") + .type(AttributeType.STRING) + .build()) + .billingMode(BillingMode.PAY_PER_REQUEST) + .timeToLiveAttribute("ExpiresAt") + .removalPolicy(RemovalPolicy.DESTROY) + .build(); + NagSuppressions.addResourceSuppressions(pendingBuilds, List.of( + new NagPackSuppression.Builder() + .id("AwsSolutions-DDB3") + .reason("The table stores short-lived CloudFormation callback state and does not require point-in-time recovery") + .build() + )); + + startBuildFunction.addEnvironment("PENDING_TABLE_NAME", pendingBuilds.getTableName()); + pendingBuilds.grantWriteData(startBuildFunction); + var reportLambda = new Lambda(this, "ReportLambda", - "/lambda/codebuild-report.py", props.getProjectName() + "-report", Duration.minutes(2), lambdaRole); + "/lambda/codebuild-report.py", props.getProjectName() + "-report", + Duration.minutes(2), lambdaRole); Function reportBuildFunction = reportLambda.getFunction(); + reportBuildFunction.addEnvironment("PENDING_TABLE_NAME", pendingBuilds.getTableName()); + pendingBuilds.grantReadWriteData(reportBuildFunction); - // Create EventBridge rule for build completion Rule buildCompleteRule = Rule.Builder.create(this, "CompleteRule") .description(props.getProjectName() + " build complete") .eventPattern(EventPattern.builder() - .source(Arrays.asList("aws.codebuild")) - .detailType(Arrays.asList("CodeBuild Build State Change")) + .source(List.of("aws.codebuild")) + .detailType(List.of("CodeBuild Build State Change")) .detail(Map.of( - "build-status", Arrays.asList("SUCCEEDED", "FAILED", "STOPPED"), - "project-name", Arrays.asList(this.codebuildProject.getProjectName()) + "build-status", List.of("SUCCEEDED", "FAILED", "FAULT", "STOPPED", "TIMED_OUT"), + "project-name", List.of(this.codebuildProject.getProjectName()) )) .build()) - .targets(Arrays.asList(new LambdaFunction(reportBuildFunction))) + .targets(List.of(new LambdaFunction(reportBuildFunction))) .build(); - // Create custom resource to trigger the build this.customResource = CustomResource.Builder.create(this, "Resource") .serviceToken(startBuildFunction.getFunctionArn()) .properties(Map.of( "ProjectName", this.codebuildProject.getProjectName(), - "CodeBuildIamRoleArn", this.codebuildProject.getRole().getRoleArn(), "ContentHash", String.valueOf(System.currentTimeMillis()) )) .build(); + this.customResource.getNode().addDependency(this.codebuildProject); + this.customResource.getNode().addDependency(vpcPolicy); + this.customResource.getNode().addDependency(pendingBuilds); this.customResource.getNode().addDependency(buildCompleteRule); + this.customResource.getNode().addDependency(startBuildFunction); this.customResource.getNode().addDependency(reportBuildFunction); // Add external dependencies (e.g., NAT Gateway, ECR Registry) diff --git a/infra/cdk/src/main/java/sample/com/constructs/Database.java b/infra/cdk/src/main/java/sample/com/constructs/Database.java index 7c9249fe..b74d9f63 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/Database.java +++ b/infra/cdk/src/main/java/sample/com/constructs/Database.java @@ -96,6 +96,7 @@ public Database(final Construct scope, final String id, final DatabaseProps prop .autoMinorVersionUpgrade(true) .build())) .enableDataApi(true) + .iamAuthentication(true) .defaultDatabaseName("workshop") .clusterIdentifier(prefix + "-db-cluster") .vpc(vpc) diff --git a/infra/cdk/src/main/java/sample/com/constructs/EcrRegistry.java b/infra/cdk/src/main/java/sample/com/constructs/EcrRegistry.java index e80c82a9..f1e71360 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/EcrRegistry.java +++ b/infra/cdk/src/main/java/sample/com/constructs/EcrRegistry.java @@ -1,6 +1,8 @@ package sample.com.constructs; +import software.amazon.awscdk.ArnComponents; import software.amazon.awscdk.CfnTag; +import software.amazon.awscdk.Stack; import software.amazon.awscdk.services.ecr.CfnRepositoryCreationTemplate; import software.amazon.awscdk.services.iam.Role; import software.amazon.awscdk.services.iam.ServicePrincipal; @@ -20,6 +22,8 @@ public class EcrRegistry extends Construct { public static class EcrRegistryProps { private String prefix = "workshop"; + private String workshopId = "base"; + private List repositoryNames = List.of(); public static Builder builder() { return new Builder(); } @@ -27,10 +31,14 @@ public static class Builder { private EcrRegistryProps props = new EcrRegistryProps(); public Builder prefix(String prefix) { props.prefix = prefix; return this; } + public Builder workshopId(String workshopId) { props.workshopId = workshopId; return this; } + public Builder repositoryNames(List repositoryNames) { props.repositoryNames = List.copyOf(repositoryNames); return this; } public EcrRegistryProps build() { return props; } } public String getPrefix() { return prefix; } + public String getWorkshopId() { return workshopId; } + public List getRepositoryNames() { return repositoryNames; } } public EcrRegistry(final Construct scope, final String id, final EcrRegistryProps props) { @@ -84,12 +92,20 @@ public EcrRegistry(final Construct scope, final String id, final EcrRegistryProp "ecr:TagResource", "ecr:PutLifecyclePolicy" )) - .resources(List.of("*")) + .resources(props.getRepositoryNames().isEmpty() + ? List.of("*") + : props.getRepositoryNames().stream() + .map(repositoryName -> Stack.of(this).formatArn(ArnComponents.builder() + .service("ecr") + .resource("repository") + .resourceName(repositoryName) + .build())) + .toList()) .build()); // Create Repository Creation Template this.repositoryCreationTemplate = CfnRepositoryCreationTemplate.Builder.create(this, "Template") - .prefix("ROOT") // Applies to all repositories + .prefix("ROOT") .appliedFor(List.of("CREATE_ON_PUSH", "REPLICATION")) .imageTagMutability("MUTABLE") .lifecyclePolicy(lifecyclePolicyJson) @@ -102,6 +118,18 @@ public EcrRegistry(final Construct scope, final String id, final EcrRegistryProp CfnTag.builder() .key("ManagedBy") .value("ecr-create-on-push") + .build(), + CfnTag.builder() + .key("WorkshopId") + .value(props.getWorkshopId()) + .build(), + CfnTag.builder() + .key("WorkshopDeploymentId") + .value(Stack.of(this).getStackId()) + .build(), + CfnTag.builder() + .key("WorkshopOwner") + .value("workshop-run") .build() )) .description("Auto-create repositories on push with lifecycle policies for " + prefix + " workshop") diff --git a/infra/cdk/src/main/java/sample/com/constructs/EcsExpressService.java b/infra/cdk/src/main/java/sample/com/constructs/EcsExpressService.java index d136f91f..48571f89 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/EcsExpressService.java +++ b/infra/cdk/src/main/java/sample/com/constructs/EcsExpressService.java @@ -66,7 +66,7 @@ public EcsExpressService(final Construct scope, final String id, final EcsExpres taskExecutionRole.addToPolicy(PolicyStatement.Builder.create() .effect(Effect.ALLOW) .actions(List.of("logs:CreateLogGroup")) - .resources(List.of("*")) + .resources(List.of("arn:aws:logs:*:*:log-group:/aws/ecs/" + appName + "*")) .build()); props.getDatabase().grantSecretsRead(taskExecutionRole); diff --git a/infra/cdk/src/main/java/sample/com/constructs/Ide.java b/infra/cdk/src/main/java/sample/com/constructs/Ide.java index 503e6cd9..b0a9d293 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/Ide.java +++ b/infra/cdk/src/main/java/sample/com/constructs/Ide.java @@ -8,6 +8,7 @@ import software.amazon.awscdk.Duration; import software.amazon.awscdk.Fn; import software.amazon.awscdk.RemovalPolicy; +import software.amazon.awscdk.Stack; import software.amazon.awscdk.services.cloudfront.AllowedMethods; import software.amazon.awscdk.services.cloudfront.BehaviorOptions; import software.amazon.awscdk.services.cloudfront.CachePolicy; @@ -93,6 +94,7 @@ public static class IdeProps { private int bootstrapTimeoutMinutes = 30; private String gitBranch = "main"; private String templateType = "base"; + private String workshopId = "base"; private Role ideRole; // Architecture-specific instance type lists @@ -118,6 +120,7 @@ public static class Builder { public Builder bootstrapTimeoutMinutes(int bootstrapTimeoutMinutes) { props.bootstrapTimeoutMinutes = bootstrapTimeoutMinutes; return this; } public Builder gitBranch(String gitBranch) { props.gitBranch = gitBranch; return this; } public Builder templateType(String templateType) { props.templateType = templateType; return this; } + public Builder workshopId(String workshopId) { props.workshopId = workshopId; return this; } public Builder ideRole(Role ideRole) { props.ideRole = ideRole; return this; } public IdeProps build() { return props; } } @@ -149,6 +152,7 @@ public List getInstanceTypes() { public int getBootstrapTimeoutMinutes() { return bootstrapTimeoutMinutes; } public String getGitBranch() { return gitBranch; } public String getTemplateType() { return templateType; } + public String getWorkshopId() { return workshopId; } public Role getIdeRole() { return ideRole; } } @@ -179,30 +183,54 @@ public Ide(final Construct scope, final String id, final IdeProps props) { } this.ideRole = props.getIdeRole(); - // Add CloudFormation signaling permissions - PolicyStatement cfnSignalPermissions = PolicyStatement.Builder.create() - .effect(Effect.ALLOW) - .actions(List.of( - "cloudformation:SignalResource" - )) - .resources(List.of("*")) - .build(); - - this.ideRole.addToPolicy(cfnSignalPermissions); - // Load IAM policy: base template uses AdministratorAccess, others use iam-policy.json if ("base".equals(props.getTemplateType())) { this.ideRole.addManagedPolicy(ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess")); } else { - String policyDocumentJson = loadFile("/iam-policy.json"); + String policyDocumentJson = loadFile("/iam-policy.json") + .replace("{{.AccountId}}", Aws.ACCOUNT_ID); var policyDocument = PolicyDocument.fromJson(new JSONObject(policyDocumentJson).toMap()); var policy = ManagedPolicy.Builder.create(this, "UserPolicy") .document(policyDocument) .build(); this.ideRole.addManagedPolicy(policy); + String roleManagementPolicyJson = loadFile("/iam-role-management-policy.json") + .replace("{{.AccountId}}", Aws.ACCOUNT_ID); + var roleManagementPolicyDocument = PolicyDocument.fromJson( + new JSONObject(roleManagementPolicyJson).toMap()); + var roleManagementPolicy = ManagedPolicy.Builder.create(this, "RoleManagementPolicy") + .document(roleManagementPolicyDocument) + .build(); + this.ideRole.addManagedPolicy(roleManagementPolicy); + + if ("java-spring-ai-agents".equals(props.getTemplateType()) + || "java-ai-agents".equals(props.getTemplateType()) + || "java-ai-agents-advanced".equals(props.getTemplateType())) { + String agentCoreManagedToolsPolicyJson = loadFile("/agentcore-managed-tools-policy.json"); + var agentCoreManagedToolsPolicyDocument = PolicyDocument.fromJson( + new JSONObject(agentCoreManagedToolsPolicyJson).toMap()); + var agentCoreManagedToolsPolicy = ManagedPolicy.Builder.create(this, "AgentCoreManagedToolsPolicy") + .document(agentCoreManagedToolsPolicyDocument) + .build(); + this.ideRole.addManagedPolicy(agentCoreManagedToolsPolicy); + } + + if ("java-ai-agents".equals(props.getTemplateType()) + || "java-ai-agents-advanced".equals(props.getTemplateType())) { + String agentCoreIdentityPolicyJson = loadFile("/agentcore-identity-policy.json") + .replace("{{.AccountId}}", Aws.ACCOUNT_ID); + var agentCoreIdentityPolicyDocument = PolicyDocument.fromJson( + new JSONObject(agentCoreIdentityPolicyJson).toMap()); + var agentCoreIdentityPolicy = ManagedPolicy.Builder.create(this, "AgentCoreIdentityPolicy") + .document(agentCoreIdentityPolicyDocument) + .build(); + this.ideRole.addManagedPolicy(agentCoreIdentityPolicy); + } + // Create permissions boundary for roles created by workshop scripts - String boundaryJson = loadFile("/workshop-boundary.json"); + String boundaryJson = loadFile("/workshop-boundary.json") + .replace("{{.AccountId}}", Aws.ACCOUNT_ID); var boundaryDocument = PolicyDocument.fromJson(new JSONObject(boundaryJson).toMap()); ManagedPolicy.Builder.create(this, "WorkshopBoundary") .managedPolicyName("workshop-boundary") @@ -219,27 +247,55 @@ public Ide(final Construct scope, final String id, final IdeProps props) { .build(); // Add specific permissions for Lambda functions - PolicyStatement lambdaPermissions = PolicyStatement.Builder.create() + lambdaRole.addToPolicy(PolicyStatement.Builder.create() .effect(Effect.ALLOW) .actions(List.of( "ec2:DescribeManagedPrefixLists", - "ec2:RunInstances", - "ec2:TerminateInstances", - "ec2:CreateTags", "ec2:DescribeInstances", "ec2:DescribeInstanceStatus", - "ec2:DescribeSubnets", - "iam:PassRole", - "ssm:DescribeInstanceInformation", - "ssm:SendCommand", - "ssm:GetCommandInvocation", - "secretsmanager:GetSecretValue", - "secretsmanager:DescribeSecret" + "ec2:DescribeSubnets" )) .resources(List.of("*")) - .build(); + .build()); - lambdaRole.addToPolicy(lambdaPermissions); + lambdaRole.addToPolicy(PolicyStatement.Builder.create() + .effect(Effect.ALLOW) + .actions(List.of("ec2:RunInstances")) + .resources(List.of( + "arn:aws:ec2:*::image/*", + "arn:aws:ec2:*:*:instance/*", + "arn:aws:ec2:*:*:network-interface/*", + "arn:aws:ec2:*:*:security-group/*", + "arn:aws:ec2:*:*:subnet/*", + "arn:aws:ec2:*:*:volume/*" + )) + .build()); + + lambdaRole.addToPolicy(PolicyStatement.Builder.create() + .effect(Effect.ALLOW) + .actions(List.of("ec2:CreateTags")) + .resources(List.of("arn:aws:ec2:*:*:instance/*")) + .conditions(Map.of( + "StringEquals", Map.of( + "ec2:CreateAction", "RunInstances", + "aws:RequestTag/Workshop", "true" + ) + )) + .build()); + + lambdaRole.addToPolicy(PolicyStatement.Builder.create() + .effect(Effect.ALLOW) + .actions(List.of("ec2:TerminateInstances")) + .resources(List.of("arn:aws:ec2:*:*:instance/*")) + .conditions(Map.of("StringEquals", Map.of("ec2:ResourceTag/Workshop", "true"))) + .build()); + + lambdaRole.addToPolicy(PolicyStatement.Builder.create() + .effect(Effect.ALLOW) + .actions(List.of("iam:PassRole")) + .resources(List.of(this.ideRole.getRoleArn())) + .conditions(Map.of("StringEquals", Map.of("iam:PassedToService", "ec2.amazonaws.com"))) + .build()); // Set up wait condition handle for bootstrap completion (needed for User Data) var waitHandle = CfnWaitConditionHandle.Builder.create(this, "WaitConditionHandle") @@ -340,6 +396,9 @@ public Ide(final Construct scope, final String id, final IdeProps props) { .replace("${GIT_BRANCH}", gitBranch) .replace("${AWS_REGION}", Aws.REGION) .replace("${TEMPLATE_TYPE}", templateType) + .replace("${WORKSHOP_ID}", props.getWorkshopId()) + .replace("${WORKSHOP_STACK_NAME}", Aws.STACK_NAME) + .replace("${WORKSHOP_DEPLOYMENT_ID}", Stack.of(this).getStackId()) .replace("${ARCH}", props.getIdeArch().getUnameValue()) .replace("${IDE_TYPE}", props.getIdeType().getScriptName()) .replace("${WAIT_CONDITION_HANDLE_URL}", waitHandle.getRef()) diff --git a/infra/cdk/src/main/java/sample/com/constructs/ThreadAnalysis.java b/infra/cdk/src/main/java/sample/com/constructs/ThreadAnalysis.java index 86daff07..c0a2640a 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/ThreadAnalysis.java +++ b/infra/cdk/src/main/java/sample/com/constructs/ThreadAnalysis.java @@ -2,6 +2,12 @@ import software.amazon.awscdk.Duration; import software.amazon.awscdk.RemovalPolicy; +import software.amazon.awscdk.aws_apigatewayv2_authorizers.HttpLambdaAuthorizer; +import software.amazon.awscdk.aws_apigatewayv2_authorizers.HttpLambdaResponseType; +import software.amazon.awscdk.aws_apigatewayv2_integrations.HttpLambdaIntegration; +import software.amazon.awscdk.services.apigatewayv2.AddRoutesOptions; +import software.amazon.awscdk.services.apigatewayv2.HttpApi; +import software.amazon.awscdk.services.apigatewayv2.HttpMethod; import software.amazon.awscdk.services.ec2.*; import software.amazon.awscdk.services.eks_v2.AccessEntry; import software.amazon.awscdk.services.eks_v2.AccessEntryType; @@ -13,13 +19,11 @@ import software.amazon.awscdk.services.iam.*; import software.amazon.awscdk.services.lambda.Code; import software.amazon.awscdk.services.lambda.Function; -import software.amazon.awscdk.services.lambda.FunctionUrl; -import software.amazon.awscdk.services.lambda.FunctionUrlAuthType; -import software.amazon.awscdk.services.lambda.FunctionUrlOptions; import software.amazon.awscdk.services.lambda.Runtime; import software.amazon.awscdk.services.logs.LogGroup; import software.amazon.awscdk.services.logs.RetentionDays; import software.amazon.awscdk.services.s3.Bucket; +import software.amazon.awscdk.services.ssm.StringParameter; import software.constructs.Construct; import java.io.IOException; @@ -30,14 +34,13 @@ /** * ThreadAnalysis construct for thread dump analysis. - * Creates Lambda function with Function URL for thread dump collection and AI analysis. + * Creates Lambda function with authenticated HTTP endpoint for thread dump collection and AI analysis. * Uses async self-invocation pattern for fast webhook response. */ public class ThreadAnalysis extends Construct { private final SecurityGroup lambdaSecurityGroup; private final Function threadDumpLambda; - private final FunctionUrl functionUrl; private final Role lambdaRole; public static class ThreadAnalysisProps { @@ -71,6 +74,7 @@ public ThreadAnalysis(final Construct scope, final String id, final ThreadAnalys super(scope, id); String prefix = props.getPrefix(); + String eksClusterName = props.getEksClusterName() != null ? props.getEksClusterName() : prefix + "-eks"; // Create Lambda role with Bedrock, EKS, and ECS access this.lambdaRole = Role.Builder.create(this, "LambdaRole") @@ -108,10 +112,13 @@ public ThreadAnalysis(final Construct scope, final String id, final ThreadAnalys .effect(Effect.ALLOW) .actions(List.of( "eks:DescribeCluster", - "eks:AccessKubernetesApi", - "eks:ListClusters", - "sts:GetCallerIdentity" + "eks:AccessKubernetesApi" )) + .resources(List.of("arn:aws:eks:*:*:cluster/" + eksClusterName)) + .build()); + lambdaRole.addToPolicy(PolicyStatement.Builder.create() + .effect(Effect.ALLOW) + .actions(List.of("eks:ListClusters", "sts:GetCallerIdentity")) .resources(List.of("*")) .build()); @@ -125,7 +132,11 @@ public ThreadAnalysis(final Construct scope, final String id, final ThreadAnalys "ecs:ListTasks", "ecs:ExecuteCommand" )) - .resources(List.of("*")) + .resources(List.of( + "arn:aws:ecs:*:*:cluster/unicorn-store-spring", + "arn:aws:ecs:*:*:service/unicorn-store-spring/*", + "arn:aws:ecs:*:*:task/unicorn-store-spring/*" + )) .build()); // Add S3 permissions for thread dumps @@ -149,7 +160,6 @@ public ThreadAnalysis(final Construct scope, final String id, final ThreadAnalys .build(); // Create Thread Dump Lambda function - String eksClusterName = props.getEksClusterName() != null ? props.getEksClusterName() : prefix + "-eks"; String bucketName = props.getWorkshopBucket() != null ? props.getWorkshopBucket().getBucketName() : ""; this.threadDumpLambda = Function.Builder.create(this, "Lambda") @@ -184,12 +194,46 @@ public ThreadAnalysis(final Construct scope, final String id, final ThreadAnalys .resources(List.of(lambdaArn)) .build()); - // Create Function URL (replaces API Gateway + VPC Endpoint) - // Auth is handled by Lambda code via basic auth against Secrets Manager - this.functionUrl = threadDumpLambda.addFunctionUrl(FunctionUrlOptions.builder() - .authType(FunctionUrlAuthType.NONE) + // Authenticate the existing Grafana Basic-auth webhook before invoking analysis + Function authorizerFunction = Function.Builder.create(this, "AuthorizerLambda") + .functionName(prefix + "-thread-analysis-authorizer") + .runtime(Runtime.PYTHON_3_13) + .handler("index.lambda_handler") + .code(Code.fromInline(loadFile("/lambda/thread-analysis-authorizer.py"))) + .timeout(Duration.seconds(10)) + .environment(Map.of("SECRET_NAME", prefix + "-ide-password")) + .build(); + authorizerFunction.addToRolePolicy(PolicyStatement.Builder.create() + .effect(Effect.ALLOW) + .actions(List.of("secretsmanager:GetSecretValue")) + .resources(List.of("arn:aws:secretsmanager:*:*:secret:" + prefix + "-ide-password*")) .build()); + HttpLambdaAuthorizer authorizer = HttpLambdaAuthorizer.Builder.create( + "ThreadAnalysisAuthorizer", authorizerFunction) + .authorizerName(prefix + "-thread-analysis-authorizer") + .identitySource(List.of("$request.header.Authorization")) + .responseTypes(List.of(HttpLambdaResponseType.SIMPLE)) + .resultsCacheTtl(Duration.seconds(0)) + .build(); + + HttpApi httpApi = HttpApi.Builder.create(this, "HttpApi") + .apiName(prefix + "-thread-analysis") + .createDefaultStage(true) + .build(); + httpApi.addRoutes(AddRoutesOptions.builder() + .path("/") + .methods(List.of(HttpMethod.POST)) + .authorizer(authorizer) + .integration(HttpLambdaIntegration.Builder.create( + "ThreadAnalysisIntegration", threadDumpLambda).build()) + .build()); + + StringParameter.Builder.create(this, "EndpointParameter") + .parameterName(prefix + "-thread-analysis-url") + .stringValue(httpApi.getApiEndpoint()) + .build(); + // Create EKS Access Entry for Lambda role (if EKS cluster provided) if (props.getEksCluster() != null) { IAccessPolicy clusterAdminPolicy = AccessPolicy.fromAccessPolicyName( @@ -258,10 +302,6 @@ public Function getThreadDumpLambda() { return threadDumpLambda; } - public FunctionUrl getFunctionUrl() { - return functionUrl; - } - public Role getLambdaRole() { return lambdaRole; } diff --git a/infra/cdk/src/main/java/sample/com/constructs/Unicorn.java b/infra/cdk/src/main/java/sample/com/constructs/Unicorn.java index d9c9e0bb..ef740978 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/Unicorn.java +++ b/infra/cdk/src/main/java/sample/com/constructs/Unicorn.java @@ -203,7 +203,10 @@ private void createEcsRoles(UnicornProps props) { ecsTaskExecutionRole.addToPolicy(PolicyStatement.Builder.create() .effect(Effect.ALLOW) .actions(List.of("logs:CreateLogGroup")) - .resources(List.of("*")) + .resources(List.of( + "arn:aws:logs:*:*:log-group:/aws/ecs/*", + "arn:aws:logs:*:*:log-group:/ecs/*" + )) .build()); // Database secrets injection at container startup (scoped) diff --git a/infra/cdk/src/main/java/sample/com/constructs/WorkshopBucket.java b/infra/cdk/src/main/java/sample/com/constructs/WorkshopBucket.java index 8bdd611d..00406238 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/WorkshopBucket.java +++ b/infra/cdk/src/main/java/sample/com/constructs/WorkshopBucket.java @@ -4,11 +4,15 @@ import software.amazon.awscdk.RemovalPolicy; import software.amazon.awscdk.services.s3.BlockPublicAccess; import software.amazon.awscdk.services.s3.Bucket; +import software.amazon.awscdk.services.s3.BucketEncryption; +import software.amazon.awscdk.services.s3.CfnBucket; import software.amazon.awscdk.services.ssm.StringParameter; import software.constructs.Construct; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; /** * WorkshopBucket construct for shared workshop resources. @@ -17,6 +21,7 @@ public class WorkshopBucket extends Construct { private final Bucket bucket; + private final Bucket accessLogBucket; private final StringParameter bucketNameParameter; public static class WorkshopBucketProps { @@ -44,12 +49,28 @@ public WorkshopBucket(final Construct scope, final String id, final WorkshopBuck String prefix = props.getPrefix(); String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + this.accessLogBucket = Bucket.Builder.create(this, "AccessLogs") + .bucketName(String.format("%s-access-logs-%s-%s-%s", prefix, Aws.ACCOUNT_ID, Aws.REGION, timestamp)) + .blockPublicAccess(BlockPublicAccess.BLOCK_ALL) + .encryption(BucketEncryption.S3_MANAGED) + .enforceSsl(true) + .removalPolicy(RemovalPolicy.DESTROY) + .build(); + ((CfnBucket) accessLogBucket.getNode().getDefaultChild()).addMetadata("checkov", Map.of( + "skip", List.of(Map.of( + "id", "CKV_AWS_18", + "comment", "Dedicated access-log target; recursive logging is intentionally disabled." + )) + )); + // Create S3 bucket for workshop data (thread dumps, profiling data) // Note: autoDeleteObjects removed - CfnPreDeleteCleanup Lambda handles bucket emptying this.bucket = Bucket.Builder.create(this, "Bucket") .bucketName(String.format("%s-bucket-%s-%s-%s", prefix, Aws.ACCOUNT_ID, Aws.REGION, timestamp)) .blockPublicAccess(BlockPublicAccess.BLOCK_ALL) .enforceSsl(true) + .serverAccessLogsBucket(accessLogBucket) + .serverAccessLogsPrefix("workshop-data/") .removalPolicy(RemovalPolicy.DESTROY) .build(); @@ -66,6 +87,10 @@ public Bucket getBucket() { return bucket; } + public Bucket getAccessLogBucket() { + return accessLogBucket; + } + public StringParameter getBucketNameParameter() { return bucketNameParameter; } diff --git a/infra/cdk/src/main/resources/agentcore-identity-policy.json b/infra/cdk/src/main/resources/agentcore-identity-policy.json new file mode 100644 index 00000000..65385317 --- /dev/null +++ b/infra/cdk/src/main/resources/agentcore-identity-policy.json @@ -0,0 +1,16 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AgentCoreIdentityCredentialSecrets", + "Effect": "Allow", + "Action": [ + "secretsmanager:CreateSecret", + "secretsmanager:PutSecretValue", + "secretsmanager:GetSecretValue", + "secretsmanager:DeleteSecret" + ], + "Resource": "arn:aws:secretsmanager:*:{{.AccountId}}:secret:bedrock-agentcore-identity!*" + } + ] +} diff --git a/infra/cdk/src/main/resources/agentcore-managed-tools-policy.json b/infra/cdk/src/main/resources/agentcore-managed-tools-policy.json new file mode 100644 index 00000000..8ce2363a --- /dev/null +++ b/infra/cdk/src/main/resources/agentcore-managed-tools-policy.json @@ -0,0 +1,36 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AgentCoreManagedBrowserStreams", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:ConnectBrowserAutomationStream", + "bedrock-agentcore:ConnectBrowserLiveViewStream" + ], + "Resource": "*" + }, + { + "Sid": "AgentCoreManagedBrowser", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetBrowserSession", + "bedrock-agentcore:StartBrowserSession", + "bedrock-agentcore:StopBrowserSession", + "bedrock-agentcore:UpdateBrowserStream" + ], + "Resource": "arn:aws:bedrock-agentcore:*:aws:browser/aws.browser.v1" + }, + { + "Sid": "AgentCoreManagedCodeInterpreter", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetCodeInterpreterSession", + "bedrock-agentcore:InvokeCodeInterpreter", + "bedrock-agentcore:StartCodeInterpreterSession", + "bedrock-agentcore:StopCodeInterpreterSession" + ], + "Resource": "arn:aws:bedrock-agentcore:*:aws:code-interpreter/aws.codeinterpreter.v1" + } + ] +} diff --git a/infra/cdk/src/main/resources/iam-policy.json b/infra/cdk/src/main/resources/iam-policy.json index 223c5d73..c799ddd0 100644 --- a/infra/cdk/src/main/resources/iam-policy.json +++ b/infra/cdk/src/main/resources/iam-policy.json @@ -1,191 +1 @@ -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "MarketplaceSubscribeClaude", - "Effect": "Allow", - "Action": [ - "aws-marketplace:Subscribe" - ], - "Resource": "*", - "Condition": { - "Null": { - "aws-marketplace:ProductId": "false" - }, - "ForAllValues:StringEquals": { - "aws-marketplace:ProductId": [ - "prod-xdkflymybwmvi", - "prod-mxcfnwvpd6kb4", - "prod-jhuafngbly644", - "prod-5ukwuglpt66kg", - "prod-ffvjxvh4ltq64" - ] - } - } - }, - { - "Sid": "AllowedServices", - "Effect": "Allow", - "Action": [ - "aws-marketplace:Unsubscribe", - "aws-marketplace:ViewSubscriptions", - "acm:*", - "bedrock:*", - "bedrock-agentcore:*", - "apigateway:*", - "cloudfront:*", - "cognito-idp:*", - "application-autoscaling:*", - "application-signals:*", - "cloudformation:*", - "cloudtrail:*", - "cloudwatch:*", - "codewhisperer:*", - "dbqms:*", - "dynamodb:*", - "ec2:*", - "ecr:*", - "ecs:*", - "eks:*", - "elasticloadbalancing:*", - "events:*", - "lambda:*", - "logs:*", - "rds:*", - "rds-data:*", - "s3:*", - "s3vectors:*", - "secretsmanager:*", - "ssm:*", - "sts:*", - "tag:*", - "xray:*", - "q:*" - ], - "Resource": "*" - }, - { - "Sid": "PassRole", - "Effect": "Allow", - "Action": [ - "iam:PassRole" - ], - "Resource": [ - "arn:aws:iam::{{.AccountId}}:role/unicorn*", - "arn:aws:iam::{{.AccountId}}:role/service-role/unicorn*", - "arn:aws:iam::{{.AccountId}}:role/ai-jvm-analyzer*", - "arn:aws:iam::{{.AccountId}}:role/perf-analyzer*", - "arn:aws:iam::{{.AccountId}}:role/perf-collector*", - "arn:aws:iam::{{.AccountId}}:role/pyroscope*", - "arn:aws:iam::{{.AccountId}}:role/grafana*", - "arn:aws:iam::{{.AccountId}}:role/workshop*", - "arn:aws:iam::{{.AccountId}}:role/aiagent*", - "arn:aws:iam::{{.AccountId}}:role/mcpserver*" - ] - }, - { - "Sid": "CreateServiceLinkedRole", - "Effect": "Allow", - "Action": [ - "iam:CreateServiceLinkedRole" - ], - "Resource": [ - "arn:aws:iam::*:role/aws-service-role/application-signals.cloudwatch.amazonaws.com/*", - "arn:aws:iam::*:role/aws-service-role/cloudtrail.amazonaws.com/*" - ] - }, - { - "Sid": "GetRole", - "Effect": "Allow", - "Action": [ - "iam:GetRole", - "iam:ListRoles", - "iam:ListRolePolicies", - "iam:ListAttachedRolePolicies" - ], - "Resource": "*" - }, - { - "Sid": "AiAgentCreateRoles", - "Effect": "Allow", - "Action": [ - "iam:CreateRole", - "iam:DeleteRole", - "iam:PutRolePolicy", - "iam:DeleteRolePolicy", - "iam:AttachRolePolicy", - "iam:DetachRolePolicy", - "iam:UpdateAssumeRolePolicy" - ], - "Resource": [ - "arn:aws:iam::{{.AccountId}}:role/aiagent*", - "arn:aws:iam::{{.AccountId}}:role/mcp*", - "arn:aws:iam::{{.AccountId}}:role/backoffice*" - ], - "Condition": { - "StringEquals": { - "iam:PermissionsBoundary": "arn:aws:iam::{{.AccountId}}:policy/workshop-boundary" - } - } - }, - { - "Sid": "AiAgentPassRole", - "Effect": "Allow", - "Action": [ - "iam:PassRole" - ], - "Resource": [ - "arn:aws:iam::{{.AccountId}}:role/aiagent*", - "arn:aws:iam::{{.AccountId}}:role/mcp*", - "arn:aws:iam::{{.AccountId}}:role/backoffice*" - ], - "Condition": { - "StringEquals": { - "iam:PassedToService": [ - "bedrock.amazonaws.com", - "bedrock-agentcore.amazonaws.com", - "lambda.amazonaws.com" - ] - } - } - }, - { - "Sid": "DenyXXLInstances", - "Effect": "Deny", - "Action": "ec2:RunInstances", - "Condition": { - "StringLike": { - "ec2:InstanceType": [ - "*4xlarge", - "*6xlarge", - "*8xlarge", - "*9xlarge", - "*10xlarge", - "*12xlarge", - "*16xlarge", - "*18xlarge", - "*24xlarge", - "f1*", - "x1*", - "z1*", - "*metal" - ] - } - }, - "Resource": ["arn:aws:ec2:*:*:instance/*"] - }, - { - "Sid": "DenyReservations", - "Effect": "Deny", - "Action": [ - "ec2:ModifyReservedInstances", - "ec2:PurchaseHostReservation", - "ec2:PurchaseReservedInstancesOffering", - "ec2:PurchaseScheduledInstances", - "rds:PurchaseReservedDBInstancesOffering", - "dynamodb:PurchaseReservedCapacityOfferings" - ], - "Resource": "*" - } - ] -} +{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"aws-marketplace:Subscribe","Resource":"*","Condition":{"Null":{"aws-marketplace:ProductId":"false"},"ForAllValues:StringEquals":{"aws-marketplace:ProductId":["prod-xdkflymybwmvi","prod-mxcfnwvpd6kb4","prod-jhuafngbly644","prod-5ukwuglpt66kg","prod-ffvjxvh4ltq64"]}}},{"Effect":"Allow","Action":["acm:*","application-autoscaling:*","application-signals:*","bedrock:*","bedrock-agentcore:*","cloudformation:*","cloudtrail:*","cloudwatch:*","cognito-idp:*","ec2:*","ecr:*","eks:*","elasticloadbalancing:*","events:*","lambda:*","logs:*","rds:*","rds-data:*","s3vectors:*","secretsmanager:*","ssm:*","xray:*"],"Resource":["arn:aws:acm:*:{{.AccountId}}:certificate/*","arn:aws:application-autoscaling:*:{{.AccountId}}:scal*/*","arn:aws:application-signals:*:{{.AccountId}}:*","arn:aws:bedrock:*::foundation-model/*","arn:aws:bedrock:*:{{.AccountId}}:*","arn:aws:bedrock-agentcore:*:{{.AccountId}}:*","arn:aws:cloudformation:*:{{.AccountId}}:stack/workshop-*","arn:aws:cloudtrail:*:{{.AccountId}}:trail/workshop-*","arn:aws:cloudwatch:*:{{.AccountId}}:*","arn:aws:cognito-idp:*:{{.AccountId}}:userpool/*","arn:aws:ec2:*:{{.AccountId}}:*/*","arn:aws:ec2:*::image/*","arn:aws:ecr:*:{{.AccountId}}:repository/ai*","arn:aws:ecr:*:{{.AccountId}}:repository/perf-*","arn:aws:ecr:*:{{.AccountId}}:repository/unicorn*","arn:aws:ecr:*:{{.AccountId}}:repository/mcp*","arn:aws:ecr:*:{{.AccountId}}:repository/backoffice*","arn:aws:eks:*:{{.AccountId}}:cluster/*","arn:aws:elasticloadbalancing:*:{{.AccountId}}:*/*","arn:aws:events:*:{{.AccountId}}:rule/*","arn:aws:lambda:*:{{.AccountId}}:function:*","arn:aws:logs:*:{{.AccountId}}:log-group:*","arn:aws:rds:*:{{.AccountId}}:*:*","arn:aws:s3vectors:*:{{.AccountId}}:bucket/*","arn:aws:secretsmanager:*:{{.AccountId}}:secret:workshop-*","arn:aws:secretsmanager:*:{{.AccountId}}:secret:aiagent-*","arn:aws:secretsmanager:*:{{.AccountId}}:secret:mcp-*","arn:aws:ssm:*:{{.AccountId}}:parameter/workshop-*"]},{"Effect":"Allow","Action":["aws-marketplace:Unsubscribe","aws-marketplace:ViewSubscriptions"],"Resource":"*","Condition":{"ForAllValues:StringEquals":{"aws-marketplace:ProductId":["prod-xdkflymybwmvi","prod-mxcfnwvpd6kb4","prod-jhuafngbly644","prod-5ukwuglpt66kg","prod-ffvjxvh4ltq64"]}}},{"Effect":"Allow","Action":["cloudfront:CreateCloudFrontOriginAccessIdentity","cloudfront:CreateDistribution"],"Resource":"*","Condition":{"StringEquals":{"aws:PrincipalAccount":"{{.AccountId}}"}}},{"Effect":"Allow","Action":["apigateway:*","cloudfront:*","dynamodb:*","ecs:*","s3:*"],"Resource":["arn:aws:apigateway:*::/apis/*","arn:aws:apigateway:*::/restapis/*","arn:aws:cloudfront::{{.AccountId}}:distribution/*","arn:aws:cloudfront::{{.AccountId}}:origin-access-identity/cloudfront/*","arn:aws:dynamodb:*:{{.AccountId}}:table/backoffice-*","arn:aws:ecs:*:{{.AccountId}}:cluster/unicorn*","arn:aws:ecs:*:{{.AccountId}}:cluster/aiagent*","arn:aws:ecs:*:{{.AccountId}}:service/*/unicorn*","arn:aws:ecs:*:{{.AccountId}}:service/*/aiagent*","arn:aws:ecs:*:{{.AccountId}}:task/*/*","arn:aws:ecs:*:{{.AccountId}}:task-definition/unicorn*:*","arn:aws:ecs:*:{{.AccountId}}:task-definition/aiagent*:*","arn:aws:s3:::workshop-*","arn:aws:s3:::aiagent-*"]},{"Effect":"Allow","Action":["acm:ListCertificates","apigateway:GET","bedrock:List*","bedrock-agentcore:List*","cloudformation:List*","cloudfront:Get*","cloudfront:List*","cognito-idp:CreateUserPool","cognito-idp:ListUserPools","ec2:Describe*","ecr:CreateRepositoryCreationTemplate","ecr:Describe*","ecr:GetAuthorizationToken","ecs:Describe*","ecs:List*","ecs:RegisterTaskDefinition","eks:CreateCluster","eks:Describe*","eks:List*","elasticloadbalancing:Describe*","lambda:List*","logs:Describe*","rds:Describe*","s3:ListAllMyBuckets","s3vectors:CreateVectorBucket","s3vectors:ListVectorBuckets","secretsmanager:ListSecrets","ssm:DescribeParameters","sts:GetCallerIdentity","tag:GetResources","iam:GetRole","iam:GetRolePolicy","iam:ListRoles","iam:ListRolePolicies","iam:ListAttachedRolePolicies"],"Resource":"*"},{"Effect":"Deny","Action":"ec2:RunInstances","Condition":{"StringLike":{"ec2:InstanceType":["*4xlarge","*6xlarge","*8xlarge","*9xlarge","*10xlarge","*12xlarge","f1*","x1*","z1*","*metal"]}},"Resource":"arn:aws:ec2:*:*:instance/*"},{"Effect":"Deny","Action":["ec2:ModifyReservedInstances","ec2:PurchaseHostReservation","ec2:PurchaseReservedInstancesOffering","ec2:PurchaseScheduledInstances","rds:PurchaseReservedDBInstancesOffering","dynamodb:PurchaseReservedCapacityOfferings"],"Resource":"*"}]} diff --git a/infra/cdk/src/main/resources/iam-role-management-policy.json b/infra/cdk/src/main/resources/iam-role-management-policy.json new file mode 100644 index 00000000..678ab861 --- /dev/null +++ b/infra/cdk/src/main/resources/iam-role-management-policy.json @@ -0,0 +1,85 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "iam:PassRole", + "Resource": [ + "arn:aws:iam::{{.AccountId}}:role/unicorn*", + "arn:aws:iam::{{.AccountId}}:role/service-role/unicorn*", + "arn:aws:iam::{{.AccountId}}:role/ai-jvm-analyzer*", + "arn:aws:iam::{{.AccountId}}:role/perf-analyzer*", + "arn:aws:iam::{{.AccountId}}:role/perf-collector*", + "arn:aws:iam::{{.AccountId}}:role/pyroscope*", + "arn:aws:iam::{{.AccountId}}:role/grafana*", + "arn:aws:iam::{{.AccountId}}:role/workshop*", + "arn:aws:iam::{{.AccountId}}:role/aiagent*", + "arn:aws:iam::{{.AccountId}}:role/mcp*", + "arn:aws:iam::{{.AccountId}}:role/backoffice*" + ], + "Condition": { + "StringEquals": { + "iam:PassedToService": [ + "bedrock.amazonaws.com", + "bedrock-agentcore.amazonaws.com", + "codebuild.amazonaws.com", + "ec2.amazonaws.com", + "ecs.amazonaws.com", + "ecs-tasks.amazonaws.com", + "lambda.amazonaws.com", + "pods.eks.amazonaws.com" + ] + } + } + }, + { + "Effect": "Allow", + "Action": "iam:CreateServiceLinkedRole", + "Resource": "arn:aws:iam::*:role/aws-service-role/*", + "Condition": { + "StringEquals": { + "iam:AWSServiceName": [ + "application-signals.cloudwatch.amazonaws.com", + "cloudtrail.amazonaws.com", + "ecs.amazonaws.com", + "elasticloadbalancing.amazonaws.com", + "network.bedrock-agentcore.amazonaws.com", + "runtime-identity.bedrock-agentcore.amazonaws.com" + ] + } + } + }, + { + "Effect": "Allow", + "Action": "iam:CreateRole", + "Resource": [ + "arn:aws:iam::{{.AccountId}}:role/aiagent*", + "arn:aws:iam::{{.AccountId}}:role/mcp*", + "arn:aws:iam::{{.AccountId}}:role/backoffice*" + ], + "Condition": { + "StringEquals": { + "iam:PermissionsBoundary": "arn:aws:iam::{{.AccountId}}:policy/workshop-boundary" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "iam:DeleteRole", + "iam:PutRolePolicy", + "iam:DeleteRolePolicy", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "iam:UpdateAssumeRolePolicy" + ], + "Resource": [ + "arn:aws:iam::{{.AccountId}}:role/aiagent-kb-role", + "arn:aws:iam::{{.AccountId}}:role/aiagent-runtime-role", + "arn:aws:iam::{{.AccountId}}:role/mcp-gateway-role", + "arn:aws:iam::{{.AccountId}}:role/mcp-currency-role", + "arn:aws:iam::{{.AccountId}}:role/backoffice-role" + ] + } + ] +} diff --git a/infra/cdk/src/main/resources/lambda/cfn-pre-delete-cleanup.py b/infra/cdk/src/main/resources/lambda/cfn-pre-delete-cleanup.py index 3b9827bd..7c89f5a9 100644 --- a/infra/cdk/src/main/resources/lambda/cfn-pre-delete-cleanup.py +++ b/infra/cdk/src/main/resources/lambda/cfn-pre-delete-cleanup.py @@ -11,13 +11,14 @@ def lambda_handler(event, context): Custom Resource handler to cleanup resources before stack deletion. - GuardDuty VPC endpoints that block VPC deletion - GuardDuty managed security groups - - S3 bucket contents for workshop- buckets + - S3 buckets supplied by the stack Note: CloudWatch logs are kept for debugging/analysis """ print(f"Event: {event}") request_type = event['RequestType'] vpc_id = event['ResourceProperties'].get('VpcId', '') + bucket_names = event['ResourceProperties'].get('BucketNames', []) try: if request_type == 'Delete': @@ -25,7 +26,7 @@ def lambda_handler(event, context): endpoint_ids = start_guardduty_endpoint_deletion(vpc_id) # While endpoints are deleting, clean up S3 - cleanup_s3_buckets() + cleanup_s3_buckets(bucket_names) # Wait for VPC endpoint deletion to complete if endpoint_ids: @@ -112,17 +113,16 @@ def cleanup_guardduty_security_groups(vpc_id, max_retries=6, retry_delay=10): print("GuardDuty security group cleanup completed") -def cleanup_s3_buckets(): - """Empty S3 buckets with workshop- prefix.""" - try: - response = s3.list_buckets() - for bucket in response.get('Buckets', []): - bucket_name = bucket['Name'] - if bucket_name.startswith('workshop-'): - print(f"Emptying S3 bucket: {bucket_name}") - empty_bucket(bucket_name) - except Exception as e: - print(f"Error listing S3 buckets: {e}") +def cleanup_s3_buckets(bucket_names): + """Delete only the S3 buckets supplied by the stack.""" + for bucket_name in bucket_names: + print(f"Deleting S3 bucket: {bucket_name}") + empty_bucket(bucket_name) + try: + s3.delete_bucket(Bucket=bucket_name) + print(f"Deleted bucket: {bucket_name}") + except Exception as e: + print(f"Error deleting bucket {bucket_name}: {e}") print("S3 bucket cleanup completed") diff --git a/infra/cdk/src/main/resources/lambda/codebuild-report.py b/infra/cdk/src/main/resources/lambda/codebuild-report.py index 22c95831..55fce80e 100644 --- a/infra/cdk/src/main/resources/lambda/codebuild-report.py +++ b/infra/cdk/src/main/resources/lambda/codebuild-report.py @@ -1,50 +1,102 @@ -import boto3 import json +import os +import urllib.request -codebuild = boto3.client('codebuild') +import boto3 -def lambda_handler(event, context): - print(f'Build status event: {event}') - - try: - # Extract build information from EventBridge event - detail = event['detail'] - build_status = detail['build-status'] - project_name = detail['project-name'] - build_id = detail['build-id'] - - print(f'Build {build_id} for project {project_name} finished with status: {build_status}') - - if build_status == 'SUCCEEDED': - print('✅ CodeBuild setup completed successfully') - elif build_status == 'FAILED': - print('❌ CodeBuild setup failed') - - # Get build details for error information - response = codebuild.batch_get_builds(ids=[build_id]) - if response['builds']: - build = response['builds'][0] - if 'logs' in build and 'cloudWatchLogs' in build['logs']: - log_group = build['logs']['cloudWatchLogs'].get('groupName') - log_stream = build['logs']['cloudWatchLogs'].get('streamName') - print(f'Check logs at: {log_group}/{log_stream}') - elif build_status == 'STOPPED': - print('⏹️ CodeBuild setup was stopped') - - return { - 'statusCode': 200, - 'body': json.dumps({ - 'message': f'Processed build status: {build_status}', - 'buildId': build_id, - 'projectName': project_name - }) +codebuild = boto3.client("codebuild") +table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) + +FAILURE_STATUSES = {"FAILED", "FAULT", "STOPPED", "TIMED_OUT"} + + +def normalized_build_id(value): + if ":build/" in value: + return value.split(":build/", 1)[1] + return value + + +def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") + + +def failure_details(build): + details = [] + for phase in build.get("phases", []): + contexts = "; ".join( + context.get("message", "") for context in phase.get("contexts", []) + ) + if phase.get("phaseStatus") in FAILURE_STATUSES or contexts: + details.append( + f"{phase.get('phaseType')}={phase.get('phaseStatus')}: {contexts}".strip() + ) + logs = build.get("logs", {}) + if logs.get("deepLink"): + details.append(f"logs={logs['deepLink']}") + return " | ".join(details) or "No phase failure details were returned" + + +def lambda_handler(event, context): + detail = event["detail"] + event_build_id = detail["build-id"] + build_id = normalized_build_id(event_build_id) + print(f"Terminal CodeBuild event for {event_build_id}: {detail['build-status']}") + + item = table.get_item(Key={"BuildId": build_id}, ConsistentRead=True).get("Item") + if not item: + raise RuntimeError(f"Pending CloudFormation callback not found for {build_id}") + + build_response = codebuild.batch_get_builds(ids=[item.get("BuildArn", event_build_id)]) + builds = build_response.get("builds", []) + if len(builds) != 1: + raise RuntimeError(f"CodeBuild build not found: {event_build_id}") + + build = builds[0] + status = build["buildStatus"] + original_event = json.loads(item["CloudFormationEvent"]) + data = { + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": item["ProjectName"], + "BuildStatus": status, + } + + if status == "SUCCEEDED": + response_status = "SUCCESS" + reason = None + elif status in FAILURE_STATUSES: + response_status = "FAILED" + reason = f"CodeBuild finished with {status}: {failure_details(build)}" + else: + raise RuntimeError(f"Received non-terminal CodeBuild status {status}") - except Exception as e: - print(f'Error processing build status: {str(e)}') - return { - 'statusCode': 500, - 'body': json.dumps({ - 'error': str(e) - }) - } \ No newline at end of file + send_response( + original_event, + context, + response_status, + data, + item["PhysicalResourceId"], + reason, + ) + table.delete_item(Key={"BuildId": build_id}) + print(f"Sent {response_status} to CloudFormation for {build_id}") diff --git a/infra/cdk/src/main/resources/lambda/codebuild-start.py b/infra/cdk/src/main/resources/lambda/codebuild-start.py index abce2ff6..9a6b99ef 100644 --- a/infra/cdk/src/main/resources/lambda/codebuild-start.py +++ b/infra/cdk/src/main/resources/lambda/codebuild-start.py @@ -1,55 +1,67 @@ -import boto3 import json -import traceback -import cfnresponse - -codebuild = boto3.client('codebuild') - -def lambda_handler(event, context): - print(f'Event: {event}') - responseData = {} - status = cfnresponse.SUCCESS - physical_id = event.get('PhysicalResourceId', 'CodeBuildSetup') - - try: - if event['RequestType'] == 'Delete': - # Nothing to clean up for CodeBuild - responseData = {'Message': 'CodeBuild setup deleted'} - cfnresponse.send(event, context, status, responseData, physical_id) - return - - if event['RequestType'] == 'Update': - # For updates, trigger a new build - pass - - # Start CodeBuild project - props = event['ResourceProperties'] - project_name = props['ProjectName'] +import os +import time +import urllib.request - print(f'Starting CodeBuild project: {project_name}') - - response = codebuild.start_build( - projectName=project_name - ) +import boto3 - build_id = response['build']['id'] - build_arn = response['build']['arn'] +codebuild = boto3.client("codebuild") +table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) - print(f'Started build: {build_id}') - responseData = { - 'BuildId': build_id, - 'BuildArn': build_arn, - 'ProjectName': project_name +def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") + - # Use build ID as physical resource ID for tracking - physical_id = build_id +def lambda_handler(event, context): + print(f"RequestType={event['RequestType']} LogicalResourceId={event['LogicalResourceId']}") + project_name = event["ResourceProperties"]["ProjectName"] + physical_id = event.get("PhysicalResourceId", project_name) - except Exception as e: - status = cfnresponse.FAILED - tb_err = traceback.format_exc() - print(tb_err) - responseData = {'Error': tb_err} + if event["RequestType"] == "Delete": + send_response(event, context, "SUCCESS", {"ProjectName": project_name}, physical_id) + return - cfnresponse.send(event, context, status, responseData, physical_id) \ No newline at end of file + try: + build = codebuild.start_build(projectName=project_name)["build"] + table.put_item( + Item={ + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": project_name, + "PhysicalResourceId": project_name, + "CloudFormationEvent": json.dumps(event), + "ExpiresAt": int(time.time()) + 7200, + } + ) + print(f"Started CodeBuild project {project_name}: {build['id']}") + except Exception as error: + print(f"Failed to start or persist CodeBuild callback: {error}") + send_response( + event, + context, + "FAILED", + {"ProjectName": project_name}, + physical_id, + str(error), + ) diff --git a/infra/cdk/src/main/resources/lambda/thread-analysis-authorizer.py b/infra/cdk/src/main/resources/lambda/thread-analysis-authorizer.py new file mode 100644 index 00000000..98165cc0 --- /dev/null +++ b/infra/cdk/src/main/resources/lambda/thread-analysis-authorizer.py @@ -0,0 +1,31 @@ +import base64 +import hmac +import json +import os + +import boto3 + +secretsmanager = boto3.client("secretsmanager") + + +def lambda_handler(event, context): + headers = event.get("headers") or {} + authorization = next( + (value for name, value in headers.items() if name.lower() == "authorization"), + "", + ) + + try: + scheme, encoded_credentials = authorization.split(" ", 1) + if scheme.lower() != "basic": + return {"isAuthorized": False} + + username, password = base64.b64decode(encoded_credentials).decode("utf-8").split(":", 1) + secret = secretsmanager.get_secret_value(SecretId=os.environ["SECRET_NAME"]) + expected_password = json.loads(secret["SecretString"])["password"] + authorized = hmac.compare_digest(username, "grafana-alerts") and hmac.compare_digest( + password, expected_password + ) + return {"isAuthorized": authorized} + except (ValueError, KeyError, TypeError, UnicodeDecodeError, json.JSONDecodeError): + return {"isAuthorized": False} diff --git a/infra/cdk/src/main/resources/userdata.sh b/infra/cdk/src/main/resources/userdata.sh index be6e8a44..2fb0a9be 100644 --- a/infra/cdk/src/main/resources/userdata.sh +++ b/infra/cdk/src/main/resources/userdata.sh @@ -8,6 +8,9 @@ set -e export GIT_BRANCH="${GIT_BRANCH}" export AWS_REGION="${AWS_REGION}" export TEMPLATE_TYPE="${TEMPLATE_TYPE}" +export WORKSHOP_ID="${WORKSHOP_ID}" +export WORKSHOP_STACK_NAME="${WORKSHOP_STACK_NAME}" +export WORKSHOP_DEPLOYMENT_ID="${WORKSHOP_DEPLOYMENT_ID}" export ARCH="${ARCH}" export IDE_TYPE="${IDE_TYPE}" export WAIT_CONDITION_HANDLE_URL="${WAIT_CONDITION_HANDLE_URL}" diff --git a/infra/cdk/src/main/resources/workshop-boundary.json b/infra/cdk/src/main/resources/workshop-boundary.json index ef30a042..5c978494 100644 --- a/infra/cdk/src/main/resources/workshop-boundary.json +++ b/infra/cdk/src/main/resources/workshop-boundary.json @@ -2,31 +2,117 @@ "Version": "2012-10-17", "Statement": [ { - "Sid": "AllowedServicesForRoles", + "Sid": "BedrockRuntime", + "Effect": "Allow", + "Action": "bedrock:*", + "Resource": [ + "arn:aws:bedrock:*::foundation-model/*", + "arn:aws:bedrock:*:{{.AccountId}}:*" + ] + }, + { + "Sid": "BedrockMarketplaceModelAccess", "Effect": "Allow", "Action": [ "aws-marketplace:Subscribe", - "aws-marketplace:Unsubscribe", - "aws-marketplace:ViewSubscriptions", - "bedrock:*", - "bedrock-agentcore:*", - "cognito-idp:*", - "cloudfront:*", - "cloudwatch:*", + "aws-marketplace:ViewSubscriptions" + ], + "Resource": "*" + }, + { + "Sid": "AgentCoreRuntime", + "Effect": "Allow", + "Action": "bedrock-agentcore:*", + "Resource": "arn:aws:bedrock-agentcore:*:{{.AccountId}}:*" + }, + { + "Sid": "AgentCoreManagedBrowserStreams", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:ConnectBrowserAutomationStream", + "bedrock-agentcore:ConnectBrowserLiveViewStream" + ], + "Resource": "*" + }, + { + "Sid": "AgentCoreManagedBrowser", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetBrowserSession", + "bedrock-agentcore:StartBrowserSession", + "bedrock-agentcore:StopBrowserSession", + "bedrock-agentcore:UpdateBrowserStream" + ], + "Resource": "arn:aws:bedrock-agentcore:*:aws:browser/aws.browser.v1" + }, + { + "Sid": "AgentCoreManagedCodeInterpreter", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetCodeInterpreterSession", + "bedrock-agentcore:InvokeCodeInterpreter", + "bedrock-agentcore:StartCodeInterpreterSession", + "bedrock-agentcore:StopCodeInterpreterSession" + ], + "Resource": "arn:aws:bedrock-agentcore:*:aws:code-interpreter/aws.codeinterpreter.v1" + }, + { + "Sid": "WorkshopData", + "Effect": "Allow", + "Action": [ "dynamodb:*", - "ec2:CreateNetworkInterface", - "ec2:DeleteNetworkInterface", - "ec2:DescribeNetworkInterfaces", - "ec2:DescribeSecurityGroups", - "ec2:DescribeSubnets", - "ec2:DescribeVpcs", "ecr:*", "lambda:InvokeFunction", "logs:*", "s3:*", "s3vectors:*", - "secretsmanager:GetSecretValue", - "xray:*" + "secretsmanager:GetSecretValue" + ], + "Resource": [ + "arn:aws:dynamodb:*:{{.AccountId}}:table/backoffice-*", + "arn:aws:dynamodb:*:{{.AccountId}}:table/backoffice-*/*", + "arn:aws:ecr:*:{{.AccountId}}:repository/aiagent*", + "arn:aws:ecr:*:{{.AccountId}}:repository/backoffice*", + "arn:aws:lambda:*:{{.AccountId}}:function:mcp-*", + "arn:aws:logs:*:{{.AccountId}}:log-group:/aws/bedrock-agentcore/*", + "arn:aws:logs:*:{{.AccountId}}:log-group:/aws/bedrock-agentcore/*:*", + "arn:aws:logs:*:{{.AccountId}}:log-group:/aws/lambda/mcp-*", + "arn:aws:logs:*:{{.AccountId}}:log-group:/aws/lambda/mcp-*:*", + "arn:aws:s3:::workshop-*", + "arn:aws:s3:::workshop-*/*", + "arn:aws:s3:::aiagent-kb-data-*", + "arn:aws:s3:::aiagent-kb-data-*/*", + "arn:aws:s3vectors:*:{{.AccountId}}:bucket/aiagent-*", + "arn:aws:secretsmanager:*:{{.AccountId}}:secret:workshop-*", + "arn:aws:secretsmanager:*:{{.AccountId}}:secret:aiagent-*", + "arn:aws:secretsmanager:*:{{.AccountId}}:secret:mcp-*", + "arn:aws:secretsmanager:*:{{.AccountId}}:secret:bedrock-agentcore-identity!*" + ] + }, + { + "Sid": "RuntimeNetworkInterfaces", + "Effect": "Allow", + "Action": [ + "ec2:CreateNetworkInterface", + "ec2:DeleteNetworkInterface" + ], + "Resource": "arn:aws:ec2:*:{{.AccountId}}:network-interface/*" + }, + { + "Sid": "RuntimeReadAndTelemetry", + "Effect": "Allow", + "Action": [ + "cloudwatch:PutMetricData", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeVpcs", + "ecr:GetAuthorizationToken", + "logs:DescribeLogGroups", + "xray:GetSamplingRules", + "xray:GetSamplingTargets", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments" ], "Resource": "*" }, diff --git a/infra/cfn/java-ai-agents-advanced-stack.yaml b/infra/cfn/java-ai-agents-advanced-stack.yaml index d9339bc0..a1059a87 100644 --- a/infra/cfn/java-ai-agents-advanced-stack.yaml +++ b/infra/cfn/java-ai-agents-advanced-stack.yaml @@ -13,10 +13,14 @@ Metadata: reason: API Gateway access logging not needed for workshop - id: AwsSolutions-COG4 reason: Workshop environment does not require Cognito User Pool authorization - - id: AwsSolutions-IAM4 - reason: AWS Managed policies are acceptable for workshop - - id: AwsSolutions-IAM5 - reason: Wildcard permissions acceptable for workshop parallel resource creation + - applies_to: + - regex: /^Policy::.*$/g + id: AwsSolutions-IAM4 + reason: AWS managed policies are acceptable for the ephemeral workshop environment + - applies_to: + - regex: /^(Action|Resource)::.*$/g + id: AwsSolutions-IAM5 + reason: Wildcard permissions are acceptable for the ephemeral workshop environment - id: AwsSolutions-RDS2 reason: Workshop non-sensitive test database does not need encryption at rest - id: AwsSolutions-RDS3 @@ -137,10 +141,21 @@ Resources: Version: "2012-10-17" PolicyName: BedrockLogging RoleName: workshop-bedrock-logging-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CfnPreDeleteCleanupFA953B95: DeletionPolicy: Delete Properties: + BucketNames: + - Ref: WorkshopBucketFD5BC43F + - Ref: WorkshopBucketAccessLogs476BAB88 ServiceToken: Fn::GetAtt: - CfnPreDeleteCleanupFunction580FB700 @@ -169,13 +184,14 @@ Resources: Custom Resource handler to cleanup resources before stack deletion. - GuardDuty VPC endpoints that block VPC deletion - GuardDuty managed security groups - - S3 bucket contents for workshop- buckets + - S3 buckets supplied by the stack Note: CloudWatch logs are kept for debugging/analysis """ print(f"Event: {event}") request_type = event['RequestType'] vpc_id = event['ResourceProperties'].get('VpcId', '') + bucket_names = event['ResourceProperties'].get('BucketNames', []) try: if request_type == 'Delete': @@ -183,7 +199,7 @@ Resources: endpoint_ids = start_guardduty_endpoint_deletion(vpc_id) # While endpoints are deleting, clean up S3 - cleanup_s3_buckets() + cleanup_s3_buckets(bucket_names) # Wait for VPC endpoint deletion to complete if endpoint_ids: @@ -270,17 +286,16 @@ Resources: print("GuardDuty security group cleanup completed") - def cleanup_s3_buckets(): - """Empty S3 buckets with workshop- prefix.""" - try: - response = s3.list_buckets() - for bucket in response.get('Buckets', []): - bucket_name = bucket['Name'] - if bucket_name.startswith('workshop-'): - print(f"Emptying S3 bucket: {bucket_name}") - empty_bucket(bucket_name) - except Exception as e: - print(f"Error listing S3 buckets: {e}") + def cleanup_s3_buckets(bucket_names): + """Delete only the S3 buckets supplied by the stack.""" + for bucket_name in bucket_names: + print(f"Deleting S3 bucket: {bucket_name}") + empty_bucket(bucket_name) + try: + s3.delete_bucket(Bucket=bucket_name) + print(f"Deleted bucket: {bucket_name}") + except Exception as e: + print(f"Error deleting bucket {bucket_name}: {e}") print("S3 bucket cleanup completed") @@ -328,6 +343,14 @@ Resources: - CfnPreDeleteCleanupRole2BF62B3D - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Timeout: 600 Type: AWS::Lambda::Function CfnPreDeleteCleanupRole2BF62B3D: @@ -345,23 +368,103 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CfnPreDeleteCleanupRoleDefaultPolicy7B910EB7: Properties: PolicyDocument: Statement: - Action: - - ec2:DeleteSecurityGroup - - ec2:DeleteVpcEndpoints - ec2:DescribeSecurityGroups - ec2:DescribeVpcEndpoints - - s3:DeleteObject - - s3:DeleteObjectVersion - - s3:ListAllMyBuckets + Effect: Allow + Resource: "*" + - Action: ec2:DeleteVpcEndpoints + Condition: + StringEquals: + ec2:Vpc: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc/ + - Ref: VpcC3027511 + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc-endpoint/* + - Action: ec2:DeleteSecurityGroup + Condition: + StringEquals: + ec2:Vpc: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc/ + - Ref: VpcC3027511 + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :security-group/* + - Action: + - s3:DeleteBucket - s3:ListBucket - s3:ListBucketVersions Effect: Allow - Resource: "*" + Resource: + - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - Action: + - s3:DeleteObject + - s3:DeleteObjectVersion + Effect: Allow + Resource: + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /* + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - /* Version: "2012-10-17" PolicyName: CfnPreDeleteCleanupRoleDefaultPolicy7B910EB7 Roles: @@ -372,13 +475,14 @@ Resources: DependsOn: - CodeBuildCompleteRuleAllowEventRuleWorkshopStackCodeBuildReportLambdaFunctionD77C60919E0B0C89 - CodeBuildCompleteRuleEE9277E8 + - CodeBuildPendingBuilds19869454 + - CodeBuildProjectPolicyDocument567377F5 + - CodeBuildProjectA0FF5539 + - CodeBuildProjectSecurityGroup7CE557B3 - CodeBuildReportLambdaFunctionA3C396F7 + - CodeBuildStartLambdaFunction8349284F Properties: - CodeBuildIamRoleArn: - Fn::GetAtt: - - CodeBuildRoleE9A44575 - - Arn - ContentHash: "1774259714915" + ContentHash: "1787668864122" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -408,7 +512,9 @@ Resources: build-status: - SUCCEEDED - FAILED + - FAULT - STOPPED + - TIMED_OUT project-name: - Ref: CodeBuildProjectA0FF5539 detail-type: @@ -416,6 +522,14 @@ Resources: source: - aws.codebuild State: ENABLED + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Targets: - Arn: Fn::GetAtt: @@ -438,6 +552,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CodeBuildLambdaRoleDefaultPolicyFB35F0AF: Properties: @@ -447,12 +569,72 @@ Resources: - codebuild:BatchGetBuilds - codebuild:StartBuild Effect: Allow - Resource: "*" + Resource: + Fn::GetAtt: + - CodeBuildProjectA0FF5539 + - Arn + - Action: + - dynamodb:BatchWriteItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + - dynamodb:PutItem + - dynamodb:UpdateItem + Effect: Allow + Resource: + Fn::GetAtt: + - CodeBuildPendingBuilds19869454 + - Arn + - Action: + - dynamodb:BatchGetItem + - dynamodb:BatchWriteItem + - dynamodb:ConditionCheckItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + - dynamodb:GetItem + - dynamodb:GetRecords + - dynamodb:GetShardIterator + - dynamodb:PutItem + - dynamodb:Query + - dynamodb:Scan + - dynamodb:UpdateItem + Effect: Allow + Resource: + Fn::GetAtt: + - CodeBuildPendingBuilds19869454 + - Arn Version: "2012-10-17" PolicyName: CodeBuildLambdaRoleDefaultPolicyFB35F0AF Roles: - Ref: CodeBuildLambdaRole655C06B4 Type: AWS::IAM::Policy + CodeBuildPendingBuilds19869454: + DeletionPolicy: Delete + Metadata: + cdk_nag: + rules_to_suppress: + - id: AwsSolutions-DDB3 + reason: The table stores short-lived CloudFormation callback state and does not require point-in-time recovery + Properties: + AttributeDefinitions: + - AttributeName: BuildId + AttributeType: S + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: BuildId + KeyType: HASH + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation + TimeToLiveSpecification: + AttributeName: ExpiresAt + Enabled: true + Type: AWS::DynamoDB::Table + UpdateReplacePolicy: Delete CodeBuildProjectA0FF5539: DependsOn: - CodeBuildProjectPolicyDocument567377F5 @@ -465,12 +647,12 @@ Resources: Environment: ComputeType: BUILD_GENERAL1_MEDIUM EnvironmentVariables: - - Name: GIT_BRANCH - Type: PLAINTEXT - Value: main - Name: TEMPLATE_TYPE Type: PLAINTEXT Value: java-ai-agents-advanced + - Name: GIT_BRANCH + Type: PLAINTEXT + Value: feat/holmes-remediation Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 ImagePullCredentialsType: CODEBUILD PrivilegedMode: false @@ -502,6 +684,14 @@ Resources: aws iam create-service-linked-role --aws-service-name network.bedrock-agentcore.amazonaws.com 2>/dev/null || true aws iam create-service-linked-role --aws-service-name runtime-identity.bedrock-agentcore.amazonaws.com 2>/dev/null || true Type: NO_SOURCE + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation TimeoutInMinutes: 30 VpcConfig: SecurityGroupIds: @@ -515,12 +705,102 @@ Resources: Ref: VpcC3027511 Type: AWS::CodeBuild::Project CodeBuildProjectPolicyDocument567377F5: + Metadata: + checkov: + skip: + - comment: CodeBuild requires ec2:DeleteNetworkInterface on wildcard resources because the API authorizes deletion against arn:aws:ec2:region:account:*/*. + id: CKV_AWS_111 Properties: PolicyDocument: Statement: - Action: - ec2:CreateNetworkInterface + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet1Subnet67A4DBCB + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet2SubnetC8EB537D + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :security-group/ + - Fn::GetAtt: + - CodeBuildProjectSecurityGroup7CE557B3 + - GroupId + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :network-interface/* + - Action: + - ec2:CreateNetworkInterfacePermission + Condition: + ArnEquals: + ec2:Subnet: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet1Subnet67A4DBCB + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet2SubnetC8EB537D + StringEquals: + ec2:AuthorizedService: codebuild.amazonaws.com + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :network-interface/* + - Action: - ec2:DeleteNetworkInterface + Effect: Allow + Resource: "*" + - Action: - ec2:DescribeDhcpOptions - ec2:DescribeNetworkInterfaces - ec2:DescribeSecurityGroups @@ -540,6 +820,14 @@ Resources: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: "-1" + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -549,57 +837,113 @@ Resources: - CodeBuildLambdaRole655C06B4 Properties: Code: - ZipFile: |- - import boto3 + ZipFile: | import json + import os + import urllib.request + + import boto3 - codebuild = boto3.client('codebuild') + codebuild = boto3.client("codebuild") + table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) - def lambda_handler(event, context): - print(f'Build status event: {event}') + FAILURE_STATUSES = {"FAILED", "FAULT", "STOPPED", "TIMED_OUT"} - try: - # Extract build information from EventBridge event - detail = event['detail'] - build_status = detail['build-status'] - project_name = detail['project-name'] - build_id = detail['build-id'] - - print(f'Build {build_id} for project {project_name} finished with status: {build_status}') - - if build_status == 'SUCCEEDED': - print('✅ CodeBuild setup completed successfully') - elif build_status == 'FAILED': - print('❌ CodeBuild setup failed') - - # Get build details for error information - response = codebuild.batch_get_builds(ids=[build_id]) - if response['builds']: - build = response['builds'][0] - if 'logs' in build and 'cloudWatchLogs' in build['logs']: - log_group = build['logs']['cloudWatchLogs'].get('groupName') - log_stream = build['logs']['cloudWatchLogs'].get('streamName') - print(f'Check logs at: {log_group}/{log_stream}') - elif build_status == 'STOPPED': - print('⏹️ CodeBuild setup was stopped') - - return { - 'statusCode': 200, - 'body': json.dumps({ - 'message': f'Processed build status: {build_status}', - 'buildId': build_id, - 'projectName': project_name - }) - } - except Exception as e: - print(f'Error processing build status: {str(e)}') - return { - 'statusCode': 500, - 'body': json.dumps({ - 'error': str(e) - }) + def normalized_build_id(value): + if ":build/" in value: + return value.split(":build/", 1)[1] + return value + + + def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") + + + def failure_details(build): + details = [] + for phase in build.get("phases", []): + contexts = "; ".join( + context.get("message", "") for context in phase.get("contexts", []) + ) + if phase.get("phaseStatus") in FAILURE_STATUSES or contexts: + details.append( + f"{phase.get('phaseType')}={phase.get('phaseStatus')}: {contexts}".strip() + ) + logs = build.get("logs", {}) + if logs.get("deepLink"): + details.append(f"logs={logs['deepLink']}") + return " | ".join(details) or "No phase failure details were returned" + + + def lambda_handler(event, context): + detail = event["detail"] + event_build_id = detail["build-id"] + build_id = normalized_build_id(event_build_id) + print(f"Terminal CodeBuild event for {event_build_id}: {detail['build-status']}") + + item = table.get_item(Key={"BuildId": build_id}, ConsistentRead=True).get("Item") + if not item: + raise RuntimeError(f"Pending CloudFormation callback not found for {build_id}") + + build_response = codebuild.batch_get_builds(ids=[item.get("BuildArn", event_build_id)]) + builds = build_response.get("builds", []) + if len(builds) != 1: + raise RuntimeError(f"CodeBuild build not found: {event_build_id}") + + build = builds[0] + status = build["buildStatus"] + original_event = json.loads(item["CloudFormationEvent"]) + data = { + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": item["ProjectName"], + "BuildStatus": status, + } + + if status == "SUCCEEDED": + response_status = "SUCCESS" + reason = None + elif status in FAILURE_STATUSES: + response_status = "FAILED" + reason = f"CodeBuild finished with {status}: {failure_details(build)}" + else: + raise RuntimeError(f"Received non-terminal CodeBuild status {status}") + + send_response( + original_event, + context, + response_status, + data, + item["PhysicalResourceId"], + reason, + ) + table.delete_item(Key={"BuildId": build_id}) + print(f"Sent {response_status} to CloudFormation for {build_id}") + Environment: + Variables: + PENDING_TABLE_NAME: + Ref: CodeBuildPendingBuilds19869454 FunctionName: workshop-setup-report Handler: index.lambda_handler Role: @@ -607,12 +951,30 @@ Resources: - CodeBuildLambdaRole655C06B4 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Timeout: 120 Type: AWS::Lambda::Function CodeBuildRoleDefaultPolicy196BAF24: Properties: PolicyDocument: Statement: + - Action: iam:CreateServiceLinkedRole + Condition: + StringEquals: + iam:AWSServiceName: + - ecs.amazonaws.com + - elasticloadbalancing.amazonaws.com + - network.bedrock-agentcore.amazonaws.com + - runtime-identity.bedrock-agentcore.amazonaws.com + Effect: Allow + Resource: arn:aws:iam::*:role/aws-service-role/* - Action: ec2:CreateNetworkInterfacePermission Condition: StringEquals: @@ -709,12 +1071,14 @@ Resources: Principal: Service: codebuild.amazonaws.com Version: "2012-10-17" - ManagedPolicyArns: - - Fn::Join: - - "" - - - "arn:" - - Ref: AWS::Partition - - :iam::aws:policy/PowerUserAccess + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CodeBuildStartLambdaFunction8349284F: DependsOn: @@ -722,62 +1086,78 @@ Resources: - CodeBuildLambdaRole655C06B4 Properties: Code: - ZipFile: |- - import boto3 + ZipFile: | import json - import traceback - import cfnresponse - - codebuild = boto3.client('codebuild') + import os + import time + import urllib.request - def lambda_handler(event, context): - print(f'Event: {event}') - responseData = {} - status = cfnresponse.SUCCESS - physical_id = event.get('PhysicalResourceId', 'CodeBuildSetup') + import boto3 - try: - if event['RequestType'] == 'Delete': - # Nothing to clean up for CodeBuild - responseData = {'Message': 'CodeBuild setup deleted'} - cfnresponse.send(event, context, status, responseData, physical_id) - return + codebuild = boto3.client("codebuild") + table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) + + + def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, + } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") - if event['RequestType'] == 'Update': - # For updates, trigger a new build - pass - # Start CodeBuild project - props = event['ResourceProperties'] - project_name = props['ProjectName'] + def lambda_handler(event, context): + print(f"RequestType={event['RequestType']} LogicalResourceId={event['LogicalResourceId']}") + project_name = event["ResourceProperties"]["ProjectName"] + physical_id = event.get("PhysicalResourceId", project_name) - print(f'Starting CodeBuild project: {project_name}') + if event["RequestType"] == "Delete": + send_response(event, context, "SUCCESS", {"ProjectName": project_name}, physical_id) + return - response = codebuild.start_build( - projectName=project_name + try: + build = codebuild.start_build(projectName=project_name)["build"] + table.put_item( + Item={ + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": project_name, + "PhysicalResourceId": project_name, + "CloudFormationEvent": json.dumps(event), + "ExpiresAt": int(time.time()) + 7200, + } ) - - build_id = response['build']['id'] - build_arn = response['build']['arn'] - - print(f'Started build: {build_id}') - - responseData = { - 'BuildId': build_id, - 'BuildArn': build_arn, - 'ProjectName': project_name - } - - # Use build ID as physical resource ID for tracking - physical_id = build_id - - except Exception as e: - status = cfnresponse.FAILED - tb_err = traceback.format_exc() - print(tb_err) - responseData = {'Error': tb_err} - - cfnresponse.send(event, context, status, responseData, physical_id) + print(f"Started CodeBuild project {project_name}: {build['id']}") + except Exception as error: + print(f"Failed to start or persist CodeBuild callback: {error}") + send_response( + event, + context, + "FAILED", + {"ProjectName": project_name}, + physical_id, + str(error), + ) + Environment: + Variables: + PENDING_TABLE_NAME: + Ref: CodeBuildPendingBuilds19869454 FunctionName: workshop-setup-start Handler: index.lambda_handler Role: @@ -785,6 +1165,14 @@ Resources: - CodeBuildLambdaRole655C06B4 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Timeout: 120 Type: AWS::Lambda::Function EcrRegistryTemplateD54113AB: @@ -835,6 +1223,13 @@ Resources: Value: workshop - Key: ManagedBy Value: ecr-create-on-push + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopOwner + Value: workshop-run Type: AWS::ECR::RepositoryCreationTemplate EcrRegistryTemplateRole9295BC5C: Properties: @@ -846,6 +1241,14 @@ Resources: Service: ecr.amazonaws.com Version: "2012-10-17" RoleName: workshop-ecr-template-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EcrRegistryTemplateRoleDefaultPolicy760EC63A: Properties: @@ -856,20 +1259,89 @@ Resources: - ecr:PutLifecyclePolicy - ecr:TagResource Effect: Allow - Resource: "*" + Resource: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ecr:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :repository/aiagent + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ecr:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :repository/backoffice Version: "2012-10-17" PolicyName: EcrRegistryTemplateRoleDefaultPolicy760EC63A Roles: - Ref: EcrRegistryTemplateRole9295BC5C Type: AWS::IAM::Policy - IdeDistribution042A6660: - DeletionPolicy: Delete - DependsOn: - - IdeEipAssociationDFF81215 + IdeAgentCoreIdentityPolicy5C973EFA: Properties: - DistributionConfig: - DefaultCacheBehavior: - AllowedMethods: + Description: "" + Path: / + PolicyDocument: + Statement: + - Action: + - secretsmanager:CreateSecret + - secretsmanager:DeleteSecret + - secretsmanager:GetSecretValue + - secretsmanager:PutSecretValue + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:bedrock-agentcore-identity!* + Sid: AgentCoreIdentityCredentialSecrets + Version: "2012-10-17" + Type: AWS::IAM::ManagedPolicy + IdeAgentCoreManagedToolsPolicy33EC19D9: + Properties: + Description: "" + Path: / + PolicyDocument: + Statement: + - Action: + - bedrock-agentcore:ConnectBrowserAutomationStream + - bedrock-agentcore:ConnectBrowserLiveViewStream + Effect: Allow + Resource: "*" + Sid: AgentCoreManagedBrowserStreams + - Action: + - bedrock-agentcore:GetBrowserSession + - bedrock-agentcore:StartBrowserSession + - bedrock-agentcore:StopBrowserSession + - bedrock-agentcore:UpdateBrowserStream + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:browser/aws.browser.v1 + Sid: AgentCoreManagedBrowser + - Action: + - bedrock-agentcore:GetCodeInterpreterSession + - bedrock-agentcore:InvokeCodeInterpreter + - bedrock-agentcore:StartCodeInterpreterSession + - bedrock-agentcore:StopCodeInterpreterSession + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:code-interpreter/aws.codeinterpreter.v1 + Sid: AgentCoreManagedCodeInterpreter + Version: "2012-10-17" + Type: AWS::IAM::ManagedPolicy + IdeDistribution042A6660: + DeletionPolicy: Delete + DependsOn: + - IdeEipAssociationDFF81215 + Properties: + DistributionConfig: + DefaultCacheBehavior: + AllowedMethods: - GET - HEAD - OPTIONS @@ -928,6 +1400,14 @@ Resources: - PublicIp - .compute-1.amazonaws.com Id: WorkshopStackIdeDistributionOrigin10FECA386 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::CloudFront::Distribution UpdateReplacePolicy: Delete IdeEC2InstanceResource438B3605: @@ -974,12 +1454,21 @@ Resources: # This keeps UserData under size limits while allowing unlimited bootstrap size # Configuration from CDK - export GIT_BRANCH="main" + export GIT_BRANCH="feat/holmes-remediation" export AWS_REGION=" - Ref: AWS::Region - |- " export TEMPLATE_TYPE="java-ai-agents-advanced" + export WORKSHOP_ID="java-ai-agents-advanced" + export WORKSHOP_STACK_NAME=" + - Ref: AWS::StackName + - |- + " + export WORKSHOP_DEPLOYMENT_ID=" + - Ref: AWS::StackId + - |- + " export ARCH="aarch64" export IDE_TYPE="code-editor" export WAIT_CONDITION_HANDLE_URL=" @@ -1114,6 +1603,14 @@ Resources: IdeElasticIP3327A0B5: Properties: Domain: vpc + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::EIP IdeInstanceLauncherFunction803C5A2A: DependsOn: @@ -1258,6 +1755,14 @@ Resources: - IdeLambdaRole82392143 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Timeout: 300 Type: AWS::Lambda::Function IdeInstanceProfile61B92038: @@ -1270,6 +1775,14 @@ Resources: Properties: GroupDescription: IDE internal security group GroupName: workshop-ide-internal-sg + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -1314,27 +1827,57 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdeLambdaRoleDefaultPolicy099093D2: Properties: PolicyDocument: Statement: - Action: - - ec2:CreateTags - ec2:DescribeInstanceStatus - ec2:DescribeInstances - ec2:DescribeManagedPrefixLists - ec2:DescribeSubnets - - ec2:RunInstances - - ec2:TerminateInstances - - iam:PassRole - - secretsmanager:DescribeSecret - - secretsmanager:GetSecretValue - - ssm:DescribeInstanceInformation - - ssm:GetCommandInvocation - - ssm:SendCommand Effect: Allow Resource: "*" + - Action: ec2:RunInstances + Effect: Allow + Resource: + - arn:aws:ec2:*:*:instance/* + - arn:aws:ec2:*:*:network-interface/* + - arn:aws:ec2:*:*:security-group/* + - arn:aws:ec2:*:*:subnet/* + - arn:aws:ec2:*:*:volume/* + - arn:aws:ec2:*::image/* + - Action: ec2:CreateTags + Condition: + StringEquals: + aws:RequestTag/Workshop: "true" + ec2:CreateAction: RunInstances + Effect: Allow + Resource: arn:aws:ec2:*:*:instance/* + - Action: ec2:TerminateInstances + Condition: + StringEquals: + ec2:ResourceTag/Workshop: "true" + Effect: Allow + Resource: arn:aws:ec2:*:*:instance/* + - Action: iam:PassRole + Condition: + StringEquals: + iam:PassedToService: ec2.amazonaws.com + Effect: Allow + Resource: + Fn::GetAtt: + - IdeRole4650E22E + - Arn Version: "2012-10-17" PolicyName: IdeLambdaRoleDefaultPolicy099093D2 Roles: @@ -1398,6 +1941,14 @@ Resources: - IdePasswordFunctionServiceRole971421B6 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Timeout: 180 Type: AWS::Lambda::Function IdePasswordFunctionServiceRole971421B6: @@ -1415,6 +1966,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdePasswordFunctionServiceRoleDefaultPolicy282A360C: Properties: @@ -1481,6 +2040,14 @@ Resources: PasswordLength: 32 SecretStringTemplate: '{"password":""}' Name: workshop-ide-password + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::SecretsManager::Secret UpdateReplacePolicy: Delete IdePrefixListLookupFunction9E5A63DE: @@ -1532,6 +2099,14 @@ Resources: - IdeLambdaRole82392143 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Timeout: 180 Type: AWS::Lambda::Function IdePrefixListResourceD2EAC007: @@ -1569,15 +2144,23 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeRoleManagementPolicyFE7F8500 + - Ref: IdeAgentCoreManagedToolsPolicy33EC19D9 + - Ref: IdeAgentCoreIdentityPolicy5C973EFA RoleName: workshop-ide-user + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdeRoleDefaultPolicyFD4BDE67: Properties: PolicyDocument: Statement: - - Action: cloudformation:SignalResource - Effect: Allow - Resource: "*" - Action: - secretsmanager:DescribeSecret - secretsmanager:GetSecretValue @@ -1589,6 +2172,155 @@ Resources: Roles: - Ref: IdeRole4650E22E Type: AWS::IAM::Policy + IdeRoleManagementPolicyFE7F8500: + Properties: + Description: "" + Path: / + PolicyDocument: + Statement: + - Action: iam:PassRole + Condition: + StringEquals: + iam:PassedToService: + - bedrock.amazonaws.com + - bedrock-agentcore.amazonaws.com + - codebuild.amazonaws.com + - ec2.amazonaws.com + - ecs.amazonaws.com + - ecs-tasks.amazonaws.com + - lambda.amazonaws.com + - pods.eks.amazonaws.com + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/ai-jvm-analyzer* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/grafana* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/perf-analyzer* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/perf-collector* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/pyroscope* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/service-role/unicorn* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/unicorn* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/workshop* + - Action: iam:CreateServiceLinkedRole + Condition: + StringEquals: + iam:AWSServiceName: + - application-signals.cloudwatch.amazonaws.com + - cloudtrail.amazonaws.com + - ecs.amazonaws.com + - elasticloadbalancing.amazonaws.com + - network.bedrock-agentcore.amazonaws.com + - runtime-identity.bedrock-agentcore.amazonaws.com + Effect: Allow + Resource: arn:aws:iam::*:role/aws-service-role/* + - Action: iam:CreateRole + Condition: + StringEquals: + iam:PermissionsBoundary: + Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :policy/workshop-boundary + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp* + - Action: + - iam:AttachRolePolicy + - iam:DeleteRole + - iam:DeleteRolePolicy + - iam:DetachRolePolicy + - iam:PutRolePolicy + - iam:UpdateAssumeRolePolicy + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent-kb-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent-runtime-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp-currency-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp-gateway-role + Version: "2012-10-17" + Type: AWS::IAM::ManagedPolicy IdeSecurityGroup73B02454: Properties: GroupDescription: IDE security group @@ -1597,6 +2329,14 @@ Resources: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: "-1" + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -1628,104 +2368,291 @@ Resources: - prod-xdkflymybwmvi - prod-mxcfnwvpd6kb4 - prod-jhuafngbly644 - - prod-4pmewlybdftbs + - prod-5ukwuglpt66kg + - prod-ffvjxvh4ltq64 "Null": aws-marketplace:ProductId: "false" Effect: Allow Resource: "*" - Sid: MarketplaceSubscribeClaude - Action: - acm:* - - apigateway:* - application-autoscaling:* - application-signals:* - - aws-marketplace:Unsubscribe - - aws-marketplace:ViewSubscriptions - bedrock-agentcore:* - bedrock:* - cloudformation:* - - cloudfront:* - cloudtrail:* - cloudwatch:* - - codewhisperer:* - cognito-idp:* - - dbqms:* - - dynamodb:* - ec2:* - ecr:* - - ecs:* - eks:* - elasticloadbalancing:* - events:* - lambda:* - logs:* - - q:* - rds-data:* - rds:* - - s3:* - s3vectors:* - secretsmanager:* - ssm:* - - sts:* - - tag:* - xray:* Effect: Allow + Resource: + - arn:aws:bedrock:*::foundation-model/* + - arn:aws:ec2:*::image/* + - Fn::Join: + - "" + - - "arn:aws:acm:*:" + - Ref: AWS::AccountId + - :certificate/* + - Fn::Join: + - "" + - - "arn:aws:application-autoscaling:*:" + - Ref: AWS::AccountId + - :scal*/* + - Fn::Join: + - "" + - - "arn:aws:application-signals:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:bedrock-agentcore:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:bedrock:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:cloudformation:*:" + - Ref: AWS::AccountId + - :stack/workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudtrail:*:" + - Ref: AWS::AccountId + - :trail/workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudwatch:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:cognito-idp:*:" + - Ref: AWS::AccountId + - :userpool/* + - Fn::Join: + - "" + - - "arn:aws:ec2:*:" + - Ref: AWS::AccountId + - :*/* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/ai* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/backoffice* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/mcp* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/perf-* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/unicorn* + - Fn::Join: + - "" + - - "arn:aws:eks:*:" + - Ref: AWS::AccountId + - :cluster/* + - Fn::Join: + - "" + - - "arn:aws:elasticloadbalancing:*:" + - Ref: AWS::AccountId + - :*/* + - Fn::Join: + - "" + - - "arn:aws:events:*:" + - Ref: AWS::AccountId + - :rule/* + - Fn::Join: + - "" + - - "arn:aws:lambda:*:" + - Ref: AWS::AccountId + - :function:* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:* + - Fn::Join: + - "" + - - "arn:aws:rds:*:" + - Ref: AWS::AccountId + - :*:* + - Fn::Join: + - "" + - - "arn:aws:s3vectors:*:" + - Ref: AWS::AccountId + - :bucket/* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:mcp-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:workshop-* + - Fn::Join: + - "" + - - "arn:aws:ssm:*:" + - Ref: AWS::AccountId + - :parameter/workshop-* + - Action: + - aws-marketplace:Unsubscribe + - aws-marketplace:ViewSubscriptions + Condition: + ForAllValues:StringEquals: + aws-marketplace:ProductId: + - prod-xdkflymybwmvi + - prod-mxcfnwvpd6kb4 + - prod-jhuafngbly644 + - prod-5ukwuglpt66kg + - prod-ffvjxvh4ltq64 + Effect: Allow Resource: "*" - Sid: AllowedServices - - Action: iam:PassRole + - Action: + - cloudfront:CreateCloudFrontOriginAccessIdentity + - cloudfront:CreateDistribution + Condition: + StringEquals: + aws:PrincipalAccount: + Ref: AWS::AccountId Effect: Allow - Resource: - - !Sub arn:aws:iam::${AWS::AccountId}:role/ai-jvm-analyzer* - - !Sub arn:aws:iam::${AWS::AccountId}:role/aiagent* - - !Sub arn:aws:iam::${AWS::AccountId}:role/mcpserver* - - !Sub arn:aws:iam::${AWS::AccountId}:role/service-role/unicorn* - - !Sub arn:aws:iam::${AWS::AccountId}:role/unicorn* - - !Sub arn:aws:iam::${AWS::AccountId}:role/workshop* - Sid: PassRole - - Action: iam:CreateServiceLinkedRole + Resource: "*" + - Action: + - apigateway:* + - cloudfront:* + - dynamodb:* + - ecs:* + - s3:* Effect: Allow Resource: - - arn:aws:iam::*:role/aws-service-role/application-signals.cloudwatch.amazonaws.com/* - - arn:aws:iam::*:role/aws-service-role/cloudtrail.amazonaws.com/* - Sid: CreateServiceLinkedRole + - arn:aws:apigateway:*::/apis/* + - arn:aws:apigateway:*::/restapis/* + - arn:aws:s3:::aiagent-* + - arn:aws:s3:::workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudfront::" + - Ref: AWS::AccountId + - :distribution/* + - Fn::Join: + - "" + - - "arn:aws:cloudfront::" + - Ref: AWS::AccountId + - :origin-access-identity/cloudfront/* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :cluster/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :cluster/unicorn* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :service/*/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :service/*/unicorn* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task-definition/aiagent*:* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task-definition/unicorn*:* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task/*/* - Action: + - acm:ListCertificates + - apigateway:GET + - bedrock-agentcore:List* + - bedrock:List* + - cloudformation:List* + - cloudfront:Get* + - cloudfront:List* + - cognito-idp:CreateUserPool + - cognito-idp:ListUserPools + - ec2:Describe* + - ecr:CreateRepositoryCreationTemplate + - ecr:Describe* + - ecr:GetAuthorizationToken + - ecs:Describe* + - ecs:List* + - ecs:RegisterTaskDefinition + - eks:CreateCluster + - eks:Describe* + - eks:List* + - elasticloadbalancing:Describe* - iam:GetRole + - iam:GetRolePolicy - iam:ListAttachedRolePolicies - iam:ListRolePolicies - iam:ListRoles + - lambda:List* + - logs:Describe* + - rds:Describe* + - s3:ListAllMyBuckets + - s3vectors:CreateVectorBucket + - s3vectors:ListVectorBuckets + - secretsmanager:ListSecrets + - ssm:DescribeParameters + - sts:GetCallerIdentity + - tag:GetResources Effect: Allow Resource: "*" - Sid: GetRole - - Action: - - iam:AttachRolePolicy - - iam:CreateRole - - iam:DeleteRole - - iam:DeleteRolePolicy - - iam:DetachRolePolicy - - iam:PutRolePolicy - - iam:UpdateAssumeRolePolicy - Condition: - StringEquals: - iam:PermissionsBoundary: !Sub arn:aws:iam::${AWS::AccountId}:policy/workshop-boundary - Effect: Allow - Resource: - - !Sub arn:aws:iam::${AWS::AccountId}:role/aiagent* - - !Sub arn:aws:iam::${AWS::AccountId}:role/backoffice* - - !Sub arn:aws:iam::${AWS::AccountId}:role/mcp* - Sid: AiAgentCreateRoles - - Action: iam:PassRole - Condition: - StringEquals: - iam:PassedToService: - - bedrock.amazonaws.com - - bedrock-agentcore.amazonaws.com - - lambda.amazonaws.com - Effect: Allow - Resource: - - !Sub arn:aws:iam::${AWS::AccountId}:role/aiagent* - - !Sub arn:aws:iam::${AWS::AccountId}:role/backoffice* - - !Sub arn:aws:iam::${AWS::AccountId}:role/mcp* - Sid: AiAgentPassRole - Action: ec2:RunInstances Condition: StringLike: @@ -1736,16 +2663,12 @@ Resources: - "*9xlarge" - "*10xlarge" - "*12xlarge" - - "*16xlarge" - - "*18xlarge" - - "*24xlarge" - f1* - x1* - z1* - "*metal" Effect: Deny Resource: arn:aws:ec2:*:*:instance/* - Sid: DenyXXLInstances - Action: - dynamodb:PurchaseReservedCapacityOfferings - ec2:ModifyReservedInstances @@ -1755,7 +2678,6 @@ Resources: - rds:PurchaseReservedDBInstancesOffering Effect: Deny Resource: "*" - Sid: DenyReservations Version: "2012-10-17" Type: AWS::IAM::ManagedPolicy IdeWaitConditionCC35C186: @@ -1776,32 +2698,164 @@ Resources: Path: / PolicyDocument: Statement: + - Action: bedrock:* + Effect: Allow + Resource: + - arn:aws:bedrock:*::foundation-model/* + - Fn::Join: + - "" + - - "arn:aws:bedrock:*:" + - Ref: AWS::AccountId + - :* + Sid: BedrockRuntime - Action: - aws-marketplace:Subscribe - - aws-marketplace:Unsubscribe - aws-marketplace:ViewSubscriptions - - bedrock-agentcore:* - - bedrock:* - - cloudfront:* - - cloudwatch:* - - cognito-idp:* + Effect: Allow + Resource: "*" + Sid: BedrockMarketplaceModelAccess + - Action: bedrock-agentcore:* + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:bedrock-agentcore:*:" + - Ref: AWS::AccountId + - :* + Sid: AgentCoreRuntime + - Action: + - bedrock-agentcore:ConnectBrowserAutomationStream + - bedrock-agentcore:ConnectBrowserLiveViewStream + Effect: Allow + Resource: "*" + Sid: AgentCoreManagedBrowserStreams + - Action: + - bedrock-agentcore:GetBrowserSession + - bedrock-agentcore:StartBrowserSession + - bedrock-agentcore:StopBrowserSession + - bedrock-agentcore:UpdateBrowserStream + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:browser/aws.browser.v1 + Sid: AgentCoreManagedBrowser + - Action: + - bedrock-agentcore:GetCodeInterpreterSession + - bedrock-agentcore:InvokeCodeInterpreter + - bedrock-agentcore:StartCodeInterpreterSession + - bedrock-agentcore:StopCodeInterpreterSession + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:code-interpreter/aws.codeinterpreter.v1 + Sid: AgentCoreManagedCodeInterpreter + - Action: - dynamodb:* - - ec2:CreateNetworkInterface - - ec2:DeleteNetworkInterface - - ec2:DescribeNetworkInterfaces - - ec2:DescribeSecurityGroups - - ec2:DescribeSubnets - - ec2:DescribeVpcs - ecr:* - lambda:InvokeFunction - logs:* - s3:* - s3vectors:* - secretsmanager:GetSecretValue - - xray:* + Effect: Allow + Resource: + - arn:aws:s3:::aiagent-kb-data-* + - arn:aws:s3:::aiagent-kb-data-*/* + - arn:aws:s3:::workshop-* + - arn:aws:s3:::workshop-*/* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-*/* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/backoffice* + - Fn::Join: + - "" + - - "arn:aws:lambda:*:" + - Ref: AWS::AccountId + - :function:mcp-* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/bedrock-agentcore/* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/bedrock-agentcore/*:* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/lambda/mcp-* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/lambda/mcp-*:* + - Fn::Join: + - "" + - - "arn:aws:s3vectors:*:" + - Ref: AWS::AccountId + - :bucket/aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:bedrock-agentcore-identity!* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:mcp-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:workshop-* + Sid: WorkshopData + - Action: + - ec2:CreateNetworkInterface + - ec2:DeleteNetworkInterface + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:ec2:*:" + - Ref: AWS::AccountId + - :network-interface/* + Sid: RuntimeNetworkInterfaces + - Action: + - cloudwatch:PutMetricData + - ec2:DescribeNetworkInterfaces + - ec2:DescribeSecurityGroups + - ec2:DescribeSubnets + - ec2:DescribeVpcs + - ecr:GetAuthorizationToken + - logs:DescribeLogGroups + - xray:GetSamplingRules + - xray:GetSamplingTargets + - xray:PutTelemetryRecords + - xray:PutTraceSegments Effect: Allow Resource: "*" - Sid: AllowedServicesForRoles + Sid: RuntimeReadAndTelemetry - Action: - account:* - iam:* @@ -1820,12 +2874,26 @@ Resources: Tags: - Key: Name Value: workshop-vpc + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::VPC VpcIGW488B0FEB: Properties: Tags: - Key: Name Value: workshop-vpc + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::InternetGateway VpcPrivateSubnet1DefaultRouteF704DE9F: Properties: @@ -1840,6 +2908,13 @@ Resources: Tags: - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -1865,6 +2940,13 @@ Resources: Value: Private - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -1881,6 +2963,13 @@ Resources: Tags: - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -1906,6 +2995,13 @@ Resources: Value: Private - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -1925,6 +3021,13 @@ Resources: Tags: - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::EIP VpcPublicSubnet1NATGateway8185E366: DependsOn: @@ -1940,12 +3043,26 @@ Resources: Tags: - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::NatGateway VpcPublicSubnet1RouteTable431DD755: Properties: Tags: - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -1971,6 +3088,13 @@ Resources: Value: Public - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -1989,6 +3113,13 @@ Resources: Tags: - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -2014,6 +3145,13 @@ Resources: Value: Public - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -2028,14 +3166,104 @@ Resources: Properties: Description: Workshop VPC ID for cross-stack reference Name: workshop-vpc-id + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-ai-agents-advanced + WorkshopOwner: cloudformation Type: String Value: Ref: VpcC3027511 Type: AWS::SSM::Parameter + WorkshopBucketAccessLogs476BAB88: + DeletionPolicy: Delete + Metadata: + checkov: + skip: + - comment: Dedicated access-log target; recursive logging is intentionally disabled. + id: CKV_AWS_18 + Properties: + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + BucketName: + Fn::Join: + - "" + - - workshop-access-logs- + - Ref: AWS::AccountId + - "-" + - Ref: AWS::Region + - "-20260825164104" + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation + Type: AWS::S3::Bucket + UpdateReplacePolicy: Delete + WorkshopBucketAccessLogsPolicy37DFEA4E: + Properties: + Bucket: + Ref: WorkshopBucketAccessLogs476BAB88 + PolicyDocument: + Statement: + - Action: s3:* + Condition: + Bool: + aws:SecureTransport: "false" + Effect: Deny + Principal: + AWS: "*" + Resource: + - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /* + - Action: s3:PutObject + Condition: + ArnLike: + aws:SourceArn: + Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + StringEquals: + aws:SourceAccount: + Ref: AWS::AccountId + Effect: Allow + Principal: + Service: logging.s3.amazonaws.com + Resource: + Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /workshop-data/* + Version: "2012-10-17" + Type: AWS::S3::BucketPolicy WorkshopBucketBucketNameParameterCEE58012: Properties: Description: Workshop bucket name for thread dumps and profiling data Name: workshop-bucket-name + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-ai-agents-advanced + WorkshopOwner: cloudformation Type: String Value: Ref: WorkshopBucketFD5BC43F @@ -2050,12 +3278,24 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260323105514" + - "-20260825164104" + LoggingConfiguration: + DestinationBucketName: + Ref: WorkshopBucketAccessLogs476BAB88 + LogFilePrefix: workshop-data/ PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents-advanced + - Key: WorkshopOwner + Value: cloudformation Type: AWS::S3::Bucket UpdateReplacePolicy: Delete WorkshopBucketPolicyA21947CB: diff --git a/infra/cfn/java-ai-agents-stack.yaml b/infra/cfn/java-ai-agents-stack.yaml index e5817963..56f4755f 100644 --- a/infra/cfn/java-ai-agents-stack.yaml +++ b/infra/cfn/java-ai-agents-stack.yaml @@ -13,10 +13,14 @@ Metadata: reason: API Gateway access logging not needed for workshop - id: AwsSolutions-COG4 reason: Workshop environment does not require Cognito User Pool authorization - - id: AwsSolutions-IAM4 - reason: AWS Managed policies are acceptable for workshop - - id: AwsSolutions-IAM5 - reason: Wildcard permissions acceptable for workshop parallel resource creation + - applies_to: + - regex: /^Policy::.*$/g + id: AwsSolutions-IAM4 + reason: AWS managed policies are acceptable for the ephemeral workshop environment + - applies_to: + - regex: /^(Action|Resource)::.*$/g + id: AwsSolutions-IAM5 + reason: Wildcard permissions are acceptable for the ephemeral workshop environment - id: AwsSolutions-RDS2 reason: Workshop non-sensitive test database does not need encryption at rest - id: AwsSolutions-RDS3 @@ -137,10 +141,21 @@ Resources: Version: "2012-10-17" PolicyName: BedrockLogging RoleName: workshop-bedrock-logging-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CfnPreDeleteCleanupFA953B95: DeletionPolicy: Delete Properties: + BucketNames: + - Ref: WorkshopBucketFD5BC43F + - Ref: WorkshopBucketAccessLogs476BAB88 ServiceToken: Fn::GetAtt: - CfnPreDeleteCleanupFunction580FB700 @@ -169,13 +184,14 @@ Resources: Custom Resource handler to cleanup resources before stack deletion. - GuardDuty VPC endpoints that block VPC deletion - GuardDuty managed security groups - - S3 bucket contents for workshop- buckets + - S3 buckets supplied by the stack Note: CloudWatch logs are kept for debugging/analysis """ print(f"Event: {event}") request_type = event['RequestType'] vpc_id = event['ResourceProperties'].get('VpcId', '') + bucket_names = event['ResourceProperties'].get('BucketNames', []) try: if request_type == 'Delete': @@ -183,7 +199,7 @@ Resources: endpoint_ids = start_guardduty_endpoint_deletion(vpc_id) # While endpoints are deleting, clean up S3 - cleanup_s3_buckets() + cleanup_s3_buckets(bucket_names) # Wait for VPC endpoint deletion to complete if endpoint_ids: @@ -270,17 +286,16 @@ Resources: print("GuardDuty security group cleanup completed") - def cleanup_s3_buckets(): - """Empty S3 buckets with workshop- prefix.""" - try: - response = s3.list_buckets() - for bucket in response.get('Buckets', []): - bucket_name = bucket['Name'] - if bucket_name.startswith('workshop-'): - print(f"Emptying S3 bucket: {bucket_name}") - empty_bucket(bucket_name) - except Exception as e: - print(f"Error listing S3 buckets: {e}") + def cleanup_s3_buckets(bucket_names): + """Delete only the S3 buckets supplied by the stack.""" + for bucket_name in bucket_names: + print(f"Deleting S3 bucket: {bucket_name}") + empty_bucket(bucket_name) + try: + s3.delete_bucket(Bucket=bucket_name) + print(f"Deleted bucket: {bucket_name}") + except Exception as e: + print(f"Error deleting bucket {bucket_name}: {e}") print("S3 bucket cleanup completed") @@ -328,6 +343,14 @@ Resources: - CfnPreDeleteCleanupRole2BF62B3D - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 600 Type: AWS::Lambda::Function CfnPreDeleteCleanupRole2BF62B3D: @@ -345,23 +368,103 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CfnPreDeleteCleanupRoleDefaultPolicy7B910EB7: Properties: PolicyDocument: Statement: - Action: - - ec2:DeleteSecurityGroup - - ec2:DeleteVpcEndpoints - ec2:DescribeSecurityGroups - ec2:DescribeVpcEndpoints - - s3:DeleteObject - - s3:DeleteObjectVersion - - s3:ListAllMyBuckets + Effect: Allow + Resource: "*" + - Action: ec2:DeleteVpcEndpoints + Condition: + StringEquals: + ec2:Vpc: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc/ + - Ref: VpcC3027511 + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc-endpoint/* + - Action: ec2:DeleteSecurityGroup + Condition: + StringEquals: + ec2:Vpc: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc/ + - Ref: VpcC3027511 + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :security-group/* + - Action: + - s3:DeleteBucket - s3:ListBucket - s3:ListBucketVersions Effect: Allow - Resource: "*" + Resource: + - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - Action: + - s3:DeleteObject + - s3:DeleteObjectVersion + Effect: Allow + Resource: + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /* + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - /* Version: "2012-10-17" PolicyName: CfnPreDeleteCleanupRoleDefaultPolicy7B910EB7 Roles: @@ -372,13 +475,14 @@ Resources: DependsOn: - CodeBuildCompleteRuleAllowEventRuleWorkshopStackCodeBuildReportLambdaFunctionD77C60919E0B0C89 - CodeBuildCompleteRuleEE9277E8 + - CodeBuildPendingBuilds19869454 + - CodeBuildProjectPolicyDocument567377F5 + - CodeBuildProjectA0FF5539 + - CodeBuildProjectSecurityGroup7CE557B3 - CodeBuildReportLambdaFunctionA3C396F7 + - CodeBuildStartLambdaFunction8349284F Properties: - CodeBuildIamRoleArn: - Fn::GetAtt: - - CodeBuildRoleE9A44575 - - Arn - ContentHash: "1770831745080" + ContentHash: "1787668860631" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -408,7 +512,9 @@ Resources: build-status: - SUCCEEDED - FAILED + - FAULT - STOPPED + - TIMED_OUT project-name: - Ref: CodeBuildProjectA0FF5539 detail-type: @@ -416,6 +522,14 @@ Resources: source: - aws.codebuild State: ENABLED + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Targets: - Arn: Fn::GetAtt: @@ -438,6 +552,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CodeBuildLambdaRoleDefaultPolicyFB35F0AF: Properties: @@ -447,12 +569,72 @@ Resources: - codebuild:BatchGetBuilds - codebuild:StartBuild Effect: Allow - Resource: "*" + Resource: + Fn::GetAtt: + - CodeBuildProjectA0FF5539 + - Arn + - Action: + - dynamodb:BatchWriteItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + - dynamodb:PutItem + - dynamodb:UpdateItem + Effect: Allow + Resource: + Fn::GetAtt: + - CodeBuildPendingBuilds19869454 + - Arn + - Action: + - dynamodb:BatchGetItem + - dynamodb:BatchWriteItem + - dynamodb:ConditionCheckItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + - dynamodb:GetItem + - dynamodb:GetRecords + - dynamodb:GetShardIterator + - dynamodb:PutItem + - dynamodb:Query + - dynamodb:Scan + - dynamodb:UpdateItem + Effect: Allow + Resource: + Fn::GetAtt: + - CodeBuildPendingBuilds19869454 + - Arn Version: "2012-10-17" PolicyName: CodeBuildLambdaRoleDefaultPolicyFB35F0AF Roles: - Ref: CodeBuildLambdaRole655C06B4 Type: AWS::IAM::Policy + CodeBuildPendingBuilds19869454: + DeletionPolicy: Delete + Metadata: + cdk_nag: + rules_to_suppress: + - id: AwsSolutions-DDB3 + reason: The table stores short-lived CloudFormation callback state and does not require point-in-time recovery + Properties: + AttributeDefinitions: + - AttributeName: BuildId + AttributeType: S + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: BuildId + KeyType: HASH + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation + TimeToLiveSpecification: + AttributeName: ExpiresAt + Enabled: true + Type: AWS::DynamoDB::Table + UpdateReplacePolicy: Delete CodeBuildProjectA0FF5539: DependsOn: - CodeBuildProjectPolicyDocument567377F5 @@ -465,12 +647,12 @@ Resources: Environment: ComputeType: BUILD_GENERAL1_MEDIUM EnvironmentVariables: + - Name: GIT_BRANCH + Type: PLAINTEXT + Value: feat/holmes-remediation - Name: TEMPLATE_TYPE Type: PLAINTEXT Value: java-ai-agents - - Name: GIT_BRANCH - Type: PLAINTEXT - Value: main Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 ImagePullCredentialsType: CODEBUILD PrivilegedMode: false @@ -502,6 +684,14 @@ Resources: aws iam create-service-linked-role --aws-service-name network.bedrock-agentcore.amazonaws.com 2>/dev/null || true aws iam create-service-linked-role --aws-service-name runtime-identity.bedrock-agentcore.amazonaws.com 2>/dev/null || true Type: NO_SOURCE + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation TimeoutInMinutes: 30 VpcConfig: SecurityGroupIds: @@ -515,12 +705,102 @@ Resources: Ref: VpcC3027511 Type: AWS::CodeBuild::Project CodeBuildProjectPolicyDocument567377F5: + Metadata: + checkov: + skip: + - comment: CodeBuild requires ec2:DeleteNetworkInterface on wildcard resources because the API authorizes deletion against arn:aws:ec2:region:account:*/*. + id: CKV_AWS_111 Properties: PolicyDocument: Statement: - Action: - ec2:CreateNetworkInterface + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet1Subnet67A4DBCB + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet2SubnetC8EB537D + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :security-group/ + - Fn::GetAtt: + - CodeBuildProjectSecurityGroup7CE557B3 + - GroupId + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :network-interface/* + - Action: + - ec2:CreateNetworkInterfacePermission + Condition: + ArnEquals: + ec2:Subnet: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet1Subnet67A4DBCB + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet2SubnetC8EB537D + StringEquals: + ec2:AuthorizedService: codebuild.amazonaws.com + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :network-interface/* + - Action: - ec2:DeleteNetworkInterface + Effect: Allow + Resource: "*" + - Action: - ec2:DescribeDhcpOptions - ec2:DescribeNetworkInterfaces - ec2:DescribeSecurityGroups @@ -540,6 +820,14 @@ Resources: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: "-1" + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -549,57 +837,113 @@ Resources: - CodeBuildLambdaRole655C06B4 Properties: Code: - ZipFile: |- - import boto3 + ZipFile: | import json + import os + import urllib.request + + import boto3 - codebuild = boto3.client('codebuild') + codebuild = boto3.client("codebuild") + table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) - def lambda_handler(event, context): - print(f'Build status event: {event}') + FAILURE_STATUSES = {"FAILED", "FAULT", "STOPPED", "TIMED_OUT"} - try: - # Extract build information from EventBridge event - detail = event['detail'] - build_status = detail['build-status'] - project_name = detail['project-name'] - build_id = detail['build-id'] - - print(f'Build {build_id} for project {project_name} finished with status: {build_status}') - - if build_status == 'SUCCEEDED': - print('✅ CodeBuild setup completed successfully') - elif build_status == 'FAILED': - print('❌ CodeBuild setup failed') - - # Get build details for error information - response = codebuild.batch_get_builds(ids=[build_id]) - if response['builds']: - build = response['builds'][0] - if 'logs' in build and 'cloudWatchLogs' in build['logs']: - log_group = build['logs']['cloudWatchLogs'].get('groupName') - log_stream = build['logs']['cloudWatchLogs'].get('streamName') - print(f'Check logs at: {log_group}/{log_stream}') - elif build_status == 'STOPPED': - print('⏹️ CodeBuild setup was stopped') - - return { - 'statusCode': 200, - 'body': json.dumps({ - 'message': f'Processed build status: {build_status}', - 'buildId': build_id, - 'projectName': project_name - }) - } - except Exception as e: - print(f'Error processing build status: {str(e)}') - return { - 'statusCode': 500, - 'body': json.dumps({ - 'error': str(e) - }) + def normalized_build_id(value): + if ":build/" in value: + return value.split(":build/", 1)[1] + return value + + + def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") + + + def failure_details(build): + details = [] + for phase in build.get("phases", []): + contexts = "; ".join( + context.get("message", "") for context in phase.get("contexts", []) + ) + if phase.get("phaseStatus") in FAILURE_STATUSES or contexts: + details.append( + f"{phase.get('phaseType')}={phase.get('phaseStatus')}: {contexts}".strip() + ) + logs = build.get("logs", {}) + if logs.get("deepLink"): + details.append(f"logs={logs['deepLink']}") + return " | ".join(details) or "No phase failure details were returned" + + + def lambda_handler(event, context): + detail = event["detail"] + event_build_id = detail["build-id"] + build_id = normalized_build_id(event_build_id) + print(f"Terminal CodeBuild event for {event_build_id}: {detail['build-status']}") + + item = table.get_item(Key={"BuildId": build_id}, ConsistentRead=True).get("Item") + if not item: + raise RuntimeError(f"Pending CloudFormation callback not found for {build_id}") + + build_response = codebuild.batch_get_builds(ids=[item.get("BuildArn", event_build_id)]) + builds = build_response.get("builds", []) + if len(builds) != 1: + raise RuntimeError(f"CodeBuild build not found: {event_build_id}") + + build = builds[0] + status = build["buildStatus"] + original_event = json.loads(item["CloudFormationEvent"]) + data = { + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": item["ProjectName"], + "BuildStatus": status, + } + + if status == "SUCCEEDED": + response_status = "SUCCESS" + reason = None + elif status in FAILURE_STATUSES: + response_status = "FAILED" + reason = f"CodeBuild finished with {status}: {failure_details(build)}" + else: + raise RuntimeError(f"Received non-terminal CodeBuild status {status}") + + send_response( + original_event, + context, + response_status, + data, + item["PhysicalResourceId"], + reason, + ) + table.delete_item(Key={"BuildId": build_id}) + print(f"Sent {response_status} to CloudFormation for {build_id}") + Environment: + Variables: + PENDING_TABLE_NAME: + Ref: CodeBuildPendingBuilds19869454 FunctionName: workshop-setup-report Handler: index.lambda_handler Role: @@ -607,12 +951,30 @@ Resources: - CodeBuildLambdaRole655C06B4 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 120 Type: AWS::Lambda::Function CodeBuildRoleDefaultPolicy196BAF24: Properties: PolicyDocument: Statement: + - Action: iam:CreateServiceLinkedRole + Condition: + StringEquals: + iam:AWSServiceName: + - ecs.amazonaws.com + - elasticloadbalancing.amazonaws.com + - network.bedrock-agentcore.amazonaws.com + - runtime-identity.bedrock-agentcore.amazonaws.com + Effect: Allow + Resource: arn:aws:iam::*:role/aws-service-role/* - Action: ec2:CreateNetworkInterfacePermission Condition: StringEquals: @@ -709,12 +1071,14 @@ Resources: Principal: Service: codebuild.amazonaws.com Version: "2012-10-17" - ManagedPolicyArns: - - Fn::Join: - - "" - - - "arn:" - - Ref: AWS::Partition - - :iam::aws:policy/PowerUserAccess + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CodeBuildStartLambdaFunction8349284F: DependsOn: @@ -722,62 +1086,78 @@ Resources: - CodeBuildLambdaRole655C06B4 Properties: Code: - ZipFile: |- - import boto3 + ZipFile: | import json - import traceback - import cfnresponse - - codebuild = boto3.client('codebuild') + import os + import time + import urllib.request - def lambda_handler(event, context): - print(f'Event: {event}') - responseData = {} - status = cfnresponse.SUCCESS - physical_id = event.get('PhysicalResourceId', 'CodeBuildSetup') + import boto3 - try: - if event['RequestType'] == 'Delete': - # Nothing to clean up for CodeBuild - responseData = {'Message': 'CodeBuild setup deleted'} - cfnresponse.send(event, context, status, responseData, physical_id) - return + codebuild = boto3.client("codebuild") + table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) + + + def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, + } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") - if event['RequestType'] == 'Update': - # For updates, trigger a new build - pass - # Start CodeBuild project - props = event['ResourceProperties'] - project_name = props['ProjectName'] + def lambda_handler(event, context): + print(f"RequestType={event['RequestType']} LogicalResourceId={event['LogicalResourceId']}") + project_name = event["ResourceProperties"]["ProjectName"] + physical_id = event.get("PhysicalResourceId", project_name) - print(f'Starting CodeBuild project: {project_name}') + if event["RequestType"] == "Delete": + send_response(event, context, "SUCCESS", {"ProjectName": project_name}, physical_id) + return - response = codebuild.start_build( - projectName=project_name + try: + build = codebuild.start_build(projectName=project_name)["build"] + table.put_item( + Item={ + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": project_name, + "PhysicalResourceId": project_name, + "CloudFormationEvent": json.dumps(event), + "ExpiresAt": int(time.time()) + 7200, + } ) - - build_id = response['build']['id'] - build_arn = response['build']['arn'] - - print(f'Started build: {build_id}') - - responseData = { - 'BuildId': build_id, - 'BuildArn': build_arn, - 'ProjectName': project_name - } - - # Use build ID as physical resource ID for tracking - physical_id = build_id - - except Exception as e: - status = cfnresponse.FAILED - tb_err = traceback.format_exc() - print(tb_err) - responseData = {'Error': tb_err} - - cfnresponse.send(event, context, status, responseData, physical_id) + print(f"Started CodeBuild project {project_name}: {build['id']}") + except Exception as error: + print(f"Failed to start or persist CodeBuild callback: {error}") + send_response( + event, + context, + "FAILED", + {"ProjectName": project_name}, + physical_id, + str(error), + ) + Environment: + Variables: + PENDING_TABLE_NAME: + Ref: CodeBuildPendingBuilds19869454 FunctionName: workshop-setup-start Handler: index.lambda_handler Role: @@ -785,6 +1165,14 @@ Resources: - CodeBuildLambdaRole655C06B4 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 120 Type: AWS::Lambda::Function EcrRegistryTemplateD54113AB: @@ -835,6 +1223,13 @@ Resources: Value: workshop - Key: ManagedBy Value: ecr-create-on-push + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopOwner + Value: workshop-run Type: AWS::ECR::RepositoryCreationTemplate EcrRegistryTemplateRole9295BC5C: Properties: @@ -846,6 +1241,14 @@ Resources: Service: ecr.amazonaws.com Version: "2012-10-17" RoleName: workshop-ecr-template-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EcrRegistryTemplateRoleDefaultPolicy760EC63A: Properties: @@ -856,20 +1259,89 @@ Resources: - ecr:PutLifecyclePolicy - ecr:TagResource Effect: Allow - Resource: "*" + Resource: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ecr:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :repository/aiagent + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ecr:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :repository/backoffice Version: "2012-10-17" PolicyName: EcrRegistryTemplateRoleDefaultPolicy760EC63A Roles: - Ref: EcrRegistryTemplateRole9295BC5C Type: AWS::IAM::Policy - IdeDistribution042A6660: - DeletionPolicy: Delete - DependsOn: - - IdeEipAssociationDFF81215 + IdeAgentCoreIdentityPolicy5C973EFA: Properties: - DistributionConfig: - DefaultCacheBehavior: - AllowedMethods: + Description: "" + Path: / + PolicyDocument: + Statement: + - Action: + - secretsmanager:CreateSecret + - secretsmanager:DeleteSecret + - secretsmanager:GetSecretValue + - secretsmanager:PutSecretValue + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:bedrock-agentcore-identity!* + Sid: AgentCoreIdentityCredentialSecrets + Version: "2012-10-17" + Type: AWS::IAM::ManagedPolicy + IdeAgentCoreManagedToolsPolicy33EC19D9: + Properties: + Description: "" + Path: / + PolicyDocument: + Statement: + - Action: + - bedrock-agentcore:ConnectBrowserAutomationStream + - bedrock-agentcore:ConnectBrowserLiveViewStream + Effect: Allow + Resource: "*" + Sid: AgentCoreManagedBrowserStreams + - Action: + - bedrock-agentcore:GetBrowserSession + - bedrock-agentcore:StartBrowserSession + - bedrock-agentcore:StopBrowserSession + - bedrock-agentcore:UpdateBrowserStream + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:browser/aws.browser.v1 + Sid: AgentCoreManagedBrowser + - Action: + - bedrock-agentcore:GetCodeInterpreterSession + - bedrock-agentcore:InvokeCodeInterpreter + - bedrock-agentcore:StartCodeInterpreterSession + - bedrock-agentcore:StopCodeInterpreterSession + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:code-interpreter/aws.codeinterpreter.v1 + Sid: AgentCoreManagedCodeInterpreter + Version: "2012-10-17" + Type: AWS::IAM::ManagedPolicy + IdeDistribution042A6660: + DeletionPolicy: Delete + DependsOn: + - IdeEipAssociationDFF81215 + Properties: + DistributionConfig: + DefaultCacheBehavior: + AllowedMethods: - GET - HEAD - OPTIONS @@ -928,6 +1400,14 @@ Resources: - PublicIp - .compute-1.amazonaws.com Id: WorkshopStackIdeDistributionOrigin10FECA386 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::CloudFront::Distribution UpdateReplacePolicy: Delete IdeEC2InstanceResource438B3605: @@ -974,12 +1454,21 @@ Resources: # This keeps UserData under size limits while allowing unlimited bootstrap size # Configuration from CDK - export GIT_BRANCH="main" + export GIT_BRANCH="feat/holmes-remediation" export AWS_REGION=" - Ref: AWS::Region - |- " export TEMPLATE_TYPE="java-ai-agents" + export WORKSHOP_ID="java-ai-agents" + export WORKSHOP_STACK_NAME=" + - Ref: AWS::StackName + - |- + " + export WORKSHOP_DEPLOYMENT_ID=" + - Ref: AWS::StackId + - |- + " export ARCH="aarch64" export IDE_TYPE="code-editor" export WAIT_CONDITION_HANDLE_URL=" @@ -1114,6 +1603,14 @@ Resources: IdeElasticIP3327A0B5: Properties: Domain: vpc + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::EIP IdeInstanceLauncherFunction803C5A2A: DependsOn: @@ -1258,6 +1755,14 @@ Resources: - IdeLambdaRole82392143 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 300 Type: AWS::Lambda::Function IdeInstanceProfile61B92038: @@ -1270,6 +1775,14 @@ Resources: Properties: GroupDescription: IDE internal security group GroupName: workshop-ide-internal-sg + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -1314,27 +1827,57 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdeLambdaRoleDefaultPolicy099093D2: Properties: PolicyDocument: Statement: - Action: - - ec2:CreateTags - ec2:DescribeInstanceStatus - ec2:DescribeInstances - ec2:DescribeManagedPrefixLists - ec2:DescribeSubnets - - ec2:RunInstances - - ec2:TerminateInstances - - iam:PassRole - - secretsmanager:DescribeSecret - - secretsmanager:GetSecretValue - - ssm:DescribeInstanceInformation - - ssm:GetCommandInvocation - - ssm:SendCommand Effect: Allow Resource: "*" + - Action: ec2:RunInstances + Effect: Allow + Resource: + - arn:aws:ec2:*:*:instance/* + - arn:aws:ec2:*:*:network-interface/* + - arn:aws:ec2:*:*:security-group/* + - arn:aws:ec2:*:*:subnet/* + - arn:aws:ec2:*:*:volume/* + - arn:aws:ec2:*::image/* + - Action: ec2:CreateTags + Condition: + StringEquals: + aws:RequestTag/Workshop: "true" + ec2:CreateAction: RunInstances + Effect: Allow + Resource: arn:aws:ec2:*:*:instance/* + - Action: ec2:TerminateInstances + Condition: + StringEquals: + ec2:ResourceTag/Workshop: "true" + Effect: Allow + Resource: arn:aws:ec2:*:*:instance/* + - Action: iam:PassRole + Condition: + StringEquals: + iam:PassedToService: ec2.amazonaws.com + Effect: Allow + Resource: + Fn::GetAtt: + - IdeRole4650E22E + - Arn Version: "2012-10-17" PolicyName: IdeLambdaRoleDefaultPolicy099093D2 Roles: @@ -1398,6 +1941,14 @@ Resources: - IdePasswordFunctionServiceRole971421B6 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 180 Type: AWS::Lambda::Function IdePasswordFunctionServiceRole971421B6: @@ -1415,6 +1966,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdePasswordFunctionServiceRoleDefaultPolicy282A360C: Properties: @@ -1481,6 +2040,14 @@ Resources: PasswordLength: 32 SecretStringTemplate: '{"password":""}' Name: workshop-ide-password + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::SecretsManager::Secret UpdateReplacePolicy: Delete IdePrefixListLookupFunction9E5A63DE: @@ -1532,6 +2099,14 @@ Resources: - IdeLambdaRole82392143 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 180 Type: AWS::Lambda::Function IdePrefixListResourceD2EAC007: @@ -1569,15 +2144,23 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeRoleManagementPolicyFE7F8500 + - Ref: IdeAgentCoreManagedToolsPolicy33EC19D9 + - Ref: IdeAgentCoreIdentityPolicy5C973EFA RoleName: workshop-ide-user + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdeRoleDefaultPolicyFD4BDE67: Properties: PolicyDocument: Statement: - - Action: cloudformation:SignalResource - Effect: Allow - Resource: "*" - Action: - secretsmanager:DescribeSecret - secretsmanager:GetSecretValue @@ -1589,6 +2172,155 @@ Resources: Roles: - Ref: IdeRole4650E22E Type: AWS::IAM::Policy + IdeRoleManagementPolicyFE7F8500: + Properties: + Description: "" + Path: / + PolicyDocument: + Statement: + - Action: iam:PassRole + Condition: + StringEquals: + iam:PassedToService: + - bedrock.amazonaws.com + - bedrock-agentcore.amazonaws.com + - codebuild.amazonaws.com + - ec2.amazonaws.com + - ecs.amazonaws.com + - ecs-tasks.amazonaws.com + - lambda.amazonaws.com + - pods.eks.amazonaws.com + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/ai-jvm-analyzer* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/grafana* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/perf-analyzer* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/perf-collector* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/pyroscope* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/service-role/unicorn* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/unicorn* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/workshop* + - Action: iam:CreateServiceLinkedRole + Condition: + StringEquals: + iam:AWSServiceName: + - application-signals.cloudwatch.amazonaws.com + - cloudtrail.amazonaws.com + - ecs.amazonaws.com + - elasticloadbalancing.amazonaws.com + - network.bedrock-agentcore.amazonaws.com + - runtime-identity.bedrock-agentcore.amazonaws.com + Effect: Allow + Resource: arn:aws:iam::*:role/aws-service-role/* + - Action: iam:CreateRole + Condition: + StringEquals: + iam:PermissionsBoundary: + Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :policy/workshop-boundary + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp* + - Action: + - iam:AttachRolePolicy + - iam:DeleteRole + - iam:DeleteRolePolicy + - iam:DetachRolePolicy + - iam:PutRolePolicy + - iam:UpdateAssumeRolePolicy + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent-kb-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent-runtime-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp-currency-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp-gateway-role + Version: "2012-10-17" + Type: AWS::IAM::ManagedPolicy IdeSecurityGroup73B02454: Properties: GroupDescription: IDE security group @@ -1597,6 +2329,14 @@ Resources: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: "-1" + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -1628,104 +2368,291 @@ Resources: - prod-xdkflymybwmvi - prod-mxcfnwvpd6kb4 - prod-jhuafngbly644 - - prod-4pmewlybdftbs + - prod-5ukwuglpt66kg + - prod-ffvjxvh4ltq64 "Null": aws-marketplace:ProductId: "false" Effect: Allow Resource: "*" - Sid: MarketplaceSubscribeClaude - Action: - acm:* - - apigateway:* - application-autoscaling:* - application-signals:* - - aws-marketplace:Unsubscribe - - aws-marketplace:ViewSubscriptions - bedrock-agentcore:* - bedrock:* - cloudformation:* - - cloudfront:* - cloudtrail:* - cloudwatch:* - - codewhisperer:* - cognito-idp:* - - dbqms:* - - dynamodb:* - ec2:* - ecr:* - - ecs:* - eks:* - elasticloadbalancing:* - events:* - lambda:* - logs:* - - q:* - rds-data:* - rds:* - - s3:* - s3vectors:* - secretsmanager:* - ssm:* - - sts:* - - tag:* - xray:* Effect: Allow + Resource: + - arn:aws:bedrock:*::foundation-model/* + - arn:aws:ec2:*::image/* + - Fn::Join: + - "" + - - "arn:aws:acm:*:" + - Ref: AWS::AccountId + - :certificate/* + - Fn::Join: + - "" + - - "arn:aws:application-autoscaling:*:" + - Ref: AWS::AccountId + - :scal*/* + - Fn::Join: + - "" + - - "arn:aws:application-signals:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:bedrock-agentcore:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:bedrock:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:cloudformation:*:" + - Ref: AWS::AccountId + - :stack/workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudtrail:*:" + - Ref: AWS::AccountId + - :trail/workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudwatch:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:cognito-idp:*:" + - Ref: AWS::AccountId + - :userpool/* + - Fn::Join: + - "" + - - "arn:aws:ec2:*:" + - Ref: AWS::AccountId + - :*/* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/ai* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/backoffice* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/mcp* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/perf-* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/unicorn* + - Fn::Join: + - "" + - - "arn:aws:eks:*:" + - Ref: AWS::AccountId + - :cluster/* + - Fn::Join: + - "" + - - "arn:aws:elasticloadbalancing:*:" + - Ref: AWS::AccountId + - :*/* + - Fn::Join: + - "" + - - "arn:aws:events:*:" + - Ref: AWS::AccountId + - :rule/* + - Fn::Join: + - "" + - - "arn:aws:lambda:*:" + - Ref: AWS::AccountId + - :function:* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:* + - Fn::Join: + - "" + - - "arn:aws:rds:*:" + - Ref: AWS::AccountId + - :*:* + - Fn::Join: + - "" + - - "arn:aws:s3vectors:*:" + - Ref: AWS::AccountId + - :bucket/* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:mcp-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:workshop-* + - Fn::Join: + - "" + - - "arn:aws:ssm:*:" + - Ref: AWS::AccountId + - :parameter/workshop-* + - Action: + - aws-marketplace:Unsubscribe + - aws-marketplace:ViewSubscriptions + Condition: + ForAllValues:StringEquals: + aws-marketplace:ProductId: + - prod-xdkflymybwmvi + - prod-mxcfnwvpd6kb4 + - prod-jhuafngbly644 + - prod-5ukwuglpt66kg + - prod-ffvjxvh4ltq64 + Effect: Allow Resource: "*" - Sid: AllowedServices - - Action: iam:PassRole + - Action: + - cloudfront:CreateCloudFrontOriginAccessIdentity + - cloudfront:CreateDistribution + Condition: + StringEquals: + aws:PrincipalAccount: + Ref: AWS::AccountId Effect: Allow - Resource: - - !Sub arn:aws:iam::${AWS::AccountId}:role/ai-jvm-analyzer* - - !Sub arn:aws:iam::${AWS::AccountId}:role/aiagent* - - !Sub arn:aws:iam::${AWS::AccountId}:role/mcpserver* - - !Sub arn:aws:iam::${AWS::AccountId}:role/service-role/unicorn* - - !Sub arn:aws:iam::${AWS::AccountId}:role/unicorn* - - !Sub arn:aws:iam::${AWS::AccountId}:role/workshop* - Sid: PassRole - - Action: iam:CreateServiceLinkedRole + Resource: "*" + - Action: + - apigateway:* + - cloudfront:* + - dynamodb:* + - ecs:* + - s3:* Effect: Allow Resource: - - arn:aws:iam::*:role/aws-service-role/application-signals.cloudwatch.amazonaws.com/* - - arn:aws:iam::*:role/aws-service-role/cloudtrail.amazonaws.com/* - Sid: CreateServiceLinkedRole + - arn:aws:apigateway:*::/apis/* + - arn:aws:apigateway:*::/restapis/* + - arn:aws:s3:::aiagent-* + - arn:aws:s3:::workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudfront::" + - Ref: AWS::AccountId + - :distribution/* + - Fn::Join: + - "" + - - "arn:aws:cloudfront::" + - Ref: AWS::AccountId + - :origin-access-identity/cloudfront/* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :cluster/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :cluster/unicorn* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :service/*/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :service/*/unicorn* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task-definition/aiagent*:* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task-definition/unicorn*:* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task/*/* - Action: + - acm:ListCertificates + - apigateway:GET + - bedrock-agentcore:List* + - bedrock:List* + - cloudformation:List* + - cloudfront:Get* + - cloudfront:List* + - cognito-idp:CreateUserPool + - cognito-idp:ListUserPools + - ec2:Describe* + - ecr:CreateRepositoryCreationTemplate + - ecr:Describe* + - ecr:GetAuthorizationToken + - ecs:Describe* + - ecs:List* + - ecs:RegisterTaskDefinition + - eks:CreateCluster + - eks:Describe* + - eks:List* + - elasticloadbalancing:Describe* - iam:GetRole + - iam:GetRolePolicy - iam:ListAttachedRolePolicies - iam:ListRolePolicies - iam:ListRoles + - lambda:List* + - logs:Describe* + - rds:Describe* + - s3:ListAllMyBuckets + - s3vectors:CreateVectorBucket + - s3vectors:ListVectorBuckets + - secretsmanager:ListSecrets + - ssm:DescribeParameters + - sts:GetCallerIdentity + - tag:GetResources Effect: Allow Resource: "*" - Sid: GetRole - - Action: - - iam:AttachRolePolicy - - iam:CreateRole - - iam:DeleteRole - - iam:DeleteRolePolicy - - iam:DetachRolePolicy - - iam:PutRolePolicy - - iam:UpdateAssumeRolePolicy - Condition: - StringEquals: - iam:PermissionsBoundary: !Sub arn:aws:iam::${AWS::AccountId}:policy/workshop-boundary - Effect: Allow - Resource: - - !Sub arn:aws:iam::${AWS::AccountId}:role/aiagent* - - !Sub arn:aws:iam::${AWS::AccountId}:role/backoffice* - - !Sub arn:aws:iam::${AWS::AccountId}:role/mcp* - Sid: AiAgentCreateRoles - - Action: iam:PassRole - Condition: - StringEquals: - iam:PassedToService: - - bedrock.amazonaws.com - - bedrock-agentcore.amazonaws.com - - lambda.amazonaws.com - Effect: Allow - Resource: - - !Sub arn:aws:iam::${AWS::AccountId}:role/aiagent* - - !Sub arn:aws:iam::${AWS::AccountId}:role/backoffice* - - !Sub arn:aws:iam::${AWS::AccountId}:role/mcp* - Sid: AiAgentPassRole - Action: ec2:RunInstances Condition: StringLike: @@ -1736,16 +2663,12 @@ Resources: - "*9xlarge" - "*10xlarge" - "*12xlarge" - - "*16xlarge" - - "*18xlarge" - - "*24xlarge" - f1* - x1* - z1* - "*metal" Effect: Deny Resource: arn:aws:ec2:*:*:instance/* - Sid: DenyXXLInstances - Action: - dynamodb:PurchaseReservedCapacityOfferings - ec2:ModifyReservedInstances @@ -1755,7 +2678,6 @@ Resources: - rds:PurchaseReservedDBInstancesOffering Effect: Deny Resource: "*" - Sid: DenyReservations Version: "2012-10-17" Type: AWS::IAM::ManagedPolicy IdeWaitConditionCC35C186: @@ -1776,32 +2698,164 @@ Resources: Path: / PolicyDocument: Statement: + - Action: bedrock:* + Effect: Allow + Resource: + - arn:aws:bedrock:*::foundation-model/* + - Fn::Join: + - "" + - - "arn:aws:bedrock:*:" + - Ref: AWS::AccountId + - :* + Sid: BedrockRuntime - Action: - aws-marketplace:Subscribe - - aws-marketplace:Unsubscribe - aws-marketplace:ViewSubscriptions - - bedrock-agentcore:* - - bedrock:* - - cloudfront:* - - cloudwatch:* - - cognito-idp:* + Effect: Allow + Resource: "*" + Sid: BedrockMarketplaceModelAccess + - Action: bedrock-agentcore:* + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:bedrock-agentcore:*:" + - Ref: AWS::AccountId + - :* + Sid: AgentCoreRuntime + - Action: + - bedrock-agentcore:ConnectBrowserAutomationStream + - bedrock-agentcore:ConnectBrowserLiveViewStream + Effect: Allow + Resource: "*" + Sid: AgentCoreManagedBrowserStreams + - Action: + - bedrock-agentcore:GetBrowserSession + - bedrock-agentcore:StartBrowserSession + - bedrock-agentcore:StopBrowserSession + - bedrock-agentcore:UpdateBrowserStream + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:browser/aws.browser.v1 + Sid: AgentCoreManagedBrowser + - Action: + - bedrock-agentcore:GetCodeInterpreterSession + - bedrock-agentcore:InvokeCodeInterpreter + - bedrock-agentcore:StartCodeInterpreterSession + - bedrock-agentcore:StopCodeInterpreterSession + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:code-interpreter/aws.codeinterpreter.v1 + Sid: AgentCoreManagedCodeInterpreter + - Action: - dynamodb:* - - ec2:CreateNetworkInterface - - ec2:DeleteNetworkInterface - - ec2:DescribeNetworkInterfaces - - ec2:DescribeSecurityGroups - - ec2:DescribeSubnets - - ec2:DescribeVpcs - ecr:* - lambda:InvokeFunction - logs:* - s3:* - s3vectors:* - secretsmanager:GetSecretValue - - xray:* + Effect: Allow + Resource: + - arn:aws:s3:::aiagent-kb-data-* + - arn:aws:s3:::aiagent-kb-data-*/* + - arn:aws:s3:::workshop-* + - arn:aws:s3:::workshop-*/* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-*/* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/backoffice* + - Fn::Join: + - "" + - - "arn:aws:lambda:*:" + - Ref: AWS::AccountId + - :function:mcp-* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/bedrock-agentcore/* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/bedrock-agentcore/*:* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/lambda/mcp-* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/lambda/mcp-*:* + - Fn::Join: + - "" + - - "arn:aws:s3vectors:*:" + - Ref: AWS::AccountId + - :bucket/aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:bedrock-agentcore-identity!* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:mcp-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:workshop-* + Sid: WorkshopData + - Action: + - ec2:CreateNetworkInterface + - ec2:DeleteNetworkInterface + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:ec2:*:" + - Ref: AWS::AccountId + - :network-interface/* + Sid: RuntimeNetworkInterfaces + - Action: + - cloudwatch:PutMetricData + - ec2:DescribeNetworkInterfaces + - ec2:DescribeSecurityGroups + - ec2:DescribeSubnets + - ec2:DescribeVpcs + - ecr:GetAuthorizationToken + - logs:DescribeLogGroups + - xray:GetSamplingRules + - xray:GetSamplingTargets + - xray:PutTelemetryRecords + - xray:PutTraceSegments Effect: Allow Resource: "*" - Sid: AllowedServicesForRoles + Sid: RuntimeReadAndTelemetry - Action: - account:* - iam:* @@ -1820,12 +2874,26 @@ Resources: Tags: - Key: Name Value: workshop-vpc + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::VPC VpcIGW488B0FEB: Properties: Tags: - Key: Name Value: workshop-vpc + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::InternetGateway VpcPrivateSubnet1DefaultRouteF704DE9F: Properties: @@ -1840,6 +2908,13 @@ Resources: Tags: - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -1865,6 +2940,13 @@ Resources: Value: Private - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -1881,6 +2963,13 @@ Resources: Tags: - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -1906,6 +2995,13 @@ Resources: Value: Private - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -1925,6 +3021,13 @@ Resources: Tags: - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::EIP VpcPublicSubnet1NATGateway8185E366: DependsOn: @@ -1940,12 +3043,26 @@ Resources: Tags: - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::NatGateway VpcPublicSubnet1RouteTable431DD755: Properties: Tags: - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -1971,6 +3088,13 @@ Resources: Value: Public - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -1989,6 +3113,13 @@ Resources: Tags: - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -2014,6 +3145,13 @@ Resources: Value: Public - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -2028,14 +3166,104 @@ Resources: Properties: Description: Workshop VPC ID for cross-stack reference Name: workshop-vpc-id + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-ai-agents + WorkshopOwner: cloudformation Type: String Value: Ref: VpcC3027511 Type: AWS::SSM::Parameter + WorkshopBucketAccessLogs476BAB88: + DeletionPolicy: Delete + Metadata: + checkov: + skip: + - comment: Dedicated access-log target; recursive logging is intentionally disabled. + id: CKV_AWS_18 + Properties: + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + BucketName: + Fn::Join: + - "" + - - workshop-access-logs- + - Ref: AWS::AccountId + - "-" + - Ref: AWS::Region + - "-20260825164100" + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation + Type: AWS::S3::Bucket + UpdateReplacePolicy: Delete + WorkshopBucketAccessLogsPolicy37DFEA4E: + Properties: + Bucket: + Ref: WorkshopBucketAccessLogs476BAB88 + PolicyDocument: + Statement: + - Action: s3:* + Condition: + Bool: + aws:SecureTransport: "false" + Effect: Deny + Principal: + AWS: "*" + Resource: + - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /* + - Action: s3:PutObject + Condition: + ArnLike: + aws:SourceArn: + Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + StringEquals: + aws:SourceAccount: + Ref: AWS::AccountId + Effect: Allow + Principal: + Service: logging.s3.amazonaws.com + Resource: + Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /workshop-data/* + Version: "2012-10-17" + Type: AWS::S3::BucketPolicy WorkshopBucketBucketNameParameterCEE58012: Properties: Description: Workshop bucket name for thread dumps and profiling data Name: workshop-bucket-name + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-ai-agents + WorkshopOwner: cloudformation Type: String Value: Ref: WorkshopBucketFD5BC43F @@ -2050,12 +3278,24 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260211184225" + - "-20260825164100" + LoggingConfiguration: + DestinationBucketName: + Ref: WorkshopBucketAccessLogs476BAB88 + LogFilePrefix: workshop-data/ PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::S3::Bucket UpdateReplacePolicy: Delete WorkshopBucketPolicyA21947CB: diff --git a/infra/cfn/java-on-amazon-eks-stack.yaml b/infra/cfn/java-on-amazon-eks-stack.yaml index 9c9bfb19..0ec70622 100644 --- a/infra/cfn/java-on-amazon-eks-stack.yaml +++ b/infra/cfn/java-on-amazon-eks-stack.yaml @@ -13,10 +13,14 @@ Metadata: reason: API Gateway access logging not needed for workshop - id: AwsSolutions-COG4 reason: Workshop environment does not require Cognito User Pool authorization - - id: AwsSolutions-IAM4 - reason: AWS Managed policies are acceptable for workshop - - id: AwsSolutions-IAM5 - reason: Wildcard permissions acceptable for workshop parallel resource creation + - applies_to: + - regex: /^Policy::.*$/g + id: AwsSolutions-IAM4 + reason: AWS managed policies are acceptable for the ephemeral workshop environment + - applies_to: + - regex: /^(Action|Resource)::.*$/g + id: AwsSolutions-IAM5 + reason: Wildcard permissions are acceptable for the ephemeral workshop environment - id: AwsSolutions-RDS2 reason: Workshop non-sensitive test database does not need encryption at rest - id: AwsSolutions-RDS3 @@ -115,6 +119,14 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/AmazonBedrockLimitedAccess RoleName: ai-jvm-analyzer-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role AiJvmAnalyzerServiceRoleDefaultPolicyE0960C92: Properties: @@ -181,10 +193,21 @@ Resources: Version: "2012-10-17" PolicyName: BedrockLogging RoleName: workshop-bedrock-logging-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CfnPreDeleteCleanupFA953B95: DeletionPolicy: Delete Properties: + BucketNames: + - Ref: WorkshopBucketFD5BC43F + - Ref: WorkshopBucketAccessLogs476BAB88 ServiceToken: Fn::GetAtt: - CfnPreDeleteCleanupFunction580FB700 @@ -213,13 +236,14 @@ Resources: Custom Resource handler to cleanup resources before stack deletion. - GuardDuty VPC endpoints that block VPC deletion - GuardDuty managed security groups - - S3 bucket contents for workshop- buckets + - S3 buckets supplied by the stack Note: CloudWatch logs are kept for debugging/analysis """ print(f"Event: {event}") request_type = event['RequestType'] vpc_id = event['ResourceProperties'].get('VpcId', '') + bucket_names = event['ResourceProperties'].get('BucketNames', []) try: if request_type == 'Delete': @@ -227,7 +251,7 @@ Resources: endpoint_ids = start_guardduty_endpoint_deletion(vpc_id) # While endpoints are deleting, clean up S3 - cleanup_s3_buckets() + cleanup_s3_buckets(bucket_names) # Wait for VPC endpoint deletion to complete if endpoint_ids: @@ -314,17 +338,16 @@ Resources: print("GuardDuty security group cleanup completed") - def cleanup_s3_buckets(): - """Empty S3 buckets with workshop- prefix.""" - try: - response = s3.list_buckets() - for bucket in response.get('Buckets', []): - bucket_name = bucket['Name'] - if bucket_name.startswith('workshop-'): - print(f"Emptying S3 bucket: {bucket_name}") - empty_bucket(bucket_name) - except Exception as e: - print(f"Error listing S3 buckets: {e}") + def cleanup_s3_buckets(bucket_names): + """Delete only the S3 buckets supplied by the stack.""" + for bucket_name in bucket_names: + print(f"Deleting S3 bucket: {bucket_name}") + empty_bucket(bucket_name) + try: + s3.delete_bucket(Bucket=bucket_name) + print(f"Deleted bucket: {bucket_name}") + except Exception as e: + print(f"Error deleting bucket {bucket_name}: {e}") print("S3 bucket cleanup completed") @@ -372,6 +395,14 @@ Resources: - CfnPreDeleteCleanupRole2BF62B3D - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Timeout: 600 Type: AWS::Lambda::Function CfnPreDeleteCleanupRole2BF62B3D: @@ -389,23 +420,103 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CfnPreDeleteCleanupRoleDefaultPolicy7B910EB7: Properties: PolicyDocument: Statement: - Action: - - ec2:DeleteSecurityGroup - - ec2:DeleteVpcEndpoints - ec2:DescribeSecurityGroups - ec2:DescribeVpcEndpoints - - s3:DeleteObject - - s3:DeleteObjectVersion - - s3:ListAllMyBuckets + Effect: Allow + Resource: "*" + - Action: ec2:DeleteVpcEndpoints + Condition: + StringEquals: + ec2:Vpc: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc/ + - Ref: VpcC3027511 + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc-endpoint/* + - Action: ec2:DeleteSecurityGroup + Condition: + StringEquals: + ec2:Vpc: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc/ + - Ref: VpcC3027511 + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :security-group/* + - Action: + - s3:DeleteBucket - s3:ListBucket - s3:ListBucketVersions Effect: Allow - Resource: "*" + Resource: + - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - Action: + - s3:DeleteObject + - s3:DeleteObjectVersion + Effect: Allow + Resource: + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /* + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - /* Version: "2012-10-17" PolicyName: CfnPreDeleteCleanupRoleDefaultPolicy7B910EB7 Roles: @@ -416,13 +527,14 @@ Resources: DependsOn: - CodeBuildCompleteRuleAllowEventRuleWorkshopStackCodeBuildReportLambdaFunctionD77C60919E0B0C89 - CodeBuildCompleteRuleEE9277E8 + - CodeBuildPendingBuilds19869454 + - CodeBuildProjectPolicyDocument567377F5 + - CodeBuildProjectA0FF5539 + - CodeBuildProjectSecurityGroup7CE557B3 - CodeBuildReportLambdaFunctionA3C396F7 + - CodeBuildStartLambdaFunction8349284F Properties: - CodeBuildIamRoleArn: - Fn::GetAtt: - - CodeBuildRoleE9A44575 - - Arn - ContentHash: "1768809154032" + ContentHash: "1787668853203" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -452,7 +564,9 @@ Resources: build-status: - SUCCEEDED - FAILED + - FAULT - STOPPED + - TIMED_OUT project-name: - Ref: CodeBuildProjectA0FF5539 detail-type: @@ -460,6 +574,14 @@ Resources: source: - aws.codebuild State: ENABLED + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Targets: - Arn: Fn::GetAtt: @@ -482,6 +604,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CodeBuildLambdaRoleDefaultPolicyFB35F0AF: Properties: @@ -491,12 +621,72 @@ Resources: - codebuild:BatchGetBuilds - codebuild:StartBuild Effect: Allow - Resource: "*" + Resource: + Fn::GetAtt: + - CodeBuildProjectA0FF5539 + - Arn + - Action: + - dynamodb:BatchWriteItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + - dynamodb:PutItem + - dynamodb:UpdateItem + Effect: Allow + Resource: + Fn::GetAtt: + - CodeBuildPendingBuilds19869454 + - Arn + - Action: + - dynamodb:BatchGetItem + - dynamodb:BatchWriteItem + - dynamodb:ConditionCheckItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + - dynamodb:GetItem + - dynamodb:GetRecords + - dynamodb:GetShardIterator + - dynamodb:PutItem + - dynamodb:Query + - dynamodb:Scan + - dynamodb:UpdateItem + Effect: Allow + Resource: + Fn::GetAtt: + - CodeBuildPendingBuilds19869454 + - Arn Version: "2012-10-17" PolicyName: CodeBuildLambdaRoleDefaultPolicyFB35F0AF Roles: - Ref: CodeBuildLambdaRole655C06B4 Type: AWS::IAM::Policy + CodeBuildPendingBuilds19869454: + DeletionPolicy: Delete + Metadata: + cdk_nag: + rules_to_suppress: + - id: AwsSolutions-DDB3 + reason: The table stores short-lived CloudFormation callback state and does not require point-in-time recovery + Properties: + AttributeDefinitions: + - AttributeName: BuildId + AttributeType: S + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: BuildId + KeyType: HASH + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation + TimeToLiveSpecification: + AttributeName: ExpiresAt + Enabled: true + Type: AWS::DynamoDB::Table + UpdateReplacePolicy: Delete CodeBuildProjectA0FF5539: DependsOn: - CodeBuildProjectPolicyDocument567377F5 @@ -509,12 +699,12 @@ Resources: Environment: ComputeType: BUILD_GENERAL1_MEDIUM EnvironmentVariables: - - Name: GIT_BRANCH - Type: PLAINTEXT - Value: main - Name: TEMPLATE_TYPE Type: PLAINTEXT Value: java-on-amazon-eks + - Name: GIT_BRANCH + Type: PLAINTEXT + Value: feat/holmes-remediation Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 ImagePullCredentialsType: CODEBUILD PrivilegedMode: false @@ -543,7 +733,17 @@ Resources: # Resolution for when creating the first service in the account aws iam create-service-linked-role --aws-service-name ecs.amazonaws.com 2>/dev/null || true aws iam create-service-linked-role --aws-service-name elasticloadbalancing.amazonaws.com 2>/dev/null || true + aws iam create-service-linked-role --aws-service-name network.bedrock-agentcore.amazonaws.com 2>/dev/null || true + aws iam create-service-linked-role --aws-service-name runtime-identity.bedrock-agentcore.amazonaws.com 2>/dev/null || true Type: NO_SOURCE + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation TimeoutInMinutes: 30 VpcConfig: SecurityGroupIds: @@ -557,12 +757,102 @@ Resources: Ref: VpcC3027511 Type: AWS::CodeBuild::Project CodeBuildProjectPolicyDocument567377F5: + Metadata: + checkov: + skip: + - comment: CodeBuild requires ec2:DeleteNetworkInterface on wildcard resources because the API authorizes deletion against arn:aws:ec2:region:account:*/*. + id: CKV_AWS_111 Properties: PolicyDocument: Statement: - Action: - ec2:CreateNetworkInterface + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet1Subnet67A4DBCB + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet2SubnetC8EB537D + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :security-group/ + - Fn::GetAtt: + - CodeBuildProjectSecurityGroup7CE557B3 + - GroupId + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :network-interface/* + - Action: + - ec2:CreateNetworkInterfacePermission + Condition: + ArnEquals: + ec2:Subnet: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet1Subnet67A4DBCB + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet2SubnetC8EB537D + StringEquals: + ec2:AuthorizedService: codebuild.amazonaws.com + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :network-interface/* + - Action: - ec2:DeleteNetworkInterface + Effect: Allow + Resource: "*" + - Action: - ec2:DescribeDhcpOptions - ec2:DescribeNetworkInterfaces - ec2:DescribeSecurityGroups @@ -582,6 +872,14 @@ Resources: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: "-1" + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -591,57 +889,113 @@ Resources: - CodeBuildLambdaRole655C06B4 Properties: Code: - ZipFile: |- - import boto3 + ZipFile: | import json + import os + import urllib.request - codebuild = boto3.client('codebuild') + import boto3 - def lambda_handler(event, context): - print(f'Build status event: {event}') + codebuild = boto3.client("codebuild") + table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) - try: - # Extract build information from EventBridge event - detail = event['detail'] - build_status = detail['build-status'] - project_name = detail['project-name'] - build_id = detail['build-id'] - - print(f'Build {build_id} for project {project_name} finished with status: {build_status}') - - if build_status == 'SUCCEEDED': - print('✅ CodeBuild setup completed successfully') - elif build_status == 'FAILED': - print('❌ CodeBuild setup failed') - - # Get build details for error information - response = codebuild.batch_get_builds(ids=[build_id]) - if response['builds']: - build = response['builds'][0] - if 'logs' in build and 'cloudWatchLogs' in build['logs']: - log_group = build['logs']['cloudWatchLogs'].get('groupName') - log_stream = build['logs']['cloudWatchLogs'].get('streamName') - print(f'Check logs at: {log_group}/{log_stream}') - elif build_status == 'STOPPED': - print('⏹️ CodeBuild setup was stopped') - - return { - 'statusCode': 200, - 'body': json.dumps({ - 'message': f'Processed build status: {build_status}', - 'buildId': build_id, - 'projectName': project_name - }) - } + FAILURE_STATUSES = {"FAILED", "FAULT", "STOPPED", "TIMED_OUT"} - except Exception as e: - print(f'Error processing build status: {str(e)}') - return { - 'statusCode': 500, - 'body': json.dumps({ - 'error': str(e) - }) + + def normalized_build_id(value): + if ":build/" in value: + return value.split(":build/", 1)[1] + return value + + + def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") + + + def failure_details(build): + details = [] + for phase in build.get("phases", []): + contexts = "; ".join( + context.get("message", "") for context in phase.get("contexts", []) + ) + if phase.get("phaseStatus") in FAILURE_STATUSES or contexts: + details.append( + f"{phase.get('phaseType')}={phase.get('phaseStatus')}: {contexts}".strip() + ) + logs = build.get("logs", {}) + if logs.get("deepLink"): + details.append(f"logs={logs['deepLink']}") + return " | ".join(details) or "No phase failure details were returned" + + + def lambda_handler(event, context): + detail = event["detail"] + event_build_id = detail["build-id"] + build_id = normalized_build_id(event_build_id) + print(f"Terminal CodeBuild event for {event_build_id}: {detail['build-status']}") + + item = table.get_item(Key={"BuildId": build_id}, ConsistentRead=True).get("Item") + if not item: + raise RuntimeError(f"Pending CloudFormation callback not found for {build_id}") + + build_response = codebuild.batch_get_builds(ids=[item.get("BuildArn", event_build_id)]) + builds = build_response.get("builds", []) + if len(builds) != 1: + raise RuntimeError(f"CodeBuild build not found: {event_build_id}") + + build = builds[0] + status = build["buildStatus"] + original_event = json.loads(item["CloudFormationEvent"]) + data = { + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": item["ProjectName"], + "BuildStatus": status, + } + + if status == "SUCCEEDED": + response_status = "SUCCESS" + reason = None + elif status in FAILURE_STATUSES: + response_status = "FAILED" + reason = f"CodeBuild finished with {status}: {failure_details(build)}" + else: + raise RuntimeError(f"Received non-terminal CodeBuild status {status}") + + send_response( + original_event, + context, + response_status, + data, + item["PhysicalResourceId"], + reason, + ) + table.delete_item(Key={"BuildId": build_id}) + print(f"Sent {response_status} to CloudFormation for {build_id}") + Environment: + Variables: + PENDING_TABLE_NAME: + Ref: CodeBuildPendingBuilds19869454 FunctionName: workshop-setup-report Handler: index.lambda_handler Role: @@ -649,12 +1003,30 @@ Resources: - CodeBuildLambdaRole655C06B4 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Timeout: 120 Type: AWS::Lambda::Function CodeBuildRoleDefaultPolicy196BAF24: Properties: PolicyDocument: Statement: + - Action: iam:CreateServiceLinkedRole + Condition: + StringEquals: + iam:AWSServiceName: + - ecs.amazonaws.com + - elasticloadbalancing.amazonaws.com + - network.bedrock-agentcore.amazonaws.com + - runtime-identity.bedrock-agentcore.amazonaws.com + Effect: Allow + Resource: arn:aws:iam::*:role/aws-service-role/* - Action: ec2:CreateNetworkInterfacePermission Condition: StringEquals: @@ -751,12 +1123,14 @@ Resources: Principal: Service: codebuild.amazonaws.com Version: "2012-10-17" - ManagedPolicyArns: - - Fn::Join: - - "" - - - "arn:" - - Ref: AWS::Partition - - :iam::aws:policy/PowerUserAccess + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CodeBuildStartLambdaFunction8349284F: DependsOn: @@ -764,62 +1138,78 @@ Resources: - CodeBuildLambdaRole655C06B4 Properties: Code: - ZipFile: |- - import boto3 + ZipFile: | import json - import traceback - import cfnresponse - - codebuild = boto3.client('codebuild') - - def lambda_handler(event, context): - print(f'Event: {event}') - responseData = {} - status = cfnresponse.SUCCESS - physical_id = event.get('PhysicalResourceId', 'CodeBuildSetup') - - try: - if event['RequestType'] == 'Delete': - # Nothing to clean up for CodeBuild - responseData = {'Message': 'CodeBuild setup deleted'} - cfnresponse.send(event, context, status, responseData, physical_id) - return - - if event['RequestType'] == 'Update': - # For updates, trigger a new build - pass - - # Start CodeBuild project - props = event['ResourceProperties'] - project_name = props['ProjectName'] - - print(f'Starting CodeBuild project: {project_name}') + import os + import time + import urllib.request - response = codebuild.start_build( - projectName=project_name - ) + import boto3 - build_id = response['build']['id'] - build_arn = response['build']['arn'] + codebuild = boto3.client("codebuild") + table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) - print(f'Started build: {build_id}') - responseData = { - 'BuildId': build_id, - 'BuildArn': build_arn, - 'ProjectName': project_name + def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") - # Use build ID as physical resource ID for tracking - physical_id = build_id - except Exception as e: - status = cfnresponse.FAILED - tb_err = traceback.format_exc() - print(tb_err) - responseData = {'Error': tb_err} + def lambda_handler(event, context): + print(f"RequestType={event['RequestType']} LogicalResourceId={event['LogicalResourceId']}") + project_name = event["ResourceProperties"]["ProjectName"] + physical_id = event.get("PhysicalResourceId", project_name) - cfnresponse.send(event, context, status, responseData, physical_id) + if event["RequestType"] == "Delete": + send_response(event, context, "SUCCESS", {"ProjectName": project_name}, physical_id) + return + + try: + build = codebuild.start_build(projectName=project_name)["build"] + table.put_item( + Item={ + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": project_name, + "PhysicalResourceId": project_name, + "CloudFormationEvent": json.dumps(event), + "ExpiresAt": int(time.time()) + 7200, + } + ) + print(f"Started CodeBuild project {project_name}: {build['id']}") + except Exception as error: + print(f"Failed to start or persist CodeBuild callback: {error}") + send_response( + event, + context, + "FAILED", + {"ProjectName": project_name}, + physical_id, + str(error), + ) + Environment: + Variables: + PENDING_TABLE_NAME: + Ref: CodeBuildPendingBuilds19869454 FunctionName: workshop-setup-start Handler: index.lambda_handler Role: @@ -827,6 +1217,14 @@ Resources: - CodeBuildLambdaRole655C06B4 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Timeout: 120 Type: AWS::Lambda::Function DatabaseCluster5B53A178: @@ -839,8 +1237,9 @@ Resources: Ref: DatabaseClusterSubnets5540150D DatabaseName: workshop EnableHttpEndpoint: true + EnableIAMDatabaseAuthentication: true Engine: aurora-postgresql - EngineVersion: "16.13 + EngineVersion: "16.13" MasterUserPassword: Fn::Join: - "" @@ -857,6 +1256,14 @@ Resources: ServerlessV2ScalingConfiguration: MaxCapacity: 4 MinCapacity: 0.5 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcSecurityGroupIds: - Fn::GetAtt: - DatabaseSG562817C8 @@ -879,6 +1286,14 @@ Resources: Engine: aurora-postgresql PromotionTier: 0 PubliclyAccessible: false + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::RDS::DBInstance UpdateReplacePolicy: Delete DatabaseClusterSubnets5540150D: @@ -887,12 +1302,25 @@ Resources: SubnetIds: - Ref: VpcPrivateSubnet1Subnet67A4DBCB - Ref: VpcPrivateSubnet2SubnetC8EB537D + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::RDS::DBSubnetGroup DatabaseConnectionString52D1E98E: Properties: AllowedPattern: .* Description: Database Connection String Name: workshop-db-connection-string + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-on-amazon-eks + WorkshopOwner: cloudformation Tier: Standard Type: String Value: @@ -918,6 +1346,14 @@ Resources: FromPort: 5432 IpProtocol: tcp ToPort: 5432 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -935,6 +1371,14 @@ Resources: PasswordLength: 30 SecretStringTemplate: '{"username":"postgres"}' Name: workshop-db-secret + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::SecretsManager::Secret UpdateReplacePolicy: Delete DatabaseSecretAttachmentE5D1B020: @@ -993,6 +1437,13 @@ Resources: Value: workshop - Key: ManagedBy Value: ecr-create-on-push + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopOwner + Value: workshop-run Type: AWS::ECR::RepositoryCreationTemplate EcrRegistryTemplateRole9295BC5C: Properties: @@ -1004,6 +1455,14 @@ Resources: Service: ecr.amazonaws.com Version: "2012-10-17" RoleName: workshop-ecr-template-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EcrRegistryTemplateRoleDefaultPolicy760EC63A: Properties: @@ -1014,7 +1473,34 @@ Resources: - ecr:PutLifecyclePolicy - ecr:TagResource Effect: Allow - Resource: "*" + Resource: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ecr:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :repository/ai-jvm-analyzer + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ecr:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :repository/perf-analyzer + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ecr:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :repository/perf-collector Version: "2012-10-17" PolicyName: EcrRegistryTemplateRoleDefaultPolicy760EC63A Roles: @@ -1044,6 +1530,14 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/AWSXrayWriteOnlyAccess RoleName: workshop-eks-cloudwatch-agent-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EksClusterB2BDED5B: DependsOn: @@ -1104,6 +1598,14 @@ Resources: StorageConfig: BlockStorage: Enabled: true + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Version: "1.34" Type: AWS::EKS::Cluster EksClusterClusterSecurityGroupfromWorkshopStackThreadAnalysisSecurityGroup80EDCDE3443FFB1A901: @@ -1141,6 +1643,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/AmazonEC2ContainerRegistryReadOnly + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EksClusterKubectlReadyBarrierA32D1E84: DependsOn: @@ -1186,6 +1696,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/AmazonEKSNetworkingPolicy + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EksInstanceAccessEntry1D317291: Properties: @@ -1204,6 +1722,14 @@ Resources: Fn::GetAtt: - IdeRole4650E22E - Arn + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: STANDARD Type: AWS::EKS::AccessEntry EksMountpointS3DriverB7062BF9: @@ -1211,6 +1737,14 @@ Resources: AddonName: aws-mountpoint-s3-csi-driver ClusterName: Ref: EksClusterB2BDED5B + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EKS::Addon EksParticipantAccessEntryCBA5D5C0: Properties: @@ -1231,6 +1765,14 @@ Resources: - - "arn:aws:iam::" - Ref: AWS::AccountId - :role/WSParticipantRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: STANDARD Type: AWS::EKS::AccessEntry EksPodIdentityAgent31D8BD3D: @@ -1238,12 +1780,28 @@ Resources: AddonName: eks-pod-identity-agent ClusterName: Ref: EksClusterB2BDED5B + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EKS::Addon EksSecretsStoreDriverA84665C0: Properties: AddonName: aws-secrets-store-csi-driver-provider ClusterName: Ref: EksClusterB2BDED5B + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EKS::Addon IdeDistribution042A6660: DeletionPolicy: Delete @@ -1311,6 +1869,14 @@ Resources: - PublicIp - .compute-1.amazonaws.com Id: WorkshopStackIdeDistributionOrigin10FECA386 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::CloudFront::Distribution UpdateReplacePolicy: Delete IdeEC2InstanceResource438B3605: @@ -1357,12 +1923,21 @@ Resources: # This keeps UserData under size limits while allowing unlimited bootstrap size # Configuration from CDK - export GIT_BRANCH="main" + export GIT_BRANCH="feat/holmes-remediation" export AWS_REGION=" - Ref: AWS::Region - |- " export TEMPLATE_TYPE="java-on-amazon-eks" + export WORKSHOP_ID="java-on-amazon-eks" + export WORKSHOP_STACK_NAME=" + - Ref: AWS::StackName + - |- + " + export WORKSHOP_DEPLOYMENT_ID=" + - Ref: AWS::StackId + - |- + " export ARCH="x86_64" export IDE_TYPE="code-editor" export WAIT_CONDITION_HANDLE_URL=" @@ -1497,6 +2072,14 @@ Resources: IdeElasticIP3327A0B5: Properties: Domain: vpc + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::EIP IdeInstanceLauncherFunction803C5A2A: DependsOn: @@ -1641,6 +2224,14 @@ Resources: - IdeLambdaRole82392143 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Timeout: 300 Type: AWS::Lambda::Function IdeInstanceProfile61B92038: @@ -1653,6 +2244,14 @@ Resources: Properties: GroupDescription: IDE internal security group GroupName: workshop-ide-internal-sg + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -1697,27 +2296,57 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdeLambdaRoleDefaultPolicy099093D2: Properties: PolicyDocument: Statement: - Action: - - ec2:CreateTags - ec2:DescribeInstanceStatus - ec2:DescribeInstances - ec2:DescribeManagedPrefixLists - ec2:DescribeSubnets - - ec2:RunInstances - - ec2:TerminateInstances - - iam:PassRole - - secretsmanager:DescribeSecret - - secretsmanager:GetSecretValue - - ssm:DescribeInstanceInformation - - ssm:GetCommandInvocation - - ssm:SendCommand Effect: Allow Resource: "*" + - Action: ec2:RunInstances + Effect: Allow + Resource: + - arn:aws:ec2:*:*:instance/* + - arn:aws:ec2:*:*:network-interface/* + - arn:aws:ec2:*:*:security-group/* + - arn:aws:ec2:*:*:subnet/* + - arn:aws:ec2:*:*:volume/* + - arn:aws:ec2:*::image/* + - Action: ec2:CreateTags + Condition: + StringEquals: + aws:RequestTag/Workshop: "true" + ec2:CreateAction: RunInstances + Effect: Allow + Resource: arn:aws:ec2:*:*:instance/* + - Action: ec2:TerminateInstances + Condition: + StringEquals: + ec2:ResourceTag/Workshop: "true" + Effect: Allow + Resource: arn:aws:ec2:*:*:instance/* + - Action: iam:PassRole + Condition: + StringEquals: + iam:PassedToService: ec2.amazonaws.com + Effect: Allow + Resource: + Fn::GetAtt: + - IdeRole4650E22E + - Arn Version: "2012-10-17" PolicyName: IdeLambdaRoleDefaultPolicy099093D2 Roles: @@ -1781,6 +2410,14 @@ Resources: - IdePasswordFunctionServiceRole971421B6 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Timeout: 180 Type: AWS::Lambda::Function IdePasswordFunctionServiceRole971421B6: @@ -1798,6 +2435,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdePasswordFunctionServiceRoleDefaultPolicy282A360C: Properties: @@ -1864,6 +2509,14 @@ Resources: PasswordLength: 32 SecretStringTemplate: '{"password":""}' Name: workshop-ide-password + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::SecretsManager::Secret UpdateReplacePolicy: Delete IdePrefixListLookupFunction9E5A63DE: @@ -1915,6 +2568,14 @@ Resources: - IdeLambdaRole82392143 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Timeout: 180 Type: AWS::Lambda::Function IdePrefixListResourceD2EAC007: @@ -1952,15 +2613,21 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeRoleManagementPolicyFE7F8500 RoleName: workshop-ide-user + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdeRoleDefaultPolicyFD4BDE67: Properties: PolicyDocument: Statement: - - Action: cloudformation:SignalResource - Effect: Allow - Resource: "*" - Action: - secretsmanager:DescribeSecret - secretsmanager:GetSecretValue @@ -1972,6 +2639,155 @@ Resources: Roles: - Ref: IdeRole4650E22E Type: AWS::IAM::Policy + IdeRoleManagementPolicyFE7F8500: + Properties: + Description: "" + Path: / + PolicyDocument: + Statement: + - Action: iam:PassRole + Condition: + StringEquals: + iam:PassedToService: + - bedrock.amazonaws.com + - bedrock-agentcore.amazonaws.com + - codebuild.amazonaws.com + - ec2.amazonaws.com + - ecs.amazonaws.com + - ecs-tasks.amazonaws.com + - lambda.amazonaws.com + - pods.eks.amazonaws.com + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/ai-jvm-analyzer* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/grafana* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/perf-analyzer* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/perf-collector* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/pyroscope* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/service-role/unicorn* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/unicorn* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/workshop* + - Action: iam:CreateServiceLinkedRole + Condition: + StringEquals: + iam:AWSServiceName: + - application-signals.cloudwatch.amazonaws.com + - cloudtrail.amazonaws.com + - ecs.amazonaws.com + - elasticloadbalancing.amazonaws.com + - network.bedrock-agentcore.amazonaws.com + - runtime-identity.bedrock-agentcore.amazonaws.com + Effect: Allow + Resource: arn:aws:iam::*:role/aws-service-role/* + - Action: iam:CreateRole + Condition: + StringEquals: + iam:PermissionsBoundary: + Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :policy/workshop-boundary + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp* + - Action: + - iam:AttachRolePolicy + - iam:DeleteRole + - iam:DeleteRolePolicy + - iam:DetachRolePolicy + - iam:PutRolePolicy + - iam:UpdateAssumeRolePolicy + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent-kb-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent-runtime-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp-currency-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp-gateway-role + Version: "2012-10-17" + Type: AWS::IAM::ManagedPolicy IdeSecurityGroup73B02454: Properties: GroupDescription: IDE security group @@ -1980,142 +2796,968 @@ Resources: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: "-1" + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup IdeSecurityGroupfromIndirectPeer80C006C8A1: Properties: - Description: HTTP from CloudFront only - FromPort: 80 - GroupId: + Description: HTTP from CloudFront only + FromPort: 80 + GroupId: + Fn::GetAtt: + - IdeSecurityGroup73B02454 + - GroupId + IpProtocol: tcp + SourcePrefixListId: + Fn::GetAtt: + - IdePrefixListResourceD2EAC007 + - PrefixListId + ToPort: 80 + Type: AWS::EC2::SecurityGroupIngress + IdeUserPolicy2460FC7D: + Properties: + Description: "" + Path: / + PolicyDocument: + Statement: + - Action: aws-marketplace:Subscribe + Condition: + ForAllValues:StringEquals: + aws-marketplace:ProductId: + - prod-xdkflymybwmvi + - prod-mxcfnwvpd6kb4 + - prod-jhuafngbly644 + - prod-5ukwuglpt66kg + - prod-ffvjxvh4ltq64 + "Null": + aws-marketplace:ProductId: "false" + Effect: Allow + Resource: "*" + - Action: + - acm:* + - application-autoscaling:* + - application-signals:* + - bedrock-agentcore:* + - bedrock:* + - cloudformation:* + - cloudtrail:* + - cloudwatch:* + - cognito-idp:* + - ec2:* + - ecr:* + - eks:* + - elasticloadbalancing:* + - events:* + - lambda:* + - logs:* + - rds-data:* + - rds:* + - s3vectors:* + - secretsmanager:* + - ssm:* + - xray:* + Effect: Allow + Resource: + - arn:aws:bedrock:*::foundation-model/* + - arn:aws:ec2:*::image/* + - Fn::Join: + - "" + - - "arn:aws:acm:*:" + - Ref: AWS::AccountId + - :certificate/* + - Fn::Join: + - "" + - - "arn:aws:application-autoscaling:*:" + - Ref: AWS::AccountId + - :scal*/* + - Fn::Join: + - "" + - - "arn:aws:application-signals:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:bedrock-agentcore:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:bedrock:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:cloudformation:*:" + - Ref: AWS::AccountId + - :stack/workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudtrail:*:" + - Ref: AWS::AccountId + - :trail/workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudwatch:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:cognito-idp:*:" + - Ref: AWS::AccountId + - :userpool/* + - Fn::Join: + - "" + - - "arn:aws:ec2:*:" + - Ref: AWS::AccountId + - :*/* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/ai* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/backoffice* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/mcp* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/perf-* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/unicorn* + - Fn::Join: + - "" + - - "arn:aws:eks:*:" + - Ref: AWS::AccountId + - :cluster/* + - Fn::Join: + - "" + - - "arn:aws:elasticloadbalancing:*:" + - Ref: AWS::AccountId + - :*/* + - Fn::Join: + - "" + - - "arn:aws:events:*:" + - Ref: AWS::AccountId + - :rule/* + - Fn::Join: + - "" + - - "arn:aws:lambda:*:" + - Ref: AWS::AccountId + - :function:* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:* + - Fn::Join: + - "" + - - "arn:aws:rds:*:" + - Ref: AWS::AccountId + - :*:* + - Fn::Join: + - "" + - - "arn:aws:s3vectors:*:" + - Ref: AWS::AccountId + - :bucket/* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:mcp-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:workshop-* + - Fn::Join: + - "" + - - "arn:aws:ssm:*:" + - Ref: AWS::AccountId + - :parameter/workshop-* + - Action: + - aws-marketplace:Unsubscribe + - aws-marketplace:ViewSubscriptions + Condition: + ForAllValues:StringEquals: + aws-marketplace:ProductId: + - prod-xdkflymybwmvi + - prod-mxcfnwvpd6kb4 + - prod-jhuafngbly644 + - prod-5ukwuglpt66kg + - prod-ffvjxvh4ltq64 + Effect: Allow + Resource: "*" + - Action: + - cloudfront:CreateCloudFrontOriginAccessIdentity + - cloudfront:CreateDistribution + Condition: + StringEquals: + aws:PrincipalAccount: + Ref: AWS::AccountId + Effect: Allow + Resource: "*" + - Action: + - apigateway:* + - cloudfront:* + - dynamodb:* + - ecs:* + - s3:* + Effect: Allow + Resource: + - arn:aws:apigateway:*::/apis/* + - arn:aws:apigateway:*::/restapis/* + - arn:aws:s3:::aiagent-* + - arn:aws:s3:::workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudfront::" + - Ref: AWS::AccountId + - :distribution/* + - Fn::Join: + - "" + - - "arn:aws:cloudfront::" + - Ref: AWS::AccountId + - :origin-access-identity/cloudfront/* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :cluster/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :cluster/unicorn* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :service/*/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :service/*/unicorn* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task-definition/aiagent*:* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task-definition/unicorn*:* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task/*/* + - Action: + - acm:ListCertificates + - apigateway:GET + - bedrock-agentcore:List* + - bedrock:List* + - cloudformation:List* + - cloudfront:Get* + - cloudfront:List* + - cognito-idp:CreateUserPool + - cognito-idp:ListUserPools + - ec2:Describe* + - ecr:CreateRepositoryCreationTemplate + - ecr:Describe* + - ecr:GetAuthorizationToken + - ecs:Describe* + - ecs:List* + - ecs:RegisterTaskDefinition + - eks:CreateCluster + - eks:Describe* + - eks:List* + - elasticloadbalancing:Describe* + - iam:GetRole + - iam:GetRolePolicy + - iam:ListAttachedRolePolicies + - iam:ListRolePolicies + - iam:ListRoles + - lambda:List* + - logs:Describe* + - rds:Describe* + - s3:ListAllMyBuckets + - s3vectors:CreateVectorBucket + - s3vectors:ListVectorBuckets + - secretsmanager:ListSecrets + - ssm:DescribeParameters + - sts:GetCallerIdentity + - tag:GetResources + Effect: Allow + Resource: "*" + - Action: ec2:RunInstances + Condition: + StringLike: + ec2:InstanceType: + - "*4xlarge" + - "*6xlarge" + - "*8xlarge" + - "*9xlarge" + - "*10xlarge" + - "*12xlarge" + - f1* + - x1* + - z1* + - "*metal" + Effect: Deny + Resource: arn:aws:ec2:*:*:instance/* + - Action: + - dynamodb:PurchaseReservedCapacityOfferings + - ec2:ModifyReservedInstances + - ec2:PurchaseHostReservation + - ec2:PurchaseReservedInstancesOffering + - ec2:PurchaseScheduledInstances + - rds:PurchaseReservedDBInstancesOffering + Effect: Deny + Resource: "*" + Version: "2012-10-17" + Type: AWS::IAM::ManagedPolicy + IdeWaitConditionCC35C186: + DependsOn: + - IdeEC2InstanceResource438B3605 + Properties: + Count: 1 + Handle: + Ref: IdeWaitConditionHandleE8345861 + Timeout: "1800" + Type: AWS::CloudFormation::WaitCondition + IdeWaitConditionHandleE8345861: + Type: AWS::CloudFormation::WaitConditionHandle + IdeWorkshopBoundaryDEE72AD4: + Properties: + Description: "" + ManagedPolicyName: workshop-boundary + Path: / + PolicyDocument: + Statement: + - Action: bedrock:* + Effect: Allow + Resource: + - arn:aws:bedrock:*::foundation-model/* + - Fn::Join: + - "" + - - "arn:aws:bedrock:*:" + - Ref: AWS::AccountId + - :* + Sid: BedrockRuntime + - Action: + - aws-marketplace:Subscribe + - aws-marketplace:ViewSubscriptions + Effect: Allow + Resource: "*" + Sid: BedrockMarketplaceModelAccess + - Action: bedrock-agentcore:* + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:bedrock-agentcore:*:" + - Ref: AWS::AccountId + - :* + Sid: AgentCoreRuntime + - Action: + - bedrock-agentcore:ConnectBrowserAutomationStream + - bedrock-agentcore:ConnectBrowserLiveViewStream + Effect: Allow + Resource: "*" + Sid: AgentCoreManagedBrowserStreams + - Action: + - bedrock-agentcore:GetBrowserSession + - bedrock-agentcore:StartBrowserSession + - bedrock-agentcore:StopBrowserSession + - bedrock-agentcore:UpdateBrowserStream + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:browser/aws.browser.v1 + Sid: AgentCoreManagedBrowser + - Action: + - bedrock-agentcore:GetCodeInterpreterSession + - bedrock-agentcore:InvokeCodeInterpreter + - bedrock-agentcore:StartCodeInterpreterSession + - bedrock-agentcore:StopCodeInterpreterSession + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:code-interpreter/aws.codeinterpreter.v1 + Sid: AgentCoreManagedCodeInterpreter + - Action: + - dynamodb:* + - ecr:* + - lambda:InvokeFunction + - logs:* + - s3:* + - s3vectors:* + - secretsmanager:GetSecretValue + Effect: Allow + Resource: + - arn:aws:s3:::aiagent-kb-data-* + - arn:aws:s3:::aiagent-kb-data-*/* + - arn:aws:s3:::workshop-* + - arn:aws:s3:::workshop-*/* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-*/* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/backoffice* + - Fn::Join: + - "" + - - "arn:aws:lambda:*:" + - Ref: AWS::AccountId + - :function:mcp-* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/bedrock-agentcore/* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/bedrock-agentcore/*:* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/lambda/mcp-* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/lambda/mcp-*:* + - Fn::Join: + - "" + - - "arn:aws:s3vectors:*:" + - Ref: AWS::AccountId + - :bucket/aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:bedrock-agentcore-identity!* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:mcp-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:workshop-* + Sid: WorkshopData + - Action: + - ec2:CreateNetworkInterface + - ec2:DeleteNetworkInterface + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:ec2:*:" + - Ref: AWS::AccountId + - :network-interface/* + Sid: RuntimeNetworkInterfaces + - Action: + - cloudwatch:PutMetricData + - ec2:DescribeNetworkInterfaces + - ec2:DescribeSecurityGroups + - ec2:DescribeSubnets + - ec2:DescribeVpcs + - ecr:GetAuthorizationToken + - logs:DescribeLogGroups + - xray:GetSamplingRules + - xray:GetSamplingTargets + - xray:PutTelemetryRecords + - xray:PutTraceSegments + Effect: Allow + Resource: "*" + Sid: RuntimeReadAndTelemetry + - Action: + - account:* + - iam:* + - organizations:* + Effect: Deny + Resource: "*" + Sid: DenyIAM + Version: "2012-10-17" + Type: AWS::IAM::ManagedPolicy + PerfPlatformAnalyzerEksPodRoleA97B019B: + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: + - sts:AssumeRole + - sts:TagSession + Effect: Allow + Principal: + Service: pods.eks.amazonaws.com + Version: "2012-10-17" + Description: Role for perf-analyzer EKS pod to access Bedrock, S3 and ECS + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/AmazonBedrockLimitedAccess + RoleName: perf-analyzer-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation + Type: AWS::IAM::Role + PerfPlatformAnalyzerEksPodRoleDefaultPolicy4542BF86: + Properties: + PolicyDocument: + Statement: + - Action: s3:ListBucket + Effect: Allow + Resource: + Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - Action: + - s3:GetObject + - s3:HeadObject + - s3:PutObject + Effect: Allow + Resource: + Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - /perf-platform/* + - Action: + - ecs:DescribeContainerInstances + - ecs:DescribeTasks + - ecs:ListTasks + Effect: Allow + Resource: "*" + Version: "2012-10-17" + PolicyName: PerfPlatformAnalyzerEksPodRoleDefaultPolicy4542BF86 + Roles: + - Ref: PerfPlatformAnalyzerEksPodRoleA97B019B + Type: AWS::IAM::Policy + PerfPlatformCollectorEksPodRole3090D9EA: + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: + - sts:AssumeRole + - sts:TagSession + Effect: Allow + Principal: + Service: pods.eks.amazonaws.com + Version: "2012-10-17" + Description: Role for perf-collector EKS DaemonSet pod to upload profiling artifacts to S3 + RoleName: perf-collector-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation + Type: AWS::IAM::Role + PerfPlatformCollectorEksPodRoleDefaultPolicy102C5ADB: + Properties: + PolicyDocument: + Statement: + - Action: + - s3:HeadObject + - s3:PutObject + Effect: Allow + Resource: + Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - /perf-platform/profiling/* + Version: "2012-10-17" + PolicyName: PerfPlatformCollectorEksPodRoleDefaultPolicy102C5ADB + Roles: + - Ref: PerfPlatformCollectorEksPodRole3090D9EA + Type: AWS::IAM::Policy + PerfPlatformGrafanaEksPodRole8BAC861C: + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: + - sts:AssumeRole + - sts:TagSession + Effect: Allow + Principal: + Service: pods.eks.amazonaws.com + Version: "2012-10-17" + Description: Role for Grafana to read CloudWatch metrics for the perf-platform Latency Metrics dashboard and ServiceLatency alert + RoleName: grafana-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation + Type: AWS::IAM::Role + PerfPlatformGrafanaEksPodRoleDefaultPolicyBFFD5487: + Properties: + PolicyDocument: + Statement: + - Action: + - cloudwatch:DescribeAlarmHistory + - cloudwatch:DescribeAlarms + - cloudwatch:DescribeAlarmsForMetric + - cloudwatch:GetMetricData + - cloudwatch:GetMetricStatistics + - cloudwatch:ListMetrics + - ec2:DescribeRegions + - ec2:DescribeTags + - tag:GetResources + Effect: Allow + Resource: "*" + Version: "2012-10-17" + PolicyName: PerfPlatformGrafanaEksPodRoleDefaultPolicyBFFD5487 + Roles: + - Ref: PerfPlatformGrafanaEksPodRole8BAC861C + Type: AWS::IAM::Policy + PerfPlatformPyroscopeEksPodRole01200CAC: + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: + - sts:AssumeRole + - sts:TagSession + Effect: Allow + Principal: + Service: pods.eks.amazonaws.com + Version: "2012-10-17" + Description: Role for Pyroscope server pod to read/write blocks in S3 under pyroscope/* + RoleName: pyroscope-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation + Type: AWS::IAM::Role + PerfPlatformPyroscopeEksPodRoleDefaultPolicy133E4C48: + Properties: + PolicyDocument: + Statement: + - Action: + - s3:GetBucketLocation + - s3:ListBucket + Condition: + StringLike: + s3:prefix: + - pyroscope/* + - pyroscope + Effect: Allow + Resource: + Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - Action: + - s3:AbortMultipartUpload + - s3:DeleteObject + - s3:GetObject + - s3:ListMultipartUploadParts + - s3:PutObject + Effect: Allow + Resource: + Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - /pyroscope/* + Version: "2012-10-17" + PolicyName: PerfPlatformPyroscopeEksPodRoleDefaultPolicy133E4C48 + Roles: + - Ref: PerfPlatformPyroscopeEksPodRole01200CAC + Type: AWS::IAM::Policy + ThreadAnalysisAuthorizerLambda8110470B: + DependsOn: + - ThreadAnalysisAuthorizerLambdaServiceRoleDefaultPolicy6A967D76 + - ThreadAnalysisAuthorizerLambdaServiceRoleA3734EF2 + Properties: + Code: + ZipFile: | + import base64 + import hmac + import json + import os + + import boto3 + + secretsmanager = boto3.client("secretsmanager") + + + def lambda_handler(event, context): + headers = event.get("headers") or {} + authorization = next( + (value for name, value in headers.items() if name.lower() == "authorization"), + "", + ) + + try: + scheme, encoded_credentials = authorization.split(" ", 1) + if scheme.lower() != "basic": + return {"isAuthorized": False} + + username, password = base64.b64decode(encoded_credentials).decode("utf-8").split(":", 1) + secret = secretsmanager.get_secret_value(SecretId=os.environ["SECRET_NAME"]) + expected_password = json.loads(secret["SecretString"])["password"] + authorized = hmac.compare_digest(username, "grafana-alerts") and hmac.compare_digest( + password, expected_password + ) + return {"isAuthorized": authorized} + except (ValueError, KeyError, TypeError, UnicodeDecodeError, json.JSONDecodeError): + return {"isAuthorized": False} + Environment: + Variables: + SECRET_NAME: workshop-ide-password + FunctionName: workshop-thread-analysis-authorizer + Handler: index.lambda_handler + Role: + Fn::GetAtt: + - ThreadAnalysisAuthorizerLambdaServiceRoleA3734EF2 + - Arn + Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation + Timeout: 10 + Type: AWS::Lambda::Function + ThreadAnalysisAuthorizerLambdaServiceRoleA3734EF2: + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation + Type: AWS::IAM::Role + ThreadAnalysisAuthorizerLambdaServiceRoleDefaultPolicy6A967D76: + Properties: + PolicyDocument: + Statement: + - Action: secretsmanager:GetSecretValue + Effect: Allow + Resource: arn:aws:secretsmanager:*:*:secret:workshop-ide-password* + Version: "2012-10-17" + PolicyName: ThreadAnalysisAuthorizerLambdaServiceRoleDefaultPolicy6A967D76 + Roles: + - Ref: ThreadAnalysisAuthorizerLambdaServiceRoleA3734EF2 + Type: AWS::IAM::Policy + ThreadAnalysisEndpointParameter6E1CF6FA: + Properties: + Name: workshop-thread-analysis-url + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-on-amazon-eks + WorkshopOwner: cloudformation + Type: String + Value: + Fn::GetAtt: + - ThreadAnalysisHttpApi2CC57DDB + - ApiEndpoint + Type: AWS::SSM::Parameter + ThreadAnalysisHttpApi2CC57DDB: + Properties: + Name: workshop-thread-analysis + ProtocolType: HTTP + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-on-amazon-eks + WorkshopOwner: cloudformation + Type: AWS::ApiGatewayV2::Api + ThreadAnalysisHttpApiDefaultStage13C90718: + Properties: + ApiId: + Ref: ThreadAnalysisHttpApi2CC57DDB + AutoDeploy: true + StageName: $default + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-on-amazon-eks + WorkshopOwner: cloudformation + Type: AWS::ApiGatewayV2::Stage + ThreadAnalysisHttpApiPOST8D6D00FF: + Properties: + ApiId: + Ref: ThreadAnalysisHttpApi2CC57DDB + AuthorizationType: CUSTOM + AuthorizerId: + Ref: ThreadAnalysisHttpApiThreadAnalysisAuthorizer2B049D8B + RouteKey: POST / + Target: + Fn::Join: + - "" + - - integrations/ + - Ref: ThreadAnalysisHttpApiPOSTThreadAnalysisIntegrationEA627632 + Type: AWS::ApiGatewayV2::Route + ThreadAnalysisHttpApiPOSTThreadAnalysisIntegrationEA627632: + Properties: + ApiId: + Ref: ThreadAnalysisHttpApi2CC57DDB + IntegrationType: AWS_PROXY + IntegrationUri: Fn::GetAtt: - - IdeSecurityGroup73B02454 - - GroupId - IpProtocol: tcp - SourcePrefixListId: + - ThreadAnalysisLambda3EE9B29D + - Arn + PayloadFormatVersion: "2.0" + Type: AWS::ApiGatewayV2::Integration + ThreadAnalysisHttpApiPOSTThreadAnalysisIntegrationPermissionC45D5F13: + Properties: + Action: lambda:InvokeFunction + FunctionName: Fn::GetAtt: - - IdePrefixListResourceD2EAC007 - - PrefixListId - ToPort: 80 - Type: AWS::EC2::SecurityGroupIngress - IdeUserPolicy2460FC7D: + - ThreadAnalysisLambda3EE9B29D + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: ThreadAnalysisHttpApi2CC57DDB + - /*/*/ + Type: AWS::Lambda::Permission + ThreadAnalysisHttpApiThreadAnalysisAuthorizer2B049D8B: Properties: - Description: "" - Path: / - PolicyDocument: - Statement: - - Action: aws-marketplace:Subscribe - Condition: - ForAllValues:StringEquals: - aws-marketplace:ProductId: - - prod-jhuafngbly644 - - prod-mxcfnwvpd6kb4 - - prod-4pmewlybdftbs - "Null": - aws-marketplace:ProductId: "false" - Effect: Allow - Resource: "*" - Sid: MarketplaceSubscribeClaude45Opus45Sonnet4Sonnet - - Action: - - acm:* - - apigateway:* - - application-autoscaling:* - - application-signals:* - - aws-marketplace:Unsubscribe - - aws-marketplace:ViewSubscriptions - - bedrock-agentcore:* - - bedrock:* - - cloudformation:* - - cloudfront:* - - cloudtrail:* - - cloudwatch:* - - codewhisperer:* - - cognito-idp:* - - ec2:* - - ecr:* - - ecs:* - - eks:* - - elasticloadbalancing:* - - events:* - - lambda:* - - logs:* - - q:* - - rds:* - - s3:* - - s3vectors:* - - secretsmanager:* - - ssm:* - - sts:* - - tag:* - - xray:* - Effect: Allow - Resource: "*" - Sid: AllowedServices - - Action: iam:PassRole - Effect: Allow - Resource: - - !Sub arn:aws:iam::${AWS::AccountId}:role/ai-jvm-analyzer* - - !Sub arn:aws:iam::${AWS::AccountId}:role/aiagent* - - !Sub arn:aws:iam::${AWS::AccountId}:role/mcpserver* - - !Sub arn:aws:iam::${AWS::AccountId}:role/service-role/unicorn* - - !Sub arn:aws:iam::${AWS::AccountId}:role/unicorn* - - !Sub arn:aws:iam::${AWS::AccountId}:role/workshop* - Sid: PassRole - - Action: iam:CreateServiceLinkedRole - Effect: Allow - Resource: - - arn:aws:iam::*:role/aws-service-role/application-signals.cloudwatch.amazonaws.com/* - - arn:aws:iam::*:role/aws-service-role/cloudtrail.amazonaws.com/* - Sid: CreateServiceLinkedRole - - Action: - - iam:GetRole - - iam:ListRoles - Effect: Allow - Resource: "*" - Sid: GetRole - - Action: ec2:RunInstances - Condition: - StringLike: - ec2:InstanceType: - - "*4xlarge" - - "*6xlarge" - - "*8xlarge" - - "*9xlarge" - - "*10xlarge" - - "*12xlarge" - - "*16xlarge" - - "*18xlarge" - - "*24xlarge" - - f1* - - x1* - - z1* - - "*metal" - Effect: Deny - Resource: arn:aws:ec2:*:*:instance/* - Sid: DenyXXLInstances - - Action: - - dynamodb:PurchaseReservedCapacityOfferings - - ec2:ModifyReservedInstances - - ec2:PurchaseHostReservation - - ec2:PurchaseReservedInstancesOffering - - ec2:PurchaseScheduledInstances - - rds:PurchaseReservedDBInstancesOffering - Effect: Deny - Resource: "*" - Sid: DenyReservations - Version: "2012-10-17" - Type: AWS::IAM::ManagedPolicy - IdeWaitConditionCC35C186: - DependsOn: - - IdeEC2InstanceResource438B3605 + ApiId: + Ref: ThreadAnalysisHttpApi2CC57DDB + AuthorizerPayloadFormatVersion: "2.0" + AuthorizerResultTtlInSeconds: 0 + AuthorizerType: REQUEST + AuthorizerUri: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":apigateway:" + - Ref: AWS::Region + - :lambda:path/2015-03-31/functions/ + - Fn::GetAtt: + - ThreadAnalysisAuthorizerLambda8110470B + - Arn + - /invocations + EnableSimpleResponses: true + IdentitySource: + - $request.header.Authorization + Name: workshop-thread-analysis-authorizer + Type: AWS::ApiGatewayV2::Authorizer + ThreadAnalysisHttpApiWorkshopStackThreadAnalysisHttpApiThreadAnalysisAuthorizer2DF767FAPermission373504DD: Properties: - Count: 1 - Handle: - Ref: IdeWaitConditionHandleE8345861 - Timeout: "1800" - Type: AWS::CloudFormation::WaitCondition - IdeWaitConditionHandleE8345861: - Type: AWS::CloudFormation::WaitConditionHandle + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - ThreadAnalysisAuthorizerLambda8110470B + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: ThreadAnalysisHttpApi2CC57DDB + - /authorizers/ + - Ref: ThreadAnalysisHttpApiThreadAnalysisAuthorizer2B049D8B + Type: AWS::Lambda::Permission ThreadAnalysisLambda3EE9B29D: DependsOn: - ThreadAnalysisLambdaRoleDefaultPolicyC7AD40BA @@ -2183,6 +3825,14 @@ Resources: - ThreadAnalysisLambdaRole00E8F59E - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Timeout: 300 VpcConfig: SecurityGroupIds: @@ -2210,21 +3860,16 @@ Resources: Fn::GetAtt: - ThreadAnalysisLambdaRole00E8F59E - Arn + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: STANDARD Type: AWS::EKS::AccessEntry - ThreadAnalysisLambdaFunctionUrl1F411C0A: - DependsOn: - - VpcPrivateSubnet1DefaultRouteF704DE9F - - VpcPrivateSubnet1RouteTableAssociation2BC202CB - - VpcPrivateSubnet2DefaultRoute5FAC9901 - - VpcPrivateSubnet2RouteTableAssociationFA51927B - Properties: - AuthType: NONE - TargetFunctionArn: - Fn::GetAtt: - - ThreadAnalysisLambda3EE9B29D - - Arn - Type: AWS::Lambda::Url ThreadAnalysisLambdaRole00E8F59E: Properties: AssumeRolePolicyDocument: @@ -2247,6 +3892,14 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole RoleName: workshop-thread-dump-lambda-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role ThreadAnalysisLambdaRoleDefaultPolicyC7AD40BA: Properties: @@ -2263,17 +3916,30 @@ Resources: Effect: Allow Resource: arn:aws:secretsmanager:*:*:secret:workshop-ide-password* - Action: - - ecs:DescribeClusters - - ecs:DescribeServices - - ecs:DescribeTasks - - ecs:ExecuteCommand - - ecs:ListTasks - eks:AccessKubernetesApi - eks:DescribeCluster + Effect: Allow + Resource: + Fn::Join: + - "" + - - arn:aws:eks:*:*:cluster/ + - Ref: EksClusterB2BDED5B + - Action: - eks:ListClusters - sts:GetCallerIdentity Effect: Allow Resource: "*" + - Action: + - ecs:DescribeClusters + - ecs:DescribeServices + - ecs:DescribeTasks + - ecs:ExecuteCommand + - ecs:ListTasks + Effect: Allow + Resource: + - arn:aws:ecs:*:*:cluster/unicorn-store-spring + - arn:aws:ecs:*:*:service/unicorn-store-spring/* + - arn:aws:ecs:*:*:task/unicorn-store-spring/* - Action: - s3:Abort* - s3:DeleteObject* @@ -2311,41 +3977,19 @@ Resources: Roles: - Ref: ThreadAnalysisLambdaRole00E8F59E Type: AWS::IAM::Policy - ThreadAnalysisLambdainvokefunctionF0D39245: - DependsOn: - - VpcPrivateSubnet1DefaultRouteF704DE9F - - VpcPrivateSubnet1RouteTableAssociation2BC202CB - - VpcPrivateSubnet2DefaultRoute5FAC9901 - - VpcPrivateSubnet2RouteTableAssociationFA51927B - Properties: - Action: lambda:InvokeFunction - FunctionName: - Fn::GetAtt: - - ThreadAnalysisLambda3EE9B29D - - Arn - InvokedViaFunctionUrl: true - Principal: "*" - Type: AWS::Lambda::Permission - ThreadAnalysisLambdainvokefunctionurlEA9E1E5F: - DependsOn: - - VpcPrivateSubnet1DefaultRouteF704DE9F - - VpcPrivateSubnet1RouteTableAssociation2BC202CB - - VpcPrivateSubnet2DefaultRoute5FAC9901 - - VpcPrivateSubnet2RouteTableAssociationFA51927B - Properties: - Action: lambda:InvokeFunctionUrl - FunctionName: - Fn::GetAtt: - - ThreadAnalysisLambda3EE9B29D - - Arn - FunctionUrlAuthType: NONE - Principal: "*" - Type: AWS::Lambda::Permission ThreadAnalysisLogGroup7EC7074A: DeletionPolicy: Delete Properties: LogGroupName: /aws/lambda/workshop-thread-dump-lambda RetentionInDays: 7 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::Logs::LogGroup UpdateReplacePolicy: Delete ThreadAnalysisSecurityGroup28B00BCE: @@ -2356,6 +4000,14 @@ Resources: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: "-1" + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -2366,11 +4018,27 @@ Resources: ImageScanningConfiguration: ScanOnPush: true RepositoryName: unicorn-store-spring + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::ECR::Repository UpdateReplacePolicy: Delete UnicornUnicornEventBusB728845C: Properties: Name: unicorns + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::Events::EventBus UnicornUnicornStoreDatabaseSetupFunction04E12F8B: DependsOn: @@ -2542,6 +4210,14 @@ Resources: - UnicornUnicornStoreDatabaseSetupFunctionServiceRole61942171 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Timeout: 180 VpcConfig: SecurityGroupIds: @@ -2577,6 +4253,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreDatabaseSetupFunctionServiceRoleDefaultPolicy00D62509: DependsOn: @@ -2681,6 +4365,14 @@ Resources: - :iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices Path: /service-role/ RoleName: unicornstore-ecs-infrastructure-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreEcsTaskExecutionRoleC2148AE8: Properties: @@ -2700,6 +4392,14 @@ Resources: - :iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy Path: /service-role/ RoleName: unicornstore-ecs-task-execution-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreEcsTaskExecutionRoleDefaultPolicy3FC9EFEE: Properties: @@ -2707,7 +4407,9 @@ Resources: Statement: - Action: logs:CreateLogGroup Effect: Allow - Resource: "*" + Resource: + - arn:aws:logs:*:*:log-group:/aws/ecs/* + - arn:aws:logs:*:*:log-group:/ecs/* - Action: - secretsmanager:DescribeSecret - secretsmanager:GetSecretValue @@ -2759,6 +4461,14 @@ Resources: - :iam::aws:policy/AWSXrayWriteOnlyAccess Path: /service-role/ RoleName: unicornstore-ecs-task-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreEcsTaskRoleDefaultPolicy477138EA: Properties: @@ -2770,6 +4480,20 @@ Resources: Fn::GetAtt: - UnicornUnicornEventBusB728845C - Arn + - Action: + - s3:HeadObject + - s3:PutObject + Effect: Allow + Resource: + Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - /perf-platform/profiling/* + - Action: ecs:DescribeTasks + Effect: Allow + Resource: "*" Version: "2012-10-17" PolicyName: UnicornUnicornStoreEcsTaskRoleDefaultPolicy477138EA Roles: @@ -2794,6 +4518,14 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy RoleName: unicornstore-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreEksPodRoleDefaultPolicy0D527B93: Properties: @@ -2860,12 +4592,26 @@ Resources: Tags: - Key: Name Value: workshop-vpc + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::VPC VpcIGW488B0FEB: Properties: Tags: - Key: Name Value: workshop-vpc + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::InternetGateway VpcPrivateSubnet1DefaultRouteF704DE9F: Properties: @@ -2882,6 +4628,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -2909,6 +4662,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -2927,6 +4687,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -2954,6 +4721,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -2975,6 +4749,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::EIP VpcPublicSubnet1NATGateway8185E366: DependsOn: @@ -2992,6 +4773,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::NatGateway VpcPublicSubnet1RouteTable431DD755: Properties: @@ -3000,6 +4788,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -3027,6 +4822,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -3047,6 +4849,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -3074,6 +4883,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -3088,14 +4904,104 @@ Resources: Properties: Description: Workshop VPC ID for cross-stack reference Name: workshop-vpc-id + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-on-amazon-eks + WorkshopOwner: cloudformation Type: String Value: Ref: VpcC3027511 Type: AWS::SSM::Parameter + WorkshopBucketAccessLogs476BAB88: + DeletionPolicy: Delete + Metadata: + checkov: + skip: + - comment: Dedicated access-log target; recursive logging is intentionally disabled. + id: CKV_AWS_18 + Properties: + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + BucketName: + Fn::Join: + - "" + - - workshop-access-logs- + - Ref: AWS::AccountId + - "-" + - Ref: AWS::Region + - "-20260825164053" + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation + Type: AWS::S3::Bucket + UpdateReplacePolicy: Delete + WorkshopBucketAccessLogsPolicy37DFEA4E: + Properties: + Bucket: + Ref: WorkshopBucketAccessLogs476BAB88 + PolicyDocument: + Statement: + - Action: s3:* + Condition: + Bool: + aws:SecureTransport: "false" + Effect: Deny + Principal: + AWS: "*" + Resource: + - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /* + - Action: s3:PutObject + Condition: + ArnLike: + aws:SourceArn: + Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + StringEquals: + aws:SourceAccount: + Ref: AWS::AccountId + Effect: Allow + Principal: + Service: logging.s3.amazonaws.com + Resource: + Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /workshop-data/* + Version: "2012-10-17" + Type: AWS::S3::BucketPolicy WorkshopBucketBucketNameParameterCEE58012: Properties: Description: Workshop bucket name for thread dumps and profiling data Name: workshop-bucket-name + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-on-amazon-eks + WorkshopOwner: cloudformation Type: String Value: Ref: WorkshopBucketFD5BC43F @@ -3110,12 +5016,24 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260119085234" + - "-20260825164053" + LoggingConfiguration: + DestinationBucketName: + Ref: WorkshopBucketAccessLogs476BAB88 + LogFilePrefix: workshop-data/ PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-amazon-eks + - Key: WorkshopOwner + Value: cloudformation Type: AWS::S3::Bucket UpdateReplacePolicy: Delete WorkshopBucketPolicyA21947CB: diff --git a/infra/cfn/java-on-aws-stack.yaml b/infra/cfn/java-on-aws-stack.yaml index d44fce94..4598cbbe 100644 --- a/infra/cfn/java-on-aws-stack.yaml +++ b/infra/cfn/java-on-aws-stack.yaml @@ -119,6 +119,14 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/AmazonBedrockLimitedAccess RoleName: ai-jvm-analyzer-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role AiJvmAnalyzerServiceRoleDefaultPolicyE0960C92: Properties: @@ -185,10 +193,21 @@ Resources: Version: "2012-10-17" PolicyName: BedrockLogging RoleName: workshop-bedrock-logging-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CfnPreDeleteCleanupFA953B95: DeletionPolicy: Delete Properties: + BucketNames: + - Ref: WorkshopBucketFD5BC43F + - Ref: WorkshopBucketAccessLogs476BAB88 ServiceToken: Fn::GetAtt: - CfnPreDeleteCleanupFunction580FB700 @@ -217,13 +236,14 @@ Resources: Custom Resource handler to cleanup resources before stack deletion. - GuardDuty VPC endpoints that block VPC deletion - GuardDuty managed security groups - - S3 bucket contents for workshop- buckets + - S3 buckets supplied by the stack Note: CloudWatch logs are kept for debugging/analysis """ print(f"Event: {event}") request_type = event['RequestType'] vpc_id = event['ResourceProperties'].get('VpcId', '') + bucket_names = event['ResourceProperties'].get('BucketNames', []) try: if request_type == 'Delete': @@ -231,7 +251,7 @@ Resources: endpoint_ids = start_guardduty_endpoint_deletion(vpc_id) # While endpoints are deleting, clean up S3 - cleanup_s3_buckets() + cleanup_s3_buckets(bucket_names) # Wait for VPC endpoint deletion to complete if endpoint_ids: @@ -318,17 +338,16 @@ Resources: print("GuardDuty security group cleanup completed") - def cleanup_s3_buckets(): - """Empty S3 buckets with workshop- prefix.""" - try: - response = s3.list_buckets() - for bucket in response.get('Buckets', []): - bucket_name = bucket['Name'] - if bucket_name.startswith('workshop-'): - print(f"Emptying S3 bucket: {bucket_name}") - empty_bucket(bucket_name) - except Exception as e: - print(f"Error listing S3 buckets: {e}") + def cleanup_s3_buckets(bucket_names): + """Delete only the S3 buckets supplied by the stack.""" + for bucket_name in bucket_names: + print(f"Deleting S3 bucket: {bucket_name}") + empty_bucket(bucket_name) + try: + s3.delete_bucket(Bucket=bucket_name) + print(f"Deleted bucket: {bucket_name}") + except Exception as e: + print(f"Error deleting bucket {bucket_name}: {e}") print("S3 bucket cleanup completed") @@ -376,6 +395,14 @@ Resources: - CfnPreDeleteCleanupRole2BF62B3D - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Timeout: 600 Type: AWS::Lambda::Function CfnPreDeleteCleanupRole2BF62B3D: @@ -393,23 +420,103 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CfnPreDeleteCleanupRoleDefaultPolicy7B910EB7: Properties: PolicyDocument: Statement: - Action: - - ec2:DeleteSecurityGroup - - ec2:DeleteVpcEndpoints - ec2:DescribeSecurityGroups - ec2:DescribeVpcEndpoints - - s3:DeleteObject - - s3:DeleteObjectVersion - - s3:ListAllMyBuckets + Effect: Allow + Resource: "*" + - Action: ec2:DeleteVpcEndpoints + Condition: + StringEquals: + ec2:Vpc: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc/ + - Ref: VpcC3027511 + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc-endpoint/* + - Action: ec2:DeleteSecurityGroup + Condition: + StringEquals: + ec2:Vpc: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc/ + - Ref: VpcC3027511 + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :security-group/* + - Action: + - s3:DeleteBucket - s3:ListBucket - s3:ListBucketVersions Effect: Allow - Resource: "*" + Resource: + - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - Action: + - s3:DeleteObject + - s3:DeleteObjectVersion + Effect: Allow + Resource: + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /* + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - /* Version: "2012-10-17" PolicyName: CfnPreDeleteCleanupRoleDefaultPolicy7B910EB7 Roles: @@ -420,13 +527,14 @@ Resources: DependsOn: - CodeBuildCompleteRuleAllowEventRuleWorkshopStackCodeBuildReportLambdaFunctionD77C60919E0B0C89 - CodeBuildCompleteRuleEE9277E8 + - CodeBuildPendingBuilds19869454 + - CodeBuildProjectPolicyDocument567377F5 + - CodeBuildProjectA0FF5539 + - CodeBuildProjectSecurityGroup7CE557B3 - CodeBuildReportLambdaFunctionA3C396F7 + - CodeBuildStartLambdaFunction8349284F Properties: - CodeBuildIamRoleArn: - Fn::GetAtt: - - CodeBuildRoleE9A44575 - - Arn - ContentHash: "1783086703971" + ContentHash: "1787668849024" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -456,7 +564,9 @@ Resources: build-status: - SUCCEEDED - FAILED + - FAULT - STOPPED + - TIMED_OUT project-name: - Ref: CodeBuildProjectA0FF5539 detail-type: @@ -464,6 +574,14 @@ Resources: source: - aws.codebuild State: ENABLED + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Targets: - Arn: Fn::GetAtt: @@ -486,6 +604,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CodeBuildLambdaRoleDefaultPolicyFB35F0AF: Properties: @@ -495,12 +621,72 @@ Resources: - codebuild:BatchGetBuilds - codebuild:StartBuild Effect: Allow - Resource: "*" + Resource: + Fn::GetAtt: + - CodeBuildProjectA0FF5539 + - Arn + - Action: + - dynamodb:BatchWriteItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + - dynamodb:PutItem + - dynamodb:UpdateItem + Effect: Allow + Resource: + Fn::GetAtt: + - CodeBuildPendingBuilds19869454 + - Arn + - Action: + - dynamodb:BatchGetItem + - dynamodb:BatchWriteItem + - dynamodb:ConditionCheckItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + - dynamodb:GetItem + - dynamodb:GetRecords + - dynamodb:GetShardIterator + - dynamodb:PutItem + - dynamodb:Query + - dynamodb:Scan + - dynamodb:UpdateItem + Effect: Allow + Resource: + Fn::GetAtt: + - CodeBuildPendingBuilds19869454 + - Arn Version: "2012-10-17" PolicyName: CodeBuildLambdaRoleDefaultPolicyFB35F0AF Roles: - Ref: CodeBuildLambdaRole655C06B4 Type: AWS::IAM::Policy + CodeBuildPendingBuilds19869454: + DeletionPolicy: Delete + Metadata: + cdk_nag: + rules_to_suppress: + - id: AwsSolutions-DDB3 + reason: The table stores short-lived CloudFormation callback state and does not require point-in-time recovery + Properties: + AttributeDefinitions: + - AttributeName: BuildId + AttributeType: S + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: BuildId + KeyType: HASH + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation + TimeToLiveSpecification: + AttributeName: ExpiresAt + Enabled: true + Type: AWS::DynamoDB::Table + UpdateReplacePolicy: Delete CodeBuildProjectA0FF5539: DependsOn: - CodeBuildProjectPolicyDocument567377F5 @@ -515,7 +701,7 @@ Resources: EnvironmentVariables: - Name: GIT_BRANCH Type: PLAINTEXT - Value: main + Value: feat/holmes-remediation - Name: TEMPLATE_TYPE Type: PLAINTEXT Value: java-on-aws @@ -550,6 +736,14 @@ Resources: aws iam create-service-linked-role --aws-service-name network.bedrock-agentcore.amazonaws.com 2>/dev/null || true aws iam create-service-linked-role --aws-service-name runtime-identity.bedrock-agentcore.amazonaws.com 2>/dev/null || true Type: NO_SOURCE + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation TimeoutInMinutes: 30 VpcConfig: SecurityGroupIds: @@ -563,12 +757,102 @@ Resources: Ref: VpcC3027511 Type: AWS::CodeBuild::Project CodeBuildProjectPolicyDocument567377F5: + Metadata: + checkov: + skip: + - comment: CodeBuild requires ec2:DeleteNetworkInterface on wildcard resources because the API authorizes deletion against arn:aws:ec2:region:account:*/*. + id: CKV_AWS_111 Properties: PolicyDocument: Statement: - Action: - ec2:CreateNetworkInterface + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet1Subnet67A4DBCB + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet2SubnetC8EB537D + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :security-group/ + - Fn::GetAtt: + - CodeBuildProjectSecurityGroup7CE557B3 + - GroupId + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :network-interface/* + - Action: + - ec2:CreateNetworkInterfacePermission + Condition: + ArnEquals: + ec2:Subnet: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet1Subnet67A4DBCB + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet2SubnetC8EB537D + StringEquals: + ec2:AuthorizedService: codebuild.amazonaws.com + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :network-interface/* + - Action: - ec2:DeleteNetworkInterface + Effect: Allow + Resource: "*" + - Action: - ec2:DescribeDhcpOptions - ec2:DescribeNetworkInterfaces - ec2:DescribeSecurityGroups @@ -588,6 +872,14 @@ Resources: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: "-1" + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -597,57 +889,113 @@ Resources: - CodeBuildLambdaRole655C06B4 Properties: Code: - ZipFile: |- - import boto3 + ZipFile: | import json + import os + import urllib.request - codebuild = boto3.client('codebuild') + import boto3 - def lambda_handler(event, context): - print(f'Build status event: {event}') + codebuild = boto3.client("codebuild") + table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) - try: - # Extract build information from EventBridge event - detail = event['detail'] - build_status = detail['build-status'] - project_name = detail['project-name'] - build_id = detail['build-id'] - - print(f'Build {build_id} for project {project_name} finished with status: {build_status}') - - if build_status == 'SUCCEEDED': - print('✅ CodeBuild setup completed successfully') - elif build_status == 'FAILED': - print('❌ CodeBuild setup failed') - - # Get build details for error information - response = codebuild.batch_get_builds(ids=[build_id]) - if response['builds']: - build = response['builds'][0] - if 'logs' in build and 'cloudWatchLogs' in build['logs']: - log_group = build['logs']['cloudWatchLogs'].get('groupName') - log_stream = build['logs']['cloudWatchLogs'].get('streamName') - print(f'Check logs at: {log_group}/{log_stream}') - elif build_status == 'STOPPED': - print('⏹️ CodeBuild setup was stopped') - - return { - 'statusCode': 200, - 'body': json.dumps({ - 'message': f'Processed build status: {build_status}', - 'buildId': build_id, - 'projectName': project_name - }) - } + FAILURE_STATUSES = {"FAILED", "FAULT", "STOPPED", "TIMED_OUT"} - except Exception as e: - print(f'Error processing build status: {str(e)}') - return { - 'statusCode': 500, - 'body': json.dumps({ - 'error': str(e) - }) + + def normalized_build_id(value): + if ":build/" in value: + return value.split(":build/", 1)[1] + return value + + + def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") + + + def failure_details(build): + details = [] + for phase in build.get("phases", []): + contexts = "; ".join( + context.get("message", "") for context in phase.get("contexts", []) + ) + if phase.get("phaseStatus") in FAILURE_STATUSES or contexts: + details.append( + f"{phase.get('phaseType')}={phase.get('phaseStatus')}: {contexts}".strip() + ) + logs = build.get("logs", {}) + if logs.get("deepLink"): + details.append(f"logs={logs['deepLink']}") + return " | ".join(details) or "No phase failure details were returned" + + + def lambda_handler(event, context): + detail = event["detail"] + event_build_id = detail["build-id"] + build_id = normalized_build_id(event_build_id) + print(f"Terminal CodeBuild event for {event_build_id}: {detail['build-status']}") + + item = table.get_item(Key={"BuildId": build_id}, ConsistentRead=True).get("Item") + if not item: + raise RuntimeError(f"Pending CloudFormation callback not found for {build_id}") + + build_response = codebuild.batch_get_builds(ids=[item.get("BuildArn", event_build_id)]) + builds = build_response.get("builds", []) + if len(builds) != 1: + raise RuntimeError(f"CodeBuild build not found: {event_build_id}") + + build = builds[0] + status = build["buildStatus"] + original_event = json.loads(item["CloudFormationEvent"]) + data = { + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": item["ProjectName"], + "BuildStatus": status, + } + + if status == "SUCCEEDED": + response_status = "SUCCESS" + reason = None + elif status in FAILURE_STATUSES: + response_status = "FAILED" + reason = f"CodeBuild finished with {status}: {failure_details(build)}" + else: + raise RuntimeError(f"Received non-terminal CodeBuild status {status}") + + send_response( + original_event, + context, + response_status, + data, + item["PhysicalResourceId"], + reason, + ) + table.delete_item(Key={"BuildId": build_id}) + print(f"Sent {response_status} to CloudFormation for {build_id}") + Environment: + Variables: + PENDING_TABLE_NAME: + Ref: CodeBuildPendingBuilds19869454 FunctionName: workshop-setup-report Handler: index.lambda_handler Role: @@ -655,12 +1003,30 @@ Resources: - CodeBuildLambdaRole655C06B4 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Timeout: 120 Type: AWS::Lambda::Function CodeBuildRoleDefaultPolicy196BAF24: Properties: PolicyDocument: Statement: + - Action: iam:CreateServiceLinkedRole + Condition: + StringEquals: + iam:AWSServiceName: + - ecs.amazonaws.com + - elasticloadbalancing.amazonaws.com + - network.bedrock-agentcore.amazonaws.com + - runtime-identity.bedrock-agentcore.amazonaws.com + Effect: Allow + Resource: arn:aws:iam::*:role/aws-service-role/* - Action: ec2:CreateNetworkInterfacePermission Condition: StringEquals: @@ -757,12 +1123,14 @@ Resources: Principal: Service: codebuild.amazonaws.com Version: "2012-10-17" - ManagedPolicyArns: - - Fn::Join: - - "" - - - "arn:" - - Ref: AWS::Partition - - :iam::aws:policy/PowerUserAccess + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CodeBuildStartLambdaFunction8349284F: DependsOn: @@ -770,62 +1138,78 @@ Resources: - CodeBuildLambdaRole655C06B4 Properties: Code: - ZipFile: |- - import boto3 + ZipFile: | import json - import traceback - import cfnresponse - - codebuild = boto3.client('codebuild') - - def lambda_handler(event, context): - print(f'Event: {event}') - responseData = {} - status = cfnresponse.SUCCESS - physical_id = event.get('PhysicalResourceId', 'CodeBuildSetup') - - try: - if event['RequestType'] == 'Delete': - # Nothing to clean up for CodeBuild - responseData = {'Message': 'CodeBuild setup deleted'} - cfnresponse.send(event, context, status, responseData, physical_id) - return - - if event['RequestType'] == 'Update': - # For updates, trigger a new build - pass - - # Start CodeBuild project - props = event['ResourceProperties'] - project_name = props['ProjectName'] - - print(f'Starting CodeBuild project: {project_name}') + import os + import time + import urllib.request - response = codebuild.start_build( - projectName=project_name - ) + import boto3 - build_id = response['build']['id'] - build_arn = response['build']['arn'] + codebuild = boto3.client("codebuild") + table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) - print(f'Started build: {build_id}') - responseData = { - 'BuildId': build_id, - 'BuildArn': build_arn, - 'ProjectName': project_name + def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") - # Use build ID as physical resource ID for tracking - physical_id = build_id - except Exception as e: - status = cfnresponse.FAILED - tb_err = traceback.format_exc() - print(tb_err) - responseData = {'Error': tb_err} + def lambda_handler(event, context): + print(f"RequestType={event['RequestType']} LogicalResourceId={event['LogicalResourceId']}") + project_name = event["ResourceProperties"]["ProjectName"] + physical_id = event.get("PhysicalResourceId", project_name) - cfnresponse.send(event, context, status, responseData, physical_id) + if event["RequestType"] == "Delete": + send_response(event, context, "SUCCESS", {"ProjectName": project_name}, physical_id) + return + + try: + build = codebuild.start_build(projectName=project_name)["build"] + table.put_item( + Item={ + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": project_name, + "PhysicalResourceId": project_name, + "CloudFormationEvent": json.dumps(event), + "ExpiresAt": int(time.time()) + 7200, + } + ) + print(f"Started CodeBuild project {project_name}: {build['id']}") + except Exception as error: + print(f"Failed to start or persist CodeBuild callback: {error}") + send_response( + event, + context, + "FAILED", + {"ProjectName": project_name}, + physical_id, + str(error), + ) + Environment: + Variables: + PENDING_TABLE_NAME: + Ref: CodeBuildPendingBuilds19869454 FunctionName: workshop-setup-start Handler: index.lambda_handler Role: @@ -833,6 +1217,14 @@ Resources: - CodeBuildLambdaRole655C06B4 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Timeout: 120 Type: AWS::Lambda::Function DatabaseCluster5B53A178: @@ -845,8 +1237,9 @@ Resources: Ref: DatabaseClusterSubnets5540150D DatabaseName: workshop EnableHttpEndpoint: true + EnableIAMDatabaseAuthentication: true Engine: aurora-postgresql - EngineVersion: "16.13 + EngineVersion: "16.13" MasterUserPassword: Fn::Join: - "" @@ -863,6 +1256,14 @@ Resources: ServerlessV2ScalingConfiguration: MaxCapacity: 4 MinCapacity: 0.5 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcSecurityGroupIds: - Fn::GetAtt: - DatabaseSG562817C8 @@ -885,6 +1286,14 @@ Resources: Engine: aurora-postgresql PromotionTier: 0 PubliclyAccessible: false + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::RDS::DBInstance UpdateReplacePolicy: Delete DatabaseClusterSubnets5540150D: @@ -893,12 +1302,25 @@ Resources: SubnetIds: - Ref: VpcPrivateSubnet1Subnet67A4DBCB - Ref: VpcPrivateSubnet2SubnetC8EB537D + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::RDS::DBSubnetGroup DatabaseConnectionString52D1E98E: Properties: AllowedPattern: .* Description: Database Connection String Name: workshop-db-connection-string + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-on-aws + WorkshopOwner: cloudformation Tier: Standard Type: String Value: @@ -924,6 +1346,14 @@ Resources: FromPort: 5432 IpProtocol: tcp ToPort: 5432 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -941,6 +1371,14 @@ Resources: PasswordLength: 30 SecretStringTemplate: '{"username":"postgres"}' Name: workshop-db-secret + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::SecretsManager::Secret UpdateReplacePolicy: Delete DatabaseSecretAttachmentE5D1B020: @@ -999,6 +1437,13 @@ Resources: Value: workshop - Key: ManagedBy Value: ecr-create-on-push + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopOwner + Value: workshop-run Type: AWS::ECR::RepositoryCreationTemplate EcrRegistryTemplateRole9295BC5C: Properties: @@ -1010,6 +1455,14 @@ Resources: Service: ecr.amazonaws.com Version: "2012-10-17" RoleName: workshop-ecr-template-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EcrRegistryTemplateRoleDefaultPolicy760EC63A: Properties: @@ -1020,7 +1473,34 @@ Resources: - ecr:PutLifecyclePolicy - ecr:TagResource Effect: Allow - Resource: "*" + Resource: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ecr:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :repository/ai-jvm-analyzer + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ecr:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :repository/perf-analyzer + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ecr:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :repository/perf-collector Version: "2012-10-17" PolicyName: EcrRegistryTemplateRoleDefaultPolicy760EC63A Roles: @@ -1050,6 +1530,14 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/AWSXrayWriteOnlyAccess RoleName: workshop-eks-cloudwatch-agent-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EksClusterB2BDED5B: DependsOn: @@ -1110,6 +1598,14 @@ Resources: StorageConfig: BlockStorage: Enabled: true + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Version: "1.34" Type: AWS::EKS::Cluster EksClusterClusterSecurityGroupfromWorkshopStackThreadAnalysisSecurityGroup80EDCDE3443FFB1A901: @@ -1147,6 +1643,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/AmazonEC2ContainerRegistryReadOnly + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EksClusterKubectlReadyBarrierA32D1E84: DependsOn: @@ -1192,6 +1696,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/AmazonEKSNetworkingPolicy + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EksInstanceAccessEntry1D317291: Properties: @@ -1210,6 +1722,14 @@ Resources: Fn::GetAtt: - IdeRole4650E22E - Arn + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: STANDARD Type: AWS::EKS::AccessEntry EksMountpointS3DriverB7062BF9: @@ -1217,6 +1737,14 @@ Resources: AddonName: aws-mountpoint-s3-csi-driver ClusterName: Ref: EksClusterB2BDED5B + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EKS::Addon EksParticipantAccessEntryCBA5D5C0: Properties: @@ -1237,6 +1765,14 @@ Resources: - - "arn:aws:iam::" - Ref: AWS::AccountId - :role/WSParticipantRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: STANDARD Type: AWS::EKS::AccessEntry EksPodIdentityAgent31D8BD3D: @@ -1244,12 +1780,28 @@ Resources: AddonName: eks-pod-identity-agent ClusterName: Ref: EksClusterB2BDED5B + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EKS::Addon EksSecretsStoreDriverA84665C0: Properties: AddonName: aws-secrets-store-csi-driver-provider ClusterName: Ref: EksClusterB2BDED5B + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EKS::Addon IdeDistribution042A6660: DeletionPolicy: Delete @@ -1317,6 +1869,14 @@ Resources: - PublicIp - .compute-1.amazonaws.com Id: WorkshopStackIdeDistributionOrigin10FECA386 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::CloudFront::Distribution UpdateReplacePolicy: Delete IdeEC2InstanceResource438B3605: @@ -1363,12 +1923,21 @@ Resources: # This keeps UserData under size limits while allowing unlimited bootstrap size # Configuration from CDK - export GIT_BRANCH="main" + export GIT_BRANCH="feat/holmes-remediation" export AWS_REGION=" - Ref: AWS::Region - |- " export TEMPLATE_TYPE="java-on-aws" + export WORKSHOP_ID="java-on-aws" + export WORKSHOP_STACK_NAME=" + - Ref: AWS::StackName + - |- + " + export WORKSHOP_DEPLOYMENT_ID=" + - Ref: AWS::StackId + - |- + " export ARCH="x86_64" export IDE_TYPE="code-editor" export WAIT_CONDITION_HANDLE_URL=" @@ -1503,6 +2072,14 @@ Resources: IdeElasticIP3327A0B5: Properties: Domain: vpc + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::EIP IdeInstanceLauncherFunction803C5A2A: DependsOn: @@ -1647,6 +2224,14 @@ Resources: - IdeLambdaRole82392143 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Timeout: 300 Type: AWS::Lambda::Function IdeInstanceProfile61B92038: @@ -1659,6 +2244,14 @@ Resources: Properties: GroupDescription: IDE internal security group GroupName: workshop-ide-internal-sg + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -1703,27 +2296,57 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdeLambdaRoleDefaultPolicy099093D2: Properties: PolicyDocument: Statement: - Action: - - ec2:CreateTags - ec2:DescribeInstanceStatus - ec2:DescribeInstances - ec2:DescribeManagedPrefixLists - ec2:DescribeSubnets - - ec2:RunInstances - - ec2:TerminateInstances - - iam:PassRole - - secretsmanager:DescribeSecret - - secretsmanager:GetSecretValue - - ssm:DescribeInstanceInformation - - ssm:GetCommandInvocation - - ssm:SendCommand Effect: Allow Resource: "*" + - Action: ec2:RunInstances + Effect: Allow + Resource: + - arn:aws:ec2:*:*:instance/* + - arn:aws:ec2:*:*:network-interface/* + - arn:aws:ec2:*:*:security-group/* + - arn:aws:ec2:*:*:subnet/* + - arn:aws:ec2:*:*:volume/* + - arn:aws:ec2:*::image/* + - Action: ec2:CreateTags + Condition: + StringEquals: + aws:RequestTag/Workshop: "true" + ec2:CreateAction: RunInstances + Effect: Allow + Resource: arn:aws:ec2:*:*:instance/* + - Action: ec2:TerminateInstances + Condition: + StringEquals: + ec2:ResourceTag/Workshop: "true" + Effect: Allow + Resource: arn:aws:ec2:*:*:instance/* + - Action: iam:PassRole + Condition: + StringEquals: + iam:PassedToService: ec2.amazonaws.com + Effect: Allow + Resource: + Fn::GetAtt: + - IdeRole4650E22E + - Arn Version: "2012-10-17" PolicyName: IdeLambdaRoleDefaultPolicy099093D2 Roles: @@ -1787,6 +2410,14 @@ Resources: - IdePasswordFunctionServiceRole971421B6 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Timeout: 180 Type: AWS::Lambda::Function IdePasswordFunctionServiceRole971421B6: @@ -1804,6 +2435,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdePasswordFunctionServiceRoleDefaultPolicy282A360C: Properties: @@ -1870,6 +2509,14 @@ Resources: PasswordLength: 32 SecretStringTemplate: '{"password":""}' Name: workshop-ide-password + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::SecretsManager::Secret UpdateReplacePolicy: Delete IdePrefixListLookupFunction9E5A63DE: @@ -1921,6 +2568,14 @@ Resources: - IdeLambdaRole82392143 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Timeout: 180 Type: AWS::Lambda::Function IdePrefixListResourceD2EAC007: @@ -1958,15 +2613,21 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeRoleManagementPolicyFE7F8500 RoleName: workshop-ide-user + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdeRoleDefaultPolicyFD4BDE67: Properties: PolicyDocument: Statement: - - Action: cloudformation:SignalResource - Effect: Allow - Resource: "*" - Action: - secretsmanager:DescribeSecret - secretsmanager:GetSecretValue @@ -1978,6 +2639,155 @@ Resources: Roles: - Ref: IdeRole4650E22E Type: AWS::IAM::Policy + IdeRoleManagementPolicyFE7F8500: + Properties: + Description: "" + Path: / + PolicyDocument: + Statement: + - Action: iam:PassRole + Condition: + StringEquals: + iam:PassedToService: + - bedrock.amazonaws.com + - bedrock-agentcore.amazonaws.com + - codebuild.amazonaws.com + - ec2.amazonaws.com + - ecs.amazonaws.com + - ecs-tasks.amazonaws.com + - lambda.amazonaws.com + - pods.eks.amazonaws.com + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/ai-jvm-analyzer* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/grafana* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/perf-analyzer* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/perf-collector* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/pyroscope* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/service-role/unicorn* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/unicorn* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/workshop* + - Action: iam:CreateServiceLinkedRole + Condition: + StringEquals: + iam:AWSServiceName: + - application-signals.cloudwatch.amazonaws.com + - cloudtrail.amazonaws.com + - ecs.amazonaws.com + - elasticloadbalancing.amazonaws.com + - network.bedrock-agentcore.amazonaws.com + - runtime-identity.bedrock-agentcore.amazonaws.com + Effect: Allow + Resource: arn:aws:iam::*:role/aws-service-role/* + - Action: iam:CreateRole + Condition: + StringEquals: + iam:PermissionsBoundary: + Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :policy/workshop-boundary + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp* + - Action: + - iam:AttachRolePolicy + - iam:DeleteRole + - iam:DeleteRolePolicy + - iam:DetachRolePolicy + - iam:PutRolePolicy + - iam:UpdateAssumeRolePolicy + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent-kb-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent-runtime-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp-currency-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp-gateway-role + Version: "2012-10-17" + Type: AWS::IAM::ManagedPolicy IdeSecurityGroup73B02454: Properties: GroupDescription: IDE security group @@ -1986,6 +2796,14 @@ Resources: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: "-1" + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -2023,103 +2841,285 @@ Resources: aws-marketplace:ProductId: "false" Effect: Allow Resource: "*" - Sid: MarketplaceSubscribeClaude - Action: - acm:* - - apigateway:* - application-autoscaling:* - application-signals:* - - aws-marketplace:Unsubscribe - - aws-marketplace:ViewSubscriptions - bedrock-agentcore:* - bedrock:* - cloudformation:* - - cloudfront:* - cloudtrail:* - cloudwatch:* - - codewhisperer:* - cognito-idp:* - - dbqms:* - - dynamodb:* - ec2:* - ecr:* - - ecs:* - eks:* - elasticloadbalancing:* - events:* - lambda:* - logs:* - - q:* - rds-data:* - rds:* - - s3:* - s3vectors:* - secretsmanager:* - ssm:* - - sts:* - - tag:* - xray:* Effect: Allow + Resource: + - arn:aws:bedrock:*::foundation-model/* + - arn:aws:ec2:*::image/* + - Fn::Join: + - "" + - - "arn:aws:acm:*:" + - Ref: AWS::AccountId + - :certificate/* + - Fn::Join: + - "" + - - "arn:aws:application-autoscaling:*:" + - Ref: AWS::AccountId + - :scal*/* + - Fn::Join: + - "" + - - "arn:aws:application-signals:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:bedrock-agentcore:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:bedrock:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:cloudformation:*:" + - Ref: AWS::AccountId + - :stack/workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudtrail:*:" + - Ref: AWS::AccountId + - :trail/workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudwatch:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:cognito-idp:*:" + - Ref: AWS::AccountId + - :userpool/* + - Fn::Join: + - "" + - - "arn:aws:ec2:*:" + - Ref: AWS::AccountId + - :*/* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/ai* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/backoffice* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/mcp* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/perf-* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/unicorn* + - Fn::Join: + - "" + - - "arn:aws:eks:*:" + - Ref: AWS::AccountId + - :cluster/* + - Fn::Join: + - "" + - - "arn:aws:elasticloadbalancing:*:" + - Ref: AWS::AccountId + - :*/* + - Fn::Join: + - "" + - - "arn:aws:events:*:" + - Ref: AWS::AccountId + - :rule/* + - Fn::Join: + - "" + - - "arn:aws:lambda:*:" + - Ref: AWS::AccountId + - :function:* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:* + - Fn::Join: + - "" + - - "arn:aws:rds:*:" + - Ref: AWS::AccountId + - :*:* + - Fn::Join: + - "" + - - "arn:aws:s3vectors:*:" + - Ref: AWS::AccountId + - :bucket/* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:mcp-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:workshop-* + - Fn::Join: + - "" + - - "arn:aws:ssm:*:" + - Ref: AWS::AccountId + - :parameter/workshop-* + - Action: + - aws-marketplace:Unsubscribe + - aws-marketplace:ViewSubscriptions + Condition: + ForAllValues:StringEquals: + aws-marketplace:ProductId: + - prod-xdkflymybwmvi + - prod-mxcfnwvpd6kb4 + - prod-jhuafngbly644 + - prod-5ukwuglpt66kg + - prod-ffvjxvh4ltq64 + Effect: Allow Resource: "*" - Sid: AllowedServices - - Action: iam:PassRole + - Action: + - cloudfront:CreateCloudFrontOriginAccessIdentity + - cloudfront:CreateDistribution + Condition: + StringEquals: + aws:PrincipalAccount: + Ref: AWS::AccountId Effect: Allow - Resource: - - !Sub arn:aws:iam::${AWS::AccountId}:role/ai-jvm-analyzer* - - !Sub arn:aws:iam::${AWS::AccountId}:role/aiagent* - - !Sub arn:aws:iam::${AWS::AccountId}:role/grafana* - - !Sub arn:aws:iam::${AWS::AccountId}:role/mcpserver* - - !Sub arn:aws:iam::${AWS::AccountId}:role/perf-analyzer* - - !Sub arn:aws:iam::${AWS::AccountId}:role/perf-collector* - - !Sub arn:aws:iam::${AWS::AccountId}:role/pyroscope* - - !Sub arn:aws:iam::${AWS::AccountId}:role/service-role/unicorn* - - !Sub arn:aws:iam::${AWS::AccountId}:role/unicorn* - - !Sub arn:aws:iam::${AWS::AccountId}:role/workshop* - Sid: PassRole - - Action: iam:CreateServiceLinkedRole + Resource: "*" + - Action: + - apigateway:* + - cloudfront:* + - dynamodb:* + - ecs:* + - s3:* Effect: Allow Resource: - - arn:aws:iam::*:role/aws-service-role/application-signals.cloudwatch.amazonaws.com/* - - arn:aws:iam::*:role/aws-service-role/cloudtrail.amazonaws.com/* - Sid: CreateServiceLinkedRole + - arn:aws:apigateway:*::/apis/* + - arn:aws:apigateway:*::/restapis/* + - arn:aws:s3:::aiagent-* + - arn:aws:s3:::workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudfront::" + - Ref: AWS::AccountId + - :distribution/* + - Fn::Join: + - "" + - - "arn:aws:cloudfront::" + - Ref: AWS::AccountId + - :origin-access-identity/cloudfront/* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :cluster/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :cluster/unicorn* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :service/*/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :service/*/unicorn* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task-definition/aiagent*:* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task-definition/unicorn*:* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task/*/* - Action: + - acm:ListCertificates + - apigateway:GET + - bedrock-agentcore:List* + - bedrock:List* + - cloudformation:List* + - cloudfront:Get* + - cloudfront:List* + - cognito-idp:CreateUserPool + - cognito-idp:ListUserPools + - ec2:Describe* + - ecr:CreateRepositoryCreationTemplate + - ecr:Describe* + - ecr:GetAuthorizationToken + - ecs:Describe* + - ecs:List* + - ecs:RegisterTaskDefinition + - eks:CreateCluster + - eks:Describe* + - eks:List* + - elasticloadbalancing:Describe* - iam:GetRole + - iam:GetRolePolicy - iam:ListAttachedRolePolicies - iam:ListRolePolicies - iam:ListRoles + - lambda:List* + - logs:Describe* + - rds:Describe* + - s3:ListAllMyBuckets + - s3vectors:CreateVectorBucket + - s3vectors:ListVectorBuckets + - secretsmanager:ListSecrets + - ssm:DescribeParameters + - sts:GetCallerIdentity + - tag:GetResources Effect: Allow Resource: "*" - Sid: GetRole - - Action: - - iam:AttachRolePolicy - - iam:CreateRole - - iam:DeleteRole - - iam:DeleteRolePolicy - - iam:DetachRolePolicy - - iam:PutRolePolicy - - iam:UpdateAssumeRolePolicy - Condition: - StringEquals: - iam:PermissionsBoundary: !Sub arn:aws:iam::${AWS::AccountId}:policy/workshop-boundary - Effect: Allow - Resource: - - !Sub arn:aws:iam::${AWS::AccountId}:role/aiagent* - - !Sub arn:aws:iam::${AWS::AccountId}:role/backoffice* - - !Sub arn:aws:iam::${AWS::AccountId}:role/mcp* - Sid: AiAgentCreateRoles - - Action: iam:PassRole - Condition: - StringEquals: - iam:PassedToService: - - bedrock.amazonaws.com - - bedrock-agentcore.amazonaws.com - - lambda.amazonaws.com - Effect: Allow - Resource: - - !Sub arn:aws:iam::${AWS::AccountId}:role/aiagent* - - !Sub arn:aws:iam::${AWS::AccountId}:role/backoffice* - - !Sub arn:aws:iam::${AWS::AccountId}:role/mcp* - Sid: AiAgentPassRole - Action: ec2:RunInstances Condition: StringLike: @@ -2130,16 +3130,12 @@ Resources: - "*9xlarge" - "*10xlarge" - "*12xlarge" - - "*16xlarge" - - "*18xlarge" - - "*24xlarge" - f1* - x1* - z1* - "*metal" Effect: Deny Resource: arn:aws:ec2:*:*:instance/* - Sid: DenyXXLInstances - Action: - dynamodb:PurchaseReservedCapacityOfferings - ec2:ModifyReservedInstances @@ -2149,7 +3145,6 @@ Resources: - rds:PurchaseReservedDBInstancesOffering Effect: Deny Resource: "*" - Sid: DenyReservations Version: "2012-10-17" Type: AWS::IAM::ManagedPolicy IdeWaitConditionCC35C186: @@ -2170,32 +3165,164 @@ Resources: Path: / PolicyDocument: Statement: + - Action: bedrock:* + Effect: Allow + Resource: + - arn:aws:bedrock:*::foundation-model/* + - Fn::Join: + - "" + - - "arn:aws:bedrock:*:" + - Ref: AWS::AccountId + - :* + Sid: BedrockRuntime + - Action: + - aws-marketplace:Subscribe + - aws-marketplace:ViewSubscriptions + Effect: Allow + Resource: "*" + Sid: BedrockMarketplaceModelAccess + - Action: bedrock-agentcore:* + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:bedrock-agentcore:*:" + - Ref: AWS::AccountId + - :* + Sid: AgentCoreRuntime + - Action: + - bedrock-agentcore:ConnectBrowserAutomationStream + - bedrock-agentcore:ConnectBrowserLiveViewStream + Effect: Allow + Resource: "*" + Sid: AgentCoreManagedBrowserStreams + - Action: + - bedrock-agentcore:GetBrowserSession + - bedrock-agentcore:StartBrowserSession + - bedrock-agentcore:StopBrowserSession + - bedrock-agentcore:UpdateBrowserStream + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:browser/aws.browser.v1 + Sid: AgentCoreManagedBrowser + - Action: + - bedrock-agentcore:GetCodeInterpreterSession + - bedrock-agentcore:InvokeCodeInterpreter + - bedrock-agentcore:StartCodeInterpreterSession + - bedrock-agentcore:StopCodeInterpreterSession + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:code-interpreter/aws.codeinterpreter.v1 + Sid: AgentCoreManagedCodeInterpreter + - Action: + - dynamodb:* + - ecr:* + - lambda:InvokeFunction + - logs:* + - s3:* + - s3vectors:* + - secretsmanager:GetSecretValue + Effect: Allow + Resource: + - arn:aws:s3:::aiagent-kb-data-* + - arn:aws:s3:::aiagent-kb-data-*/* + - arn:aws:s3:::workshop-* + - arn:aws:s3:::workshop-*/* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-*/* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/backoffice* + - Fn::Join: + - "" + - - "arn:aws:lambda:*:" + - Ref: AWS::AccountId + - :function:mcp-* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/bedrock-agentcore/* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/bedrock-agentcore/*:* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/lambda/mcp-* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/lambda/mcp-*:* + - Fn::Join: + - "" + - - "arn:aws:s3vectors:*:" + - Ref: AWS::AccountId + - :bucket/aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:bedrock-agentcore-identity!* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:mcp-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:workshop-* + Sid: WorkshopData - Action: - - aws-marketplace:Subscribe - - aws-marketplace:Unsubscribe - - aws-marketplace:ViewSubscriptions - - bedrock-agentcore:* - - bedrock:* - - cloudfront:* - - cloudwatch:* - - cognito-idp:* - - dynamodb:* - ec2:CreateNetworkInterface - ec2:DeleteNetworkInterface + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:ec2:*:" + - Ref: AWS::AccountId + - :network-interface/* + Sid: RuntimeNetworkInterfaces + - Action: + - cloudwatch:PutMetricData - ec2:DescribeNetworkInterfaces - ec2:DescribeSecurityGroups - ec2:DescribeSubnets - ec2:DescribeVpcs - - ecr:* - - lambda:InvokeFunction - - logs:* - - s3:* - - s3vectors:* - - secretsmanager:GetSecretValue - - xray:* + - ecr:GetAuthorizationToken + - logs:DescribeLogGroups + - xray:GetSamplingRules + - xray:GetSamplingTargets + - xray:PutTelemetryRecords + - xray:PutTraceSegments Effect: Allow Resource: "*" - Sid: AllowedServicesForRoles + Sid: RuntimeReadAndTelemetry - Action: - account:* - iam:* @@ -2224,6 +3351,14 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/AmazonBedrockLimitedAccess RoleName: perf-analyzer-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role PerfPlatformAnalyzerEksPodRoleDefaultPolicy4542BF86: Properties: @@ -2271,6 +3406,14 @@ Resources: Version: "2012-10-17" Description: Role for perf-collector EKS DaemonSet pod to upload profiling artifacts to S3 RoleName: perf-collector-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role PerfPlatformCollectorEksPodRoleDefaultPolicy102C5ADB: Properties: @@ -2305,6 +3448,14 @@ Resources: Version: "2012-10-17" Description: Role for Grafana to read CloudWatch metrics for the perf-platform Latency Metrics dashboard and ServiceLatency alert RoleName: grafana-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role PerfPlatformGrafanaEksPodRoleDefaultPolicyBFFD5487: Properties: @@ -2340,6 +3491,14 @@ Resources: Version: "2012-10-17" Description: Role for Pyroscope server pod to read/write blocks in S3 under pyroscope/* RoleName: pyroscope-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role PerfPlatformPyroscopeEksPodRoleDefaultPolicy133E4C48: Properties: @@ -2377,6 +3536,228 @@ Resources: Roles: - Ref: PerfPlatformPyroscopeEksPodRole01200CAC Type: AWS::IAM::Policy + ThreadAnalysisAuthorizerLambda8110470B: + DependsOn: + - ThreadAnalysisAuthorizerLambdaServiceRoleDefaultPolicy6A967D76 + - ThreadAnalysisAuthorizerLambdaServiceRoleA3734EF2 + Properties: + Code: + ZipFile: | + import base64 + import hmac + import json + import os + + import boto3 + + secretsmanager = boto3.client("secretsmanager") + + + def lambda_handler(event, context): + headers = event.get("headers") or {} + authorization = next( + (value for name, value in headers.items() if name.lower() == "authorization"), + "", + ) + + try: + scheme, encoded_credentials = authorization.split(" ", 1) + if scheme.lower() != "basic": + return {"isAuthorized": False} + + username, password = base64.b64decode(encoded_credentials).decode("utf-8").split(":", 1) + secret = secretsmanager.get_secret_value(SecretId=os.environ["SECRET_NAME"]) + expected_password = json.loads(secret["SecretString"])["password"] + authorized = hmac.compare_digest(username, "grafana-alerts") and hmac.compare_digest( + password, expected_password + ) + return {"isAuthorized": authorized} + except (ValueError, KeyError, TypeError, UnicodeDecodeError, json.JSONDecodeError): + return {"isAuthorized": False} + Environment: + Variables: + SECRET_NAME: workshop-ide-password + FunctionName: workshop-thread-analysis-authorizer + Handler: index.lambda_handler + Role: + Fn::GetAtt: + - ThreadAnalysisAuthorizerLambdaServiceRoleA3734EF2 + - Arn + Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation + Timeout: 10 + Type: AWS::Lambda::Function + ThreadAnalysisAuthorizerLambdaServiceRoleA3734EF2: + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation + Type: AWS::IAM::Role + ThreadAnalysisAuthorizerLambdaServiceRoleDefaultPolicy6A967D76: + Properties: + PolicyDocument: + Statement: + - Action: secretsmanager:GetSecretValue + Effect: Allow + Resource: arn:aws:secretsmanager:*:*:secret:workshop-ide-password* + Version: "2012-10-17" + PolicyName: ThreadAnalysisAuthorizerLambdaServiceRoleDefaultPolicy6A967D76 + Roles: + - Ref: ThreadAnalysisAuthorizerLambdaServiceRoleA3734EF2 + Type: AWS::IAM::Policy + ThreadAnalysisEndpointParameter6E1CF6FA: + Properties: + Name: workshop-thread-analysis-url + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-on-aws + WorkshopOwner: cloudformation + Type: String + Value: + Fn::GetAtt: + - ThreadAnalysisHttpApi2CC57DDB + - ApiEndpoint + Type: AWS::SSM::Parameter + ThreadAnalysisHttpApi2CC57DDB: + Properties: + Name: workshop-thread-analysis + ProtocolType: HTTP + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-on-aws + WorkshopOwner: cloudformation + Type: AWS::ApiGatewayV2::Api + ThreadAnalysisHttpApiDefaultStage13C90718: + Properties: + ApiId: + Ref: ThreadAnalysisHttpApi2CC57DDB + AutoDeploy: true + StageName: $default + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-on-aws + WorkshopOwner: cloudformation + Type: AWS::ApiGatewayV2::Stage + ThreadAnalysisHttpApiPOST8D6D00FF: + Properties: + ApiId: + Ref: ThreadAnalysisHttpApi2CC57DDB + AuthorizationType: CUSTOM + AuthorizerId: + Ref: ThreadAnalysisHttpApiThreadAnalysisAuthorizer2B049D8B + RouteKey: POST / + Target: + Fn::Join: + - "" + - - integrations/ + - Ref: ThreadAnalysisHttpApiPOSTThreadAnalysisIntegrationEA627632 + Type: AWS::ApiGatewayV2::Route + ThreadAnalysisHttpApiPOSTThreadAnalysisIntegrationEA627632: + Properties: + ApiId: + Ref: ThreadAnalysisHttpApi2CC57DDB + IntegrationType: AWS_PROXY + IntegrationUri: + Fn::GetAtt: + - ThreadAnalysisLambda3EE9B29D + - Arn + PayloadFormatVersion: "2.0" + Type: AWS::ApiGatewayV2::Integration + ThreadAnalysisHttpApiPOSTThreadAnalysisIntegrationPermissionC45D5F13: + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - ThreadAnalysisLambda3EE9B29D + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: ThreadAnalysisHttpApi2CC57DDB + - /*/*/ + Type: AWS::Lambda::Permission + ThreadAnalysisHttpApiThreadAnalysisAuthorizer2B049D8B: + Properties: + ApiId: + Ref: ThreadAnalysisHttpApi2CC57DDB + AuthorizerPayloadFormatVersion: "2.0" + AuthorizerResultTtlInSeconds: 0 + AuthorizerType: REQUEST + AuthorizerUri: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":apigateway:" + - Ref: AWS::Region + - :lambda:path/2015-03-31/functions/ + - Fn::GetAtt: + - ThreadAnalysisAuthorizerLambda8110470B + - Arn + - /invocations + EnableSimpleResponses: true + IdentitySource: + - $request.header.Authorization + Name: workshop-thread-analysis-authorizer + Type: AWS::ApiGatewayV2::Authorizer + ThreadAnalysisHttpApiWorkshopStackThreadAnalysisHttpApiThreadAnalysisAuthorizer2DF767FAPermission373504DD: + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - ThreadAnalysisAuthorizerLambda8110470B + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: ThreadAnalysisHttpApi2CC57DDB + - /authorizers/ + - Ref: ThreadAnalysisHttpApiThreadAnalysisAuthorizer2B049D8B + Type: AWS::Lambda::Permission ThreadAnalysisLambda3EE9B29D: DependsOn: - ThreadAnalysisLambdaRoleDefaultPolicyC7AD40BA @@ -2444,6 +3825,14 @@ Resources: - ThreadAnalysisLambdaRole00E8F59E - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Timeout: 300 VpcConfig: SecurityGroupIds: @@ -2471,21 +3860,16 @@ Resources: Fn::GetAtt: - ThreadAnalysisLambdaRole00E8F59E - Arn + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: STANDARD Type: AWS::EKS::AccessEntry - ThreadAnalysisLambdaFunctionUrl1F411C0A: - DependsOn: - - VpcPrivateSubnet1DefaultRouteF704DE9F - - VpcPrivateSubnet1RouteTableAssociation2BC202CB - - VpcPrivateSubnet2DefaultRoute5FAC9901 - - VpcPrivateSubnet2RouteTableAssociationFA51927B - Properties: - AuthType: NONE - TargetFunctionArn: - Fn::GetAtt: - - ThreadAnalysisLambda3EE9B29D - - Arn - Type: AWS::Lambda::Url ThreadAnalysisLambdaRole00E8F59E: Properties: AssumeRolePolicyDocument: @@ -2508,6 +3892,14 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole RoleName: workshop-thread-dump-lambda-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role ThreadAnalysisLambdaRoleDefaultPolicyC7AD40BA: Properties: @@ -2524,17 +3916,30 @@ Resources: Effect: Allow Resource: arn:aws:secretsmanager:*:*:secret:workshop-ide-password* - Action: - - ecs:DescribeClusters - - ecs:DescribeServices - - ecs:DescribeTasks - - ecs:ExecuteCommand - - ecs:ListTasks - eks:AccessKubernetesApi - eks:DescribeCluster + Effect: Allow + Resource: + Fn::Join: + - "" + - - arn:aws:eks:*:*:cluster/ + - Ref: EksClusterB2BDED5B + - Action: - eks:ListClusters - sts:GetCallerIdentity Effect: Allow Resource: "*" + - Action: + - ecs:DescribeClusters + - ecs:DescribeServices + - ecs:DescribeTasks + - ecs:ExecuteCommand + - ecs:ListTasks + Effect: Allow + Resource: + - arn:aws:ecs:*:*:cluster/unicorn-store-spring + - arn:aws:ecs:*:*:service/unicorn-store-spring/* + - arn:aws:ecs:*:*:task/unicorn-store-spring/* - Action: - s3:Abort* - s3:DeleteObject* @@ -2572,41 +3977,19 @@ Resources: Roles: - Ref: ThreadAnalysisLambdaRole00E8F59E Type: AWS::IAM::Policy - ThreadAnalysisLambdainvokefunctionF0D39245: - DependsOn: - - VpcPrivateSubnet1DefaultRouteF704DE9F - - VpcPrivateSubnet1RouteTableAssociation2BC202CB - - VpcPrivateSubnet2DefaultRoute5FAC9901 - - VpcPrivateSubnet2RouteTableAssociationFA51927B - Properties: - Action: lambda:InvokeFunction - FunctionName: - Fn::GetAtt: - - ThreadAnalysisLambda3EE9B29D - - Arn - InvokedViaFunctionUrl: true - Principal: "*" - Type: AWS::Lambda::Permission - ThreadAnalysisLambdainvokefunctionurlEA9E1E5F: - DependsOn: - - VpcPrivateSubnet1DefaultRouteF704DE9F - - VpcPrivateSubnet1RouteTableAssociation2BC202CB - - VpcPrivateSubnet2DefaultRoute5FAC9901 - - VpcPrivateSubnet2RouteTableAssociationFA51927B - Properties: - Action: lambda:InvokeFunctionUrl - FunctionName: - Fn::GetAtt: - - ThreadAnalysisLambda3EE9B29D - - Arn - FunctionUrlAuthType: NONE - Principal: "*" - Type: AWS::Lambda::Permission ThreadAnalysisLogGroup7EC7074A: DeletionPolicy: Delete Properties: LogGroupName: /aws/lambda/workshop-thread-dump-lambda RetentionInDays: 7 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::Logs::LogGroup UpdateReplacePolicy: Delete ThreadAnalysisSecurityGroup28B00BCE: @@ -2617,6 +4000,14 @@ Resources: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: "-1" + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -2627,11 +4018,27 @@ Resources: ImageScanningConfiguration: ScanOnPush: true RepositoryName: unicorn-store-spring + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::ECR::Repository UpdateReplacePolicy: Delete UnicornUnicornEventBusB728845C: Properties: Name: unicorns + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::Events::EventBus UnicornUnicornStoreDatabaseSetupFunction04E12F8B: DependsOn: @@ -2803,6 +4210,14 @@ Resources: - UnicornUnicornStoreDatabaseSetupFunctionServiceRole61942171 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Timeout: 180 VpcConfig: SecurityGroupIds: @@ -2838,6 +4253,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreDatabaseSetupFunctionServiceRoleDefaultPolicy00D62509: DependsOn: @@ -2942,6 +4365,14 @@ Resources: - :iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices Path: /service-role/ RoleName: unicornstore-ecs-infrastructure-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreEcsTaskExecutionRoleC2148AE8: Properties: @@ -2961,6 +4392,14 @@ Resources: - :iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy Path: /service-role/ RoleName: unicornstore-ecs-task-execution-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreEcsTaskExecutionRoleDefaultPolicy3FC9EFEE: Properties: @@ -2968,7 +4407,9 @@ Resources: Statement: - Action: logs:CreateLogGroup Effect: Allow - Resource: "*" + Resource: + - arn:aws:logs:*:*:log-group:/aws/ecs/* + - arn:aws:logs:*:*:log-group:/ecs/* - Action: - secretsmanager:DescribeSecret - secretsmanager:GetSecretValue @@ -3020,6 +4461,14 @@ Resources: - :iam::aws:policy/AWSXrayWriteOnlyAccess Path: /service-role/ RoleName: unicornstore-ecs-task-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreEcsTaskRoleDefaultPolicy477138EA: Properties: @@ -3069,6 +4518,14 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy RoleName: unicornstore-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreEksPodRoleDefaultPolicy0D527B93: Properties: @@ -3135,12 +4592,26 @@ Resources: Tags: - Key: Name Value: workshop-vpc + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::VPC VpcIGW488B0FEB: Properties: Tags: - Key: Name Value: workshop-vpc + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::InternetGateway VpcPrivateSubnet1DefaultRouteF704DE9F: Properties: @@ -3157,6 +4628,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -3184,6 +4662,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -3202,6 +4687,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -3229,6 +4721,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -3250,6 +4749,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::EIP VpcPublicSubnet1NATGateway8185E366: DependsOn: @@ -3267,6 +4773,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::NatGateway VpcPublicSubnet1RouteTable431DD755: Properties: @@ -3275,6 +4788,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -3302,6 +4822,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -3322,6 +4849,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -3349,6 +4883,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -3363,14 +4904,104 @@ Resources: Properties: Description: Workshop VPC ID for cross-stack reference Name: workshop-vpc-id + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-on-aws + WorkshopOwner: cloudformation Type: String Value: Ref: VpcC3027511 Type: AWS::SSM::Parameter + WorkshopBucketAccessLogs476BAB88: + DeletionPolicy: Delete + Metadata: + checkov: + skip: + - comment: Dedicated access-log target; recursive logging is intentionally disabled. + id: CKV_AWS_18 + Properties: + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + BucketName: + Fn::Join: + - "" + - - workshop-access-logs- + - Ref: AWS::AccountId + - "-" + - Ref: AWS::Region + - "-20260825164049" + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation + Type: AWS::S3::Bucket + UpdateReplacePolicy: Delete + WorkshopBucketAccessLogsPolicy37DFEA4E: + Properties: + Bucket: + Ref: WorkshopBucketAccessLogs476BAB88 + PolicyDocument: + Statement: + - Action: s3:* + Condition: + Bool: + aws:SecureTransport: "false" + Effect: Deny + Principal: + AWS: "*" + Resource: + - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /* + - Action: s3:PutObject + Condition: + ArnLike: + aws:SourceArn: + Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + StringEquals: + aws:SourceAccount: + Ref: AWS::AccountId + Effect: Allow + Principal: + Service: logging.s3.amazonaws.com + Resource: + Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /workshop-data/* + Version: "2012-10-17" + Type: AWS::S3::BucketPolicy WorkshopBucketBucketNameParameterCEE58012: Properties: Description: Workshop bucket name for thread dumps and profiling data Name: workshop-bucket-name + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-on-aws + WorkshopOwner: cloudformation Type: String Value: Ref: WorkshopBucketFD5BC43F @@ -3385,12 +5016,24 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260703155143" + - "-20260825164049" + LoggingConfiguration: + DestinationBucketName: + Ref: WorkshopBucketAccessLogs476BAB88 + LogFilePrefix: workshop-data/ PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-on-aws + - Key: WorkshopOwner + Value: cloudformation Type: AWS::S3::Bucket UpdateReplacePolicy: Delete WorkshopBucketPolicyA21947CB: diff --git a/infra/cfn/java-spring-ai-agents-stack.yaml b/infra/cfn/java-spring-ai-agents-stack.yaml index ed60a539..0812c57a 100644 --- a/infra/cfn/java-spring-ai-agents-stack.yaml +++ b/infra/cfn/java-spring-ai-agents-stack.yaml @@ -13,10 +13,14 @@ Metadata: reason: API Gateway access logging not needed for workshop - id: AwsSolutions-COG4 reason: Workshop environment does not require Cognito User Pool authorization - - id: AwsSolutions-IAM4 - reason: AWS Managed policies are acceptable for workshop - - id: AwsSolutions-IAM5 - reason: Wildcard permissions acceptable for workshop parallel resource creation + - applies_to: + - regex: /^Policy::.*$/g + id: AwsSolutions-IAM4 + reason: AWS managed policies are acceptable for the ephemeral workshop environment + - applies_to: + - regex: /^(Action|Resource)::.*$/g + id: AwsSolutions-IAM5 + reason: Wildcard permissions are acceptable for the ephemeral workshop environment - id: AwsSolutions-RDS2 reason: Workshop non-sensitive test database does not need encryption at rest - id: AwsSolutions-RDS3 @@ -99,6 +103,14 @@ Resources: AiAgentEcsCluster79AA4F07: Properties: ClusterName: aiagent + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::ECS::Cluster AiAgentEksRole773CEF68: Properties: @@ -119,6 +131,14 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/AmazonBedrockFullAccess RoleName: aiagent-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role AiAgentEksRoleDefaultPolicy98379356: Properties: @@ -216,6 +236,14 @@ Resources: MaxTaskCount: 4 MinTaskCount: 1 ServiceName: aiagent + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation TaskRoleArn: Fn::GetAtt: - AiAgentTaskRole77A94BF0 @@ -239,6 +267,14 @@ Resources: - :iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices Path: /service-role/ RoleName: aiagent-ecs-infrastructure-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role AiAgentLambdaRoleDefaultPolicy499E8DB7: Properties: @@ -299,12 +335,28 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/AmazonBedrockFullAccess RoleName: aiagent-lambda-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role AiAgentLogGroup9D1D96C1: DeletionPolicy: Delete Properties: LogGroupName: /aws/ecs/aiagent RetentionInDays: 731 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::Logs::LogGroup UpdateReplacePolicy: Delete AiAgentRuntimeRole4D8E9BEA: @@ -334,20 +386,108 @@ Resources: - PolicyDocument: Statement: - Action: - - aws-marketplace:Subscribe - - aws-marketplace:Unsubscribe - - aws-marketplace:ViewSubscriptions - - bedrock-agentcore:* - - bedrock:* - - cloudwatch:* - - ecr:* - - logs:* - - xray:* + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + Effect: Allow + Resource: + - arn:aws:bedrock:*::foundation-model/* + - Fn::Join: + - "" + - - "arn:aws:bedrock:*:" + - Ref: AWS::AccountId + - :inference-profile/* + - Action: + - bedrock:Retrieve + - bedrock:RetrieveAndGenerate + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:bedrock:*:" + - Ref: AWS::AccountId + - :knowledge-base/* + - Action: + - bedrock-agentcore:CreateEvent + - bedrock-agentcore:GetEvent + - bedrock-agentcore:GetWorkloadAccessToken + - bedrock-agentcore:GetWorkloadAccessTokenForJWT + - bedrock-agentcore:GetWorkloadAccessTokenForUserId + - bedrock-agentcore:InvokeAgentRuntime + - bedrock-agentcore:InvokeGateway + - bedrock-agentcore:ListEvents + - bedrock-agentcore:RetrieveMemoryRecords + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:bedrock-agentcore:*:" + - Ref: AWS::AccountId + - :* + - Action: + - bedrock-agentcore:ConnectBrowserAutomationStream + - bedrock-agentcore:ConnectBrowserLiveViewStream + - ecr:GetAuthorizationToken + - logs:DescribeLogGroups + - xray:GetSamplingRules + - xray:GetSamplingTargets + - xray:PutTelemetryRecords + - xray:PutTraceSegments + Effect: Allow + Resource: "*" + - Action: + - bedrock-agentcore:GetBrowserSession + - bedrock-agentcore:StartBrowserSession + - bedrock-agentcore:StopBrowserSession + - bedrock-agentcore:UpdateBrowserStream + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:browser/aws.browser.v1 + - Action: + - bedrock-agentcore:GetCodeInterpreterSession + - bedrock-agentcore:InvokeCodeInterpreter + - bedrock-agentcore:StartCodeInterpreterSession + - bedrock-agentcore:StopCodeInterpreterSession + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:code-interpreter/aws.codeinterpreter.v1 + - Action: + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/aiagent + - Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:DescribeLogStreams + - logs:PutLogEvents + - logs:PutResourcePolicy + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/bedrock-agentcore/runtimes/* + - Action: cloudwatch:PutMetricData + Condition: + StringEquals: + cloudwatch:namespace: bedrock-agentcore Effect: Allow Resource: "*" Version: "2012-10-17" PolicyName: AgentCoreExecutionPolicy RoleName: aiagent-agentcore-runtime-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role AiAgentTaskExecutionRoleDB6B93A6: Properties: @@ -367,6 +507,14 @@ Resources: - :iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy Path: /service-role/ RoleName: aiagent-ecs-task-execution-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role AiAgentTaskExecutionRoleDefaultPolicy1E8C5A54: Properties: @@ -374,7 +522,7 @@ Resources: Statement: - Action: logs:CreateLogGroup Effect: Allow - Resource: "*" + Resource: arn:aws:logs:*:*:log-group:/aws/ecs/aiagent* - Action: - secretsmanager:DescribeSecret - secretsmanager:GetSecretValue @@ -431,6 +579,14 @@ Resources: - :iam::aws:policy/AmazonBedrockFullAccess Path: /service-role/ RoleName: aiagent-ecs-task-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role BedrockLoggingRole71F633EF: Properties: @@ -473,10 +629,21 @@ Resources: Version: "2012-10-17" PolicyName: BedrockLogging RoleName: workshop-bedrock-logging-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CfnPreDeleteCleanupFA953B95: DeletionPolicy: Delete Properties: + BucketNames: + - Ref: WorkshopBucketFD5BC43F + - Ref: WorkshopBucketAccessLogs476BAB88 ServiceToken: Fn::GetAtt: - CfnPreDeleteCleanupFunction580FB700 @@ -505,13 +672,14 @@ Resources: Custom Resource handler to cleanup resources before stack deletion. - GuardDuty VPC endpoints that block VPC deletion - GuardDuty managed security groups - - S3 bucket contents for workshop- buckets + - S3 buckets supplied by the stack Note: CloudWatch logs are kept for debugging/analysis """ print(f"Event: {event}") request_type = event['RequestType'] vpc_id = event['ResourceProperties'].get('VpcId', '') + bucket_names = event['ResourceProperties'].get('BucketNames', []) try: if request_type == 'Delete': @@ -519,7 +687,7 @@ Resources: endpoint_ids = start_guardduty_endpoint_deletion(vpc_id) # While endpoints are deleting, clean up S3 - cleanup_s3_buckets() + cleanup_s3_buckets(bucket_names) # Wait for VPC endpoint deletion to complete if endpoint_ids: @@ -606,17 +774,16 @@ Resources: print("GuardDuty security group cleanup completed") - def cleanup_s3_buckets(): - """Empty S3 buckets with workshop- prefix.""" - try: - response = s3.list_buckets() - for bucket in response.get('Buckets', []): - bucket_name = bucket['Name'] - if bucket_name.startswith('workshop-'): - print(f"Emptying S3 bucket: {bucket_name}") - empty_bucket(bucket_name) - except Exception as e: - print(f"Error listing S3 buckets: {e}") + def cleanup_s3_buckets(bucket_names): + """Delete only the S3 buckets supplied by the stack.""" + for bucket_name in bucket_names: + print(f"Deleting S3 bucket: {bucket_name}") + empty_bucket(bucket_name) + try: + s3.delete_bucket(Bucket=bucket_name) + print(f"Deleted bucket: {bucket_name}") + except Exception as e: + print(f"Error deleting bucket {bucket_name}: {e}") print("S3 bucket cleanup completed") @@ -664,6 +831,14 @@ Resources: - CfnPreDeleteCleanupRole2BF62B3D - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 600 Type: AWS::Lambda::Function CfnPreDeleteCleanupRole2BF62B3D: @@ -681,23 +856,103 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CfnPreDeleteCleanupRoleDefaultPolicy7B910EB7: Properties: PolicyDocument: Statement: - Action: - - ec2:DeleteSecurityGroup - - ec2:DeleteVpcEndpoints - ec2:DescribeSecurityGroups - ec2:DescribeVpcEndpoints - - s3:DeleteObject - - s3:DeleteObjectVersion - - s3:ListAllMyBuckets + Effect: Allow + Resource: "*" + - Action: ec2:DeleteVpcEndpoints + Condition: + StringEquals: + ec2:Vpc: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc/ + - Ref: VpcC3027511 + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc-endpoint/* + - Action: ec2:DeleteSecurityGroup + Condition: + StringEquals: + ec2:Vpc: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :vpc/ + - Ref: VpcC3027511 + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :security-group/* + - Action: + - s3:DeleteBucket - s3:ListBucket - s3:ListBucketVersions Effect: Allow - Resource: "*" + Resource: + - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - Action: + - s3:DeleteObject + - s3:DeleteObjectVersion + Effect: Allow + Resource: + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /* + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + - /* Version: "2012-10-17" PolicyName: CfnPreDeleteCleanupRoleDefaultPolicy7B910EB7 Roles: @@ -708,13 +963,14 @@ Resources: DependsOn: - CodeBuildCompleteRuleAllowEventRuleWorkshopStackCodeBuildReportLambdaFunctionD77C60919E0B0C89 - CodeBuildCompleteRuleEE9277E8 + - CodeBuildPendingBuilds19869454 + - CodeBuildProjectPolicyDocument567377F5 + - CodeBuildProjectA0FF5539 + - CodeBuildProjectSecurityGroup7CE557B3 - CodeBuildReportLambdaFunctionA3C396F7 + - CodeBuildStartLambdaFunction8349284F Properties: - CodeBuildIamRoleArn: - Fn::GetAtt: - - CodeBuildRoleE9A44575 - - Arn - ContentHash: "1769326880641" + ContentHash: "1787668856875" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -744,7 +1000,9 @@ Resources: build-status: - SUCCEEDED - FAILED + - FAULT - STOPPED + - TIMED_OUT project-name: - Ref: CodeBuildProjectA0FF5539 detail-type: @@ -752,6 +1010,14 @@ Resources: source: - aws.codebuild State: ENABLED + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Targets: - Arn: Fn::GetAtt: @@ -774,6 +1040,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CodeBuildLambdaRoleDefaultPolicyFB35F0AF: Properties: @@ -783,12 +1057,72 @@ Resources: - codebuild:BatchGetBuilds - codebuild:StartBuild Effect: Allow - Resource: "*" + Resource: + Fn::GetAtt: + - CodeBuildProjectA0FF5539 + - Arn + - Action: + - dynamodb:BatchWriteItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + - dynamodb:PutItem + - dynamodb:UpdateItem + Effect: Allow + Resource: + Fn::GetAtt: + - CodeBuildPendingBuilds19869454 + - Arn + - Action: + - dynamodb:BatchGetItem + - dynamodb:BatchWriteItem + - dynamodb:ConditionCheckItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + - dynamodb:GetItem + - dynamodb:GetRecords + - dynamodb:GetShardIterator + - dynamodb:PutItem + - dynamodb:Query + - dynamodb:Scan + - dynamodb:UpdateItem + Effect: Allow + Resource: + Fn::GetAtt: + - CodeBuildPendingBuilds19869454 + - Arn Version: "2012-10-17" PolicyName: CodeBuildLambdaRoleDefaultPolicyFB35F0AF Roles: - Ref: CodeBuildLambdaRole655C06B4 Type: AWS::IAM::Policy + CodeBuildPendingBuilds19869454: + DeletionPolicy: Delete + Metadata: + cdk_nag: + rules_to_suppress: + - id: AwsSolutions-DDB3 + reason: The table stores short-lived CloudFormation callback state and does not require point-in-time recovery + Properties: + AttributeDefinitions: + - AttributeName: BuildId + AttributeType: S + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: BuildId + KeyType: HASH + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation + TimeToLiveSpecification: + AttributeName: ExpiresAt + Enabled: true + Type: AWS::DynamoDB::Table + UpdateReplacePolicy: Delete CodeBuildProjectA0FF5539: DependsOn: - CodeBuildProjectPolicyDocument567377F5 @@ -806,7 +1140,7 @@ Resources: Value: java-spring-ai-agents - Name: GIT_BRANCH Type: PLAINTEXT - Value: main + Value: feat/holmes-remediation Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 ImagePullCredentialsType: CODEBUILD PrivilegedMode: false @@ -838,6 +1172,14 @@ Resources: aws iam create-service-linked-role --aws-service-name network.bedrock-agentcore.amazonaws.com 2>/dev/null || true aws iam create-service-linked-role --aws-service-name runtime-identity.bedrock-agentcore.amazonaws.com 2>/dev/null || true Type: NO_SOURCE + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation TimeoutInMinutes: 30 VpcConfig: SecurityGroupIds: @@ -851,12 +1193,102 @@ Resources: Ref: VpcC3027511 Type: AWS::CodeBuild::Project CodeBuildProjectPolicyDocument567377F5: + Metadata: + checkov: + skip: + - comment: CodeBuild requires ec2:DeleteNetworkInterface on wildcard resources because the API authorizes deletion against arn:aws:ec2:region:account:*/*. + id: CKV_AWS_111 Properties: PolicyDocument: Statement: - Action: - ec2:CreateNetworkInterface + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet1Subnet67A4DBCB + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet2SubnetC8EB537D + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :security-group/ + - Fn::GetAtt: + - CodeBuildProjectSecurityGroup7CE557B3 + - GroupId + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :network-interface/* + - Action: + - ec2:CreateNetworkInterfacePermission + Condition: + ArnEquals: + ec2:Subnet: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet1Subnet67A4DBCB + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet2SubnetC8EB537D + StringEquals: + ec2:AuthorizedService: codebuild.amazonaws.com + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :network-interface/* + - Action: - ec2:DeleteNetworkInterface + Effect: Allow + Resource: "*" + - Action: - ec2:DescribeDhcpOptions - ec2:DescribeNetworkInterfaces - ec2:DescribeSecurityGroups @@ -876,6 +1308,14 @@ Resources: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: "-1" + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -885,57 +1325,113 @@ Resources: - CodeBuildLambdaRole655C06B4 Properties: Code: - ZipFile: |- - import boto3 + ZipFile: | import json + import os + import urllib.request - codebuild = boto3.client('codebuild') + import boto3 - def lambda_handler(event, context): - print(f'Build status event: {event}') + codebuild = boto3.client("codebuild") + table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) - try: - # Extract build information from EventBridge event - detail = event['detail'] - build_status = detail['build-status'] - project_name = detail['project-name'] - build_id = detail['build-id'] - - print(f'Build {build_id} for project {project_name} finished with status: {build_status}') - - if build_status == 'SUCCEEDED': - print('✅ CodeBuild setup completed successfully') - elif build_status == 'FAILED': - print('❌ CodeBuild setup failed') - - # Get build details for error information - response = codebuild.batch_get_builds(ids=[build_id]) - if response['builds']: - build = response['builds'][0] - if 'logs' in build and 'cloudWatchLogs' in build['logs']: - log_group = build['logs']['cloudWatchLogs'].get('groupName') - log_stream = build['logs']['cloudWatchLogs'].get('streamName') - print(f'Check logs at: {log_group}/{log_stream}') - elif build_status == 'STOPPED': - print('⏹️ CodeBuild setup was stopped') - - return { - 'statusCode': 200, - 'body': json.dumps({ - 'message': f'Processed build status: {build_status}', - 'buildId': build_id, - 'projectName': project_name - }) - } + FAILURE_STATUSES = {"FAILED", "FAULT", "STOPPED", "TIMED_OUT"} - except Exception as e: - print(f'Error processing build status: {str(e)}') - return { - 'statusCode': 500, - 'body': json.dumps({ - 'error': str(e) - }) + + def normalized_build_id(value): + if ":build/" in value: + return value.split(":build/", 1)[1] + return value + + + def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") + + + def failure_details(build): + details = [] + for phase in build.get("phases", []): + contexts = "; ".join( + context.get("message", "") for context in phase.get("contexts", []) + ) + if phase.get("phaseStatus") in FAILURE_STATUSES or contexts: + details.append( + f"{phase.get('phaseType')}={phase.get('phaseStatus')}: {contexts}".strip() + ) + logs = build.get("logs", {}) + if logs.get("deepLink"): + details.append(f"logs={logs['deepLink']}") + return " | ".join(details) or "No phase failure details were returned" + + + def lambda_handler(event, context): + detail = event["detail"] + event_build_id = detail["build-id"] + build_id = normalized_build_id(event_build_id) + print(f"Terminal CodeBuild event for {event_build_id}: {detail['build-status']}") + + item = table.get_item(Key={"BuildId": build_id}, ConsistentRead=True).get("Item") + if not item: + raise RuntimeError(f"Pending CloudFormation callback not found for {build_id}") + + build_response = codebuild.batch_get_builds(ids=[item.get("BuildArn", event_build_id)]) + builds = build_response.get("builds", []) + if len(builds) != 1: + raise RuntimeError(f"CodeBuild build not found: {event_build_id}") + + build = builds[0] + status = build["buildStatus"] + original_event = json.loads(item["CloudFormationEvent"]) + data = { + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": item["ProjectName"], + "BuildStatus": status, + } + + if status == "SUCCEEDED": + response_status = "SUCCESS" + reason = None + elif status in FAILURE_STATUSES: + response_status = "FAILED" + reason = f"CodeBuild finished with {status}: {failure_details(build)}" + else: + raise RuntimeError(f"Received non-terminal CodeBuild status {status}") + + send_response( + original_event, + context, + response_status, + data, + item["PhysicalResourceId"], + reason, + ) + table.delete_item(Key={"BuildId": build_id}) + print(f"Sent {response_status} to CloudFormation for {build_id}") + Environment: + Variables: + PENDING_TABLE_NAME: + Ref: CodeBuildPendingBuilds19869454 FunctionName: workshop-setup-report Handler: index.lambda_handler Role: @@ -943,12 +1439,30 @@ Resources: - CodeBuildLambdaRole655C06B4 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 120 Type: AWS::Lambda::Function CodeBuildRoleDefaultPolicy196BAF24: Properties: PolicyDocument: Statement: + - Action: iam:CreateServiceLinkedRole + Condition: + StringEquals: + iam:AWSServiceName: + - ecs.amazonaws.com + - elasticloadbalancing.amazonaws.com + - network.bedrock-agentcore.amazonaws.com + - runtime-identity.bedrock-agentcore.amazonaws.com + Effect: Allow + Resource: arn:aws:iam::*:role/aws-service-role/* - Action: ec2:CreateNetworkInterfacePermission Condition: StringEquals: @@ -1045,12 +1559,14 @@ Resources: Principal: Service: codebuild.amazonaws.com Version: "2012-10-17" - ManagedPolicyArns: - - Fn::Join: - - "" - - - "arn:" - - Ref: AWS::Partition - - :iam::aws:policy/PowerUserAccess + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role CodeBuildStartLambdaFunction8349284F: DependsOn: @@ -1058,62 +1574,78 @@ Resources: - CodeBuildLambdaRole655C06B4 Properties: Code: - ZipFile: |- - import boto3 + ZipFile: | import json - import traceback - import cfnresponse + import os + import time + import urllib.request + + import boto3 + + codebuild = boto3.client("codebuild") + table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) + + + def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, + } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") - codebuild = boto3.client('codebuild') def lambda_handler(event, context): - print(f'Event: {event}') - responseData = {} - status = cfnresponse.SUCCESS - physical_id = event.get('PhysicalResourceId', 'CodeBuildSetup') - - try: - if event['RequestType'] == 'Delete': - # Nothing to clean up for CodeBuild - responseData = {'Message': 'CodeBuild setup deleted'} - cfnresponse.send(event, context, status, responseData, physical_id) - return + print(f"RequestType={event['RequestType']} LogicalResourceId={event['LogicalResourceId']}") + project_name = event["ResourceProperties"]["ProjectName"] + physical_id = event.get("PhysicalResourceId", project_name) - if event['RequestType'] == 'Update': - # For updates, trigger a new build - pass - - # Start CodeBuild project - props = event['ResourceProperties'] - project_name = props['ProjectName'] - - print(f'Starting CodeBuild project: {project_name}') + if event["RequestType"] == "Delete": + send_response(event, context, "SUCCESS", {"ProjectName": project_name}, physical_id) + return - response = codebuild.start_build( - projectName=project_name + try: + build = codebuild.start_build(projectName=project_name)["build"] + table.put_item( + Item={ + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": project_name, + "PhysicalResourceId": project_name, + "CloudFormationEvent": json.dumps(event), + "ExpiresAt": int(time.time()) + 7200, + } ) - - build_id = response['build']['id'] - build_arn = response['build']['arn'] - - print(f'Started build: {build_id}') - - responseData = { - 'BuildId': build_id, - 'BuildArn': build_arn, - 'ProjectName': project_name - } - - # Use build ID as physical resource ID for tracking - physical_id = build_id - - except Exception as e: - status = cfnresponse.FAILED - tb_err = traceback.format_exc() - print(tb_err) - responseData = {'Error': tb_err} - - cfnresponse.send(event, context, status, responseData, physical_id) + print(f"Started CodeBuild project {project_name}: {build['id']}") + except Exception as error: + print(f"Failed to start or persist CodeBuild callback: {error}") + send_response( + event, + context, + "FAILED", + {"ProjectName": project_name}, + physical_id, + str(error), + ) + Environment: + Variables: + PENDING_TABLE_NAME: + Ref: CodeBuildPendingBuilds19869454 FunctionName: workshop-setup-start Handler: index.lambda_handler Role: @@ -1121,6 +1653,14 @@ Resources: - CodeBuildLambdaRole655C06B4 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 120 Type: AWS::Lambda::Function DatabaseCluster5B53A178: @@ -1133,8 +1673,9 @@ Resources: Ref: DatabaseClusterSubnets5540150D DatabaseName: workshop EnableHttpEndpoint: true + EnableIAMDatabaseAuthentication: true Engine: aurora-postgresql - EngineVersion: "16.13 + EngineVersion: "16.13" MasterUserPassword: Fn::Join: - "" @@ -1151,6 +1692,14 @@ Resources: ServerlessV2ScalingConfiguration: MaxCapacity: 4 MinCapacity: 0.5 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcSecurityGroupIds: - Fn::GetAtt: - DatabaseSG562817C8 @@ -1173,6 +1722,14 @@ Resources: Engine: aurora-postgresql PromotionTier: 0 PubliclyAccessible: false + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::RDS::DBInstance UpdateReplacePolicy: Delete DatabaseClusterSubnets5540150D: @@ -1181,12 +1738,25 @@ Resources: SubnetIds: - Ref: VpcPrivateSubnet1Subnet67A4DBCB - Ref: VpcPrivateSubnet2SubnetC8EB537D + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::RDS::DBSubnetGroup DatabaseConnectionString52D1E98E: Properties: AllowedPattern: .* Description: Database Connection String Name: workshop-db-connection-string + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-spring-ai-agents + WorkshopOwner: cloudformation Tier: Standard Type: String Value: @@ -1212,6 +1782,14 @@ Resources: FromPort: 5432 IpProtocol: tcp ToPort: 5432 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -1229,6 +1807,14 @@ Resources: PasswordLength: 30 SecretStringTemplate: '{"username":"postgres"}' Name: workshop-db-secret + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::SecretsManager::Secret UpdateReplacePolicy: Delete DatabaseSecretAttachmentE5D1B020: @@ -1287,6 +1873,13 @@ Resources: Value: workshop - Key: ManagedBy Value: ecr-create-on-push + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopOwner + Value: workshop-run Type: AWS::ECR::RepositoryCreationTemplate EcrRegistryTemplateRole9295BC5C: Properties: @@ -1298,6 +1891,14 @@ Resources: Service: ecr.amazonaws.com Version: "2012-10-17" RoleName: workshop-ecr-template-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EcrRegistryTemplateRoleDefaultPolicy760EC63A: Properties: @@ -1308,7 +1909,25 @@ Resources: - ecr:PutLifecyclePolicy - ecr:TagResource Effect: Allow - Resource: "*" + Resource: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ecr:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :repository/aiagent + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ecr:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :repository/mcpserver Version: "2012-10-17" PolicyName: EcrRegistryTemplateRoleDefaultPolicy760EC63A Roles: @@ -1338,6 +1957,14 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/AWSXrayWriteOnlyAccess RoleName: workshop-eks-cloudwatch-agent-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EksClusterB2BDED5B: DependsOn: @@ -1398,6 +2025,14 @@ Resources: StorageConfig: BlockStorage: Enabled: true + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Version: "1.34" Type: AWS::EKS::Cluster EksClusterClusternodePoolRoleE206FBFC: @@ -1420,6 +2055,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/AmazonEC2ContainerRegistryReadOnly + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EksClusterKubectlReadyBarrierA32D1E84: DependsOn: @@ -1465,6 +2108,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/AmazonEKSNetworkingPolicy + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role EksInstanceAccessEntry1D317291: Properties: @@ -1483,6 +2134,14 @@ Resources: Fn::GetAtt: - IdeRole4650E22E - Arn + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: STANDARD Type: AWS::EKS::AccessEntry EksMountpointS3DriverB7062BF9: @@ -1490,6 +2149,14 @@ Resources: AddonName: aws-mountpoint-s3-csi-driver ClusterName: Ref: EksClusterB2BDED5B + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EKS::Addon EksParticipantAccessEntryCBA5D5C0: Properties: @@ -1510,6 +2177,14 @@ Resources: - - "arn:aws:iam::" - Ref: AWS::AccountId - :role/WSParticipantRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: STANDARD Type: AWS::EKS::AccessEntry EksPodIdentityAgent31D8BD3D: @@ -1517,13 +2192,59 @@ Resources: AddonName: eks-pod-identity-agent ClusterName: Ref: EksClusterB2BDED5B + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EKS::Addon EksSecretsStoreDriverA84665C0: Properties: AddonName: aws-secrets-store-csi-driver-provider ClusterName: Ref: EksClusterB2BDED5B + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EKS::Addon + IdeAgentCoreManagedToolsPolicy33EC19D9: + Properties: + Description: "" + Path: / + PolicyDocument: + Statement: + - Action: + - bedrock-agentcore:ConnectBrowserAutomationStream + - bedrock-agentcore:ConnectBrowserLiveViewStream + Effect: Allow + Resource: "*" + Sid: AgentCoreManagedBrowserStreams + - Action: + - bedrock-agentcore:GetBrowserSession + - bedrock-agentcore:StartBrowserSession + - bedrock-agentcore:StopBrowserSession + - bedrock-agentcore:UpdateBrowserStream + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:browser/aws.browser.v1 + Sid: AgentCoreManagedBrowser + - Action: + - bedrock-agentcore:GetCodeInterpreterSession + - bedrock-agentcore:InvokeCodeInterpreter + - bedrock-agentcore:StartCodeInterpreterSession + - bedrock-agentcore:StopCodeInterpreterSession + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:code-interpreter/aws.codeinterpreter.v1 + Sid: AgentCoreManagedCodeInterpreter + Version: "2012-10-17" + Type: AWS::IAM::ManagedPolicy IdeDistribution042A6660: DeletionPolicy: Delete DependsOn: @@ -1590,6 +2311,14 @@ Resources: - PublicIp - .compute-1.amazonaws.com Id: WorkshopStackIdeDistributionOrigin10FECA386 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::CloudFront::Distribution UpdateReplacePolicy: Delete IdeEC2InstanceResource438B3605: @@ -1636,12 +2365,21 @@ Resources: # This keeps UserData under size limits while allowing unlimited bootstrap size # Configuration from CDK - export GIT_BRANCH="main" + export GIT_BRANCH="feat/holmes-remediation" export AWS_REGION=" - Ref: AWS::Region - |- " export TEMPLATE_TYPE="java-spring-ai-agents" + export WORKSHOP_ID="java-spring-ai-agents" + export WORKSHOP_STACK_NAME=" + - Ref: AWS::StackName + - |- + " + export WORKSHOP_DEPLOYMENT_ID=" + - Ref: AWS::StackId + - |- + " export ARCH="x86_64" export IDE_TYPE="code-editor" export WAIT_CONDITION_HANDLE_URL=" @@ -1776,6 +2514,14 @@ Resources: IdeElasticIP3327A0B5: Properties: Domain: vpc + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::EIP IdeInstanceLauncherFunction803C5A2A: DependsOn: @@ -1920,6 +2666,14 @@ Resources: - IdeLambdaRole82392143 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 300 Type: AWS::Lambda::Function IdeInstanceProfile61B92038: @@ -1932,6 +2686,14 @@ Resources: Properties: GroupDescription: IDE internal security group GroupName: workshop-ide-internal-sg + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -1976,27 +2738,57 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdeLambdaRoleDefaultPolicy099093D2: Properties: PolicyDocument: Statement: - Action: - - ec2:CreateTags - ec2:DescribeInstanceStatus - ec2:DescribeInstances - ec2:DescribeManagedPrefixLists - ec2:DescribeSubnets - - ec2:RunInstances - - ec2:TerminateInstances - - iam:PassRole - - secretsmanager:DescribeSecret - - secretsmanager:GetSecretValue - - ssm:DescribeInstanceInformation - - ssm:GetCommandInvocation - - ssm:SendCommand Effect: Allow Resource: "*" + - Action: ec2:RunInstances + Effect: Allow + Resource: + - arn:aws:ec2:*:*:instance/* + - arn:aws:ec2:*:*:network-interface/* + - arn:aws:ec2:*:*:security-group/* + - arn:aws:ec2:*:*:subnet/* + - arn:aws:ec2:*:*:volume/* + - arn:aws:ec2:*::image/* + - Action: ec2:CreateTags + Condition: + StringEquals: + aws:RequestTag/Workshop: "true" + ec2:CreateAction: RunInstances + Effect: Allow + Resource: arn:aws:ec2:*:*:instance/* + - Action: ec2:TerminateInstances + Condition: + StringEquals: + ec2:ResourceTag/Workshop: "true" + Effect: Allow + Resource: arn:aws:ec2:*:*:instance/* + - Action: iam:PassRole + Condition: + StringEquals: + iam:PassedToService: ec2.amazonaws.com + Effect: Allow + Resource: + Fn::GetAtt: + - IdeRole4650E22E + - Arn Version: "2012-10-17" PolicyName: IdeLambdaRoleDefaultPolicy099093D2 Roles: @@ -2060,6 +2852,14 @@ Resources: - IdePasswordFunctionServiceRole971421B6 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 180 Type: AWS::Lambda::Function IdePasswordFunctionServiceRole971421B6: @@ -2077,6 +2877,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdePasswordFunctionServiceRoleDefaultPolicy282A360C: Properties: @@ -2143,6 +2951,14 @@ Resources: PasswordLength: 32 SecretStringTemplate: '{"password":""}' Name: workshop-ide-password + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::SecretsManager::Secret UpdateReplacePolicy: Delete IdePrefixListLookupFunction9E5A63DE: @@ -2194,6 +3010,14 @@ Resources: - IdeLambdaRole82392143 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 180 Type: AWS::Lambda::Function IdePrefixListResourceD2EAC007: @@ -2231,15 +3055,22 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeRoleManagementPolicyFE7F8500 + - Ref: IdeAgentCoreManagedToolsPolicy33EC19D9 RoleName: workshop-ide-user + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role IdeRoleDefaultPolicyFD4BDE67: Properties: PolicyDocument: Statement: - - Action: cloudformation:SignalResource - Effect: Allow - Resource: "*" - Action: - secretsmanager:DescribeSecret - secretsmanager:GetSecretValue @@ -2251,6 +3082,155 @@ Resources: Roles: - Ref: IdeRole4650E22E Type: AWS::IAM::Policy + IdeRoleManagementPolicyFE7F8500: + Properties: + Description: "" + Path: / + PolicyDocument: + Statement: + - Action: iam:PassRole + Condition: + StringEquals: + iam:PassedToService: + - bedrock.amazonaws.com + - bedrock-agentcore.amazonaws.com + - codebuild.amazonaws.com + - ec2.amazonaws.com + - ecs.amazonaws.com + - ecs-tasks.amazonaws.com + - lambda.amazonaws.com + - pods.eks.amazonaws.com + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/ai-jvm-analyzer* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/grafana* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/perf-analyzer* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/perf-collector* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/pyroscope* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/service-role/unicorn* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/unicorn* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/workshop* + - Action: iam:CreateServiceLinkedRole + Condition: + StringEquals: + iam:AWSServiceName: + - application-signals.cloudwatch.amazonaws.com + - cloudtrail.amazonaws.com + - ecs.amazonaws.com + - elasticloadbalancing.amazonaws.com + - network.bedrock-agentcore.amazonaws.com + - runtime-identity.bedrock-agentcore.amazonaws.com + Effect: Allow + Resource: arn:aws:iam::*:role/aws-service-role/* + - Action: iam:CreateRole + Condition: + StringEquals: + iam:PermissionsBoundary: + Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :policy/workshop-boundary + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice* + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp* + - Action: + - iam:AttachRolePolicy + - iam:DeleteRole + - iam:DeleteRolePolicy + - iam:DetachRolePolicy + - iam:PutRolePolicy + - iam:UpdateAssumeRolePolicy + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent-kb-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/aiagent-runtime-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/backoffice-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp-currency-role + - Fn::Join: + - "" + - - "arn:aws:iam::" + - Ref: AWS::AccountId + - :role/mcp-gateway-role + Version: "2012-10-17" + Type: AWS::IAM::ManagedPolicy IdeSecurityGroup73B02454: Properties: GroupDescription: IDE security group @@ -2259,6 +3239,14 @@ Resources: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: "-1" + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -2287,71 +3275,294 @@ Resources: Condition: ForAllValues:StringEquals: aws-marketplace:ProductId: - - prod-jhuafngbly644 + - prod-xdkflymybwmvi - prod-mxcfnwvpd6kb4 - - prod-4pmewlybdftbs + - prod-jhuafngbly644 + - prod-5ukwuglpt66kg + - prod-ffvjxvh4ltq64 "Null": aws-marketplace:ProductId: "false" Effect: Allow Resource: "*" - Sid: MarketplaceSubscribeClaude45Opus45Sonnet4Sonnet - Action: - acm:* - - apigateway:* - application-autoscaling:* - application-signals:* - - aws-marketplace:Unsubscribe - - aws-marketplace:ViewSubscriptions - bedrock-agentcore:* - bedrock:* - cloudformation:* - - cloudfront:* - cloudtrail:* - cloudwatch:* - - codewhisperer:* - cognito-idp:* - ec2:* - ecr:* - - ecs:* - eks:* - elasticloadbalancing:* - events:* - lambda:* - logs:* - - q:* + - rds-data:* - rds:* - - s3:* - s3vectors:* - secretsmanager:* - ssm:* - - sts:* - - tag:* - xray:* Effect: Allow + Resource: + - arn:aws:bedrock:*::foundation-model/* + - arn:aws:ec2:*::image/* + - Fn::Join: + - "" + - - "arn:aws:acm:*:" + - Ref: AWS::AccountId + - :certificate/* + - Fn::Join: + - "" + - - "arn:aws:application-autoscaling:*:" + - Ref: AWS::AccountId + - :scal*/* + - Fn::Join: + - "" + - - "arn:aws:application-signals:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:bedrock-agentcore:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:bedrock:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:cloudformation:*:" + - Ref: AWS::AccountId + - :stack/workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudtrail:*:" + - Ref: AWS::AccountId + - :trail/workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudwatch:*:" + - Ref: AWS::AccountId + - :* + - Fn::Join: + - "" + - - "arn:aws:cognito-idp:*:" + - Ref: AWS::AccountId + - :userpool/* + - Fn::Join: + - "" + - - "arn:aws:ec2:*:" + - Ref: AWS::AccountId + - :*/* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/ai* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/backoffice* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/mcp* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/perf-* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/unicorn* + - Fn::Join: + - "" + - - "arn:aws:eks:*:" + - Ref: AWS::AccountId + - :cluster/* + - Fn::Join: + - "" + - - "arn:aws:elasticloadbalancing:*:" + - Ref: AWS::AccountId + - :*/* + - Fn::Join: + - "" + - - "arn:aws:events:*:" + - Ref: AWS::AccountId + - :rule/* + - Fn::Join: + - "" + - - "arn:aws:lambda:*:" + - Ref: AWS::AccountId + - :function:* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:* + - Fn::Join: + - "" + - - "arn:aws:rds:*:" + - Ref: AWS::AccountId + - :*:* + - Fn::Join: + - "" + - - "arn:aws:s3vectors:*:" + - Ref: AWS::AccountId + - :bucket/* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:mcp-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:workshop-* + - Fn::Join: + - "" + - - "arn:aws:ssm:*:" + - Ref: AWS::AccountId + - :parameter/workshop-* + - Action: + - aws-marketplace:Unsubscribe + - aws-marketplace:ViewSubscriptions + Condition: + ForAllValues:StringEquals: + aws-marketplace:ProductId: + - prod-xdkflymybwmvi + - prod-mxcfnwvpd6kb4 + - prod-jhuafngbly644 + - prod-5ukwuglpt66kg + - prod-ffvjxvh4ltq64 + Effect: Allow Resource: "*" - Sid: AllowedServices - - Action: iam:PassRole + - Action: + - cloudfront:CreateCloudFrontOriginAccessIdentity + - cloudfront:CreateDistribution + Condition: + StringEquals: + aws:PrincipalAccount: + Ref: AWS::AccountId Effect: Allow - Resource: - - !Sub arn:aws:iam::${AWS::AccountId}:role/ai-jvm-analyzer* - - !Sub arn:aws:iam::${AWS::AccountId}:role/aiagent* - - !Sub arn:aws:iam::${AWS::AccountId}:role/mcpserver* - - !Sub arn:aws:iam::${AWS::AccountId}:role/service-role/unicorn* - - !Sub arn:aws:iam::${AWS::AccountId}:role/unicorn* - - !Sub arn:aws:iam::${AWS::AccountId}:role/workshop* - Sid: PassRole - - Action: iam:CreateServiceLinkedRole + Resource: "*" + - Action: + - apigateway:* + - cloudfront:* + - dynamodb:* + - ecs:* + - s3:* Effect: Allow Resource: - - arn:aws:iam::*:role/aws-service-role/application-signals.cloudwatch.amazonaws.com/* - - arn:aws:iam::*:role/aws-service-role/cloudtrail.amazonaws.com/* - Sid: CreateServiceLinkedRole + - arn:aws:apigateway:*::/apis/* + - arn:aws:apigateway:*::/restapis/* + - arn:aws:s3:::aiagent-* + - arn:aws:s3:::workshop-* + - Fn::Join: + - "" + - - "arn:aws:cloudfront::" + - Ref: AWS::AccountId + - :distribution/* + - Fn::Join: + - "" + - - "arn:aws:cloudfront::" + - Ref: AWS::AccountId + - :origin-access-identity/cloudfront/* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :cluster/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :cluster/unicorn* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :service/*/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :service/*/unicorn* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task-definition/aiagent*:* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task-definition/unicorn*:* + - Fn::Join: + - "" + - - "arn:aws:ecs:*:" + - Ref: AWS::AccountId + - :task/*/* - Action: + - acm:ListCertificates + - apigateway:GET + - bedrock-agentcore:List* + - bedrock:List* + - cloudformation:List* + - cloudfront:Get* + - cloudfront:List* + - cognito-idp:CreateUserPool + - cognito-idp:ListUserPools + - ec2:Describe* + - ecr:CreateRepositoryCreationTemplate + - ecr:Describe* + - ecr:GetAuthorizationToken + - ecs:Describe* + - ecs:List* + - ecs:RegisterTaskDefinition + - eks:CreateCluster + - eks:Describe* + - eks:List* + - elasticloadbalancing:Describe* - iam:GetRole + - iam:GetRolePolicy + - iam:ListAttachedRolePolicies + - iam:ListRolePolicies - iam:ListRoles + - lambda:List* + - logs:Describe* + - rds:Describe* + - s3:ListAllMyBuckets + - s3vectors:CreateVectorBucket + - s3vectors:ListVectorBuckets + - secretsmanager:ListSecrets + - ssm:DescribeParameters + - sts:GetCallerIdentity + - tag:GetResources Effect: Allow Resource: "*" - Sid: GetRole - Action: ec2:RunInstances Condition: StringLike: @@ -2362,46 +3573,220 @@ Resources: - "*9xlarge" - "*10xlarge" - "*12xlarge" - - "*16xlarge" - - "*18xlarge" - - "*24xlarge" - f1* - x1* - z1* - "*metal" Effect: Deny Resource: arn:aws:ec2:*:*:instance/* - Sid: DenyXXLInstances - Action: - - dynamodb:PurchaseReservedCapacityOfferings - - ec2:ModifyReservedInstances - - ec2:PurchaseHostReservation - - ec2:PurchaseReservedInstancesOffering - - ec2:PurchaseScheduledInstances - - rds:PurchaseReservedDBInstancesOffering + - dynamodb:PurchaseReservedCapacityOfferings + - ec2:ModifyReservedInstances + - ec2:PurchaseHostReservation + - ec2:PurchaseReservedInstancesOffering + - ec2:PurchaseScheduledInstances + - rds:PurchaseReservedDBInstancesOffering + Effect: Deny + Resource: "*" + Version: "2012-10-17" + Type: AWS::IAM::ManagedPolicy + IdeWaitConditionCC35C186: + DependsOn: + - IdeEC2InstanceResource438B3605 + Properties: + Count: 1 + Handle: + Ref: IdeWaitConditionHandleE8345861 + Timeout: "1800" + Type: AWS::CloudFormation::WaitCondition + IdeWaitConditionHandleE8345861: + Type: AWS::CloudFormation::WaitConditionHandle + IdeWorkshopBoundaryDEE72AD4: + Properties: + Description: "" + ManagedPolicyName: workshop-boundary + Path: / + PolicyDocument: + Statement: + - Action: bedrock:* + Effect: Allow + Resource: + - arn:aws:bedrock:*::foundation-model/* + - Fn::Join: + - "" + - - "arn:aws:bedrock:*:" + - Ref: AWS::AccountId + - :* + Sid: BedrockRuntime + - Action: + - aws-marketplace:Subscribe + - aws-marketplace:ViewSubscriptions + Effect: Allow + Resource: "*" + Sid: BedrockMarketplaceModelAccess + - Action: bedrock-agentcore:* + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:bedrock-agentcore:*:" + - Ref: AWS::AccountId + - :* + Sid: AgentCoreRuntime + - Action: + - bedrock-agentcore:ConnectBrowserAutomationStream + - bedrock-agentcore:ConnectBrowserLiveViewStream + Effect: Allow + Resource: "*" + Sid: AgentCoreManagedBrowserStreams + - Action: + - bedrock-agentcore:GetBrowserSession + - bedrock-agentcore:StartBrowserSession + - bedrock-agentcore:StopBrowserSession + - bedrock-agentcore:UpdateBrowserStream + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:browser/aws.browser.v1 + Sid: AgentCoreManagedBrowser + - Action: + - bedrock-agentcore:GetCodeInterpreterSession + - bedrock-agentcore:InvokeCodeInterpreter + - bedrock-agentcore:StartCodeInterpreterSession + - bedrock-agentcore:StopCodeInterpreterSession + Effect: Allow + Resource: arn:aws:bedrock-agentcore:*:aws:code-interpreter/aws.codeinterpreter.v1 + Sid: AgentCoreManagedCodeInterpreter + - Action: + - dynamodb:* + - ecr:* + - lambda:InvokeFunction + - logs:* + - s3:* + - s3vectors:* + - secretsmanager:GetSecretValue + Effect: Allow + Resource: + - arn:aws:s3:::aiagent-kb-data-* + - arn:aws:s3:::aiagent-kb-data-*/* + - arn:aws:s3:::workshop-* + - arn:aws:s3:::workshop-*/* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-* + - Fn::Join: + - "" + - - "arn:aws:dynamodb:*:" + - Ref: AWS::AccountId + - :table/backoffice-*/* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/aiagent* + - Fn::Join: + - "" + - - "arn:aws:ecr:*:" + - Ref: AWS::AccountId + - :repository/backoffice* + - Fn::Join: + - "" + - - "arn:aws:lambda:*:" + - Ref: AWS::AccountId + - :function:mcp-* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/bedrock-agentcore/* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/bedrock-agentcore/*:* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/lambda/mcp-* + - Fn::Join: + - "" + - - "arn:aws:logs:*:" + - Ref: AWS::AccountId + - :log-group:/aws/lambda/mcp-*:* + - Fn::Join: + - "" + - - "arn:aws:s3vectors:*:" + - Ref: AWS::AccountId + - :bucket/aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:aiagent-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:bedrock-agentcore-identity!* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:mcp-* + - Fn::Join: + - "" + - - "arn:aws:secretsmanager:*:" + - Ref: AWS::AccountId + - :secret:workshop-* + Sid: WorkshopData + - Action: + - ec2:CreateNetworkInterface + - ec2:DeleteNetworkInterface + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:ec2:*:" + - Ref: AWS::AccountId + - :network-interface/* + Sid: RuntimeNetworkInterfaces + - Action: + - cloudwatch:PutMetricData + - ec2:DescribeNetworkInterfaces + - ec2:DescribeSecurityGroups + - ec2:DescribeSubnets + - ec2:DescribeVpcs + - ecr:GetAuthorizationToken + - logs:DescribeLogGroups + - xray:GetSamplingRules + - xray:GetSamplingTargets + - xray:PutTelemetryRecords + - xray:PutTraceSegments + Effect: Allow + Resource: "*" + Sid: RuntimeReadAndTelemetry + - Action: + - account:* + - iam:* + - organizations:* Effect: Deny Resource: "*" - Sid: DenyReservations + Sid: DenyIAM Version: "2012-10-17" Type: AWS::IAM::ManagedPolicy - IdeWaitConditionCC35C186: - DependsOn: - - IdeEC2InstanceResource438B3605 - Properties: - Count: 1 - Handle: - Ref: IdeWaitConditionHandleE8345861 - Timeout: "1800" - Type: AWS::CloudFormation::WaitCondition - IdeWaitConditionHandleE8345861: - Type: AWS::CloudFormation::WaitConditionHandle PlaceholderImageBuild084DA641: DeletionPolicy: Delete DependsOn: - EcrRegistryTemplateD54113AB - PlaceholderImageBuildCompleteRuleAllowEventRuleWorkshopStackPlaceholderImageBuildReportLambdaFunction17D5E0E6696B4706 - PlaceholderImageBuildCompleteRuleD3DA254B + - PlaceholderImageBuildPendingBuilds86736129 + - PlaceholderImageBuildProjectPolicyDocument31093CFB + - PlaceholderImageBuildProjectC08F4D66 + - PlaceholderImageBuildProjectSecurityGroupA7FE7BBC - PlaceholderImageBuildReportLambdaFunctionD1A0B620 + - PlaceholderImageBuildStartLambdaFunctionF132A6DF - VpcIGW488B0FEB - VpcPrivateSubnet1DefaultRouteF704DE9F - VpcPrivateSubnet1RouteTable901BAEEE @@ -2424,11 +3809,7 @@ Resources: - VpcC3027511 - VpcVPCGW42EC8516 Properties: - CodeBuildIamRoleArn: - Fn::GetAtt: - - PlaceholderImageBuildRole66BA72FE - - Arn - ContentHash: "1769326881001" + ContentHash: "1787668857026" ProjectName: Ref: PlaceholderImageBuildProjectC08F4D66 ServiceToken: @@ -2458,7 +3839,9 @@ Resources: build-status: - SUCCEEDED - FAILED + - FAULT - STOPPED + - TIMED_OUT project-name: - Ref: PlaceholderImageBuildProjectC08F4D66 detail-type: @@ -2466,6 +3849,14 @@ Resources: source: - aws.codebuild State: ENABLED + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Targets: - Arn: Fn::GetAtt: @@ -2488,6 +3879,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role PlaceholderImageBuildLambdaRoleDefaultPolicy59DD48E5: Properties: @@ -2497,12 +3896,72 @@ Resources: - codebuild:BatchGetBuilds - codebuild:StartBuild Effect: Allow - Resource: "*" + Resource: + Fn::GetAtt: + - PlaceholderImageBuildProjectC08F4D66 + - Arn + - Action: + - dynamodb:BatchWriteItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + - dynamodb:PutItem + - dynamodb:UpdateItem + Effect: Allow + Resource: + Fn::GetAtt: + - PlaceholderImageBuildPendingBuilds86736129 + - Arn + - Action: + - dynamodb:BatchGetItem + - dynamodb:BatchWriteItem + - dynamodb:ConditionCheckItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + - dynamodb:GetItem + - dynamodb:GetRecords + - dynamodb:GetShardIterator + - dynamodb:PutItem + - dynamodb:Query + - dynamodb:Scan + - dynamodb:UpdateItem + Effect: Allow + Resource: + Fn::GetAtt: + - PlaceholderImageBuildPendingBuilds86736129 + - Arn Version: "2012-10-17" PolicyName: PlaceholderImageBuildLambdaRoleDefaultPolicy59DD48E5 Roles: - Ref: PlaceholderImageBuildLambdaRole8EDC67D7 Type: AWS::IAM::Policy + PlaceholderImageBuildPendingBuilds86736129: + DeletionPolicy: Delete + Metadata: + cdk_nag: + rules_to_suppress: + - id: AwsSolutions-DDB3 + reason: The table stores short-lived CloudFormation callback state and does not require point-in-time recovery + Properties: + AttributeDefinitions: + - AttributeName: BuildId + AttributeType: S + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: BuildId + KeyType: HASH + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation + TimeToLiveSpecification: + AttributeName: ExpiresAt + Enabled: true + Type: AWS::DynamoDB::Table + UpdateReplacePolicy: Delete PlaceholderImageBuildProjectC08F4D66: DependsOn: - PlaceholderImageBuildProjectPolicyDocument31093CFB @@ -2568,6 +4027,14 @@ Resources: docker tag placeholder $ECR_REGISTRY/aiagent:latest docker push $ECR_REGISTRY/aiagent:latest Type: NO_SOURCE + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation TimeoutInMinutes: 30 VpcConfig: SecurityGroupIds: @@ -2581,12 +4048,102 @@ Resources: Ref: VpcC3027511 Type: AWS::CodeBuild::Project PlaceholderImageBuildProjectPolicyDocument31093CFB: + Metadata: + checkov: + skip: + - comment: CodeBuild requires ec2:DeleteNetworkInterface on wildcard resources because the API authorizes deletion against arn:aws:ec2:region:account:*/*. + id: CKV_AWS_111 Properties: PolicyDocument: Statement: - Action: - ec2:CreateNetworkInterface + Effect: Allow + Resource: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet1Subnet67A4DBCB + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet2SubnetC8EB537D + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :security-group/ + - Fn::GetAtt: + - PlaceholderImageBuildProjectSecurityGroupA7FE7BBC + - GroupId + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :network-interface/* + - Action: + - ec2:CreateNetworkInterfacePermission + Condition: + ArnEquals: + ec2:Subnet: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet1Subnet67A4DBCB + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :subnet/ + - Ref: VpcPrivateSubnet2SubnetC8EB537D + StringEquals: + ec2:AuthorizedService: codebuild.amazonaws.com + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :network-interface/* + - Action: - ec2:DeleteNetworkInterface + Effect: Allow + Resource: "*" + - Action: - ec2:DescribeDhcpOptions - ec2:DescribeNetworkInterfaces - ec2:DescribeSecurityGroups @@ -2606,6 +4163,14 @@ Resources: - CidrIp: 0.0.0.0/0 Description: Allow all outbound traffic by default IpProtocol: "-1" + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::SecurityGroup @@ -2615,57 +4180,113 @@ Resources: - PlaceholderImageBuildLambdaRole8EDC67D7 Properties: Code: - ZipFile: |- - import boto3 + ZipFile: | import json + import os + import urllib.request - codebuild = boto3.client('codebuild') + import boto3 - def lambda_handler(event, context): - print(f'Build status event: {event}') + codebuild = boto3.client("codebuild") + table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) - try: - # Extract build information from EventBridge event - detail = event['detail'] - build_status = detail['build-status'] - project_name = detail['project-name'] - build_id = detail['build-id'] - - print(f'Build {build_id} for project {project_name} finished with status: {build_status}') - - if build_status == 'SUCCEEDED': - print('✅ CodeBuild setup completed successfully') - elif build_status == 'FAILED': - print('❌ CodeBuild setup failed') - - # Get build details for error information - response = codebuild.batch_get_builds(ids=[build_id]) - if response['builds']: - build = response['builds'][0] - if 'logs' in build and 'cloudWatchLogs' in build['logs']: - log_group = build['logs']['cloudWatchLogs'].get('groupName') - log_stream = build['logs']['cloudWatchLogs'].get('streamName') - print(f'Check logs at: {log_group}/{log_stream}') - elif build_status == 'STOPPED': - print('⏹️ CodeBuild setup was stopped') - - return { - 'statusCode': 200, - 'body': json.dumps({ - 'message': f'Processed build status: {build_status}', - 'buildId': build_id, - 'projectName': project_name - }) - } + FAILURE_STATUSES = {"FAILED", "FAULT", "STOPPED", "TIMED_OUT"} - except Exception as e: - print(f'Error processing build status: {str(e)}') - return { - 'statusCode': 500, - 'body': json.dumps({ - 'error': str(e) - }) + + def normalized_build_id(value): + if ":build/" in value: + return value.split(":build/", 1)[1] + return value + + + def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") + + + def failure_details(build): + details = [] + for phase in build.get("phases", []): + contexts = "; ".join( + context.get("message", "") for context in phase.get("contexts", []) + ) + if phase.get("phaseStatus") in FAILURE_STATUSES or contexts: + details.append( + f"{phase.get('phaseType')}={phase.get('phaseStatus')}: {contexts}".strip() + ) + logs = build.get("logs", {}) + if logs.get("deepLink"): + details.append(f"logs={logs['deepLink']}") + return " | ".join(details) or "No phase failure details were returned" + + + def lambda_handler(event, context): + detail = event["detail"] + event_build_id = detail["build-id"] + build_id = normalized_build_id(event_build_id) + print(f"Terminal CodeBuild event for {event_build_id}: {detail['build-status']}") + + item = table.get_item(Key={"BuildId": build_id}, ConsistentRead=True).get("Item") + if not item: + raise RuntimeError(f"Pending CloudFormation callback not found for {build_id}") + + build_response = codebuild.batch_get_builds(ids=[item.get("BuildArn", event_build_id)]) + builds = build_response.get("builds", []) + if len(builds) != 1: + raise RuntimeError(f"CodeBuild build not found: {event_build_id}") + + build = builds[0] + status = build["buildStatus"] + original_event = json.loads(item["CloudFormationEvent"]) + data = { + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": item["ProjectName"], + "BuildStatus": status, + } + + if status == "SUCCEEDED": + response_status = "SUCCESS" + reason = None + elif status in FAILURE_STATUSES: + response_status = "FAILED" + reason = f"CodeBuild finished with {status}: {failure_details(build)}" + else: + raise RuntimeError(f"Received non-terminal CodeBuild status {status}") + + send_response( + original_event, + context, + response_status, + data, + item["PhysicalResourceId"], + reason, + ) + table.delete_item(Key={"BuildId": build_id}) + print(f"Sent {response_status} to CloudFormation for {build_id}") + Environment: + Variables: + PENDING_TABLE_NAME: + Ref: PlaceholderImageBuildPendingBuilds86736129 FunctionName: workshop-placeholder-images-report Handler: index.lambda_handler Role: @@ -2673,6 +4294,14 @@ Resources: - PlaceholderImageBuildLambdaRole8EDC67D7 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 120 Type: AWS::Lambda::Function PlaceholderImageBuildRole66BA72FE: @@ -2684,17 +4313,40 @@ Resources: Principal: Service: codebuild.amazonaws.com Version: "2012-10-17" - ManagedPolicyArns: - - Fn::Join: - - "" - - - "arn:" - - Ref: AWS::Partition - - :iam::aws:policy/PowerUserAccess + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role PlaceholderImageBuildRoleDefaultPolicy6FEC4468: Properties: PolicyDocument: Statement: + - Action: + - ecr:GetAuthorizationToken + - sts:GetCallerIdentity + Effect: Allow + Resource: "*" + - Action: + - ecr:BatchCheckLayerAvailability + - ecr:CompleteLayerUpload + - ecr:GetDownloadUrlForLayer + - ecr:InitiateLayerUpload + - ecr:PutImage + - ecr:UploadLayerPart + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:ecr:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :repository/aiagent - Action: ec2:CreateNetworkInterfacePermission Condition: StringEquals: @@ -2788,62 +4440,78 @@ Resources: - PlaceholderImageBuildLambdaRole8EDC67D7 Properties: Code: - ZipFile: |- - import boto3 + ZipFile: | import json - import traceback - import cfnresponse - - codebuild = boto3.client('codebuild') + import os + import time + import urllib.request - def lambda_handler(event, context): - print(f'Event: {event}') - responseData = {} - status = cfnresponse.SUCCESS - physical_id = event.get('PhysicalResourceId', 'CodeBuildSetup') + import boto3 - try: - if event['RequestType'] == 'Delete': - # Nothing to clean up for CodeBuild - responseData = {'Message': 'CodeBuild setup deleted'} - cfnresponse.send(event, context, status, responseData, physical_id) - return + codebuild = boto3.client("codebuild") + table = boto3.resource("dynamodb").Table(os.environ["PENDING_TABLE_NAME"]) + + + def send_response(event, context, status, data, physical_id, reason=None): + body = json.dumps( + { + "Status": status, + "Reason": reason or f"See CloudWatch Logs: {context.log_stream_name}", + "PhysicalResourceId": physical_id, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, + } + ).encode("utf-8") + request = urllib.request.Request( + event["ResponseURL"], + data=body, + method="PUT", + headers={"content-type": "", "content-length": str(len(body))}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + if response.status >= 300: + raise RuntimeError(f"CloudFormation response failed with HTTP {response.status}") - if event['RequestType'] == 'Update': - # For updates, trigger a new build - pass - # Start CodeBuild project - props = event['ResourceProperties'] - project_name = props['ProjectName'] + def lambda_handler(event, context): + print(f"RequestType={event['RequestType']} LogicalResourceId={event['LogicalResourceId']}") + project_name = event["ResourceProperties"]["ProjectName"] + physical_id = event.get("PhysicalResourceId", project_name) - print(f'Starting CodeBuild project: {project_name}') + if event["RequestType"] == "Delete": + send_response(event, context, "SUCCESS", {"ProjectName": project_name}, physical_id) + return - response = codebuild.start_build( - projectName=project_name + try: + build = codebuild.start_build(projectName=project_name)["build"] + table.put_item( + Item={ + "BuildId": build["id"], + "BuildArn": build["arn"], + "ProjectName": project_name, + "PhysicalResourceId": project_name, + "CloudFormationEvent": json.dumps(event), + "ExpiresAt": int(time.time()) + 7200, + } ) - - build_id = response['build']['id'] - build_arn = response['build']['arn'] - - print(f'Started build: {build_id}') - - responseData = { - 'BuildId': build_id, - 'BuildArn': build_arn, - 'ProjectName': project_name - } - - # Use build ID as physical resource ID for tracking - physical_id = build_id - - except Exception as e: - status = cfnresponse.FAILED - tb_err = traceback.format_exc() - print(tb_err) - responseData = {'Error': tb_err} - - cfnresponse.send(event, context, status, responseData, physical_id) + print(f"Started CodeBuild project {project_name}: {build['id']}") + except Exception as error: + print(f"Failed to start or persist CodeBuild callback: {error}") + send_response( + event, + context, + "FAILED", + {"ProjectName": project_name}, + physical_id, + str(error), + ) + Environment: + Variables: + PENDING_TABLE_NAME: + Ref: PlaceholderImageBuildPendingBuilds86736129 FunctionName: workshop-placeholder-images-start Handler: index.lambda_handler Role: @@ -2851,11 +4519,27 @@ Resources: - PlaceholderImageBuildLambdaRole8EDC67D7 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 120 Type: AWS::Lambda::Function UnicornUnicornEventBusB728845C: Properties: Name: unicorns + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::Events::EventBus UnicornUnicornStoreDatabaseSetupFunction04E12F8B: DependsOn: @@ -3027,6 +4711,14 @@ Resources: - UnicornUnicornStoreDatabaseSetupFunctionServiceRole61942171 - Arn Runtime: python3.13 + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Timeout: 180 VpcConfig: SecurityGroupIds: @@ -3062,6 +4754,14 @@ Resources: - - "arn:" - Ref: AWS::Partition - :iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreDatabaseSetupFunctionServiceRoleDefaultPolicy00D62509: DependsOn: @@ -3166,6 +4866,14 @@ Resources: - :iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices Path: /service-role/ RoleName: unicornstore-ecs-infrastructure-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreEcsTaskExecutionRoleC2148AE8: Properties: @@ -3185,6 +4893,14 @@ Resources: - :iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy Path: /service-role/ RoleName: unicornstore-ecs-task-execution-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreEcsTaskExecutionRoleDefaultPolicy3FC9EFEE: Properties: @@ -3192,7 +4908,9 @@ Resources: Statement: - Action: logs:CreateLogGroup Effect: Allow - Resource: "*" + Resource: + - arn:aws:logs:*:*:log-group:/aws/ecs/* + - arn:aws:logs:*:*:log-group:/ecs/* - Action: - secretsmanager:DescribeSecret - secretsmanager:GetSecretValue @@ -3244,6 +4962,14 @@ Resources: - :iam::aws:policy/AWSXrayWriteOnlyAccess Path: /service-role/ RoleName: unicornstore-ecs-task-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreEcsTaskRoleDefaultPolicy477138EA: Properties: @@ -3279,6 +5005,14 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy RoleName: unicornstore-eks-pod-role + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::IAM::Role UnicornUnicornStoreEksPodRoleDefaultPolicy0D527B93: Properties: @@ -3345,12 +5079,26 @@ Resources: Tags: - Key: Name Value: workshop-vpc + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::VPC VpcIGW488B0FEB: Properties: Tags: - Key: Name Value: workshop-vpc + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::InternetGateway VpcPrivateSubnet1DefaultRouteF704DE9F: Properties: @@ -3367,6 +5115,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -3394,6 +5149,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -3412,6 +5174,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -3439,6 +5208,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PrivateSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -3460,6 +5236,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::EIP VpcPublicSubnet1NATGateway8185E366: DependsOn: @@ -3477,6 +5260,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::EC2::NatGateway VpcPublicSubnet1RouteTable431DD755: Properties: @@ -3485,6 +5275,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -3512,6 +5309,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet1 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -3532,6 +5336,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::RouteTable @@ -3559,6 +5370,13 @@ Resources: Value: "1" - Key: Name Value: WorkshopStack/Vpc/Vpc/PublicSubnet2 + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation VpcId: Ref: VpcC3027511 Type: AWS::EC2::Subnet @@ -3573,14 +5391,104 @@ Resources: Properties: Description: Workshop VPC ID for cross-stack reference Name: workshop-vpc-id + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-spring-ai-agents + WorkshopOwner: cloudformation Type: String Value: Ref: VpcC3027511 Type: AWS::SSM::Parameter + WorkshopBucketAccessLogs476BAB88: + DeletionPolicy: Delete + Metadata: + checkov: + skip: + - comment: Dedicated access-log target; recursive logging is intentionally disabled. + id: CKV_AWS_18 + Properties: + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + BucketName: + Fn::Join: + - "" + - - workshop-access-logs- + - Ref: AWS::AccountId + - "-" + - Ref: AWS::Region + - "-20260825164056" + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation + Type: AWS::S3::Bucket + UpdateReplacePolicy: Delete + WorkshopBucketAccessLogsPolicy37DFEA4E: + Properties: + Bucket: + Ref: WorkshopBucketAccessLogs476BAB88 + PolicyDocument: + Statement: + - Action: s3:* + Condition: + Bool: + aws:SecureTransport: "false" + Effect: Deny + Principal: + AWS: "*" + Resource: + - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /* + - Action: s3:PutObject + Condition: + ArnLike: + aws:SourceArn: + Fn::GetAtt: + - WorkshopBucketFD5BC43F + - Arn + StringEquals: + aws:SourceAccount: + Ref: AWS::AccountId + Effect: Allow + Principal: + Service: logging.s3.amazonaws.com + Resource: + Fn::Join: + - "" + - - Fn::GetAtt: + - WorkshopBucketAccessLogs476BAB88 + - Arn + - /workshop-data/* + Version: "2012-10-17" + Type: AWS::S3::BucketPolicy WorkshopBucketBucketNameParameterCEE58012: Properties: Description: Workshop bucket name for thread dumps and profiling data Name: workshop-bucket-name + Tags: + WorkshopDeploymentId: + Ref: AWS::StackId + WorkshopId: java-spring-ai-agents + WorkshopOwner: cloudformation Type: String Value: Ref: WorkshopBucketFD5BC43F @@ -3595,12 +5503,24 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260125084120" + - "-20260825164056" + LoggingConfiguration: + DestinationBucketName: + Ref: WorkshopBucketAccessLogs476BAB88 + LogFilePrefix: workshop-data/ PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true + Tags: + - Key: WorkshopDeploymentId + Value: + Ref: AWS::StackId + - Key: WorkshopId + Value: java-spring-ai-agents + - Key: WorkshopOwner + Value: cloudformation Type: AWS::S3::Bucket UpdateReplacePolicy: Delete WorkshopBucketPolicyA21947CB: diff --git a/infra/package.json b/infra/package.json index 8617ade4..0cc9bc1e 100644 --- a/infra/package.json +++ b/infra/package.json @@ -4,7 +4,8 @@ "description": "Unified AWS workshop infrastructure", "scripts": { "gen": "./scripts/cfn/generate.sh", - "sync": "./scripts/cfn/sync.sh" + "sync": "./scripts/cfn/sync.sh", + "ws-test:gen": "node ./scripts/ws-test/generate.mjs --menu" }, "author": "", "license": "ISC" diff --git a/infra/scripts/cfn/generate.sh b/infra/scripts/cfn/generate.sh index 7c6b4684..07668715 100755 --- a/infra/scripts/cfn/generate.sh +++ b/infra/scripts/cfn/generate.sh @@ -1,89 +1,84 @@ #!/bin/bash # Template generation script -source "$(dirname "$0")/../lib/common.sh" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" -# Change to CDK directory -cd "$(dirname "$0")/../../cdk" || { - log_error "Failed to change to CDK directory" +INFRA_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +CONFIG_FILE="$INFRA_DIR/workshops.json" +CDK_DIR="$INFRA_DIR/cdk" + +if [[ ! -f "$CONFIG_FILE" ]]; then + log_error "Workshop registry not found: $CONFIG_FILE" exit 1 -} +fi + +all_templates=() +while IFS= read -r template; do + all_templates+=("$template") +done < <(jq -r '.workshops[].template' "$CONFIG_FILE") + +if [[ "${#all_templates[@]}" -eq 0 ]]; then + log_error "No workshops configured in $CONFIG_FILE" + exit 1 +fi -# Display menu echo "" echo "Select template to generate:" echo " 0) All templates" -echo " 1) java-on-aws" -echo " 2) java-on-amazon-eks" -echo " 3) java-spring-ai-agents" -echo " 4) java-ai-agents" -echo " 5) java-ai-agents-advanced" +for index in "${!all_templates[@]}"; do + echo " $((index + 1))) ${all_templates[$index]}" +done echo "" -read -p "Enter choice [0-5]: " choice - -# Determine which templates to generate -case $choice in - 0) templates=("java-on-aws" "java-on-amazon-eks" "java-spring-ai-agents" "java-ai-agents" "java-ai-agents-advanced") ;; - 1) templates=("java-on-aws") ;; - 2) templates=("java-on-amazon-eks") ;; - 3) templates=("java-spring-ai-agents") ;; - 4) templates=("java-ai-agents") ;; - 5) templates=("java-ai-agents-advanced") ;; - *) - log_error "Invalid choice: $choice" - exit 1 - ;; -esac +read -r -p "Enter choice [0-${#all_templates[@]}]: " choice + +if [[ "$choice" == "0" ]]; then + templates=("${all_templates[@]}") +elif [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#all_templates[@]} )); then + templates=("${all_templates[$((choice - 1))]}") +else + log_error "Invalid choice: $choice" + exit 1 +fi -log_info "Generating CloudFormation templates..." +cd "$CDK_DIR" || { + log_error "Failed to change to CDK directory" + exit 1 +} -# Clean and build Maven project +log_info "Generating CloudFormation templates..." log_info "Building CDK project..." mvn clean package -q || { log_error "Maven build failed" exit 1 } -# Get current git branch GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main") log_info "Using git branch: $GIT_BRANCH" - -# Create cfn directory if it doesn't exist mkdir -p ../cfn -# Function to generate and process template generate_template() { local template_type=$1 local output_file="../cfn/${template_type}-stack.yaml" log_info "Generating $template_type template..." - - # Set environment variable for CDK export TEMPLATE_TYPE="$template_type" - # Generate CloudFormation template - cdk synth WorkshopStack --yaml --path-metadata false --version-reporting false --context git.branch="$GIT_BRANCH" --context template.type="$template_type" > "$output_file" || { + cdk synth WorkshopStack --path-metadata false --version-reporting false \ + --context git.branch="$GIT_BRANCH" --context template.type="$template_type" > "$output_file" || { log_error "CDK synthesis failed for $template_type" return 1 } - # Apply CloudFormation substitutions and remove CDK dependencies log_info "Processing $template_type template..." - if [[ -f "$output_file" ]]; then - # Check if we're on macOS or Linux for sed syntax - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' 's/arn:aws:iam::{{\.AccountId}}:/!Sub arn:aws:iam::${AWS::AccountId}:/g' "$output_file" - sed -i '' '/BootstrapVersion:/,/Description.*cdk:skip/d' "$output_file" - else - sed -i 's/arn:aws:iam::{{\.AccountId}}:/!Sub arn:aws:iam::${AWS::AccountId}:/g' "$output_file" - sed -i '/BootstrapVersion:/,/Description.*cdk:skip/d' "$output_file" - fi + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' 's/arn:aws:iam::{{\.AccountId}}:/!Sub arn:aws:iam::${AWS::AccountId}:/g' "$output_file" + sed -i '' '/BootstrapVersion:/,/Description.*cdk:skip/d' "$output_file" else - log_error "Template file $output_file was not created" - return 1 + sed -i 's/arn:aws:iam::{{\.AccountId}}:/!Sub arn:aws:iam::${AWS::AccountId}:/g' "$output_file" + sed -i '/BootstrapVersion:/,/Description.*cdk:skip/d' "$output_file" fi - # Sort YAML keys for deterministic output log_info "Sorting keys in $template_type template..." yq -i 'sort_keys(..)' "$output_file" || { log_error "Failed to sort keys in $output_file" @@ -93,7 +88,6 @@ generate_template() { log_success "Generated $template_type template: $output_file" } -# Generate selected templates for template in "${templates[@]}"; do generate_template "$template" done diff --git a/infra/scripts/cfn/sync.sh b/infra/scripts/cfn/sync.sh index d374fbad..342d9b07 100755 --- a/infra/scripts/cfn/sync.sh +++ b/infra/scripts/cfn/sync.sh @@ -1,101 +1,128 @@ #!/bin/bash -# Workshop sync script -# Copies workshop-specific CloudFormation templates and shared IAM policy to workshop directories -# Target directories are sibling folders to the repo: ../../java-on-aws/static, etc. -# Structure: workshops/java-on-aws/static, workshops/java-on-eks/static, workshops/java-on-aws (this repo) - +# Copies workshop-specific CloudFormation templates and policy files +# to sibling workshop repositories defined in infra/workshops.json. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/common.sh" -# Change to infra directory (script may be called from different locations) -cd "$SCRIPT_DIR/../.." || { - log_error "Failed to change to infra directory" +INFRA_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +REPO_ROOT="$(cd "$INFRA_DIR/.." && pwd)" +WORKSPACE_ROOT="$(dirname "$REPO_ROOT")" +CONFIG_FILE="$INFRA_DIR/workshops.json" +SHARED_POLICY_FILE="$INFRA_DIR/cdk/src/main/resources/iam-policy.json" +ROLE_MANAGEMENT_POLICY_FILE="$INFRA_DIR/cdk/src/main/resources/iam-role-management-policy.json" +AGENTCORE_IDENTITY_POLICY_FILE="$INFRA_DIR/cdk/src/main/resources/agentcore-identity-policy.json" +AGENTCORE_MANAGED_TOOLS_POLICY_FILE="$INFRA_DIR/cdk/src/main/resources/agentcore-managed-tools-policy.json" + +if [[ ! -f "$CONFIG_FILE" ]]; then + log_error "Workshop registry not found: $CONFIG_FILE" exit 1 -} - -WORKSHOPS=("java-on-aws" "java-on-amazon-eks" "java-spring-ai-agents" "java-ai-agents" "java-ai-agents-advanced") +fi +if [[ ! -f "$SHARED_POLICY_FILE" ]]; then + log_error "Shared policy file not found: $SHARED_POLICY_FILE" + exit 1 +fi +if [[ ! -f "$ROLE_MANAGEMENT_POLICY_FILE" ]]; then + log_error "Role management policy file not found: $ROLE_MANAGEMENT_POLICY_FILE" + exit 1 +fi +if [[ ! -f "$AGENTCORE_IDENTITY_POLICY_FILE" ]]; then + log_error "AgentCore Identity policy file not found: $AGENTCORE_IDENTITY_POLICY_FILE" + exit 1 +fi +if [[ ! -f "$AGENTCORE_MANAGED_TOOLS_POLICY_FILE" ]]; then + log_error "AgentCore managed tools policy file not found: $AGENTCORE_MANAGED_TOOLS_POLICY_FILE" + exit 1 +fi -# Shared IAM policy file used by all workshops -SHARED_POLICY_FILE="cdk/src/main/resources/iam-policy.json" +all_templates=() +all_repositories=() +while IFS=$'\t' read -r template repository; do + all_templates+=("$template") + all_repositories+=("$repository") +done < <(jq -r '.workshops[] | [.template, .repository] | @tsv' "$CONFIG_FILE") -if [[ ! -f "$SHARED_POLICY_FILE" ]]; then - log_error "Shared policy file $SHARED_POLICY_FILE not found" +if [[ "${#all_templates[@]}" -eq 0 ]]; then + log_error "No workshops configured in $CONFIG_FILE" exit 1 fi -# Display menu echo "" echo "Select template to sync:" echo " 0) All templates" -echo " 1) java-on-aws" -echo " 2) java-on-amazon-eks" -echo " 3) java-spring-ai-agents" -echo " 4) java-ai-agents" -echo " 5) java-ai-agents-advanced" +for index in "${!all_templates[@]}"; do + echo " $((index + 1))) ${all_templates[$index]} -> ${all_repositories[$index]}" +done echo "" -read -p "Enter choice [0-5]: " choice - -# Determine which workshops to sync -case $choice in - 0) selected_workshops=("${WORKSHOPS[@]}") ;; - 1) selected_workshops=("java-on-aws") ;; - 2) selected_workshops=("java-on-amazon-eks") ;; - 3) selected_workshops=("java-spring-ai-agents") ;; - 4) selected_workshops=("java-ai-agents") ;; - 5) selected_workshops=("java-ai-agents-advanced") ;; - *) - log_error "Invalid choice: $choice" - exit 1 - ;; -esac +read -r -p "Enter choice [0-${#all_templates[@]}]: " choice -log_info "Syncing CloudFormation templates and policies to workshop directories..." +selected_indexes=() +if [[ "$choice" == "0" ]]; then + selected_indexes=("${!all_templates[@]}") +elif [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#all_templates[@]} )); then + selected_indexes=("$((choice - 1))") +else + log_error "Invalid choice: $choice" + exit 1 +fi +log_info "Syncing CloudFormation templates and policies to workshop repositories..." synced_count=0 -# Map template name to actual folder name (when they differ) -get_folder_name() { - case "$1" in - "java-on-aws") echo "java-on-aws-immersion-day" ;; - *) echo "$1" ;; - esac -} - -for workshop in "${selected_workshops[@]}"; do - # Target is sibling to repo root: ../../{folder}/static - folder_name=$(get_folder_name "$workshop") - target_dir="../../$folder_name/static" - - if [[ -d "$target_dir" ]]; then - # Copy workshop-specific CloudFormation template -> workshop-stack.yaml - template_file="cfn/${workshop}-stack.yaml" - if [[ -f "$template_file" ]]; then - cp "$template_file" "$target_dir/workshop-stack.yaml" || { - log_error "Failed to copy template for $workshop" - exit 1 - } - log_success "Synced $template_file to $folder_name/static/workshop-stack.yaml" - else - log_error "Template file $template_file not found" - exit 1 - fi +for index in "${selected_indexes[@]}"; do + template="${all_templates[$index]}" + repository="${all_repositories[$index]}" + target_dir="$WORKSPACE_ROOT/$repository/static" + template_file="$INFRA_DIR/cfn/${template}-stack.yaml" + + if [[ ! -d "$target_dir" ]]; then + log_info "Directory not found, skipping $repository: $target_dir" + continue + fi + if [[ ! -f "$template_file" ]]; then + log_error "Template file not found: $template_file" + exit 1 + fi + + cp "$template_file" "$target_dir/workshop-stack.yaml" || { + log_error "Failed to copy template for $template" + exit 1 + } + log_success "Synced $template_file to $repository/static/workshop-stack.yaml" - # Copy shared IAM policy -> iam-policy.json - cp "$SHARED_POLICY_FILE" "$target_dir/iam-policy.json" || { - log_error "Failed to copy policy for $workshop" + cp "$SHARED_POLICY_FILE" "$target_dir/iam-policy.json" || { + log_error "Failed to copy policy for $template" + exit 1 + } + log_success "Synced $SHARED_POLICY_FILE to $repository/static/iam-policy.json" + + cp "$ROLE_MANAGEMENT_POLICY_FILE" "$target_dir/iam-role-management-policy.json" || { + log_error "Failed to copy role management policy for $template" + exit 1 + } + log_success "Synced $ROLE_MANAGEMENT_POLICY_FILE to $repository/static/iam-role-management-policy.json" + + if [[ "$template" == "java-spring-ai-agents" || "$template" == "java-ai-agents" || "$template" == "java-ai-agents-advanced" ]]; then + cp "$AGENTCORE_MANAGED_TOOLS_POLICY_FILE" "$target_dir/agentcore-managed-tools-policy.json" || { + log_error "Failed to copy AgentCore managed tools policy for $template" exit 1 } - log_success "Synced $SHARED_POLICY_FILE to $folder_name/static/iam-policy.json" + log_success "Synced $AGENTCORE_MANAGED_TOOLS_POLICY_FILE to $repository/static/agentcore-managed-tools-policy.json" + fi - ((synced_count++)) - else - log_info "Directory $target_dir not found, skipping $workshop ($folder_name)" + if [[ "$template" == "java-ai-agents" || "$template" == "java-ai-agents-advanced" ]]; then + cp "$AGENTCORE_IDENTITY_POLICY_FILE" "$target_dir/agentcore-identity-policy.json" || { + log_error "Failed to copy AgentCore Identity policy for $template" + exit 1 + } + log_success "Synced $AGENTCORE_IDENTITY_POLICY_FILE to $repository/static/agentcore-identity-policy.json" fi + + synced_count=$((synced_count + 1)) done -if [[ $synced_count -eq 0 ]]; then - log_warning "No workshop directories found. Expected sibling directories: ../../java-on-aws/static, etc." +if [[ "$synced_count" -eq 0 ]]; then + log_warning "No workshop repositories were synchronized under $WORKSPACE_ROOT" else log_success "Synced $synced_count workshop(s) successfully!" fi diff --git a/infra/scripts/cleanup/java-on-aws.sh b/infra/scripts/cleanup/java-on-aws.sh new file mode 100755 index 00000000..d6c7b613 --- /dev/null +++ b/infra/scripts/cleanup/java-on-aws.sh @@ -0,0 +1,960 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Cleanup for the java-on-aws workshop. +# +# Safety model: +# 1. Validate the immutable CloudFormation stack ID, account, Region, workshop +# ID, stack-owned VPC, and stack-owned EKS cluster. +# 2. Treat the validated EKS cluster and VPC as dedicated workshop boundaries. +# 3. Enumerate Kubernetes and ECS workloads instead of naming applications. +# 4. Skip ECS cleanly when the participant selected only the EKS path. +# 5. Retain the validated CloudFormation stack after external dependencies are removed. + +readonly EXPECTED_WORKSHOP_ID="java-on-aws" +readonly FOUNDATION_OWNER="cloudformation" +readonly RUNTIME_OWNER="workshop-run" +readonly DEFAULT_WAIT_SECONDS=600 +readonly POLL_SECONDS=10 + +MODE="interactive" +WAIT_SECONDS="${WORKSHOP_CLEANUP_WAIT_SECONDS:-$DEFAULT_WAIT_SECONDS}" +ERRORS=0 +VPC_ID="" +EKS_CLUSTER_NAME="" +STACK_RESOURCES_JSON="" +KUBE_CONFIG="" +KUBE_CONTEXT="" + +declare -a VPC_SUBNET_IDS=() +declare -a RUNTIME_ECS_CLUSTERS=() +declare -a RUNTIME_LOAD_BALANCERS=() +declare -a RUNTIME_TARGET_GROUPS=() +declare -a RUNTIME_ECR_REPOSITORIES=() +declare -a TASK_DEFINITIONS=() +declare -a LOG_GROUPS=() +declare -a SECURITY_GROUPS=() +declare -a STACK_RESOURCE_IDS=() + +log_info() { printf '[INFO] %s\n' "$*"; } +log_warn() { printf '[WARN] %s\n' "$*" >&2; } +log_error() { printf '[ERROR] %s\n' "$*" >&2; } +record_error() { log_error "$*"; ERRORS=$((ERRORS + 1)); } + +usage() { + cat <<'EOF' +Usage: java-on-aws.sh [--plan | --yes] [--wait-seconds SECONDS] + + --plan Validate ownership and show discovered resources only. + --yes Execute without prompting. This explicitly confirms deletion. + --wait-seconds Per-phase timeout in seconds (default: 600). + -h, --help Show this help. + +Without --plan or --yes, the exact stack name must be typed before deletion. +EOF +} + +aws_cli() { + aws --region "$AWS_REGION" --no-cli-pager "$@" +} + +kubectl_cli() { + command kubectl --kubeconfig "$KUBE_CONFIG" --context "$KUBE_CONTEXT" "$@" +} + +cleanup_local_state() { + [[ -z "$KUBE_CONFIG" ]] || rm -f "$KUBE_CONFIG" +} + +fail_discovery() { + log_error "$*" + exit 1 +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --plan) + [[ "$MODE" == "interactive" ]] || { log_error "Choose only one mode"; exit 2; } + MODE="plan" + ;; + --yes) + [[ "$MODE" == "interactive" ]] || { log_error "Choose only one mode"; exit 2; } + MODE="confirmed" + ;; + --wait-seconds) + shift + [[ $# -gt 0 && "$1" =~ ^[1-9][0-9]*$ ]] || { + log_error "--wait-seconds requires a positive integer" + exit 2 + } + WAIT_SECONDS="$1" + ;; + -h|--help) + usage + exit 0 + ;; + *) + log_error "Unknown argument: $1" + usage >&2 + exit 2 + ;; + esac + shift + done +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || { + log_error "Required command not found: $1" + exit 1 + } +} + +require_value() { + local name="$1" + [[ -n "${!name:-}" ]] || { + log_error "$name is not set; source /etc/profile.d/workshop.sh" + exit 1 + } +} + +array_contains() { + local needle="$1" item + shift + for item in "$@"; do + [[ "$item" == "$needle" ]] && return 0 + done + return 1 +} + +append_unique() { + local array_name="$1" value="$2" + [[ -n "$value" && "$value" != "None" ]] || return + case "$array_name" in + SECURITY_GROUPS) + array_contains "$value" "${SECURITY_GROUPS[@]:-}" || SECURITY_GROUPS+=("$value") + ;; + TASK_DEFINITIONS) + array_contains "$value" "${TASK_DEFINITIONS[@]:-}" || TASK_DEFINITIONS+=("$value") + ;; + LOG_GROUPS) + array_contains "$value" "${LOG_GROUPS[@]:-}" || LOG_GROUPS+=("$value") + ;; + *) + log_error "Unsupported cleanup collection: $array_name" + exit 1 + ;; + esac +} + +is_stack_resource() { + array_contains "$1" "${STACK_RESOURCE_IDS[@]:-}" +} + +json_array_tag() { + local json="$1" key="$2" + jq -r --arg key "$key" \ + '[(.Tags // .tags // [])[] | select((.Key // .key) == $key) | (.Value // .value)][0] // empty' \ + <<<"$json" +} + +json_map_tag() { + local json="$1" key="$2" + jq -r --arg key "$key" '.tags[$key] // empty' <<<"$json" +} + +assert_not_owned_by_other_stack() { + local description="$1" id="$2" tags_json="$3" format="${4:-array}" owner_stack + if [[ "$format" == "map" ]]; then + owner_stack=$(json_map_tag "$tags_json" 'aws:cloudformation:stack-id') + else + owner_stack=$(json_array_tag "$tags_json" 'aws:cloudformation:stack-id') + fi + if [[ -n "$owner_stack" && "$owner_stack" != "$WORKSHOP_DEPLOYMENT_ID" ]]; then + fail_discovery "$description $id belongs to another CloudFormation stack: $owner_stack" + fi +} + +wait_until() { + local description="$1" + shift + local deadline=$((SECONDS + WAIT_SECONDS)) + until "$@"; do + if (( SECONDS >= deadline )); then + record_error "Timed out waiting for $description" + return 1 + fi + sleep "$POLL_SECONDS" + done +} + +validate_foundation_tags() { + local description="$1" id="$2" deployment="$3" workshop="$4" owner="$5" + [[ "$deployment" == "$WORKSHOP_DEPLOYMENT_ID" ]] || { + log_error "$description $id has a different WorkshopDeploymentId" + exit 1 + } + [[ "$workshop" == "$EXPECTED_WORKSHOP_ID" && "$owner" == "$FOUNDATION_OWNER" ]] || { + log_error "$description $id is not owned by this java-on-aws foundation" + exit 1 + } +} + +validate_identity() { + local stack_json actual_id actual_name stack_status caller_account stack_region stack_account + local -a vpc_ids=() eks_names=() + + [[ "$WORKSHOP_ID" == "$EXPECTED_WORKSHOP_ID" ]] || { + log_error "This cleanup only supports WorkshopId=$EXPECTED_WORKSHOP_ID; found $WORKSHOP_ID" + exit 1 + } + + stack_json=$(aws_cli cloudformation describe-stacks --stack-name "$WORKSHOP_STACK_NAME" --output json) + actual_id=$(jq -r '.Stacks[0].StackId // empty' <<<"$stack_json") + actual_name=$(jq -r '.Stacks[0].StackName // empty' <<<"$stack_json") + stack_status=$(jq -r '.Stacks[0].StackStatus // empty' <<<"$stack_json") + + [[ -n "$actual_id" && "$actual_id" == "$WORKSHOP_DEPLOYMENT_ID" ]] || { + log_error "WORKSHOP_DEPLOYMENT_ID does not match the deployed stack" + exit 1 + } + [[ "$actual_name" == "$WORKSHOP_STACK_NAME" ]] || { + log_error "WORKSHOP_STACK_NAME does not match the deployed stack" + exit 1 + } + [[ "$stack_status" != DELETE_* ]] || { + log_error "Stack deletion is already in progress or complete: $stack_status" + exit 1 + } + + caller_account=$(aws_cli sts get-caller-identity --query Account --output text) + stack_region=$(cut -d: -f4 <<<"$actual_id") + stack_account=$(cut -d: -f5 <<<"$actual_id") + [[ "$caller_account" == "$stack_account" ]] || { + log_error "Current account $caller_account does not own the stack" + exit 1 + } + [[ "$AWS_REGION" == "$stack_region" ]] || { + log_error "Current Region $AWS_REGION does not match stack Region $stack_region" + exit 1 + } + + STACK_RESOURCES_JSON=$(aws_cli cloudformation list-stack-resources --stack-name "$actual_id" --output json) + while IFS= read -r physical_id; do + [[ -n "$physical_id" ]] && STACK_RESOURCE_IDS+=("$physical_id") + done < <(jq -r '.StackResourceSummaries[].PhysicalResourceId // empty' <<<"$STACK_RESOURCES_JSON") + + mapfile -t vpc_ids < <(jq -r \ + '.StackResourceSummaries[] | select(.ResourceType == "AWS::EC2::VPC") | .PhysicalResourceId' \ + <<<"$STACK_RESOURCES_JSON") + [[ ${#vpc_ids[@]} -eq 1 && -n "${vpc_ids[0]}" ]] || { + log_error "Expected exactly one stack VPC; found ${#vpc_ids[@]}" + exit 1 + } + VPC_ID="${vpc_ids[0]}" + + local vpc_json vpc_resource + vpc_json=$(aws_cli ec2 describe-vpcs --vpc-ids "$VPC_ID" --output json) + vpc_resource=$(jq -c '.Vpcs[0]' <<<"$vpc_json") + validate_foundation_tags "VPC" "$VPC_ID" \ + "$(json_array_tag "$vpc_resource" WorkshopDeploymentId)" \ + "$(json_array_tag "$vpc_resource" WorkshopId)" \ + "$(json_array_tag "$vpc_resource" WorkshopOwner)" + + mapfile -t VPC_SUBNET_IDS < <(aws_cli ec2 describe-subnets \ + --filters "Name=vpc-id,Values=$VPC_ID" --query 'Subnets[].SubnetId' --output text | tr '\t' '\n') + [[ ${#VPC_SUBNET_IDS[@]} -gt 0 ]] || { + log_error "Validated VPC $VPC_ID has no subnets" + exit 1 + } + + mapfile -t eks_names < <(jq -r \ + '.StackResourceSummaries[] | select(.ResourceType == "AWS::EKS::Cluster") | .PhysicalResourceId' \ + <<<"$STACK_RESOURCES_JSON") + [[ ${#eks_names[@]} -eq 1 && -n "${eks_names[0]}" ]] || { + log_error "Expected exactly one stack EKS cluster; found ${#eks_names[@]}" + exit 1 + } + EKS_CLUSTER_NAME="${eks_names[0]}" + + local cluster_json cluster_arn cluster_tags + cluster_json=$(aws_cli eks describe-cluster --name "$EKS_CLUSTER_NAME" --output json) + cluster_arn=$(jq -r '.cluster.arn' <<<"$cluster_json") + [[ "$(jq -r '.cluster.resourcesVpcConfig.vpcId' <<<"$cluster_json")" == "$VPC_ID" ]] || { + log_error "EKS cluster $EKS_CLUSTER_NAME is outside validated VPC $VPC_ID" + exit 1 + } + cluster_tags=$(aws_cli eks list-tags-for-resource --resource-arn "$cluster_arn" --output json) + validate_foundation_tags "EKS cluster" "$EKS_CLUSTER_NAME" \ + "$(json_map_tag "$cluster_tags" WorkshopDeploymentId)" \ + "$(json_map_tag "$cluster_tags" WorkshopId)" \ + "$(json_map_tag "$cluster_tags" WorkshopOwner)" + + log_info "Validated stack: $WORKSHOP_DEPLOYMENT_ID" + log_info "Validated account/Region: $caller_account / $AWS_REGION" + log_info "Validated dedicated VPC: $VPC_ID" + log_info "Validated dedicated EKS cluster: $EKS_CLUSTER_NAME" +} + +service_description() { + local cluster_arn="$1" service_arn="$2" classic="" express="" + local classic_ok=1 express_ok=1 + + # Express Gateway services are also visible through describe-services, so + # check the Express API first to preserve the correct deletion semantics. + express=$(aws_cli ecs describe-express-gateway-service --service-arn "$service_arn" --output json 2>/dev/null) || express_ok=0 + if (( express_ok == 1 )) && [[ "$(jq -r '.service // empty' <<<"$express")" != "" ]]; then + jq -c '.service + {workshopCleanupType:"express"}' <<<"$express" + return + fi + + classic=$(aws_cli ecs describe-services --cluster "$cluster_arn" --services "$service_arn" --output json 2>/dev/null) || classic_ok=0 + if (( classic_ok == 1 )) && [[ "$(jq -r '.services | length' <<<"$classic")" -gt 0 ]]; then + jq -c '.services[0] + {workshopCleanupType:"classic"}' <<<"$classic" + return + fi + + log_error "Neither Express nor classic ECS APIs could describe $service_arn" + return 1 +} + +service_subnets() { + local cluster_arn="$1" service_arn="$2" description="$3" + local task_arns tasks_json subnets + + subnets=$(jq -r '.. | objects | .subnets? // empty | .[]?' <<<"$description" | sort -u) + if [[ -n "$subnets" ]]; then + printf '%s\n' "$subnets" + return 0 + fi + + task_arns=$(aws_cli ecs list-tasks --cluster "$cluster_arn" --service-name "$service_arn" \ + --query 'taskArns[]' --output text 2>/dev/null) || return 3 + [[ -n "$task_arns" && "$task_arns" != "None" ]] || return 3 + + # shellcheck disable=SC2086 + tasks_json=$(aws_cli ecs describe-tasks --cluster "$cluster_arn" --tasks $task_arns --output json 2>/dev/null) || return 3 + subnets=$(jq -r '.tasks[].attachments[].details[] | select(.name == "subnetId") | .value' <<<"$tasks_json" | sort -u) + [[ -n "$subnets" ]] || return 3 + printf '%s\n' "$subnets" +} + +service_belongs_to_vpc() { + local cluster_arn="$1" service_arn="$2" description="$3" subnet subnets + local inside=0 outside=0 + + subnets=$(service_subnets "$cluster_arn" "$service_arn" "$description") || return $? + while IFS= read -r subnet; do + [[ -n "$subnet" ]] || continue + if array_contains "$subnet" "${VPC_SUBNET_IDS[@]}"; then + inside=$((inside + 1)) + else + outside=$((outside + 1)) + fi + done <<<"$subnets" + + (( inside > 0 && outside == 0 )) && return 0 + (( outside > 0 && inside == 0 )) && return 1 + return 2 +} + +collect_service_dependencies() { + local description="$1" value + while IFS= read -r value; do append_unique SECURITY_GROUPS "$value"; done \ + < <(jq -r '.. | objects | .securityGroups? // empty | .[]?' <<<"$description" | sort -u) + while IFS= read -r value; do append_unique TASK_DEFINITIONS "$value"; done \ + < <(jq -r '.. | objects | (.taskDefinition? // .taskDefinitionArn? // empty)' <<<"$description" | sort -u) + while IFS= read -r value; do append_unique LOG_GROUPS "$value"; done \ + < <(jq -r '.. | objects | (.logGroup? // .options?["awslogs-group"]? // empty)' <<<"$description" | sort -u) +} + +cluster_tasks_belong_to_vpc() { + local cluster_arn="$1" task_output task_arn task_json subnet + local inside=0 outside=0 + task_output=$(aws_cli ecs list-tasks --cluster "$cluster_arn" --query 'taskArns[]' --output text 2>/dev/null) || return 3 + for task_arn in ${task_output//$'\t'/ }; do + [[ -n "$task_arn" && "$task_arn" != "None" ]] || continue + task_json=$(aws_cli ecs describe-tasks --cluster "$cluster_arn" --tasks "$task_arn" --output json 2>/dev/null) || return 3 + subnet=$(jq -r '[.tasks[0].attachments[].details[] | select(.name == "subnetId") | .value][0] // empty' <<<"$task_json") + [[ -n "$subnet" ]] || return 3 + if array_contains "$subnet" "${VPC_SUBNET_IDS[@]}"; then + inside=$((inside + 1)) + else + outside=$((outside + 1)) + fi + done + (( inside > 0 && outside == 0 )) && return 0 + (( outside > 0 && inside == 0 )) && return 1 + (( inside == 0 && outside == 0 )) && return 1 + return 2 +} + +validate_cluster_tasks() { + local cluster_arn="$1" task_output task_arn task_json subnet eni eni_json group_id tags + task_output=$(aws_cli ecs list-tasks --cluster "$cluster_arn" --query 'taskArns[]' --output text 2>/dev/null) || \ + fail_discovery "Could not enumerate tasks in ECS cluster $cluster_arn" + for task_arn in ${task_output//$'\t'/ }; do + [[ -n "$task_arn" && "$task_arn" != "None" ]] || continue + task_json=$(aws_cli ecs describe-tasks --cluster "$cluster_arn" --tasks "$task_arn" --output json 2>/dev/null) || \ + fail_discovery "Could not describe ECS task $task_arn" + append_unique TASK_DEFINITIONS "$(jq -r '.tasks[0].taskDefinitionArn // empty' <<<"$task_json")" + subnet=$(jq -r '[.tasks[0].attachments[].details[] | select(.name == "subnetId") | .value][0] // empty' <<<"$task_json") + [[ -n "$subnet" ]] || fail_discovery "ECS task $task_arn has no verifiable subnet attachment" + array_contains "$subnet" "${VPC_SUBNET_IDS[@]}" || \ + fail_discovery "ECS task $task_arn is outside validated VPC $VPC_ID" + + tags=$(aws_cli ecs list-tags-for-resource --resource-arn "$task_arn" --output json 2>/dev/null) || \ + fail_discovery "Could not inspect ownership tags for ECS task $task_arn" + assert_not_owned_by_other_stack "ECS task" "$task_arn" "$tags" + + eni=$(jq -r '[.tasks[0].attachments[].details[] | select(.name == "networkInterfaceId") | .value][0] // empty' <<<"$task_json") + if [[ -n "$eni" ]]; then + eni_json=$(aws_cli ec2 describe-network-interfaces --network-interface-ids "$eni" --output json 2>/dev/null) || \ + fail_discovery "Could not inspect network interface $eni for task $task_arn" + [[ "$(jq -r '.NetworkInterfaces[0].VpcId' <<<"$eni_json")" == "$VPC_ID" ]] || \ + fail_discovery "Task network interface $eni is outside validated VPC" + while IFS= read -r group_id; do append_unique SECURITY_GROUPS "$group_id"; done \ + < <(jq -r '.NetworkInterfaces[0].Groups[].GroupId' <<<"$eni_json") + fi + done +} + +discover_ecs_clusters() { + local cluster_output cluster_arn cluster_name cluster_tags service_output service_arn service_tags description + local service_count belongs_count state + local -a service_arns=() + + cluster_output=$(aws_cli ecs list-clusters --query 'clusterArns[]' --output text 2>/dev/null) || \ + fail_discovery "Could not enumerate ECS clusters" + for cluster_arn in ${cluster_output//$'\t'/ }; do + [[ -n "$cluster_arn" && "$cluster_arn" != "None" ]] || continue + cluster_name="${cluster_arn##*/}" + if jq -e --arg cluster_arn "$cluster_arn" --arg cluster_name "$cluster_name" ' + .StackResourceSummaries[] + | select(.ResourceType == "AWS::ECS::Cluster") + | select(.PhysicalResourceId == $cluster_arn or .PhysicalResourceId == $cluster_name) + ' <<<"$STACK_RESOURCES_JSON" >/dev/null; then + continue + fi + + service_output=$(aws_cli ecs list-services --cluster "$cluster_arn" --query 'serviceArns[]' --output text 2>/dev/null) || \ + fail_discovery "Could not enumerate services in ECS cluster $cluster_arn" + service_arns=() + for service_arn in ${service_output//$'\t'/ }; do + [[ -n "$service_arn" && "$service_arn" != "None" ]] && service_arns+=("$service_arn") + done + service_count=${#service_arns[@]} + if (( service_count == 0 )); then + state=0 + cluster_tasks_belong_to_vpc "$cluster_arn" || state=$? + case "$state" in + 0) + cluster_tags=$(aws_cli ecs list-tags-for-resource --resource-arn "$cluster_arn" --output json 2>/dev/null) || \ + fail_discovery "Could not inspect ownership tags for ECS cluster $cluster_arn" + assert_not_owned_by_other_stack "ECS cluster" "$cluster_arn" "$cluster_tags" + validate_cluster_tasks "$cluster_arn" + RUNTIME_ECS_CLUSTERS+=("$cluster_arn") + ;; + 1) + ;; + 2) + fail_discovery "ECS cluster $cluster_arn mixes workshop and external standalone tasks" + ;; + *) + fail_discovery "ECS cluster $cluster_arn has tasks with unverifiable VPC attachment" + ;; + esac + continue + fi + belongs_count=0 + + for service_arn in "${service_arns[@]}"; do + description=$(service_description "$cluster_arn" "$service_arn") || \ + fail_discovery "Cannot describe ECS service $service_arn" + + state=0 + service_belongs_to_vpc "$cluster_arn" "$service_arn" "$description" || state=$? + case "$state" in + 0) + service_tags=$(aws_cli ecs list-tags-for-resource --resource-arn "$service_arn" --output json 2>/dev/null) || \ + fail_discovery "Could not inspect ownership tags for ECS service $service_arn" + assert_not_owned_by_other_stack "ECS service" "$service_arn" "$service_tags" + belongs_count=$((belongs_count + 1)) + collect_service_dependencies "$description" + ;; + 1) + ;; + 2) + fail_discovery "ECS service $service_arn mixes workshop and external subnets" + ;; + *) + fail_discovery "ECS service $service_arn has no verifiable VPC attachment" + ;; + esac + done + + if (( belongs_count > 0 && belongs_count != service_count )); then + fail_discovery "ECS cluster $cluster_arn mixes workshop and non-workshop VPC services" + fi + if (( belongs_count == service_count )); then + cluster_tags=$(aws_cli ecs list-tags-for-resource --resource-arn "$cluster_arn" --output json 2>/dev/null) || \ + fail_discovery "Could not inspect ownership tags for ECS cluster $cluster_arn" + assert_not_owned_by_other_stack "ECS cluster" "$cluster_arn" "$cluster_tags" + validate_cluster_tasks "$cluster_arn" + RUNTIME_ECS_CLUSTERS+=("$cluster_arn") + fi + done + + if [[ ${#RUNTIME_ECS_CLUSTERS[@]} -eq 0 ]]; then + log_info "No ECS services use the validated workshop VPC; ECS cleanup will be skipped" + fi +} + +discover_vpc_load_balancers() { + local load_balancer_output target_group_output arn lb_json tags subnet target_vpc belongs + + load_balancer_output=$(aws_cli elbv2 describe-load-balancers --query 'LoadBalancers[].LoadBalancerArn' --output text 2>/dev/null) || \ + fail_discovery "Could not enumerate load balancers" + for arn in ${load_balancer_output//$'\t'/ }; do + [[ -n "$arn" && "$arn" != "None" ]] || continue + is_stack_resource "$arn" && continue + lb_json=$(aws_cli elbv2 describe-load-balancers --load-balancer-arns "$arn" --output json 2>/dev/null) || \ + fail_discovery "Could not describe load balancer $arn" + belongs=0 + while IFS= read -r subnet; do + array_contains "$subnet" "${VPC_SUBNET_IDS[@]}" && belongs=1 + done < <(jq -r '.LoadBalancers[0].AvailabilityZones[].SubnetId' <<<"$lb_json") + if (( belongs == 1 )); then + tags=$(aws_cli elbv2 describe-tags --resource-arns "$arn" --query 'TagDescriptions[0]' --output json 2>/dev/null) || \ + fail_discovery "Could not inspect ownership tags for load balancer $arn" + assert_not_owned_by_other_stack "Load balancer" "$arn" "$tags" + RUNTIME_LOAD_BALANCERS+=("$arn") + while IFS= read -r subnet; do append_unique SECURITY_GROUPS "$subnet"; done \ + < <(jq -r '.LoadBalancers[0].SecurityGroups[]?' <<<"$lb_json") + fi + done + + target_group_output=$(aws_cli elbv2 describe-target-groups \ + --query 'TargetGroups[].[TargetGroupArn,VpcId]' --output text 2>/dev/null) || \ + fail_discovery "Could not enumerate target groups" + while IFS=$'\t' read -r arn target_vpc; do + [[ -n "$arn" && "$target_vpc" == "$VPC_ID" ]] || continue + is_stack_resource "$arn" && continue + tags=$(aws_cli elbv2 describe-tags --resource-arns "$arn" --query 'TagDescriptions[0]' --output json 2>/dev/null) || \ + fail_discovery "Could not inspect ownership tags for target group $arn" + assert_not_owned_by_other_stack "Target group" "$arn" "$tags" + RUNTIME_TARGET_GROUPS+=("$arn") + done <<<"$target_group_output" +} + +discover_runtime_ecr() { + local repository_output name arn tags + repository_output=$(aws_cli ecr describe-repositories \ + --query 'repositories[].[repositoryName,repositoryArn]' --output text 2>/dev/null) || \ + fail_discovery "Could not enumerate ECR repositories" + while IFS=$'\t' read -r name arn; do + [[ -n "$name" && -n "$arn" ]] || continue + tags=$(aws_cli ecr list-tags-for-resource --resource-arn "$arn" --output json 2>/dev/null) || \ + fail_discovery "Could not inspect ownership tags for ECR repository $arn" + if [[ "$(json_array_tag "$tags" WorkshopId)" == "$EXPECTED_WORKSHOP_ID" && + "$(json_array_tag "$tags" WorkshopDeploymentId)" == "$WORKSHOP_DEPLOYMENT_ID" && + "$(json_array_tag "$tags" WorkshopOwner)" == "$RUNTIME_OWNER" ]]; then + assert_not_owned_by_other_stack "ECR repository" "$arn" "$tags" + RUNTIME_ECR_REPOSITORIES+=("$name") + fi + done <<<"$repository_output" +} + +validate_pod_identity_ownership() { + local association_output association association_json association_tags + association_output=$(aws_cli eks list-pod-identity-associations --cluster-name "$EKS_CLUSTER_NAME" \ + --query 'associations[].associationId' --output text 2>/dev/null) || \ + fail_discovery "Could not enumerate EKS Pod Identity associations" + for association in ${association_output//$'\t'/ }; do + [[ -n "$association" && "$association" != "None" ]] || continue + association_json=$(aws_cli eks describe-pod-identity-association --cluster-name "$EKS_CLUSTER_NAME" \ + --association-id "$association" --output json 2>/dev/null) || \ + fail_discovery "Could not inspect Pod Identity association $association" + association_tags=$(jq -c '{tags:(.association.tags // {})}' <<<"$association_json") + assert_not_owned_by_other_stack "Pod Identity association" "$association" "$association_tags" map + done +} + +discover_resources() { + KUBE_CONFIG=$(mktemp "${TMPDIR:-/tmp}/java-on-aws-cleanup-kubeconfig.XXXXXX") + KUBE_CONTEXT="cleanup-${EKS_CLUSTER_NAME}-${WORKSHOP_DEPLOYMENT_ID##*/}" + aws_cli eks update-kubeconfig --name "$EKS_CLUSTER_NAME" \ + --alias "$KUBE_CONTEXT" --kubeconfig "$KUBE_CONFIG" >/dev/null + validate_pod_identity_ownership + discover_ecs_clusters + discover_vpc_load_balancers + discover_runtime_ecr +} + +print_plan() { + local ingress_count service_count association_count + ingress_count=$(kubectl_cli get ingress --all-namespaces -o json | jq '.items | length') + service_count=$(kubectl_cli get service --all-namespaces -o json | jq '[.items[] | select(.spec.type == "LoadBalancer")] | length') + association_count=$(aws_cli eks list-pod-identity-associations --cluster-name "$EKS_CLUSTER_NAME" \ + --query 'length(associations)' --output text) + + printf '\njava-on-aws cleanup plan:\n' + printf ' Kubernetes Ingresses: %s\n' "$ingress_count" + printf ' Kubernetes LoadBalancer Services: %s\n' "$service_count" + printf ' EKS Pod Identity associations: %s\n' "$association_count" + printf ' ECS clusters attached to VPC: %d\n' "${#RUNTIME_ECS_CLUSTERS[@]}" + printf ' Load balancers in VPC: %d\n' "${#RUNTIME_LOAD_BALANCERS[@]}" + printf ' Target groups in VPC: %d\n' "${#RUNTIME_TARGET_GROUPS[@]}" + printf ' Tagged runtime ECR repositories: %d\n' "${#RUNTIME_ECR_REPOSITORIES[@]}" + printf ' CloudFormation stack: %s\n\n' "$WORKSHOP_STACK_NAME" +} + +confirm_cleanup() { + [[ "$MODE" == "confirmed" ]] && return + local confirmation + printf 'This permanently deletes java-on-aws runtime resources and stack %s.\n' "$WORKSHOP_STACK_NAME" + read -r -p "Type the exact stack name to continue: " confirmation + [[ "$confirmation" == "$WORKSHOP_STACK_NAME" ]] || { + log_error "Confirmation did not match; nothing was deleted" + exit 1 + } +} + +kubernetes_dependencies_gone() { + local ingress_count service_count + ingress_count=$(kubectl_cli get ingress --all-namespaces -o json 2>/dev/null | jq '.items | length') || return 1 + service_count=$(kubectl_cli get service --all-namespaces -o json 2>/dev/null | jq '[.items[] | select(.spec.type == "LoadBalancer")] | length') || return 1 + [[ "$ingress_count" -eq 0 && "$service_count" -eq 0 ]] +} + +cleanup_eks_dependencies() { + local namespace name association association_json association_tags ingress_json service_json association_output + ingress_json=$(kubectl_cli get ingress --all-namespaces -o json) || { + record_error "Could not enumerate EKS Ingresses before deletion" + return + } + service_json=$(kubectl_cli get service --all-namespaces -o json) || { + record_error "Could not enumerate EKS Services before deletion" + return + } + + log_info "Deleting every Ingress from validated EKS cluster $EKS_CLUSTER_NAME" + while IFS=$'\t' read -r namespace name; do + [[ -n "$namespace" && -n "$name" ]] || continue + kubectl_cli delete ingress "$name" --namespace "$namespace" --ignore-not-found \ + --wait=true --timeout="${WAIT_SECONDS}s" || record_error "Failed to delete Ingress $namespace/$name" + done < <(jq -r '.items[] | [.metadata.namespace,.metadata.name] | @tsv' <<<"$ingress_json") + + log_info "Deleting every LoadBalancer Service from validated EKS cluster" + while IFS=$'\t' read -r namespace name; do + [[ -n "$namespace" && -n "$name" ]] || continue + kubectl_cli delete service "$name" --namespace "$namespace" --ignore-not-found \ + --wait=true --timeout="${WAIT_SECONDS}s" || record_error "Failed to delete Service $namespace/$name" + done < <(jq -r '.items[] | select(.spec.type == "LoadBalancer") | [.metadata.namespace,.metadata.name] | @tsv' <<<"$service_json") + wait_until "Kubernetes load-balancer dependencies" kubernetes_dependencies_gone || true + + association_output=$(aws_cli eks list-pod-identity-associations --cluster-name "$EKS_CLUSTER_NAME" \ + --query 'associations[].associationId' --output text 2>/dev/null) || { + record_error "Could not enumerate EKS Pod Identity associations" + return + } + log_info "Deleting all Pod Identity associations from validated EKS cluster" + for association in ${association_output//$'\t'/ }; do + [[ -n "$association" && "$association" != "None" ]] || continue + association_json=$(aws_cli eks describe-pod-identity-association --cluster-name "$EKS_CLUSTER_NAME" \ + --association-id "$association" --output json 2>/dev/null) || { + record_error "Could not inspect Pod Identity association $association" + continue + } + association_tags=$(jq -c '{tags:(.association.tags // {})}' <<<"$association_json") + assert_not_owned_by_other_stack "Pod Identity association" "$association" "$association_tags" map + aws_cli eks delete-pod-identity-association --cluster-name "$EKS_CLUSTER_NAME" \ + --association-id "$association" >/dev/null || record_error "Failed to delete Pod Identity association $association" + done +} + +classic_service_gone() { + local cluster="$1" service="$2" output status + output=$(aws_cli ecs describe-services --cluster "$cluster" --services "$service" \ + --query 'services[0].status' --output text 2>&1) || return 1 + status="$output" + [[ -z "$status" || "$status" == "None" || "$status" == "INACTIVE" ]] +} + +express_service_gone() { + local output + if output=$(aws_cli ecs describe-express-gateway-service --service-arn "$1" 2>&1); then + return 1 + fi + [[ "$output" == *"ResourceNotFoundException"* || "$output" == *"not found"* ]] +} + +cluster_gone() { + local output status + output=$(aws_cli ecs describe-clusters --clusters "$1" --query 'clusters[0].status' --output text 2>&1) || return 1 + status="$output" + [[ -z "$status" || "$status" == "None" || "$status" == "INACTIVE" ]] +} + +cleanup_ecs_dependencies() { + local cluster_arn service_output service_arn service_tags description type task_output task_arn state + for cluster_arn in "${RUNTIME_ECS_CLUSTERS[@]}"; do + log_info "Deleting services from workshop ECS cluster $cluster_arn" + service_output=$(aws_cli ecs list-services --cluster "$cluster_arn" --query 'serviceArns[]' --output text 2>/dev/null) || { + record_error "Could not re-enumerate services in ECS cluster $cluster_arn" + continue + } + for service_arn in ${service_output//$'\t'/ }; do + [[ -n "$service_arn" && "$service_arn" != "None" ]] || continue + service_tags=$(aws_cli ecs list-tags-for-resource --resource-arn "$service_arn" --output json 2>/dev/null) || { + record_error "Could not re-check ownership for ECS service $service_arn" + continue + } + assert_not_owned_by_other_stack "ECS service" "$service_arn" "$service_tags" + description=$(service_description "$cluster_arn" "$service_arn") || { + record_error "Cannot describe ECS service $service_arn" + continue + } + state=0 + service_belongs_to_vpc "$cluster_arn" "$service_arn" "$description" || state=$? + if (( state != 0 )); then + record_error "ECS service $service_arn no longer has an unambiguous workshop VPC attachment" + continue + fi + collect_service_dependencies "$description" + type=$(jq -r '.workshopCleanupType' <<<"$description") + if [[ "$type" == "express" ]]; then + aws_cli ecs delete-express-gateway-service --service-arn "$service_arn" >/dev/null || \ + record_error "Failed to delete Express service $service_arn" + wait_until "Express service $service_arn" express_service_gone "$service_arn" || true + else + aws_cli ecs update-service --cluster "$cluster_arn" --service "$service_arn" \ + --desired-count 0 >/dev/null || record_error "Failed to scale down service $service_arn" + aws_cli ecs delete-service --cluster "$cluster_arn" --service "$service_arn" \ + --force >/dev/null || record_error "Failed to delete service $service_arn" + wait_until "ECS service $service_arn" classic_service_gone "$cluster_arn" "$service_arn" || true + fi + done + + validate_cluster_tasks "$cluster_arn" + task_output=$(aws_cli ecs list-tasks --cluster "$cluster_arn" --query 'taskArns[]' --output text 2>/dev/null) || { + record_error "Could not re-enumerate tasks in ECS cluster $cluster_arn" + continue + } + for task_arn in ${task_output//$'\t'/ }; do + [[ -n "$task_arn" && "$task_arn" != "None" ]] || continue + aws_cli ecs stop-task --cluster "$cluster_arn" --task "$task_arn" \ + --reason "java-on-aws cleanup" >/dev/null || record_error "Failed to stop task $task_arn" + done + + if wait_until "ECS cluster deletion request for $cluster_arn" \ + aws_cli ecs delete-cluster --cluster "$cluster_arn"; then + wait_until "ECS cluster $cluster_arn" cluster_gone "$cluster_arn" || true + fi + done +} + +load_balancer_state() { + local output + if output=$(aws_cli elbv2 describe-load-balancers --load-balancer-arns "$1" 2>&1); then + return 0 + fi + if [[ "$output" == *"LoadBalancerNotFound"* || "$output" == *"not found"* ]]; then + return 1 + fi + return 2 +} + +load_balancer_gone() { + local state=0 + load_balancer_state "$1" || state=$? + [[ "$state" -eq 1 ]] +} + +target_group_state() { + local output + if output=$(aws_cli elbv2 describe-target-groups --target-group-arns "$1" 2>&1); then + return 0 + fi + if [[ "$output" == *"TargetGroupNotFound"* || "$output" == *"not found"* ]]; then + return 1 + fi + return 2 +} + +cleanup_vpc_load_balancers() { + local arn listener_output listener state + for arn in "${RUNTIME_LOAD_BALANCERS[@]}"; do + state=0 + load_balancer_state "$arn" || state=$? + if (( state == 1 )); then + log_info "Load balancer already removed by its controller: $arn" + continue + elif (( state != 0 )); then + record_error "Could not verify load balancer state: $arn" + continue + fi + log_info "Deleting non-stack load balancer in workshop VPC: $arn" + listener_output=$(aws_cli elbv2 describe-listeners --load-balancer-arn "$arn" \ + --query 'Listeners[].ListenerArn' --output text 2>/dev/null) || { + record_error "Could not enumerate listeners for load balancer $arn" + continue + } + for listener in ${listener_output//$'\t'/ }; do + [[ -n "$listener" && "$listener" != "None" ]] || continue + aws_cli elbv2 delete-listener --listener-arn "$listener" >/dev/null || record_error "Failed to delete listener $listener" + done + aws_cli elbv2 delete-load-balancer --load-balancer-arn "$arn" >/dev/null || record_error "Failed to delete load balancer $arn" + wait_until "load balancer $arn" load_balancer_gone "$arn" || true + done + + for arn in "${RUNTIME_TARGET_GROUPS[@]}"; do + state=0 + target_group_state "$arn" || state=$? + if (( state == 1 )); then + log_info "Target group already removed by its controller: $arn" + continue + elif (( state != 0 )); then + record_error "Could not verify target group state: $arn" + continue + fi + log_info "Deleting non-stack target group in workshop VPC: $arn" + aws_cli elbv2 delete-target-group --target-group-arn "$arn" >/dev/null || record_error "Failed to delete target group $arn" + done +} + +cleanup_task_definitions_and_logs() { + local arn group tags log_json log_arn + for arn in "${TASK_DEFINITIONS[@]}"; do + is_stack_resource "$arn" && continue + tags=$(aws_cli ecs list-tags-for-resource --resource-arn "$arn" --output json 2>/dev/null) || { + record_error "Could not inspect ownership tags for task definition $arn" + continue + } + assert_not_owned_by_other_stack "Task definition" "$arn" "$tags" + while IFS= read -r group; do append_unique LOG_GROUPS "$group"; done \ + < <(aws_cli ecs describe-task-definition --task-definition "$arn" --output json 2>/dev/null | \ + jq -r '.taskDefinition.containerDefinitions[].logConfiguration.options["awslogs-group"]? // empty' | sort -u) + log_info "Deleting task definition used by workshop ECS service: $arn" + aws_cli ecs deregister-task-definition --task-definition "$arn" >/dev/null || true + aws_cli ecs delete-task-definitions --task-definitions "$arn" >/dev/null || record_error "Failed to delete task definition $arn" + done + for group in "${LOG_GROUPS[@]}"; do + [[ -n "$group" ]] || continue + log_json=$(aws_cli logs describe-log-groups --log-group-name-prefix "$group" --output json 2>/dev/null) || { + record_error "Could not inspect log group $group" + continue + } + log_arn=$(jq -r --arg group "$group" '[.logGroups[] | select(.logGroupName == $group) | (.logGroupArn // .arn)][0] // empty' <<<"$log_json") + [[ -n "$log_arn" ]] || continue + log_arn="${log_arn%:*}" + tags=$(aws_cli logs list-tags-for-resource --resource-arn "$log_arn" --output json 2>/dev/null) || { + record_error "Could not inspect ownership tags for log group $group" + continue + } + assert_not_owned_by_other_stack "Log group" "$group" "$tags" map + log_info "Deleting log group used by workshop ECS service: $group" + aws_cli logs delete-log-group --log-group-name "$group" >/dev/null || record_error "Failed to delete log group $group" + done +} + +cleanup_runtime_ecr() { + local repository repository_json repository_arn tags + for repository in "${RUNTIME_ECR_REPOSITORIES[@]}"; do + if ! repository_json=$(aws_cli ecr describe-repositories --repository-names "$repository" --output json 2>&1); then + if [[ "$repository_json" == *"RepositoryNotFoundException"* || "$repository_json" == *"not found"* ]]; then + log_info "ECR repository already removed: $repository" + else + record_error "Could not verify ECR repository state: $repository" + fi + continue + fi + repository_arn=$(jq -r '.repositories[0].repositoryArn // empty' <<<"$repository_json") + [[ -n "$repository_arn" ]] || { + record_error "ECR repository $repository returned no ARN" + continue + } + tags=$(aws_cli ecr list-tags-for-resource --resource-arn "$repository_arn" --output json 2>/dev/null) || { + record_error "Could not re-check ownership tags for ECR repository $repository" + continue + } + assert_not_owned_by_other_stack "ECR repository" "$repository_arn" "$tags" + if [[ "$(json_array_tag "$tags" WorkshopId)" != "$EXPECTED_WORKSHOP_ID" || + "$(json_array_tag "$tags" WorkshopDeploymentId)" != "$WORKSHOP_DEPLOYMENT_ID" || + "$(json_array_tag "$tags" WorkshopOwner)" != "$RUNTIME_OWNER" ]]; then + record_error "ECR repository $repository no longer matches workshop runtime ownership" + continue + fi + log_info "Deleting tagged runtime ECR repository: $repository" + aws_cli ecr delete-repository --repository-name "$repository" --force >/dev/null || record_error "Failed to delete ECR repository $repository" + done +} + +cleanup_security_groups() { + local group_id group_json group_resource + for group_id in "${SECURITY_GROUPS[@]}"; do + [[ -n "$group_id" ]] || continue + is_stack_resource "$group_id" && continue + if ! group_json=$(aws_cli ec2 describe-security-groups --group-ids "$group_id" --output json 2>&1); then + if [[ "$group_json" == *"InvalidGroup.NotFound"* || "$group_json" == *"not found"* ]]; then + log_info "Security group already removed: $group_id" + else + record_error "Could not verify security group state: $group_id" + fi + continue + fi + group_resource=$(jq -c '.SecurityGroups[0]' <<<"$group_json") + assert_not_owned_by_other_stack "Security group" "$group_id" "$group_resource" + log_info "Deleting security group used by removed runtime dependency: $group_id" + wait_until "security group $group_id" aws_cli ec2 delete-security-group --group-id "$group_id" || true + done +} + +request_stack_deletion() { + if (( ERRORS > 0 )); then + log_error "$ERRORS cleanup operation(s) failed; stack deletion was not requested" + exit 1 + fi + aws_cli cloudformation delete-stack --stack-name "$WORKSHOP_DEPLOYMENT_ID" + log_info "CloudFormation deletion requested for validated stack $WORKSHOP_DEPLOYMENT_ID" +} + +main() { + parse_args "$@" + require_command aws + require_command jq + require_command kubectl + + if [[ -r /etc/profile.d/workshop.sh ]]; then + # shellcheck disable=SC1091 + source /etc/profile.d/workshop.sh + fi + require_value AWS_REGION + require_value WORKSHOP_ID + require_value WORKSHOP_STACK_NAME + require_value WORKSHOP_DEPLOYMENT_ID + export AWS_PAGER="" + trap cleanup_local_state EXIT + + validate_identity + discover_resources + print_plan + if [[ "$MODE" == "plan" ]]; then + log_info "Plan only; nothing was deleted" + return + fi + + confirm_cleanup + cleanup_eks_dependencies + cleanup_ecs_dependencies + cleanup_vpc_load_balancers + cleanup_task_definitions_and_logs + cleanup_runtime_ecr + cleanup_security_groups + request_stack_deletion +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/infra/scripts/deploy/java-on-aws/5-ai-jvm-analyzer.sh b/infra/scripts/deploy/java-on-aws/5-ai-jvm-analyzer.sh index c3466548..980f45c3 100755 --- a/infra/scripts/deploy/java-on-aws/5-ai-jvm-analyzer.sh +++ b/infra/scripts/deploy/java-on-aws/5-ai-jvm-analyzer.sh @@ -1,7 +1,7 @@ #!/bin/bash # AI JVM Analyzer - Deploy continuous profiling and AI-powered analysis -# Based on: java-on-aws-immersion-day/content/analysis/ai-jvm-analyzer/ +# Based on workshop content under content/analysis/ai-jvm-analyzer/ # Covers: Continuous profiling, AI JVM Analyzer deployment, up to Grafana credentials retrieval # # Prerequisites: 1-containerize.sh and 2-eks.sh must be run first diff --git a/infra/scripts/deploy/java-on-aws/6-perf-platform-eks.sh b/infra/scripts/deploy/java-on-aws/6-perf-platform-eks.sh index 8243063e..4d772c85 100755 --- a/infra/scripts/deploy/java-on-aws/6-perf-platform-eks.sh +++ b/infra/scripts/deploy/java-on-aws/6-perf-platform-eks.sh @@ -4,7 +4,7 @@ # onboard the workload, create the latency alert rule, and drive the regression. # # Mirrors the workshop content, EKS path only: -# java-on-aws-immersion-day/content/analysis/perf-platform/ +# content/analysis/perf-platform/ # collector/ -> build + deploy the perf-collector DaemonSet # analyzer/ -> build + deploy the perf-analyzer Service # on-demand/ -> onboard unicorn-store-spring (label + annotations) diff --git a/infra/scripts/deploy/java-spring-ai-agents/00-deploy-all.sh b/infra/scripts/deploy/java-spring-ai-agents/00-deploy-all.sh new file mode 100755 index 00000000..bfe43942 --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/00-deploy-all.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=_suite-lib.sh +source "${SCRIPT_DIR}/_suite-lib.sh" + +usage() { + cat <<'EOF' +Usage: 00-deploy-all.sh --target eks|ecs|lambda|agentcore [--force] [--rotate-passwords] + +Runs shared setup, prerequisite validation, MCP deployment, Cognito, exactly one +AI-agent target, observability, and the hard-failing test suite. Cleanup is never run. +EOF +} + +TARGET="" +TARGET_COUNT=0 +FORCE=false +ROTATE=false +while (($#)); do + case "$1" in + --target) + [[ $# -ge 2 ]] || die "--target requires a value" + TARGET="$2" + ((TARGET_COUNT += 1)) + shift 2 + ;; + --force) FORCE=true; shift ;; + --rotate-passwords) ROTATE=true; shift ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; die "Unknown argument: $1" ;; + esac +done +[[ "${TARGET_COUNT}" -eq 1 ]] || { usage >&2; die "Exactly one --target is required"; } +[[ "${TARGET}" =~ ^(eks|ecs|lambda|agentcore)$ ]] || { usage >&2; die "--target must be eks, ecs, lambda, or agentcore"; } +print_prerequisites "all shared-stage tools plus the selected target's deployment tools" + +setup_args=() +security_args=() +${FORCE} && setup_args+=(--force) +${ROTATE} && security_args+=(--rotate-passwords) + +"${SCRIPT_DIR}/01-setup.sh" "${setup_args[@]}" +"${SCRIPT_DIR}/02-memory.sh" +"${SCRIPT_DIR}/03-knowledge.sh" +"${SCRIPT_DIR}/04-mcp-server.sh" +"${SCRIPT_DIR}/05-security.sh" "${security_args[@]}" +case "${TARGET}" in + eks) "${SCRIPT_DIR}/10-deploy-eks.sh" ;; + ecs) "${SCRIPT_DIR}/11-deploy-ecs.sh" ;; + lambda) "${SCRIPT_DIR}/12-deploy-lambda.sh" ;; + agentcore) "${SCRIPT_DIR}/13-deploy-agentcore.sh" ;; +esac +"${SCRIPT_DIR}/20-observability.sh" +"${SCRIPT_DIR}/30-test.sh" --target "${TARGET}" +log "Deployment and tests completed for target: ${TARGET}" diff --git a/infra/scripts/deploy/java-spring-ai-agents/01-setup.sh b/infra/scripts/deploy/java-spring-ai-agents/01-setup.sh new file mode 100755 index 00000000..a2f9bd8a --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/01-setup.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +# shellcheck source=_suite-lib.sh +source "${SCRIPT_DIR}/_suite-lib.sh" + +FORCE=false +if [[ "${1:-}" == "--force" ]]; then FORCE=true; shift; fi +(($# == 0)) || die "Usage: 01-setup.sh [--force]" +print_prerequisites "rsync and the checked-out java-on-aws seed applications" +init_context +require_cmd rsync + +AI_SEED="${REPO_ROOT}/apps/aiagent" +MCP_SEED="${REPO_ROOT}/apps/unicorn-store-spring" +[[ -d "${AI_SEED}" ]] || die "AI-agent seed not found: ${AI_SEED}" +[[ -d "${MCP_SEED}" ]] || die "MCP seed not found: ${MCP_SEED}" + +prepare_destination() { + local destination="$1" label="$2" + if [[ -d "${destination}" && "${FORCE}" != true ]]; then + log "Preserving existing ${label}: ${destination} (use --force to refresh suite-managed files)" + return 1 + fi + mkdir -p "${destination}" + return 0 +} + +if prepare_destination "${AIAGENT_DIR}" "AI-agent directory"; then + if [[ "${FORCE}" == true ]]; then rm -rf "${AIAGENT_DIR}/src/test"; fi + rsync -a --delete "${AI_SEED}/" "${AIAGENT_DIR}/" --exclude .git --exclude target --exclude src/test + mkdir -p "${AIAGENT_DIR}/src/main/java/com/example/agent" "${AIAGENT_DIR}/src/main/resources/static" + + cat > "${AIAGENT_DIR}/pom.xml" <<'EOF' + + + 4.0.0 + org.springframework.bootspring-boot-starter-parent4.1.0 + com.exampleagent0.0.1-SNAPSHOT + agentUnicorn Rentals AI Agent with Spring AI and Amazon Bedrock + 252.0.1 + + org.springframework.bootspring-boot-starter-web + org.springframework.bootspring-boot-starter-webflux + org.springframework.bootspring-boot-starter-actuator + org.springframework.bootspring-boot-starter-oauth2-resource-server + org.springframework.aispring-ai-starter-model-bedrock-converse + org.springframework.aispring-ai-starter-model-bedrock + org.springframework.aispring-ai-starter-model-chat-memory-repository-jdbc + org.springframework.aispring-ai-vector-store-advisor + org.springframework.aispring-ai-starter-vector-store-pgvector + org.springframework.aispring-ai-starter-mcp-client + org.postgresqlpostgresqlruntime + org.springframework.bootspring-boot-starter-testtest + + org.springframework.aispring-ai-bom${spring-ai.version}pomimport + + org.springframework.bootspring-boot-maven-plugin + com.google.cloud.toolsjib-maven-plugin3.5.1public.ecr.aws/docker/library/amazoncorretto:25-alpine1000 + + +EOF + + cat > "${AIAGENT_DIR}/src/main/resources/application.properties" <<'EOF' +logging.level.org.springframework.ai=DEBUG +# Modern Spring AI 2.0 Bedrock Converse properties. +spring.ai.bedrock.aws.timeout=120s +spring.ai.bedrock.converse.chat.model=global.anthropic.claude-sonnet-4-6 +spring.ai.bedrock.converse.chat.max-tokens=4096 +spring.ai.bedrock.converse.chat.temperature=0.7 +spring.ai.chat.memory.repository.jdbc.initialize-schema=always +spring.ai.model.embedding=bedrock-titan +spring.ai.bedrock.titan.embedding.model=amazon.titan-embed-text-v2:0 +spring.ai.bedrock.titan.embedding.input-type=text +spring.ai.vectorstore.pgvector.initialize-schema=true +spring.ai.vectorstore.pgvector.dimensions=1024 +spring.ai.mcp.client.toolcallback.enabled=true +spring.security.oauth2.resourceserver.jwt.issuer-uri=${COGNITO_ISSUER_URI:} +EOF + + cat > "${AIAGENT_DIR}/src/main/java/com/example/agent/InvocationRequest.java" <<'EOF' +package com.example.agent; +public record InvocationRequest(String prompt, String verificationDocument) {} +EOF + cat > "${AIAGENT_DIR}/src/main/java/com/example/agent/DateTimeTools.java" <<'EOF' +package com.example.agent; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +class DateTimeTools { + @Tool(description = "Get the current date and time in a specific time zone. Use for questions requiring current date knowledge.") + public String getCurrentDateTime(@ToolParam(description = "Time zone ID, for example Europe/Paris, America/New_York, or UTC") String timeZone) { + return ZonedDateTime.now(ZoneId.of(timeZone)).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } +} +EOF + cat > "${AIAGENT_DIR}/src/main/java/com/example/agent/WeatherTools.java" <<'EOF' +package com.example.agent; +import java.net.http.HttpClient; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestClient; +class WeatherTools { + private static final Logger log = LoggerFactory.getLogger(WeatherTools.class); + private static final ParameterizedTypeReference> MAP_TYPE = new ParameterizedTypeReference<>() {}; + private final RestClient restClient = RestClient.builder().requestFactory(new JdkClientHttpRequestFactory(HttpClient.newHttpClient())).build(); + @Tool(description = "Get the weather forecast for a city on a specific date.") + @SuppressWarnings("unchecked") + public String getWeather(@ToolParam(description = "City name") String city, @ToolParam(description = "Date in YYYY-MM-DD format") String date) { + log.info("getWeather called with city={}, date={}", city, date); + try { + var geo = restClient.get().uri("https://geocoding-api.open-meteo.com/v1/search?name={city}&count=1", city).retrieve().body(MAP_TYPE); + var results = (List>) geo.get("results"); + if (results == null || results.isEmpty()) return "City not found: " + city; + var loc = results.get(0); + var weather = restClient.get().uri("https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&daily=temperature_2m_max,temperature_2m_min&timezone=auto&start_date={startDate}&end_date={endDate}", loc.get("latitude"), loc.get("longitude"), date, date).retrieve().body(MAP_TYPE); + if (weather.containsKey("error")) return "Weather API error: " + weather.get("reason"); + var daily = (Map>) weather.get("daily"); + var units = (Map) weather.get("daily_units"); + return "Weather for %s on %s: Min: %.1f%s, Max: %.1f%s".formatted(loc.get("name"), date, daily.get("temperature_2m_min").get(0).doubleValue(), units.get("temperature_2m_min"), daily.get("temperature_2m_max").get(0).doubleValue(), units.get("temperature_2m_max")); + } catch (Exception e) { + log.error("getWeather error", e); + return "Error fetching weather: " + e.getMessage(); + } + } +} +EOF + cat > "${AIAGENT_DIR}/src/main/java/com/example/agent/ChatService.java" <<'EOF' +package com.example.agent; +import java.util.List; +import javax.sql.DataSource; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; +import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; +import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository; +import org.springframework.ai.chat.memory.repository.jdbc.PostgresChatMemoryRepositoryDialect; +import org.springframework.ai.document.Document; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +@Service +public class ChatService { + private static final String DEFAULT_SYSTEM_PROMPT = """ + You are a helpful AI assistant for Unicorn Rentals, a fictional company that rents unicorns. + Be friendly, helpful, and concise in your responses. + If you don't have information, say I don't know; do not invent it. + """; + private final ChatClient chatClient; + private final VectorStore vectorStore; + public ChatService(ChatClient.Builder builder, DataSource dataSource, VectorStore vectorStore, ToolCallbackProvider tools) { + this.vectorStore = vectorStore; + var repository = JdbcChatMemoryRepository.builder().dataSource(dataSource).dialect(new PostgresChatMemoryRepositoryDialect()).build(); + var memory = MessageWindowChatMemory.builder().chatMemoryRepository(repository).maxMessages(20).build(); + this.chatClient = builder.defaultSystem(DEFAULT_SYSTEM_PROMPT) + .defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build(), QuestionAnswerAdvisor.builder(vectorStore).build()) + .defaultTools(new DateTimeTools(), new WeatherTools()).defaultToolCallbacks(tools).build(); + } + public Flux chat(String prompt, String username) { + return chatClient.prompt().user(prompt).advisors(a -> a.param(ChatMemory.CONVERSATION_ID, username)).stream().content(); + } + public void loadDocument(String content) { vectorStore.add(List.of(new Document(content))); } +} +EOF + cat > "${AIAGENT_DIR}/src/main/java/com/example/agent/InvocationController.java" <<'EOF' +package com.example.agent; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Flux; +@RestController +@CrossOrigin(origins = "*") +public class InvocationController { + private static final int MAX_VERIFICATION_DOCUMENT_LENGTH = 4096; + private final ChatService chatService; + public InvocationController(ChatService chatService) { this.chatService = chatService; } + @PostMapping(value = "invocations", produces = MediaType.TEXT_PLAIN_VALUE) + public Flux handleInvocation(@RequestBody InvocationRequest request, @AuthenticationPrincipal Jwt jwt) { + if (request.verificationDocument() != null) { + requireAdmin(jwt); + loadVerificationDocument(request.verificationDocument()); + return Flux.just("Knowledge loaded"); + } + if (jwt == null) return chatService.chat(request.prompt(), "default"); + String visitorId = jwt.getSubject().replace("-", "").substring(0, 25); + return chatService.chat(request.prompt(), visitorId + ":" + jwt.getClaim("auth_time")); + } + @PostMapping(value = "load", consumes = MediaType.TEXT_PLAIN_VALUE) + public void loadDocument(@RequestBody String content, @AuthenticationPrincipal Jwt jwt) { + requireAdmin(jwt); + loadVerificationDocument(content); + } + private void requireAdmin(Jwt jwt) { + String username = jwt == null ? null : jwt.getClaimAsString("cognito:username"); + if (username == null && jwt != null) username = jwt.getClaimAsString("username"); + if (!"admin".equals(username)) throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + private void loadVerificationDocument(String content) { + if (content.isBlank() || content.length() > MAX_VERIFICATION_DOCUMENT_LENGTH) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Knowledge document must contain 1-4096 characters"); + } + chatService.loadDocument(content); + } +} +EOF + cat > "${AIAGENT_DIR}/src/main/java/com/example/agent/SecurityConfig.java" <<'EOF' +package com.example.agent; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +@Configuration +@EnableWebSecurity +public class SecurityConfig { + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") private String issuerUri; + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()); + http.authorizeHttpRequests(auth -> auth.requestMatchers("/", "/*.js", "/*.css", "/*.json", "/*.svg", "/*.html", "/actuator/**").permitAll()); + if (issuerUri != null && !issuerUri.isBlank()) { + http.authorizeHttpRequests(auth -> auth.requestMatchers("/invocations", "/load").authenticated().anyRequest().permitAll()) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); + } else { + http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + } + return http.build(); + } +} +EOF + state_set AIAGENT_SOURCE_READY true + log "Materialized complete AI-agent source at ${AIAGENT_DIR}" +fi + +if prepare_destination "${MCPSERVER_DIR}" "MCP-server directory"; then + if [[ "${FORCE}" == true ]]; then rm -rf "${MCPSERVER_DIR}/src/test"; fi + rsync -a --delete "${MCP_SEED}/" "${MCPSERVER_DIR}/" --exclude .git --exclude target --exclude src/test + require_cmd python3 + python3 - "${MCPSERVER_DIR}/pom.xml" <<'PY' +from pathlib import Path +import sys +p = Path(sys.argv[1]) +s = p.read_text() +if "spring-ai-bom" not in s: + marker = " \n \n" + bom = """ \n org.springframework.ai\n spring-ai-bom\n 2.0.1\n pom\n import\n \n""" + s = s.replace(marker, marker + bom, 1) +if "spring-ai-starter-mcp-server-webmvc" not in s: + marker = " " + dep = """ \n org.springframework.ai\n spring-ai-starter-mcp-server-webmvc\n \n\n""" + s = s.replace(marker, dep + marker, 1) +p.write_text(s) +PY + cat > "${MCPSERVER_DIR}/src/main/resources/application.properties" <<'EOF' +spring.ai.mcp.server.name=unicorn-store-spring +spring.ai.mcp.server.version=1.0.0 +spring.ai.mcp.server.protocol=STREAMABLE +logging.level.org.springframework.ai=DEBUG +EOF + cat > "${MCPSERVER_DIR}/src/main/java/com/unicorn/store/service/UnicornTools.java" <<'EOF' +package com.unicorn.store.service; +import com.unicorn.store.model.Unicorn; +import java.util.List; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.method.MethodToolCallbackProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; +@Component +public class UnicornTools { + private final UnicornService unicornService; + public UnicornTools(UnicornService unicornService) { this.unicornService = unicornService; } + @Bean + public ToolCallbackProvider unicornToolsProvider(UnicornTools tools) { + return MethodToolCallbackProvider.builder().toolObjects(tools).build(); + } + @Tool(description = "Create a new unicorn in the unicorn store.") + public Unicorn createUnicorn(Unicorn unicorn) { return unicornService.createUnicorn(unicorn); } + @Tool(description = "Get a list of all unicorns in the unicorn store") + public List getAllUnicorns(String... parameters) { return unicornService.getAllUnicorns(); } +} +EOF + state_set MCPSERVER_SOURCE_READY true + log "Materialized complete MCP-server source at ${MCPSERVER_DIR}" +fi + +log "Setup complete. No Git repository was initialized and no commit was created." diff --git a/infra/scripts/deploy/java-spring-ai-agents/02-memory.sh b/infra/scripts/deploy/java-spring-ai-agents/02-memory.sh new file mode 100755 index 00000000..6413535a --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/02-memory.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=_suite-lib.sh +source "${SCRIPT_DIR}/_suite-lib.sh" + +print_prerequisites "SSM, Secrets Manager, and RDS read access" +init_context + +DB_PARAMETER_NAME="workshop-db-connection-string" +DB_SECRET_ID="workshop-db-secret" +DB_CLUSTER_ID="workshop-db-cluster" + +DB_URL=$(aws_cli ssm get-parameter --name "${DB_PARAMETER_NAME}" --query 'Parameter.Value' --output text) +[[ "${DB_URL}" == jdbc:postgresql://* ]] || die "${DB_PARAMETER_NAME} is not a PostgreSQL JDBC URL" +DB_SECRET_ARN=$(aws_cli secretsmanager describe-secret --secret-id "${DB_SECRET_ID}" --query ARN --output text) +DB_CLUSTER_ARN=$(aws_cli rds describe-db-clusters --db-cluster-identifier "${DB_CLUSTER_ID}" \ + --query 'DBClusters[0].DBClusterArn' --output text) +DB_STATUS=$(aws_cli rds describe-db-clusters --db-cluster-identifier "${DB_CLUSTER_ID}" \ + --query 'DBClusters[0].Status' --output text) +[[ "${DB_STATUS}" == "available" ]] || die "Aurora cluster ${DB_CLUSTER_ID} is not available: ${DB_STATUS}" + +state_set DB_PARAMETER_NAME "${DB_PARAMETER_NAME}" +state_set DB_SECRET_ID "${DB_SECRET_ID}" +state_set DB_SECRET_ARN "${DB_SECRET_ARN}" +state_set DB_CLUSTER_ID "${DB_CLUSTER_ID}" +state_set DB_CLUSTER_ARN "${DB_CLUSTER_ARN}" +state_set DB_NAME "workshop" +state_set DB_URL "${DB_URL}" +log "Validated predeployed Aurora for JDBC conversation memory. No credentials were read or persisted." diff --git a/infra/scripts/deploy/java-spring-ai-agents/03-knowledge.sh b/infra/scripts/deploy/java-spring-ai-agents/03-knowledge.sh new file mode 100755 index 00000000..419a1124 --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/03-knowledge.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=_suite-lib.sh +source "${SCRIPT_DIR}/_suite-lib.sh" + +print_prerequisites "RDS Data API read access and Bedrock model access" +init_context +require_state DB_CLUSTER_ARN DB_SECRET_ARN DB_NAME + +RESULT=$(aws_cli rds-data execute-statement --resource-arn "${DB_CLUSTER_ARN}" \ + --secret-arn "${DB_SECRET_ARN}" --database "${DB_NAME}" \ + --sql "SELECT extversion FROM pg_extension WHERE extname = 'vector'" --include-result-metadata) +PGVECTOR_VERSION=$(jq -r '.records[0][0].stringValue // empty' <<<"${RESULT}") +[[ -n "${PGVECTOR_VERSION}" ]] || die "The pgvector extension is not installed in the predeployed Aurora database" + +state_set PGVECTOR_VERSION "${PGVECTOR_VERSION}" +state_set EMBEDDING_MODEL_ID "amazon.titan-embed-text-v2:0" +state_set EMBEDDING_DIMENSIONS "1024" +log "Validated PgVector ${PGVECTOR_VERSION} for Aurora/PgVector RAG." diff --git a/infra/scripts/deploy/java-spring-ai-agents/04-mcp-server.sh b/infra/scripts/deploy/java-spring-ai-agents/04-mcp-server.sh new file mode 100755 index 00000000..71748075 --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/04-mcp-server.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=_suite-lib.sh +source "${SCRIPT_DIR}/_suite-lib.sh" + +print_prerequisites "kubectl, docker, Maven, EKS access, and predeployed mcpserver ECR/IAM/database resources" +init_context +require_state DB_PARAMETER_NAME DB_SECRET_ID +ensure_eks_context +require_workshop_role unicornstore-eks-pod-role +[[ -f "${MCPSERVER_DIR}/pom.xml" ]] || die "MCP source not found. Run 01-setup.sh first." + +build_and_push_jib "${MCPSERVER_DIR}" mcpserver latest +IMAGE_DIGEST=$(aws_cli ecr describe-images --repository-name mcpserver --image-ids imageTag=latest \ + --query 'imageDetails[0].imageDigest' --output text) +IMAGE_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/mcpserver@${IMAGE_DIGEST}" +state_set MCP_IMAGE_URI "${IMAGE_URI}" + +if kubectl get namespace mcpserver >/dev/null 2>&1; then + [[ -n "${MCP_NAMESPACE_CREATED:-}" ]] || state_set MCP_NAMESPACE_CREATED false +else + kubectl create namespace mcpserver + state_set MCP_NAMESPACE_CREATED true + kubectl label namespace mcpserver "app.kubernetes.io/managed-by=${SUITE_OWNER}" --overwrite +fi + +if kubectl get serviceaccount mcpserver -n mcpserver >/dev/null 2>&1; then + [[ -n "${MCP_SERVICE_ACCOUNT_CREATED:-}" ]] || state_set MCP_SERVICE_ACCOUNT_CREATED false +else + kubectl create serviceaccount mcpserver -n mcpserver + state_set MCP_SERVICE_ACCOUNT_CREATED true + kubectl label serviceaccount mcpserver -n mcpserver "app.kubernetes.io/managed-by=${SUITE_OWNER}" --overwrite +fi + +upsert_pod_identity mcpserver mcpserver "arn:aws:iam::${ACCOUNT_ID}:role/unicornstore-eks-pod-role" MCP +mkdir -p "${MCPSERVER_DIR}/k8s" +backup_k8s_resource mcpserver secretproviderclass mcpserver-secrets MCP_SPC_BACKUP_PATH +backup_k8s_resource mcpserver deployment mcpserver MCP_DEPLOYMENT_BACKUP_PATH +backup_k8s_resource mcpserver service mcpserver MCP_SERVICE_BACKUP_PATH +backup_k8s_resource mcpserver ingress mcpserver MCP_INGRESS_BACKUP_PATH +cat > "${MCPSERVER_DIR}/k8s/secret-provider-class.yaml" <<'EOF' +apiVersion: secrets-store.csi.x-k8s.io/v1 +kind: SecretProviderClass +metadata: + name: mcpserver-secrets + namespace: mcpserver + labels: + app.kubernetes.io/managed-by: java-spring-ai-agents-suite +spec: + provider: aws + parameters: + usePodIdentity: "true" + objects: | + - objectName: "workshop-db-secret" + objectType: "secretsmanager" + jmesPath: + - path: "password" + objectAlias: "spring.datasource.password" + - path: "username" + objectAlias: "spring.datasource.username" + - objectName: "workshop-db-connection-string" + objectType: "ssmparameter" + objectAlias: "spring.datasource.url" +EOF +cat > "${MCPSERVER_DIR}/k8s/deployment.yaml" < "${MCPSERVER_DIR}/k8s/service.yaml" < "${MCPSERVER_DIR}/k8s/ingress.yaml" <= 8) and .Policies.PasswordPolicy.RequireUppercase and .Policies.PasswordPolicy.RequireLowercase and .Policies.PasswordPolicy.RequireNumbers' <<<"${pool}") + [[ "${compatible}" == true ]] || die "Existing ${POOL_NAME} has an incompatible password policy; refusing to replace unrelated settings" +fi +state_set COGNITO_USER_POOL_ID "${USER_POOL_ID}" + +CLIENT_ID=$(aws_cli cognito-idp list-user-pool-clients --user-pool-id "${USER_POOL_ID}" \ + --query "UserPoolClients[?ClientName=='${CLIENT_NAME}'].ClientId | [0]" --output text) +DESIRED_FLOWS='["ALLOW_USER_PASSWORD_AUTH","ALLOW_USER_SRP_AUTH","ALLOW_REFRESH_TOKEN_AUTH"]' +if is_none "${CLIENT_ID}"; then + CLIENT_ID=$(aws_cli cognito-idp create-user-pool-client --user-pool-id "${USER_POOL_ID}" \ + --client-name "${CLIENT_NAME}" --no-generate-secret \ + --explicit-auth-flows ALLOW_USER_PASSWORD_AUTH ALLOW_USER_SRP_AUTH ALLOW_REFRESH_TOKEN_AUTH \ + --query 'UserPoolClient.ClientId' --output text) + state_set COGNITO_CLIENT_CREATED true +else + [[ -n "${COGNITO_CLIENT_CREATED:-}" ]] || state_set COGNITO_CLIENT_CREATED false + current_client=$(aws_cli cognito-idp describe-user-pool-client --user-pool-id "${USER_POOL_ID}" \ + --client-id "${CLIENT_ID}" --query UserPoolClient) + [[ "$(jq -r 'has("ClientSecret")' <<<"${current_client}")" != true ]] || \ + die "Existing ${CLIENT_NAME} has a client secret; refusing to persist or replace secret-bearing client configuration" + current_input=$(jq -c --arg pool "${USER_POOL_ID}" '{UserPoolId:$pool,ClientId,ClientName,RefreshTokenValidity,AccessTokenValidity,IdTokenValidity,TokenValidityUnits,ReadAttributes,WriteAttributes,ExplicitAuthFlows,SupportedIdentityProviders,CallbackURLs,LogoutURLs,DefaultRedirectURI,AllowedOAuthFlows,AllowedOAuthScopes,AllowedOAuthFlowsUserPoolClient,AnalyticsConfiguration,PreventUserExistenceErrors,EnableTokenRevocation,EnablePropagateAdditionalUserContextData,AuthSessionValidity,RefreshTokenRotation} | with_entries(select(.value != null))' <<<"${current_client}") + current_flows=$(jq -c '.ExplicitAuthFlows // [] | sort' <<<"${current_input}") + if [[ "${current_flows}" != "$(jq -c 'sort' <<<"${DESIRED_FLOWS}")" ]]; then + [[ -n "${COGNITO_CLIENT_ORIGINAL_CONFIG_B64:-}" ]] || state_set COGNITO_CLIENT_ORIGINAL_CONFIG_B64 "$(encode_b64 "${current_input}")" + desired_input=$(jq -c --argjson flows "${DESIRED_FLOWS}" '.ExplicitAuthFlows=$flows' <<<"${current_input}") + aws_cli cognito-idp update-user-pool-client --cli-input-json "${desired_input}" >/dev/null + fi +fi +state_set COGNITO_CLIENT_ID "${CLIENT_ID}" +ISSUER_URI="https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}" +state_set COGNITO_ISSUER_URI "${ISSUER_URI}" + +created_users="${COGNITO_CREATED_USERS:-}" +append_created_user() { + local user="$1" + case ",${created_users}," in + *",${user},"*) ;; + *) created_users="${created_users:+${created_users},}${user}" ;; + esac +} +for user in admin alice bob; do + if aws_cli cognito-idp admin-get-user --user-pool-id "${USER_POOL_ID}" --username "${user}" >/dev/null 2>&1; then + if [[ "${ROTATE}" == true ]]; then + [[ -n "${IDE_PASSWORD:-}" ]] || die "IDE_PASSWORD is required with --rotate-passwords" + aws_cli cognito-idp admin-set-user-password --user-pool-id "${USER_POOL_ID}" --username "${user}" \ + --password "${IDE_PASSWORD}" --permanent >/dev/null + fi + else + [[ -n "${IDE_PASSWORD:-}" ]] || die "IDE_PASSWORD is required to create missing Cognito user ${user}" + aws_cli cognito-idp admin-create-user --user-pool-id "${USER_POOL_ID}" --username "${user}" \ + --temporary-password "${IDE_PASSWORD}" --message-action SUPPRESS >/dev/null + aws_cli cognito-idp admin-set-user-password --user-pool-id "${USER_POOL_ID}" --username "${user}" \ + --password "${IDE_PASSWORD}" --permanent >/dev/null + append_created_user "${user}" + fi +done +state_set COGNITO_CREATED_USERS "${created_users}" + +mkdir -p "${AIAGENT_DIR}/src/main/resources/static" +cat > "${AIAGENT_DIR}/src/main/resources/static/config.json" <> .gitignore -echo "*.jar" >> .gitignore -git add . -git commit -q -m "initial commit" -log_success "Application copied and initialized" - -# Update configuration -log_info "Adding MCP server configuration to application.yaml..." -yq -i '.spring.ai.mcp.server.name = "unicorn-store-spring" | - .spring.ai.mcp.server.version = "1.0.0" | - .spring.ai.mcp.server.protocol = "STREAMABLE" | - .logging.level."org.springframework.ai" = "DEBUG"' \ - ~/environment/mcpserver/src/main/resources/application.yaml -log_success "Configuration updated" - -# Add Spring AI BOM to dependencyManagement -log_info "Adding Spring AI BOM to pom.xml..." -sed -i '//,/<\/dependencyManagement>/ { - //a\ - \ - org.springframework.ai\ - spring-ai-bom\ - 1.1.2\ - pom\ - import\ - -}' ~/environment/mcpserver/pom.xml -log_success "Spring AI BOM added" - -# Add MCP server starter dependency -log_info "Adding MCP server starter dependency..." -sed -i '//i\ - \ - org.springframework.ai\ - spring-ai-starter-mcp-server-webmvc\ - -' ~/environment/mcpserver/pom.xml -log_success "MCP server starter added" - -# Create UnicornTools.java -log_info "Creating UnicornTools.java..." -cat <<'EOF' > ~/environment/mcpserver/src/main/java/com/unicorn/store/service/UnicornTools.java -package com.unicorn.store.service; - -import org.springframework.ai.tool.annotation.Tool; -import org.springframework.ai.tool.ToolCallbackProvider; -import org.springframework.ai.tool.method.MethodToolCallbackProvider; -import org.springframework.context.annotation.Bean; -import org.springframework.stereotype.Component; -import java.util.List; -import com.unicorn.store.model.Unicorn; - -@Component -public class UnicornTools { - private final UnicornService unicornService; - - public UnicornTools(UnicornService unicornService) { - this.unicornService = unicornService; - } - - @Bean - public ToolCallbackProvider unicornToolsProvider(UnicornTools unicornTools) { - return MethodToolCallbackProvider.builder() - .toolObjects(unicornTools) - .build(); - } - - @Tool(description = "Create a new unicorn in the unicorn store.") - public Unicorn createUnicorn(Unicorn unicorn) { - return unicornService.createUnicorn(unicorn); - } - - @Tool(description = "Get a list of all unicorns in the unicorn store") - public List getAllUnicorns(String... parameters) { - return unicornService.getAllUnicorns(); - } -} -EOF -log_success "UnicornTools.java created" - -# Commit changes -log_info "Committing changes..." -cd ~/environment/mcpserver -git add . -git commit -m "Add MCP server" -log_success "Changes committed" - -# ============================================================================ -# Deploy to Amazon EKS -# Based on: java-spring-ai-agents/content/deploy-mcp-server/index.en.md -# ============================================================================ - -# Build and push container image using Jib -log_info "Logging in to ECR..." -aws ecr get-login-password --region ${AWS_REGION} \ - | docker login --username AWS --password-stdin ${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com -log_success "ECR login successful" - -log_info "Building and pushing container image with Jib..." -cd ~/environment/mcpserver -mvn compile jib:build \ - -Dimage=${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/mcpserver:latest \ - -DskipTests -log_success "Container image pushed" - -# Create namespace -log_info "Creating namespace ${NAMESPACE}..." -kubectl create namespace mcpserver -log_success "Namespace created" - -# Create service account -log_info "Creating service account ${APP_NAME}..." -kubectl create serviceaccount mcpserver -n mcpserver -log_success "Service account created" - -# Create Pod Identity association -log_info "Creating Pod Identity association..." -aws eks create-pod-identity-association \ - --cluster-name workshop-eks \ - --namespace mcpserver \ - --service-account mcpserver \ - --role-arn arn:aws:iam::${ACCOUNT_ID}:role/unicornstore-eks-pod-role \ - --no-cli-pager - -# Verify Pod Identity association -log_info "Verifying Pod Identity association..." -for i in {1..10}; do - ASSOCIATION_ID=$(aws eks list-pod-identity-associations --cluster-name workshop-eks --no-cli-pager \ - | jq -r '.associations[] | select(.namespace=="mcpserver") | .associationId') - if [[ -n "${ASSOCIATION_ID}" ]]; then - break - fi - log_info "Waiting for Pod Identity association to propagate... ($i/10)" - sleep 2 -done - -if [[ -z "${ASSOCIATION_ID}" ]]; then - log_error "Pod Identity association not found after waiting" - exit 1 -fi - -aws eks describe-pod-identity-association \ - --cluster-name workshop-eks \ - --association-id ${ASSOCIATION_ID} \ - --no-cli-pager > /dev/null -log_success "Pod Identity association verified (ID: ${ASSOCIATION_ID})" - -# Create k8s directory -log_info "Creating k8s directory..." -mkdir -p ~/environment/mcpserver/k8s - -# Create and apply SecretProviderClass -log_info "Creating SecretProviderClass..." -cat < ~/environment/mcpserver/k8s/secret-provider-class.yaml -apiVersion: secrets-store.csi.x-k8s.io/v1 -kind: SecretProviderClass -metadata: - name: mcpserver-secrets - namespace: mcpserver -spec: - provider: aws - parameters: - usePodIdentity: "true" - objects: | - - objectName: "workshop-db-secret" - objectType: "secretsmanager" - jmesPath: - - path: "password" - objectAlias: "spring.datasource.password" - - path: "username" - objectAlias: "spring.datasource.username" - - objectName: "workshop-db-connection-string" - objectType: "ssmparameter" - objectAlias: "spring.datasource.url" -EOF -kubectl apply -f ~/environment/mcpserver/k8s/secret-provider-class.yaml -log_success "SecretProviderClass created" - -# Create and apply Deployment -log_info "Creating Deployment..." -ECR_URI=${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/mcpserver -cat < ~/environment/mcpserver/k8s/deployment.yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mcpserver - namespace: mcpserver - labels: - app: mcpserver -spec: - replicas: 1 - selector: - matchLabels: - app: mcpserver - template: - metadata: - labels: - app: mcpserver - spec: - serviceAccountName: mcpserver - nodeSelector: - karpenter.sh/nodepool: workshop - containers: - - name: mcpserver - image: ${ECR_URI}:latest - imagePullPolicy: Always - ports: - - containerPort: 8080 - env: - - name: SPRING_CONFIG_IMPORT - value: "optional:configtree:/mnt/secrets-store/" - resources: - requests: - cpu: "1" - memory: "2Gi" - limits: - cpu: "1" - memory: "2Gi" - livenessProbe: - httpGet: - path: /actuator/health/liveness - port: 8080 - failureThreshold: 6 - periodSeconds: 5 - readinessProbe: - httpGet: - path: /actuator/health/readiness - port: 8080 - failureThreshold: 6 - periodSeconds: 5 - initialDelaySeconds: 10 - startupProbe: - httpGet: - path: /actuator/health/liveness - port: 8080 - failureThreshold: 10 - periodSeconds: 5 - initialDelaySeconds: 20 - volumeMounts: - - name: secrets-store - mountPath: "/mnt/secrets-store" - readOnly: true - securityContext: - runAsNonRoot: true - runAsUser: 1000 - allowPrivilegeEscalation: false - lifecycle: - preStop: - exec: - command: ["sh", "-c", "sleep 10"] - volumes: - - name: secrets-store - csi: - driver: secrets-store.csi.k8s.io - readOnly: true - volumeAttributes: - secretProviderClass: mcpserver-secrets -EOF -kubectl apply -f ~/environment/mcpserver/k8s/deployment.yaml -log_success "Deployment created" - -# Create and apply Service -log_info "Creating Service..." -cat < ~/environment/mcpserver/k8s/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: mcpserver - namespace: mcpserver - labels: - app: mcpserver -spec: - type: ClusterIP - selector: - app: mcpserver - ports: - - port: 80 - targetPort: 8080 - protocol: TCP -EOF -kubectl apply -f ~/environment/mcpserver/k8s/service.yaml -log_success "Service created" - -# Create and apply Ingress (VPC-internal) -log_info "Creating Ingress (internal ALB)..." -cat < ~/environment/mcpserver/k8s/ingress.yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: mcpserver - namespace: mcpserver - annotations: - alb.ingress.kubernetes.io/scheme: internal - alb.ingress.kubernetes.io/target-type: ip - alb.ingress.kubernetes.io/healthcheck-path: /actuator/health - labels: - app: mcpserver -spec: - ingressClassName: alb - rules: - - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: mcpserver - port: - number: 80 -EOF -kubectl apply -f ~/environment/mcpserver/k8s/ingress.yaml -log_success "Ingress created" - -# Wait for deployment -log_info "Waiting for deployment to be ready..." -kubectl wait deployment mcpserver -n mcpserver --for condition=Available=True --timeout=180s -kubectl get deployment mcpserver -n mcpserver -log_success "Deployment ready" - -# Wait for ALB and test -log_info "Waiting for internal ALB to be provisioned (this may take 2-5 minutes)..." -MCP_URL=http://$(kubectl get ingress mcpserver -n mcpserver \ - -o jsonpath='{.status.loadBalancer.ingress[0].hostname}') - -while ! curl -s --max-time 5 ${MCP_URL} > /dev/null 2>&1; do - echo "Waiting for load balancer..." && sleep 15 -done - -log_success "MCP Server URL: ${MCP_URL}" - -# Test the MCP server -log_info "Testing MCP Server..." -curl -s ${MCP_URL}; echo - -log_info "Creating test unicorn..." -curl -X POST ${MCP_URL}/unicorns \ - -H "Content-Type: application/json" \ - -d '{"name": "rainbow", "age": "5", "type": "classic", "size": "medium"}'; echo -log_success "MCP Server test completed" - -# Commit k8s manifests -log_info "Committing k8s manifests..." -cd ~/environment/mcpserver -git add . -git commit -m "Add k8s manifests" -log_success "Changes committed" - -log_success "MCP Server deployment completed" -echo "✅ Success: MCP Server deployed to EKS (URL: ${MCP_URL})" diff --git a/infra/scripts/deploy/java-spring-ai-agents/10-deploy-eks.sh b/infra/scripts/deploy/java-spring-ai-agents/10-deploy-eks.sh new file mode 100755 index 00000000..213e26ca --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/10-deploy-eks.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=_suite-lib.sh +source "${SCRIPT_DIR}/_suite-lib.sh" + +print_prerequisites "kubectl, docker, Maven, and the shared 02-05 stages" +init_context +load_state +require_state MCP_URL COGNITO_ISSUER_URI DB_PARAMETER_NAME DB_SECRET_ID +ensure_eks_context +require_workshop_role aiagent-eks-pod-role +[[ -f "${AIAGENT_DIR}/pom.xml" ]] || die "AI-agent source not found. Run 01-setup.sh first." + +build_and_push_jib "${AIAGENT_DIR}" aiagent latest +IMAGE_DIGEST=$(aws_cli ecr describe-images --repository-name aiagent --image-ids imageTag=latest \ + --query 'imageDetails[0].imageDigest' --output text) +IMAGE_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/aiagent@${IMAGE_DIGEST}" +state_set EKS_IMAGE_URI "${IMAGE_URI}" + +if kubectl get namespace aiagent >/dev/null 2>&1; then + [[ -n "${EKS_NAMESPACE_CREATED:-}" ]] || state_set EKS_NAMESPACE_CREATED false +else + kubectl create namespace aiagent + state_set EKS_NAMESPACE_CREATED true + kubectl label namespace aiagent "app.kubernetes.io/managed-by=${SUITE_OWNER}" --overwrite +fi +if kubectl get serviceaccount aiagent -n aiagent >/dev/null 2>&1; then + [[ -n "${EKS_SERVICE_ACCOUNT_CREATED:-}" ]] || state_set EKS_SERVICE_ACCOUNT_CREATED false +else + kubectl create serviceaccount aiagent -n aiagent + state_set EKS_SERVICE_ACCOUNT_CREATED true + kubectl label serviceaccount aiagent -n aiagent "app.kubernetes.io/managed-by=${SUITE_OWNER}" --overwrite +fi +upsert_pod_identity aiagent aiagent "arn:aws:iam::${ACCOUNT_ID}:role/aiagent-eks-pod-role" EKS + +mkdir -p "${AIAGENT_DIR}/k8s" +backup_k8s_resource aiagent secretproviderclass aiagent-secrets EKS_SPC_BACKUP_PATH +backup_k8s_resource aiagent deployment aiagent EKS_DEPLOYMENT_BACKUP_PATH +backup_k8s_resource aiagent service aiagent EKS_SERVICE_BACKUP_PATH +backup_k8s_resource aiagent ingress aiagent EKS_INGRESS_BACKUP_PATH +cat > "${AIAGENT_DIR}/k8s/secret-provider-class.yaml" <<'EOF' +apiVersion: secrets-store.csi.x-k8s.io/v1 +kind: SecretProviderClass +metadata: + name: aiagent-secrets + namespace: aiagent + labels: {app.kubernetes.io/managed-by: java-spring-ai-agents-suite} +spec: + provider: aws + parameters: + usePodIdentity: "true" + objects: | + - objectName: "workshop-db-secret" + objectType: "secretsmanager" + jmesPath: + - {path: "password", objectAlias: "spring.datasource.password"} + - {path: "username", objectAlias: "spring.datasource.username"} + - objectName: "workshop-db-connection-string" + objectType: "ssmparameter" + objectAlias: "spring.datasource.url" +EOF +cat > "${AIAGENT_DIR}/k8s/deployment.yaml" < "${AIAGENT_DIR}/k8s/service.yaml" < "${AIAGENT_DIR}/k8s/ingress.yaml" < "${backup_file}" + else + jq -c '{image,containerPort,awsLogsConfiguration,repositoryCredentials,command,environment,secrets} | with_entries(select(.value != null))' \ + <<<"${CURRENT_CONTAINER}" > "${backup_file}" + fi + chmod 600 "${backup_file}" + state_set ECS_ORIGINAL_PRIMARY_CONTAINER_PATH "${backup_file}" + state_unset ECS_ORIGINAL_PRIMARY_CONTAINER_B64 +elif [[ ! -f "${ECS_ORIGINAL_PRIMARY_CONTAINER_PATH}" ]]; then + die "ECS restore snapshot is missing: ${ECS_ORIGINAL_PRIMARY_CONTAINER_PATH}" +fi + +aws_cli ecs update-service --cluster aiagent --service aiagent --deployment-configuration \ + '{"maximumPercent":200,"minimumHealthyPercent":0,"bakeTimeInMinutes":0,"canaryConfiguration":{"canaryPercent":100,"canaryBakeTimeInMinutes":0}}' >/dev/null +DESIRED_ENV=$(jq -c --arg mcp "${MCP_URL}" --arg issuer "${COGNITO_ISSUER_URI}" ' + map(select(.name != "SPRING_AI_MCP_CLIENT_STREAMABLEHTTP_CONNECTIONS_SERVER1_URL" and .name != "SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI")) + + [{name:"SPRING_AI_MCP_CLIENT_STREAMABLEHTTP_CONNECTIONS_SERVER1_URL",value:$mcp},{name:"SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI",value:$issuer}]' <<<"${CURRENT_ENV}") +PRIMARY=$(jq -c --arg image "${IMAGE_URI}" --argjson env "${DESIRED_ENV}" '{image,containerPort,awsLogsConfiguration,repositoryCredentials,command,secrets} | with_entries(select(.value != null)) | .image=$image | .environment=$env' <<<"${CURRENT_CONTAINER}") +aws_cli ecs update-express-gateway-service --service-arn "${SERVICE_ARN}" --primary-container "${PRIMARY}" >/dev/null + +stable=false +for i in {1..40}; do + SERVICE_STATUS=$(aws_cli ecs describe-services --cluster aiagent --services aiagent --query 'services[0]') + deployments=$(jq '.deployments | length' <<<"${SERVICE_STATUS}") + running=$(jq -r '.runningCount' <<<"${SERVICE_STATUS}") + desired=$(jq -r '.desiredCount' <<<"${SERVICE_STATUS}") + active_image=$(aws_cli ecs describe-express-gateway-service --service-arn "${SERVICE_ARN}" \ + --query 'service.activeConfigurations[0].primaryContainer.image' --output text) + if [[ "${deployments}" == "1" && "${running}" == "${desired}" && "${active_image}" == "${IMAGE_URI}" ]]; then stable=true; break; fi + log "Waiting for ECS deployment (${i}/40): deployments=${deployments}, running=${running}/${desired}" + ((i == 40)) || sleep 15 +done +[[ "${stable}" == true ]] || die "ECS deployment did not stabilize on image ${IMAGE_URI}" +ENDPOINT_HOST=$(aws_cli ecs describe-express-gateway-service --service-arn "${SERVICE_ARN}" \ + --query 'service.activeConfigurations[0].ingressPaths[0].endpoint' --output text) +[[ -n "${ENDPOINT_HOST}" && "${ENDPOINT_HOST}" != "None" ]] || die "ECS Express endpoint is unavailable" +AIAGENT_ENDPOINT="https://${ENDPOINT_HOST}" +wait_for_http_status "ECS AI-agent health" "${AIAGENT_ENDPOINT}/actuator/health" '^(200)$' 30 10 +state_set ECS_SERVICE_ARN "${SERVICE_ARN}" +state_set ECS_IMAGE_URI "${IMAGE_URI}" +state_set ACTIVE_TARGET ecs +state_set AIAGENT_ENDPOINT "${AIAGENT_ENDPOINT}" +log "AI agent updated on the precreated ECS service: ${AIAGENT_ENDPOINT}. The service is never deleted by this suite." diff --git a/infra/scripts/deploy/java-spring-ai-agents/12-deploy-lambda.sh b/infra/scripts/deploy/java-spring-ai-agents/12-deploy-lambda.sh new file mode 100755 index 00000000..69a79932 --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/12-deploy-lambda.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=_suite-lib.sh +source "${SCRIPT_DIR}/_suite-lib.sh" + +print_prerequisites "Maven, zip, S3/Lambda/EC2 access, and the shared 02-05 stages" +init_context +load_state +require_cmd mvn +require_cmd zip +require_state MCP_URL COGNITO_ISSUER_URI DB_PARAMETER_NAME DB_SECRET_ID +require_workshop_role aiagent-lambda-role +[[ -f "${AIAGENT_DIR}/pom.xml" ]] || die "AI-agent source not found. Run 01-setup.sh first." + +tmp_dir=$(mktemp -d "${WORK_DIR}/lambda.XXXXXX") +trap 'rm -rf "${tmp_dir}"' EXIT +cat > "${AIAGENT_DIR}/run.sh" <<'EOF' +#!/usr/bin/env bash +exec java -jar agent-0.0.1-SNAPSHOT.jar +EOF +chmod +x "${AIAGENT_DIR}/run.sh" +(cd "${AIAGENT_DIR}" && mvn -ntp clean package -DskipTests) +cp "${AIAGENT_DIR}/target/agent-0.0.1-SNAPSHOT.jar" "${AIAGENT_DIR}/run.sh" "${tmp_dir}/" +(cd "${tmp_dir}" && zip -q aiagent-deployment.zip agent-0.0.1-SNAPSHOT.jar run.sh) + +WORKSHOP_BUCKET=$(aws_cli ssm get-parameter --name workshop-bucket-name --query 'Parameter.Value' --output text) +S3_KEY="lambda/aiagent-deployment.zip" +if [[ -z "${LAMBDA_PACKAGE_PREEXISTED:-}" ]]; then + if aws_cli s3api head-object --bucket "${WORKSHOP_BUCKET}" --key "${S3_KEY}" >/dev/null 2>&1; then + LAMBDA_PACKAGE_BACKUP_KEY="lambda/aiagent-deployment.pre-${SUITE_OWNER}.zip" + aws_cli s3api copy-object --bucket "${WORKSHOP_BUCKET}" --key "${LAMBDA_PACKAGE_BACKUP_KEY}" \ + --copy-source "${WORKSHOP_BUCKET}/${S3_KEY}" >/dev/null + state_set LAMBDA_PACKAGE_PREEXISTED true + state_set LAMBDA_PACKAGE_BACKUP_KEY "${LAMBDA_PACKAGE_BACKUP_KEY}" + else + state_set LAMBDA_PACKAGE_PREEXISTED false + fi +fi +aws_cli s3 cp "${tmp_dir}/aiagent-deployment.zip" "s3://${WORKSHOP_BUCKET}/${S3_KEY}" --only-show-errors +state_set LAMBDA_S3_KEY "${S3_KEY}" +state_set LAMBDA_PACKAGE_UPLOADED true + +ROLE_ARN=$(aws_cli iam get-role --role-name aiagent-lambda-role --query 'Role.Arn' --output text) +VPC_ID=$(aws_cli ssm get-parameter --name workshop-vpc-id --query 'Parameter.Value' --output text 2>/dev/null || true) +if is_none "${VPC_ID}"; then + VPC_ID=$(aws_cli ec2 describe-vpcs --filters Name=tag:Name,Values=workshop-vpc --query 'Vpcs[0].VpcId' --output text) +fi +SUBNET_JSON=$(aws_cli ec2 describe-subnets --filters "Name=vpc-id,Values=${VPC_ID}" "Name=tag:aws-cdk:subnet-type,Values=Private" \ + --query 'Subnets[*].SubnetId' --output json) +[[ "$(jq length <<<"${SUBNET_JSON}")" -gt 0 ]] || die "No private subnets found in ${VPC_ID}" +SG_ID=$(aws_cli ec2 describe-security-groups --filters "Name=vpc-id,Values=${VPC_ID}" "Name=group-name,Values=aiagent-lambda-sg" \ + --query 'SecurityGroups[0].GroupId' --output text) +if is_none "${SG_ID}"; then + SG_ID=$(aws_cli ec2 create-security-group --group-name aiagent-lambda-sg \ + --description "AI Agent Lambda security group managed by ${SUITE_OWNER}" --vpc-id "${VPC_ID}" --query GroupId --output text) + aws_cli ec2 create-tags --resources "${SG_ID}" --tags "Key=suite,Value=${SUITE_OWNER}" >/dev/null + state_set LAMBDA_SG_CREATED true +else + [[ -n "${LAMBDA_SG_CREATED:-}" ]] || state_set LAMBDA_SG_CREATED false +fi +state_set LAMBDA_SG_ID "${SG_ID}" + +DB_URL=$(aws_cli ssm get-parameter --name "${DB_PARAMETER_NAME}" --query Parameter.Value --output text) +DB_JSON=$(aws_cli secretsmanager get-secret-value --secret-id "${DB_SECRET_ID}" --query SecretString --output text) +DB_USER=$(jq -r .username <<<"${DB_JSON}") +DB_PASS=$(jq -r .password <<<"${DB_JSON}") +ENV_FILE="${tmp_dir}/environment.json" +jq -n --arg db_url "${DB_URL}" --arg db_user "${DB_USER}" --arg db_pass "${DB_PASS}" \ + --arg mcp "${MCP_URL}" --arg issuer "${COGNITO_ISSUER_URI}" \ + '{Variables:{PORT:"8080",AWS_LWA_ENABLE_COMPRESSION:"false",SPRING_PROFILES_ACTIVE:"lambda",AWS_LAMBDA_EXEC_WRAPPER:"/opt/bootstrap",AWS_LWA_INVOKE_MODE:"response_stream",SPRING_DATASOURCE_URL:$db_url,SPRING_DATASOURCE_USERNAME:$db_user,SPRING_DATASOURCE_PASSWORD:$db_pass,SPRING_AI_MCP_CLIENT_STREAMABLEHTTP_CONNECTIONS_SERVER1_URL:$mcp,SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:$issuer}}' > "${ENV_FILE}" +chmod 600 "${ENV_FILE}" +unset DB_JSON DB_USER DB_PASS +VPC_CONFIG=$(jq -nc --argjson subnets "${SUBNET_JSON}" --arg sg "${SG_ID}" '{SubnetIds:$subnets,SecurityGroupIds:[$sg]}') +LAYER_ARN="arn:aws:lambda:${AWS_REGION}:753240598075:layer:LambdaAdapterLayerX86:25" + +if aws_cli lambda get-function --function-name aiagent >/dev/null 2>&1; then + if [[ "${LAMBDA_CREATED:-}" != true && -z "${LAMBDA_BACKUP_VERSION:-}" ]]; then + state_set LAMBDA_CREATED false + BACKUP_VERSION=$(aws_cli lambda publish-version --function-name aiagent \ + --description "Pre-${SUITE_OWNER} backup" --query Version --output text) + state_set LAMBDA_BACKUP_VERSION "${BACKUP_VERSION}" + fi + aws_cli lambda update-function-code --function-name aiagent --s3-bucket "${WORKSHOP_BUCKET}" --s3-key "${S3_KEY}" >/dev/null + aws_cli lambda wait function-updated-v2 --function-name aiagent + aws_cli lambda update-function-configuration --function-name aiagent --runtime java25 --role "${ROLE_ARN}" \ + --handler run.sh --timeout 60 --memory-size 2048 --layers "${LAYER_ARN}" \ + --environment "file://${ENV_FILE}" --vpc-config "${VPC_CONFIG}" >/dev/null + aws_cli lambda wait function-updated-v2 --function-name aiagent +else + aws_cli lambda create-function --function-name aiagent --runtime java25 --role "${ROLE_ARN}" --handler run.sh \ + --code "S3Bucket=${WORKSHOP_BUCKET},S3Key=${S3_KEY}" --timeout 60 --memory-size 2048 \ + --layers "${LAYER_ARN}" --environment "file://${ENV_FILE}" --vpc-config "${VPC_CONFIG}" \ + --tags "suite=${SUITE_OWNER}" >/dev/null + state_set LAMBDA_CREATED true + aws_cli lambda wait function-active-v2 --function-name aiagent +fi + +CORS='AllowOrigins=*,AllowMethods=*,AllowHeaders=date,keep-alive,x-custom-header,content-type,ExposeHeaders=date,keep-alive,MaxAge=86400' +if URL_CONFIG=$(aws_cli lambda get-function-url-config --function-name aiagent 2>/dev/null); then + if [[ -z "${LAMBDA_URL_ORIGINAL_B64:-}" ]]; then + state_set LAMBDA_URL_CREATED false + state_set LAMBDA_URL_ORIGINAL_B64 "$(encode_b64 "$(jq -c '{AuthType,InvokeMode,Cors}' <<<"${URL_CONFIG}")")" + fi + aws_cli lambda update-function-url-config --function-name aiagent --auth-type NONE --invoke-mode RESPONSE_STREAM --cors "${CORS}" >/dev/null +else + aws_cli lambda create-function-url-config --function-name aiagent --auth-type NONE --invoke-mode RESPONSE_STREAM --cors "${CORS}" >/dev/null + state_set LAMBDA_URL_CREATED true +fi + +POLICY=$(aws_cli lambda get-policy --function-name aiagent --query Policy --output text 2>/dev/null || printf '{"Statement":[]}') +if ! jq -e '.Statement[]? | select(.Sid == "FunctionURLAllowPublicAccess")' >/dev/null <<<"${POLICY}"; then + aws_cli lambda add-permission --function-name aiagent --statement-id FunctionURLAllowPublicAccess \ + --action lambda:InvokeFunctionUrl --principal '*' --function-url-auth-type NONE >/dev/null + state_set LAMBDA_PERMISSION_URL_CREATED true +fi +if ! jq -e '.Statement[]? | select(.Sid == "FunctionURLPublicInvoke")' >/dev/null <<<"${POLICY}"; then + aws_cli lambda add-permission --function-name aiagent --statement-id FunctionURLPublicInvoke \ + --action lambda:InvokeFunction --principal '*' --invoked-via-function-url >/dev/null + state_set LAMBDA_PERMISSION_INVOKE_CREATED true +fi + +AIAGENT_ENDPOINT=$(aws_cli lambda get-function-url-config --function-name aiagent --query FunctionUrl --output text) +wait_for_http_status "Lambda AI-agent" "${AIAGENT_ENDPOINT}" '^(200)$' 30 10 +state_set ACTIVE_TARGET lambda +state_set AIAGENT_ENDPOINT "${AIAGENT_ENDPOINT%/}" +log "AI agent created or updated on Lambda using s3://${WORKSHOP_BUCKET}/${S3_KEY}: ${AIAGENT_ENDPOINT}" diff --git a/infra/scripts/deploy/java-spring-ai-agents/13-deploy-agentcore.sh b/infra/scripts/deploy/java-spring-ai-agents/13-deploy-agentcore.sh new file mode 100755 index 00000000..1f69651a --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/13-deploy-agentcore.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=_suite-lib.sh +source "${SCRIPT_DIR}/_suite-lib.sh" + +print_prerequisites "docker buildx, Python 3, ECR, AgentCore control-plane, S3, and CloudFront access" +init_context +load_state +require_cmd docker +require_cmd python3 +require_cmd rsync +require_state MCP_URL COGNITO_USER_POOL_ID COGNITO_CLIENT_ID DB_PARAMETER_NAME DB_SECRET_ID +require_workshop_role aiagent-agentcore-runtime-role +ensure_ecr_repository aiagent +[[ -f "${AIAGENT_DIR}/pom.xml" ]] || die "AI-agent source not found. Run 01-setup.sh first." + +BUILD_DIR="${WORK_DIR}/agentcore-build" +mkdir -p "${BUILD_DIR}" +rsync -a --delete "${AIAGENT_DIR}/" "${BUILD_DIR}/" --exclude .git --exclude target --exclude k8s +python3 - "${BUILD_DIR}/pom.xml" <<'PY' +from pathlib import Path +import sys +p = Path(sys.argv[1]) +s = p.read_text() +if "spring-ai-agentcore-bom" not in s: + marker = "" + bom = "org.springaicommunityspring-ai-agentcore-bom2.1.0pomimport" + if marker not in s: + raise SystemExit("dependencyManagement marker not found") + s = s.replace(marker, marker + bom, 1) +if "spring-ai-agentcore-runtime-starter" not in s: + marker = " " + dep = "\n org.springaicommunityspring-ai-agentcore-runtime-starter" + if marker not in s: + raise SystemExit("dependencies marker not found") + s = s.replace(marker, marker + dep, 1) +p.write_text(s) +PY +cat > "${BUILD_DIR}/src/main/java/com/example/agent/InvocationService.java" <<'EOF' +package com.example.agent; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.springaicommunity.agentcore.annotation.AgentCoreInvocation; +import org.springaicommunity.agentcore.context.AgentCoreContext; +import org.springaicommunity.agentcore.context.AgentCoreHeaders; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +@Service +public class InvocationService { + private static final int MAX_VERIFICATION_DOCUMENT_LENGTH = 4096; + private final ChatService chatService; + private final ObjectMapper objectMapper = new ObjectMapper(); + public InvocationService(ChatService chatService) { this.chatService = chatService; } + @AgentCoreInvocation + public Flux handleInvocation(InvocationRequest request, AgentCoreContext context) throws Exception { + String authorization = context.getHeader(AgentCoreHeaders.AUTHORIZATION); + String jwt = authorization.replace("Bearer ", ""); + String payload = new String(Base64.getUrlDecoder().decode(jwt.split("\\.")[1]), StandardCharsets.UTF_8); + JsonNode claims = objectMapper.readTree(payload); + String username = claims.path("cognito:username").asText(claims.path("username").asText()); + if (request.verificationDocument() != null) { + if (!"admin".equals(username)) throw new SecurityException("Only the workshop administrator can load verification knowledge"); + String document = request.verificationDocument(); + if (document.isBlank() || document.length() > MAX_VERIFICATION_DOCUMENT_LENGTH) { + throw new IllegalArgumentException("Knowledge document must contain 1-4096 characters"); + } + chatService.loadDocument(document); + return Flux.just("Knowledge loaded"); + } + String visitorId = claims.get("sub").asText().replace("-", "").substring(0, 25); + return chatService.chat(request.prompt(), visitorId + ":" + claims.get("auth_time").asText()); + } +} +EOF +cat > "${BUILD_DIR}/Dockerfile" <<'EOF' +FROM public.ecr.aws/docker/library/maven:3-amazoncorretto-25-al2023 AS builder +COPY pom.xml pom.xml +COPY src src +RUN rm -rf src/main/resources/static && mvn -ntp clean package -DskipTests && mv target/agent-0.0.1-SNAPSHOT.jar app.jar +FROM public.ecr.aws/docker/library/amazoncorretto:25-al2023 +RUN yum install -y shadow-utils && yum clean all && groupadd --system spring -g 1000 && adduser spring -u 1000 -g 1000 +COPY --from=builder app.jar /app.jar +USER 1000:1000 +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "/app.jar"] +EOF + +REGISTRY="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" +ECR_URI="${REGISTRY}/aiagent:latest" +aws_cli ecr get-login-password | docker login --username AWS --password-stdin "${REGISTRY}" +if ! docker buildx inspect java-spring-ai-agents-suite >/dev/null 2>&1; then + docker buildx create --name java-spring-ai-agents-suite --driver docker-container >/dev/null +fi +docker buildx build --builder java-spring-ai-agents-suite --platform linux/arm64 -t "${ECR_URI}" --push "${BUILD_DIR}" +IMAGE_DIGEST=$(aws_cli ecr describe-images --repository-name aiagent --image-ids imageTag=latest \ + --query 'imageDetails[0].imageDigest' --output text) +CONTAINER_URI="${REGISTRY}/aiagent@${IMAGE_DIGEST}" +state_set AGENTCORE_IMAGE_URI "${CONTAINER_URI}" + +VPC_ID=$(aws_cli ssm get-parameter --name workshop-vpc-id --query Parameter.Value --output text 2>/dev/null || true) +if is_none "${VPC_ID}"; then + VPC_ID=$(aws_cli ec2 describe-vpcs --filters Name=tag:Name,Values=workshop-vpc --query 'Vpcs[0].VpcId' --output text) +fi +if [[ "${AWS_REGION}" == "us-east-1" ]]; then + SUBNET_JSON=$(aws_cli ec2 describe-subnets --filters "Name=vpc-id,Values=${VPC_ID}" \ + "Name=tag:aws-cdk:subnet-type,Values=Private" "Name=availability-zone-id,Values=use1-az1,use1-az2,use1-az4" \ + --query 'Subnets[*].SubnetId' --output json) +else + warn "AgentCore supported Availability Zones vary by Region; using all workshop private subnets for ${AWS_REGION}" + SUBNET_JSON=$(aws_cli ec2 describe-subnets --filters "Name=vpc-id,Values=${VPC_ID}" \ + "Name=tag:aws-cdk:subnet-type,Values=Private" --query 'Subnets[*].SubnetId' --output json) +fi +[[ "$(jq length <<<"${SUBNET_JSON}")" -gt 0 ]] || die "No AgentCore-compatible private subnets found" +SG_ID=$(aws_cli ec2 describe-security-groups --filters "Name=vpc-id,Values=${VPC_ID}" "Name=group-name,Values=workshop-db-sg" \ + --query 'SecurityGroups[0].GroupId' --output text) +[[ -n "${SG_ID}" && "${SG_ID}" != "None" ]] || die "Required workshop-db-sg was not found" +NETWORK=$(jq -nc --argjson subnets "${SUBNET_JSON}" --arg sg "${SG_ID}" '{networkMode:"VPC",networkModeConfig:{subnets:$subnets,securityGroups:[$sg]}}') +DISCOVERY_URL="https://cognito-idp.${AWS_REGION}.amazonaws.com/${COGNITO_USER_POOL_ID}/.well-known/openid-configuration" +AUTHORIZER=$(jq -nc --arg url "${DISCOVERY_URL}" --arg client "${COGNITO_CLIENT_ID}" '{customJWTAuthorizer:{discoveryUrl:$url,allowedClients:[$client]}}') +HEADERS='{"requestHeaderAllowlist":["Authorization"]}' +ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/aiagent-agentcore-runtime-role" +DB_URL=$(aws_cli ssm get-parameter --name "${DB_PARAMETER_NAME}" --query Parameter.Value --output text) +DB_JSON=$(aws_cli secretsmanager get-secret-value --secret-id "${DB_SECRET_ID}" --query SecretString --output text) +DB_USER=$(jq -r .username <<<"${DB_JSON}") +DB_PASS=$(jq -r .password <<<"${DB_JSON}") +DESIRED_ENV=$(jq -nc --arg db_url "${DB_URL}" --arg db_user "${DB_USER}" --arg db_pass "${DB_PASS}" --arg mcp "${MCP_URL}" \ + '{SPRING_DATASOURCE_URL:$db_url,SPRING_DATASOURCE_USERNAME:$db_user,SPRING_DATASOURCE_PASSWORD:$db_pass,SPRING_AI_MCP_CLIENT_STREAMABLEHTTP_CONNECTIONS_SERVER1_URL:$mcp}') +unset DB_JSON DB_USER DB_PASS + +RUNTIME_NAME="aiagent" +RUNTIME_ID=$(aws_cli bedrock-agentcore-control list-agent-runtimes \ + --query "agentRuntimes[?agentRuntimeName=='${RUNTIME_NAME}'].agentRuntimeId | [0]" --output text) +if is_none "${RUNTIME_ID}"; then + RUNTIME_ID=$(aws_cli bedrock-agentcore-control create-agent-runtime --agent-runtime-name "${RUNTIME_NAME}" --role-arn "${ROLE_ARN}" \ + --agent-runtime-artifact "{\"containerConfiguration\":{\"containerUri\":\"${CONTAINER_URI}\"}}" \ + --network-configuration "${NETWORK}" --authorizer-configuration "${AUTHORIZER}" \ + --request-header-configuration "${HEADERS}" --environment-variables "${DESIRED_ENV}" \ + --tags "suite=${SUITE_OWNER}" --query agentRuntimeId --output text) + state_set AGENTCORE_RUNTIME_CREATED true +else + [[ "${AGENTCORE_RUNTIME_CREATED:-}" == true ]] || \ + die "Runtime ${RUNTIME_NAME} exists but is not recorded as suite-created in ${STATE_FILE}; refusing to update it" + aws_cli bedrock-agentcore-control update-agent-runtime --agent-runtime-id "${RUNTIME_ID}" --role-arn "${ROLE_ARN}" \ + --agent-runtime-artifact "{\"containerConfiguration\":{\"containerUri\":\"${CONTAINER_URI}\"}}" \ + --network-configuration "${NETWORK}" --authorizer-configuration "${AUTHORIZER}" \ + --request-header-configuration "${HEADERS}" --environment-variables "${DESIRED_ENV}" >/dev/null +fi +state_set AGENTCORE_RUNTIME_ID "${RUNTIME_ID}" + +status="" +for i in {1..60}; do + status=$(aws_cli bedrock-agentcore-control get-agent-runtime --agent-runtime-id "${RUNTIME_ID}" --query status --output text) + [[ "${status}" == "READY" ]] && break + [[ "${status}" == "FAILED" ]] && die "AgentCore Runtime entered FAILED state" + log "Waiting for AgentCore Runtime: ${status} (${i}/60)" + ((i == 60)) || sleep 10 +done +[[ "${status}" == "READY" ]] || die "AgentCore Runtime did not become READY" +RUNTIME_ARN="arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:runtime/${RUNTIME_ID}" +ENCODED_ARN=$(printf '%s' "${RUNTIME_ARN}" | jq -sRr @uri) +AIAGENT_ENDPOINT="https://bedrock-agentcore.${AWS_REGION}.amazonaws.com/runtimes/${ENCODED_ARN}/invocations?qualifier=DEFAULT" +state_set AGENTCORE_LOG_GROUP "/aws/bedrock-agentcore/runtimes/${RUNTIME_ID}-DEFAULT" + +UI_BUCKET="aiagent-ui-${ACCOUNT_ID}-${AWS_REGION}" +if aws_cli s3api head-bucket --bucket "${UI_BUCKET}" >/dev/null 2>&1; then + if [[ "${AGENTCORE_UI_BUCKET_CREATED:-}" != true ]]; then + tags=$(aws_cli s3api get-bucket-tagging --bucket "${UI_BUCKET}" --query 'TagSet' --output json 2>/dev/null || printf '[]') + jq -e --arg owner "${SUITE_OWNER}" '.[] | select(.Key == "suite" and .Value == $owner)' >/dev/null <<<"${tags}" || \ + die "UI bucket ${UI_BUCKET} exists but is not owned by this suite" + fi + state_set AGENTCORE_UI_BUCKET_CREATED true +else + if [[ "${AWS_REGION}" == "us-east-1" ]]; then + aws_cli s3api create-bucket --bucket "${UI_BUCKET}" >/dev/null + else + aws_cli s3api create-bucket --bucket "${UI_BUCKET}" --create-bucket-configuration "LocationConstraint=${AWS_REGION}" >/dev/null + fi + aws_cli s3api put-public-access-block --bucket "${UI_BUCKET}" \ + --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true >/dev/null + aws_cli s3api put-bucket-tagging --bucket "${UI_BUCKET}" --tagging "TagSet=[{Key=suite,Value=${SUITE_OWNER}}]" >/dev/null + state_set AGENTCORE_UI_BUCKET_CREATED true +fi +state_set AGENTCORE_UI_BUCKET "${UI_BUCKET}" + +OAI_COMMENT="${SUITE_OWNER}-aiagent-ui" +OAI_ID=$(aws_cli cloudfront list-cloud-front-origin-access-identities \ + --query "CloudFrontOriginAccessIdentityList.Items[?Comment=='${OAI_COMMENT}'].Id | [0]" --output text) +if is_none "${OAI_ID}"; then + OAI_ID=$(aws_cli cloudfront create-cloud-front-origin-access-identity \ + --cloud-front-origin-access-identity-config "CallerReference=${SUITE_OWNER}-$(date +%s),Comment=${OAI_COMMENT}" \ + --query CloudFrontOriginAccessIdentity.Id --output text) + state_set AGENTCORE_OAI_CREATED true +else + state_set AGENTCORE_OAI_CREATED true +fi +state_set AGENTCORE_OAI_ID "${OAI_ID}" +OAI_CANONICAL=$(aws_cli cloudfront get-cloud-front-origin-access-identity --id "${OAI_ID}" \ + --query CloudFrontOriginAccessIdentity.S3CanonicalUserId --output text) +POLICY=$(jq -nc --arg user "${OAI_CANONICAL}" --arg bucket "${UI_BUCKET}" '{Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{CanonicalUser:$user},Action:"s3:GetObject",Resource:("arn:aws:s3:::"+$bucket+"/*")}]}' ) +aws_cli s3api put-bucket-policy --bucket "${UI_BUCKET}" --policy "${POLICY}" + +DIST_COMMENT="${SUITE_OWNER}-aiagent-ui" +DIST_ID=$(aws_cli cloudfront list-distributions --query "DistributionList.Items[?Comment=='${DIST_COMMENT}'].Id | [0]" --output text) +ORIGIN_DOMAIN="${UI_BUCKET}.s3.${AWS_REGION}.amazonaws.com" +if is_none "${DIST_ID}"; then + DIST_FILE="${WORK_DIR}/cloudfront-create.json" + jq -n --arg caller "${SUITE_OWNER}-$(date +%s)" --arg comment "${DIST_COMMENT}" --arg bucket "${UI_BUCKET}" \ + --arg domain "${ORIGIN_DOMAIN}" --arg oai "origin-access-identity/cloudfront/${OAI_ID}" '{CallerReference:$caller,Comment:$comment,Enabled:true,DefaultRootObject:"index.html",Origins:{Quantity:1,Items:[{Id:("S3-"+$bucket),DomainName:$domain,S3OriginConfig:{OriginAccessIdentity:$oai}}]},DefaultCacheBehavior:{TargetOriginId:("S3-"+$bucket),ViewerProtocolPolicy:"redirect-to-https",AllowedMethods:{Quantity:2,Items:["GET","HEAD"],CachedMethods:{Quantity:2,Items:["GET","HEAD"]}},ForwardedValues:{QueryString:false,Cookies:{Forward:"none"}},MinTTL:0,DefaultTTL:300,MaxTTL:86400,Compress:true},CustomErrorResponses:{Quantity:1,Items:[{ErrorCode:403,ResponsePagePath:"/index.html",ResponseCode:"200",ErrorCachingMinTTL:10}]},PriceClass:"PriceClass_100"}' > "${DIST_FILE}" + created=$(aws_cli cloudfront create-distribution --distribution-config "file://${DIST_FILE}") + DIST_ID=$(jq -r '.Distribution.Id' <<<"${created}") + state_set AGENTCORE_DISTRIBUTION_CREATED true +else + state_set AGENTCORE_DISTRIBUTION_CREATED true + current_file="${WORK_DIR}/cloudfront-current.json" + desired_file="${WORK_DIR}/cloudfront-update.json" + aws_cli cloudfront get-distribution-config --id "${DIST_ID}" > "${current_file}" + etag=$(jq -r .ETag "${current_file}") + jq --arg comment "${DIST_COMMENT}" --arg bucket "${UI_BUCKET}" --arg domain "${ORIGIN_DOMAIN}" \ + --arg oai "origin-access-identity/cloudfront/${OAI_ID}" '.DistributionConfig | .Comment=$comment | .Enabled=true | .DefaultRootObject="index.html" | .Origins={Quantity:1,Items:[{Id:("S3-"+$bucket),DomainName:$domain,S3OriginConfig:{OriginAccessIdentity:$oai}}]} | .DefaultCacheBehavior.TargetOriginId=("S3-"+$bucket)' "${current_file}" > "${desired_file}" + aws_cli cloudfront update-distribution --id "${DIST_ID}" --if-match "${etag}" --distribution-config "file://${desired_file}" >/dev/null +fi +state_set AGENTCORE_DISTRIBUTION_ID "${DIST_ID}" + +cat > "${AIAGENT_DIR}/src/main/resources/static/config.json" </dev/null + +cf_status="" +for i in {1..60}; do + cf_status=$(aws_cli cloudfront get-distribution --id "${DIST_ID}" --query Distribution.Status --output text) + [[ "${cf_status}" == "Deployed" ]] && break + log "Waiting for CloudFront distribution: ${cf_status} (${i}/60)" + ((i == 60)) || sleep 15 +done +[[ "${cf_status}" == "Deployed" ]] || die "CloudFront distribution did not deploy" +CF_DOMAIN=$(aws_cli cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text) +wait_for_http_status "AgentCore UI" "https://${CF_DOMAIN}" '^(200)$' 20 10 +state_set AGENTCORE_UI_ENDPOINT "https://${CF_DOMAIN}" +state_set ACTIVE_TARGET agentcore +state_set AIAGENT_ENDPOINT "${AIAGENT_ENDPOINT}" +log "AgentCore Runtime created or updated: ${RUNTIME_ID}" +log "AgentCore UI created or updated: https://${CF_DOMAIN}" diff --git a/infra/scripts/deploy/java-spring-ai-agents/2-cognito.sh b/infra/scripts/deploy/java-spring-ai-agents/2-cognito.sh deleted file mode 100755 index 92bf8713..00000000 --- a/infra/scripts/deploy/java-spring-ai-agents/2-cognito.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/bin/bash - -# Cognito - Create Amazon Cognito User Pool, client, and test users -# Based on: java-spring-ai-agents/content/security/index.en.md -# Note: This script only sets up Cognito infrastructure, does not modify the application - -# Source common utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../../lib/common.sh" - -# Source environment variables -source /etc/profile.d/workshop.sh - -log_info "Setting up Amazon Cognito for AI Agent..." -log_info "AWS Account: ${ACCOUNT_ID}" -log_info "AWS Region: ${AWS_REGION}" - -# Create User Pool -log_info "Creating Amazon Cognito User Pool..." -USER_POOL_ID=$(aws cognito-idp create-user-pool \ - --pool-name "aiagent-user-pool" \ - --policies '{ - "PasswordPolicy": { - "MinimumLength": 8, - "RequireUppercase": true, - "RequireLowercase": true, - "RequireNumbers": true, - "RequireSymbols": false - } - }' \ - --auto-verified-attributes email \ - --username-configuration '{"CaseSensitive": false}' \ - --region ${AWS_REGION} \ - --no-cli-pager \ - --query 'UserPool.Id' --output text) -log_success "User Pool created: ${USER_POOL_ID}" - -# Create app client -log_info "Creating app client..." -USER_POOL_ID=$(aws cognito-idp list-user-pools --max-results 60 --no-cli-pager \ - --query "UserPools[?Name=='aiagent-user-pool'].Id | [0]" --output text) - -CLIENT_ID=$(aws cognito-idp create-user-pool-client \ - --user-pool-id "${USER_POOL_ID}" \ - --client-name "aiagent-client" \ - --no-generate-secret \ - --explicit-auth-flows ALLOW_USER_PASSWORD_AUTH ALLOW_USER_SRP_AUTH ALLOW_REFRESH_TOKEN_AUTH \ - --region ${AWS_REGION} \ - --no-cli-pager \ - --query 'UserPoolClient.ClientId' --output text) -log_success "App client created: ${CLIENT_ID}" - -# Create test users -log_info "Creating test users (admin, alice, bob)..." -USER_POOL_ID=$(aws cognito-idp list-user-pools --max-results 60 --no-cli-pager \ - --query "UserPools[?Name=='aiagent-user-pool'].Id | [0]" --output text) - -for USER in admin alice bob; do - aws cognito-idp admin-create-user \ - --user-pool-id "${USER_POOL_ID}" \ - --username "${USER}" \ - --temporary-password "${IDE_PASSWORD}" \ - --message-action SUPPRESS \ - --region ${AWS_REGION} \ - --no-cli-pager - - aws cognito-idp admin-set-user-password \ - --user-pool-id "${USER_POOL_ID}" \ - --username "${USER}" \ - --password "${IDE_PASSWORD}" \ - --permanent \ - --region ${AWS_REGION} \ - --no-cli-pager -done -log_success "Test users created: admin, alice, bob" - -# Create config file for UI -log_info "Creating UI config file..." -USER_POOL_ID=$(aws cognito-idp list-user-pools --max-results 60 --no-cli-pager \ - --query "UserPools[?Name=='aiagent-user-pool'].Id | [0]" --output text) -CLIENT_ID=$(aws cognito-idp list-user-pool-clients --user-pool-id "${USER_POOL_ID}" --no-cli-pager \ - --query "UserPoolClients[?ClientName=='aiagent-client'].ClientId | [0]" --output text) - -mkdir -p ~/environment/aiagent/src/main/resources/static -cat > ~/environment/aiagent/src/main/resources/static/config.json << EOF -{ - "userPoolId": "${USER_POOL_ID}", - "clientId": "${CLIENT_ID}", - "apiEndpoint": "invocations" -} -EOF -log_success "UI config file created" - -# Output summary -log_info "Cognito configuration summary:" -echo " User Pool ID: ${USER_POOL_ID}" -echo " Client ID: ${CLIENT_ID}" -echo " Issuer URI: https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}" -echo " Test users: admin, alice, bob (password: \${IDE_PASSWORD})" - -log_success "Amazon Cognito setup completed" -echo "✅ Success: Cognito User Pool and test users created" -echo "Test users: admin, alice, bob" -echo "Password: ${IDE_PASSWORD}" diff --git a/infra/scripts/deploy/java-spring-ai-agents/20-observability.sh b/infra/scripts/deploy/java-spring-ai-agents/20-observability.sh new file mode 100755 index 00000000..0579fa43 --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/20-observability.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=_suite-lib.sh +source "${SCRIPT_DIR}/_suite-lib.sh" + +print_prerequisites "Bedrock logging, CloudWatch Logs, S3, IAM, and SSM access" +init_context +require_workshop_role workshop-bedrock-logging-role + +LOG_GROUP="/aws/bedrock/model-invocations" +if aws_cli logs describe-log-groups --log-group-name-prefix "${LOG_GROUP}" \ + --query "logGroups[?logGroupName=='${LOG_GROUP}'].logGroupName | [0]" --output text | grep -qx "${LOG_GROUP}"; then + [[ -n "${BEDROCK_LOG_GROUP_CREATED:-}" ]] || state_set BEDROCK_LOG_GROUP_CREATED false +else + aws_cli logs create-log-group --log-group-name "${LOG_GROUP}" + state_set BEDROCK_LOG_GROUP_CREATED true +fi + +WORKSHOP_BUCKET=$(aws_cli ssm get-parameter --name workshop-bucket-name --query Parameter.Value --output text) +ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/workshop-bedrock-logging-role" +DESIRED=$(jq -nc --arg group "${LOG_GROUP}" --arg role "${ROLE_ARN}" --arg bucket "${WORKSHOP_BUCKET}" \ + '{loggingConfig:{cloudWatchConfig:{logGroupName:$group,roleArn:$role,largeDataDeliveryS3Config:{bucketName:$bucket,keyPrefix:"bedrock-logs"}},s3Config:{bucketName:$bucket,keyPrefix:"bedrock-logs"},textDataDeliveryEnabled:true,imageDataDeliveryEnabled:true,embeddingDataDeliveryEnabled:true}}') +CURRENT=$(aws_cli bedrock get-model-invocation-logging-configuration) +if [[ -z "${BEDROCK_LOGGING_ORIGINAL_B64:-}" ]]; then + if [[ -n "${CURRENT}" && "$(jq -r '.loggingConfig // empty' <<<"${CURRENT}")" != "" ]]; then + state_set BEDROCK_LOGGING_ORIGINAL_B64 "$(encode_b64 "$(jq -c '.loggingConfig' <<<"${CURRENT}")")" + else + state_set BEDROCK_LOGGING_ORIGINAL_B64 __NONE__ + fi +fi +if [[ "$(jq -S '.loggingConfig' <<<"${CURRENT:-{}}")" != "$(jq -S '.loggingConfig' <<<"${DESIRED}")" ]]; then + config_file="${WORK_DIR}/bedrock-logging.json" + printf '%s\n' "${DESIRED}" > "${config_file}" + aws_cli bedrock put-model-invocation-logging-configuration --cli-input-json "file://${config_file}" +fi +state_set BEDROCK_LOG_GROUP "${LOG_GROUP}" +state_set BEDROCK_LOGGING_CONFIGURED true +log "Bedrock model invocation logging is configured idempotently for CloudWatch Logs and the workshop S3 bucket." diff --git a/infra/scripts/deploy/java-spring-ai-agents/3-app.sh b/infra/scripts/deploy/java-spring-ai-agents/3-app.sh deleted file mode 100755 index fa96698f..00000000 --- a/infra/scripts/deploy/java-spring-ai-agents/3-app.sh +++ /dev/null @@ -1,426 +0,0 @@ -#!/bin/bash - -# AI Agent Application - Create complete application with all features -# Based on: create + persona + memory + knowledge + tools + mcp-client + security modules -# Creates final state of all files ready for deployment or local run - -# Source common utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../../lib/common.sh" - -# Source environment variables -source /etc/profile.d/workshop.sh - -APP_DIR=~/environment/aiagent - -log_info "Creating AI Agent application..." -log_info "AWS Account: ${ACCOUNT_ID}" -log_info "AWS Region: ${AWS_REGION}" - -# ============================================================================ -# Generate project with Spring Initializr -# ============================================================================ -log_info "Generating project with Spring Initializr..." -cd ~/environment/ -curl -s https://start.spring.io/starter.zip \ - -d type=maven-project \ - -d language=java \ - -d packaging=jar \ - -d javaVersion=25 \ - -d bootVersion=3.5.9 \ - -d baseDir=aiagent \ - -d groupId=com.example \ - -d artifactId=agent \ - -d name=agent \ - -d description='AI Agent with Spring AI and Amazon Bedrock' \ - -d dependencies=spring-ai-bedrock-converse,web,webflux,actuator \ - -o aiagent.zip - -unzip -q aiagent.zip -rm aiagent.zip -log_success "Project generated" - -# ============================================================================ -# application.properties - Final state with all configurations -# ============================================================================ -log_info "Creating application.properties..." -cat <<'EOF' > ~/environment/aiagent/src/main/resources/application.properties -logging.level.org.springframework.ai=DEBUG - -# Amazon Bedrock Configuration -spring.ai.bedrock.converse.chat.options.model=global.anthropic.claude-sonnet-4-20250514-v1:0 -spring.ai.bedrock.converse.chat.options.max-tokens=4096 - -# JDBC Memory Configuration -spring.ai.chat.memory.repository.jdbc.initialize-schema=always - -# RAG Configuration -spring.ai.model.embedding=bedrock-titan -spring.ai.bedrock.titan.embedding.model=amazon.titan-embed-text-v2:0 -spring.ai.bedrock.titan.embedding.input-type=text -spring.ai.vectorstore.pgvector.initialize-schema=true -spring.ai.vectorstore.pgvector.dimensions=1024 - -# MCP Client Configuration -spring.ai.mcp.client.toolcallback.enabled=true - -# Security Configuration -spring.security.oauth2.resourceserver.jwt.issuer-uri=${COGNITO_ISSUER_URI:} -EOF -log_success "application.properties created" - -# ============================================================================ -# pom.xml - Add all dependencies -# ============================================================================ -log_info "Adding dependencies to pom.xml..." - -# Add Security dependencies -sed -i '0,//{//a\ - \ - \ - org.springframework.boot\ - spring-boot-starter-oauth2-resource-server\ - -}' ~/environment/aiagent/pom.xml - -# Add MCP Client dependencies -sed -i '0,//{//a\ - \ - \ - org.springframework.ai\ - spring-ai-starter-mcp-client\ - -}' ~/environment/aiagent/pom.xml - -# Add RAG Dependencies -sed -i '0,//{//a\ - \ - \ - org.springframework.ai\ - spring-ai-advisors-vector-store\ - \ - \ - org.springframework.ai\ - spring-ai-starter-vector-store-pgvector\ - \ - \ - org.springframework.ai\ - spring-ai-starter-model-bedrock\ - -}' ~/environment/aiagent/pom.xml - -# Add JDBC Memory dependencies -sed -i '0,//{//a\ - \ - \ - org.springframework.ai\ - spring-ai-starter-model-chat-memory-repository-jdbc\ - \ - \ - org.postgresql\ - postgresql\ - runtime\ - -}' ~/environment/aiagent/pom.xml - -log_success "Dependencies added" - -# ============================================================================ -# Java source files - Final state -# ============================================================================ -log_info "Creating Java source files..." - -# InvocationRequest.java -cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/InvocationRequest.java -package com.example.agent; - -public record InvocationRequest(String prompt) {} -EOF - -# DateTimeTools.java -cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/DateTimeTools.java -package com.example.agent; - -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import org.springframework.ai.tool.annotation.Tool; -import org.springframework.ai.tool.annotation.ToolParam; - -class DateTimeTools { - - @Tool(description = """ - Get the current date and time in a specific time zone. - Use for answering questions requiring date time knowledge, - like today, tomorrow, next week, next month. - """) - public String getCurrentDateTime( - @ToolParam(description = "Time zone ID, e.g. Europe/Paris, America/New_York, UTC") - String timeZone) { - return ZonedDateTime.now(ZoneId.of(timeZone)) - .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); - } -} -EOF - -# WeatherTools.java -cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/WeatherTools.java -package com.example.agent; - -import java.net.http.HttpClient; -import java.util.List; -import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.ai.tool.annotation.Tool; -import org.springframework.ai.tool.annotation.ToolParam; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.client.JdkClientHttpRequestFactory; -import org.springframework.web.client.RestClient; - -class WeatherTools { - - private static final Logger log = LoggerFactory.getLogger(WeatherTools.class); - private static final ParameterizedTypeReference> MAP_TYPE = - new ParameterizedTypeReference<>() {}; - private final RestClient restClient = RestClient.builder() - .requestFactory(new JdkClientHttpRequestFactory(HttpClient.newHttpClient())) - .build(); - - @Tool(description = """ - Get weather forecast for a city on a specific date. - Use for answering questions about weather forecasts. - """) - public String getWeather( - @ToolParam(description = "City name, e.g. Paris, London, New York") String city, - @ToolParam(description = "Date in YYYY-MM-DD format, e.g. 2025-01-27") String date) { - log.info("getWeather called with city={}, date={}", city, date); - try { - var geo = restClient.get() - .uri("https://geocoding-api.open-meteo.com/v1/search?name={city}&count=1", city) - .retrieve().body(MAP_TYPE); - - var results = (List>) geo.get("results"); - if (results == null || results.isEmpty()) return "City not found: " + city; - - var loc = results.get(0); - var weather = restClient.get() - .uri("https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" + - "&daily=temperature_2m_max,temperature_2m_min&timezone=auto" + - "&start_date={startDate}&end_date={endDate}", - loc.get("latitude"), loc.get("longitude"), date, date) - .retrieve().body(MAP_TYPE); - - if (weather.containsKey("error")) { - var error = "Weather API error: " + weather.get("reason"); - log.warn(error); - return error; - } - - var daily = (Map>) weather.get("daily"); - var units = (Map) weather.get("daily_units"); - - var result = "Weather for %s on %s: Min: %.1f%s, Max: %.1f%s".formatted( - loc.get("name"), date, - daily.get("temperature_2m_min").get(0).doubleValue(), units.get("temperature_2m_min"), - daily.get("temperature_2m_max").get(0).doubleValue(), units.get("temperature_2m_max")); - log.info("getWeather result: {}", result); - return result; - } catch (Exception e) { - log.error("getWeather error", e); - return "Error fetching weather: " + e.getMessage(); - } - } -} -EOF - -# SecurityConfig.java -cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/SecurityConfig.java -package com.example.agent; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") - private String issuerUri; - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.csrf(csrf -> csrf.disable()); - http.authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/*.js", "/*.css", "/*.json", "/*.svg", "/*.html").permitAll() - .requestMatchers("/actuator/**").permitAll() - ); - - if (issuerUri != null && !issuerUri.isBlank()) { - http.authorizeHttpRequests(auth -> auth - .requestMatchers("/invocations").authenticated() - .anyRequest().permitAll()) - .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); - } else { - http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); - } - - return http.build(); - } -} -EOF - -# ChatService.java - Final state with all features -cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java -package com.example.agent; - -import org.springframework.ai.chat.client.ChatClient; -import org.springframework.stereotype.Service; -import reactor.core.publisher.Flux; -import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; -import org.springframework.ai.chat.memory.ChatMemory; -import org.springframework.ai.chat.memory.MessageWindowChatMemory; -import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository; -import org.springframework.ai.chat.memory.repository.jdbc.PostgresChatMemoryRepositoryDialect; -import javax.sql.DataSource; -import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; -import org.springframework.ai.document.Document; -import org.springframework.ai.vectorstore.VectorStore; -import java.util.List; -import org.springframework.ai.tool.ToolCallbackProvider; - -@Service -public class ChatService { - - private static final String DEFAULT_SYSTEM_PROMPT = """ - You are a helpful AI assistant for Unicorn Rentals, a fictional company that rents unicorns. - Be friendly, helpful, and concise in your responses. - If you don't have information, say I don't know, don't think up. - """; - - private final ChatClient chatClient; - private final VectorStore vectorStore; - - public ChatService(ChatClient.Builder chatClientBuilder, DataSource dataSource, VectorStore vectorStore, ToolCallbackProvider tools) { - - this.vectorStore = vectorStore; - - var chatMemoryRepository = JdbcChatMemoryRepository.builder() - .dataSource(dataSource) - .dialect(new PostgresChatMemoryRepositoryDialect()) - .build(); - - var chatMemory = MessageWindowChatMemory.builder() - .chatMemoryRepository(chatMemoryRepository) - .maxMessages(20) - .build(); - - this.chatClient = chatClientBuilder - .defaultSystem(DEFAULT_SYSTEM_PROMPT) - .defaultAdvisors( - MessageChatMemoryAdvisor.builder(chatMemory).build(), - QuestionAnswerAdvisor.builder(vectorStore).build() - ) - .defaultTools(new DateTimeTools(), new WeatherTools()) - .defaultToolCallbacks(tools) - .build(); - } - - public Flux chat(String prompt, String username) { - return chatClient.prompt().user(prompt) - .advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, username)) - .stream().content(); - } - - public void loadDocument(String content) { - vectorStore.add(List.of(new Document(content))); - } -} -EOF - -# InvocationController.java - Final state with security and /load endpoint -cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/InvocationController.java -package com.example.agent; - -import org.springframework.http.MediaType; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.web.bind.annotation.*; -import reactor.core.publisher.Flux; - -@RestController -@CrossOrigin(origins = "*") -public class InvocationController { - private final ChatService chatService; - - public InvocationController(ChatService chatService) { - this.chatService = chatService; - } - - @PostMapping(value = "invocations", produces = MediaType.TEXT_PLAIN_VALUE) - public Flux handleInvocation( - @RequestBody InvocationRequest request, - @AuthenticationPrincipal Jwt jwt) { - if (jwt == null) { - return chatService.chat(request.prompt(), "default"); - } - String visitorId = jwt.getSubject().replace("-", "").substring(0, 25); - String sessionId = jwt.getClaim("auth_time").toString(); - return chatService.chat(request.prompt(), visitorId + ":" + sessionId); - } - - @PostMapping(value = "load", consumes = MediaType.TEXT_PLAIN_VALUE) - public void loadDocument(@RequestBody String content) { - chatService.loadDocument(content); - } -} -EOF - -log_success "Java source files created" - -# ============================================================================ -# Static files and config -# ============================================================================ -log_info "Copying static files..." -cp ~/java-on-aws/apps/aiagent/src/main/resources/static/* \ - ~/environment/aiagent/src/main/resources/static/ - -# Create Cognito config.json (if Cognito exists) -USER_POOL_ID=$(aws cognito-idp list-user-pools --max-results 60 --no-cli-pager \ - --query "UserPools[?Name=='aiagent-user-pool'].Id | [0]" --output text 2>/dev/null || echo "") - -if [[ -n "${USER_POOL_ID}" && "${USER_POOL_ID}" != "None" ]]; then - CLIENT_ID=$(aws cognito-idp list-user-pool-clients --user-pool-id "${USER_POOL_ID}" --no-cli-pager \ - --query "UserPoolClients[?ClientName=='aiagent-client'].ClientId | [0]" --output text) - - cat > ~/environment/aiagent/src/main/resources/static/config.json << EOF -{ - "userPoolId": "${USER_POOL_ID}", - "clientId": "${CLIENT_ID}", - "apiEndpoint": "invocations" -} -EOF - log_success "Cognito config.json created" -else - log_info "Cognito not configured, skipping config.json" -fi - -# ============================================================================ -# Initialize Git repository -# ============================================================================ -log_info "Initializing Git repository..." -cd ~/environment/aiagent -git config --global user.email "workshop-user@example.com" -git config --global user.name "workshop-user" -git init -b main -git add . -git commit -q -m "Create AI Agent with all features" -log_success "Git repository initialized" - -log_success "AI Agent application created" -echo "✅ Success: AI Agent ready at ~/environment/aiagent" diff --git a/infra/scripts/deploy/java-spring-ai-agents/30-test.sh b/infra/scripts/deploy/java-spring-ai-agents/30-test.sh new file mode 100755 index 00000000..55124dca --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/30-test.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=_suite-lib.sh +source "${SCRIPT_DIR}/_suite-lib.sh" + +TARGET="" +while (($#)); do + case "$1" in + --target) [[ $# -ge 2 ]] || die "--target requires a value"; TARGET="$2"; shift 2 ;; + -h|--help) echo "Usage: 30-test.sh [--target eks|ecs|lambda|agentcore]"; exit 0 ;; + *) die "Unknown argument: $1" ;; + esac +done +print_prerequisites "curl, Cognito credentials in IDE_PASSWORD, and a deployed target" +init_context +load_state +TARGET="${TARGET:-${ACTIVE_TARGET:-}}" +[[ "${TARGET}" =~ ^(eks|ecs|lambda|agentcore)$ ]] || die "No valid target selected" +[[ "${ACTIVE_TARGET:-}" == "${TARGET}" ]] || die "State endpoint belongs to ${ACTIVE_TARGET:-none}, not ${TARGET}" +require_state AIAGENT_ENDPOINT COGNITO_CLIENT_ID COGNITO_USER_POOL_ID MCP_SAMPLE_NAME +[[ -n "${IDE_PASSWORD:-}" ]] || die "IDE_PASSWORD is required to authenticate test user alice" +require_cmd curl + +AUTH=$(aws_cli cognito-idp initiate-auth --client-id "${COGNITO_CLIENT_ID}" --auth-flow USER_PASSWORD_AUTH \ + --auth-parameters "USERNAME=alice,PASSWORD=${IDE_PASSWORD}" --query AuthenticationResult --output json) +ADMIN_AUTH=$(aws_cli cognito-idp initiate-auth --client-id "${COGNITO_CLIENT_ID}" --auth-flow USER_PASSWORD_AUTH \ + --auth-parameters "USERNAME=admin,PASSWORD=${IDE_PASSWORD}" --query AuthenticationResult --output json) +if [[ "${TARGET}" == agentcore ]]; then + TOKEN=$(jq -r '.AccessToken // empty' <<<"${AUTH}") + ADMIN_TOKEN=$(jq -r '.AccessToken // empty' <<<"${ADMIN_AUTH}") + INVOKE_URL="${AIAGENT_ENDPOINT}" + status=$(aws_cli bedrock-agentcore-control get-agent-runtime --agent-runtime-id "${AGENTCORE_RUNTIME_ID}" --query status --output text) + [[ "${status}" == READY ]] || die "AgentCore health check failed: ${status}" +else + TOKEN=$(jq -r '.IdToken // empty' <<<"${AUTH}") + ADMIN_TOKEN=$(jq -r '.IdToken // empty' <<<"${ADMIN_AUTH}") + INVOKE_URL="${AIAGENT_ENDPOINT%/}/invocations" + HEALTH=$(curl --fail-with-body -sS --connect-timeout 10 --max-time 30 "${AIAGENT_ENDPOINT%/}/actuator/health") + [[ "$(jq -r '.status // empty' <<<"${HEALTH}")" == UP ]] || die "Health endpoint did not report UP" +fi +[[ -n "${TOKEN}" && -n "${ADMIN_TOKEN}" ]] || die "Cognito authentication returned no user or administrator token" + +USER_INVOKE_HEADERS=(-H 'Accept: text/plain, text/event-stream') +ADMIN_INVOKE_HEADERS=(-H 'Accept: text/plain, text/event-stream') +if [[ "${TARGET}" == agentcore ]]; then + require_cmd python3 + USER_RUNTIME_SESSION_ID=$(python3 -c 'import uuid; print(uuid.uuid4())') + ADMIN_RUNTIME_SESSION_ID=$(python3 -c 'import uuid; print(uuid.uuid4())') + USER_INVOKE_HEADERS+=(-H "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id: ${USER_RUNTIME_SESSION_ID}") + ADMIN_INVOKE_HEADERS+=(-H "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id: ${ADMIN_RUNTIME_SESSION_ID}") +fi + +unauth_status=$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 10 --max-time 30 -X POST "${INVOKE_URL}" \ + -H 'Content-Type: application/json' -d '{"prompt":"authentication check"}' || true) +[[ "${unauth_status}" == 401 || "${unauth_status}" == 403 ]] || die "Unauthenticated invocation returned HTTP ${unauth_status}, expected 401 or 403" +log "Health and authentication checks passed" +log "Behavioral tests: persona, conversation memory, PgVector RAG, date/time tool, and MCP inventory" + +tmp_dir=$(mktemp -d "${WORK_DIR}/tests.XXXXXX") +trap 'rm -rf "${tmp_dir}"' EXIT +invoke() { + local name="$1" prompt="$2" output + output="${tmp_dir}/${name}.txt" + curl --fail-with-body -sS -N --connect-timeout 10 --max-time 180 -X POST "${INVOKE_URL}" \ + -H 'Content-Type: application/json' -H "Authorization: Bearer ${TOKEN}" \ + "${USER_INVOKE_HEADERS[@]}" \ + --data "$(jq -nc --arg prompt "${prompt}" '{prompt:$prompt}')" > "${output}" + if [[ "${TARGET}" == agentcore ]]; then + sed 's/^data:[[:space:]]*//' "${output}" | tr -d '\r' > "${output}.normalized" + mv "${output}.normalized" "${output}" + fi + [[ -s "${output}" ]] || die "${name} invocation returned an empty response" + printf '%s' "${output}" +} +assert_matches() { + local file="$1" regex="$2" description="$3" response_preview + if ! grep -Eiq "${regex}" "${file}"; then + response_preview=$(tr '\n' ' ' < "${file}" | cut -c1-500) + warn "${description} response: ${response_preview}" + die "${description} response lacked expected capability evidence" + fi +} + +log "Testing persona: Who are you?" +file=$(invoke persona "Who are you?") +assert_matches "${file}" 'unicorn|rental' "Persona" +log "Persona check passed" + +log "Testing conversation memory with two turns" +invoke memory_store "My name is Alex. Please remember it." >/dev/null +file=$(invoke memory_recall "What is my name?") +assert_matches "${file}" '(^|[^[:alpha:]])Alex([^[:alpha:]]|$)' "Conversation memory" +log "Conversation memory check passed" + +log "Testing PgVector RAG" +RAG_MARKER="rag-${SUITE_OWNER}-${ACCOUNT_ID}-${AWS_REGION}-v1" +file=$(invoke rag_existing "According to the Unicorn Rentals verification archive, what exact archive marker is associated with unicorn origins?") +if ! grep -Fqi -- "${RAG_MARKER}" "${file}"; then + RAG_DOCUMENT="Unicorn Rentals verification archive marker ${RAG_MARKER}: unicorn traditions include Chinese Qilin, Indian seals, and Greek accounts." + curl --fail-with-body -sS -N --connect-timeout 10 --max-time 180 -X POST "${INVOKE_URL}" \ + -H 'Content-Type: application/json' -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + "${ADMIN_INVOKE_HEADERS[@]}" \ + --data "$(jq -nc --arg prompt "Load verification knowledge." --arg document "${RAG_DOCUMENT}" \ + '{prompt:$prompt,verificationDocument:$document}')" >/dev/null + for attempt in {1..6}; do + file=$(invoke "rag_${attempt}" "According to the Unicorn Rentals verification archive, what exact archive marker is associated with unicorn origins?") + grep -Fqi -- "${RAG_MARKER}" "${file}" && break + ((attempt == 6)) || sleep 5 + done +else + log "Stable RAG verification marker is already retrievable; skipping document insertion." +fi +assert_matches "${file}" "${RAG_MARKER}" "PgVector RAG" +log "PgVector RAG check passed" + +log "Testing date/time tool" +utc_before=$(date -u +%Y-%m-%dT%H:%M) +file=$(invoke tools "Use the date and time tool to report the current UTC timestamp. Reply with an ISO 8601 timestamp in YYYY-MM-DDTHH:MM:SSZ form.") +utc_after=$(date -u +%Y-%m-%dT%H:%M) +assert_matches "${file}" "(${utc_before}|${utc_after}):[0-5][0-9]Z" "Date/time tool" +log "Date/time tool check passed" + +log "Testing MCP Unicorn inventory" +file=$(invoke mcp "Use the Unicorn Store tools and list the available unicorns, including their names.") +assert_matches "${file}" "${MCP_SAMPLE_NAME}|suite.unicorn|classic.small" "MCP" +log "MCP inventory check passed" + +log "All hard-failing checks passed: health, auth, persona, memory, RAG, tools, and MCP." diff --git a/infra/scripts/deploy/java-spring-ai-agents/4-app-local.sh b/infra/scripts/deploy/java-spring-ai-agents/4-app-local.sh deleted file mode 100755 index dd18697e..00000000 --- a/infra/scripts/deploy/java-spring-ai-agents/4-app-local.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/bin/bash - -# AI Agent Local Run - Start application locally with full security, MCP, and database -# Requires: 1-mcp-server.sh (MCP server on EKS), 2-cognito.sh (Cognito), 3-app.sh (application) - -# Source common utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../../lib/common.sh" - -# Source environment variables -source /etc/profile.d/workshop.sh - -APP_DIR=~/environment/aiagent - -log_info "Starting AI Agent locally with full configuration..." -log_info "AWS Account: ${ACCOUNT_ID}" -log_info "AWS Region: ${AWS_REGION}" - -# ============================================================================ -# Get MCP Server URL from EKS -# ============================================================================ -log_info "Getting MCP Server URL from EKS..." -MCP_URL=http://$(kubectl get ingress mcpserver -n mcpserver \ - -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' 2>/dev/null || echo "") - -if [[ -z "${MCP_URL}" || "${MCP_URL}" == "http://" ]]; then - log_error "MCP Server not found on EKS. Run 1-mcp-server.sh first." - exit 1 -fi - -# Verify MCP server is accessible -if ! curl -s --max-time 5 ${MCP_URL} > /dev/null 2>&1; then - log_error "MCP Server at ${MCP_URL} is not responding" - exit 1 -fi -log_success "MCP Server URL: ${MCP_URL}" - -# ============================================================================ -# Get Cognito configuration -# ============================================================================ -log_info "Getting Cognito configuration..." -USER_POOL_ID=$(aws cognito-idp list-user-pools --max-results 60 --no-cli-pager \ - --query "UserPools[?Name=='aiagent-user-pool'].Id | [0]" --output text 2>/dev/null || echo "") - -if [[ -z "${USER_POOL_ID}" || "${USER_POOL_ID}" == "None" ]]; then - log_error "Cognito User Pool not found. Run 2-cognito.sh first." - exit 1 -fi - -COGNITO_ISSUER_URI="https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}" -log_success "Cognito Issuer URI: ${COGNITO_ISSUER_URI}" - -# ============================================================================ -# Get database credentials -# ============================================================================ -log_info "Getting database credentials..." -SPRING_DATASOURCE_URL=$(aws ssm get-parameter --name workshop-db-connection-string --no-cli-pager \ - | jq --raw-output '.Parameter.Value') -SPRING_DATASOURCE_USERNAME=$(aws secretsmanager get-secret-value --secret-id workshop-db-secret --no-cli-pager \ - | jq --raw-output '.SecretString' | jq -r .username) -SPRING_DATASOURCE_PASSWORD=$(aws secretsmanager get-secret-value --secret-id workshop-db-secret --no-cli-pager \ - | jq --raw-output '.SecretString' | jq -r .password) -log_success "Database credentials retrieved" - -# ============================================================================ -# Verify application exists -# ============================================================================ -if [[ ! -d "${APP_DIR}" ]]; then - log_error "AI Agent application not found at ${APP_DIR}. Run 3-app.sh first." - exit 1 -fi - -# ============================================================================ -# Start the application -# ============================================================================ -log_info "Starting AI Agent application..." -log_info "Configuration:" -echo " MCP Server: ${MCP_URL}" -echo " Cognito: ${COGNITO_ISSUER_URI}" -echo " Database: ${SPRING_DATASOURCE_URL}" - -cd ${APP_DIR} - -export SPRING_DATASOURCE_URL -export SPRING_DATASOURCE_USERNAME -export SPRING_DATASOURCE_PASSWORD -export COGNITO_ISSUER_URI -export SPRING_AI_MCP_CLIENT_STREAMABLEHTTP_CONNECTIONS_SERVER1_URL=${MCP_URL} - -log_info "Running: ./mvnw spring-boot:run" -./mvnw spring-boot:run diff --git a/infra/scripts/deploy/java-spring-ai-agents/5-eks.sh b/infra/scripts/deploy/java-spring-ai-agents/5-eks.sh deleted file mode 100755 index 7ae07c66..00000000 --- a/infra/scripts/deploy/java-spring-ai-agents/5-eks.sh +++ /dev/null @@ -1,311 +0,0 @@ -#!/bin/bash - -# Deploy AI Agent to Amazon EKS -# Based on: java-spring-ai-agents/content/deploy/eks/index.en.md - -# Source common utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../../lib/common.sh" - -# Source environment variables -source /etc/profile.d/workshop.sh - -APP_DIR=~/environment/aiagent -APP_NAME="aiagent" -NAMESPACE="aiagent" -CLUSTER_NAME="workshop-eks" -ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${APP_NAME}" - -log_info "Deploying AI Agent to Amazon EKS..." -log_info "AWS Account: ${ACCOUNT_ID}" -log_info "AWS Region: ${AWS_REGION}" -log_info "ECR URI: ${ECR_URI}" - -# Verify application exists -if [[ ! -d "${APP_DIR}" ]]; then - log_error "AI Agent application not found at ${APP_DIR}. Run 3-app.sh first." - exit 1 -fi - -# ============================================================================ -# Add Jib plugin and build container image -# ============================================================================ -log_info "Adding Jib plugin to pom.xml..." -grep -q 'jib-maven-plugin' ~/environment/aiagent/pom.xml || \ -sed -i '/<\/plugins>/i\ - \ - com.google.cloud.tools\ - jib-maven-plugin\ - 3.5.1\ - \ - \ - public.ecr.aws/docker/library/amazoncorretto:25-alpine\ - \ - \ - 1000\ - \ - \ - ' ~/environment/aiagent/pom.xml -log_success "Jib plugin added" - -log_info "Logging in to ECR..." -aws ecr get-login-password --region ${AWS_REGION} \ - | docker login --username AWS --password-stdin ${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com -log_success "ECR login successful" - -log_info "Building and pushing container image with Jib..." -cd ~/environment/aiagent -mvn compile jib:build \ - -Dimage=${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/aiagent:latest \ - -DskipTests -log_success "Container image pushed" - -# ============================================================================ -# Create namespace and service account -# ============================================================================ -log_info "Creating namespace ${NAMESPACE}..." -kubectl create namespace aiagent -log_success "Namespace created" - -log_info "Creating service account ${APP_NAME}..." -kubectl create serviceaccount aiagent -n aiagent -log_success "Service account created" - -# ============================================================================ -# Configure Pod Identity -# ============================================================================ -log_info "Creating Pod Identity association..." -aws eks create-pod-identity-association \ - --cluster-name workshop-eks \ - --namespace aiagent \ - --service-account aiagent \ - --role-arn arn:aws:iam::${ACCOUNT_ID}:role/aiagent-eks-pod-role \ - --no-cli-pager - -log_info "Verifying Pod Identity association..." -for i in {1..10}; do - ASSOCIATION_ID=$(aws eks list-pod-identity-associations --cluster-name workshop-eks --no-cli-pager \ - | jq -r '.associations[] | select(.namespace=="aiagent") | .associationId') - if [[ -n "${ASSOCIATION_ID}" ]]; then - break - fi - log_info "Waiting for Pod Identity association to propagate... ($i/10)" - sleep 2 -done - -if [[ -z "${ASSOCIATION_ID}" ]]; then - log_error "Pod Identity association not found after waiting" - exit 1 -fi - -aws eks describe-pod-identity-association \ - --cluster-name workshop-eks \ - --association-id ${ASSOCIATION_ID} \ - --no-cli-pager > /dev/null -log_success "Pod Identity association verified (ID: ${ASSOCIATION_ID})" - -# ============================================================================ -# Create Kubernetes manifests -# ============================================================================ -log_info "Creating k8s directory..." -mkdir -p ~/environment/aiagent/k8s - -# SecretProviderClass -log_info "Creating SecretProviderClass..." -cat < ~/environment/aiagent/k8s/secret-provider-class.yaml -apiVersion: secrets-store.csi.x-k8s.io/v1 -kind: SecretProviderClass -metadata: - name: aiagent-secrets - namespace: aiagent -spec: - provider: aws - parameters: - usePodIdentity: "true" - objects: | - - objectName: "workshop-db-secret" - objectType: "secretsmanager" - jmesPath: - - path: "password" - objectAlias: "spring.datasource.password" - - path: "username" - objectAlias: "spring.datasource.username" - - objectName: "workshop-db-connection-string" - objectType: "ssmparameter" - objectAlias: "spring.datasource.url" -EOF -kubectl apply -f ~/environment/aiagent/k8s/secret-provider-class.yaml -log_success "SecretProviderClass created" - -# Get MCP URL and Cognito Issuer URI -log_info "Getting MCP Server URL and Cognito configuration..." -MCP_URL=http://$(kubectl get ingress mcpserver -n mcpserver \ - -o jsonpath='{.status.loadBalancer.ingress[0].hostname}') -echo "MCP URL: ${MCP_URL}" - -USER_POOL_ID=$(aws cognito-idp list-user-pools --max-results 60 --no-cli-pager \ - --query "UserPools[?Name=='aiagent-user-pool'].Id | [0]" --output text) -COGNITO_ISSUER_URI="https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}" -echo "Cognito Issuer URI: ${COGNITO_ISSUER_URI}" - -# Deployment -log_info "Creating Deployment..." -ECR_URI=${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/aiagent -cat < ~/environment/aiagent/k8s/deployment.yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: aiagent - namespace: aiagent - labels: - app: aiagent -spec: - replicas: 1 - selector: - matchLabels: - app: aiagent - template: - metadata: - labels: - app: aiagent - spec: - serviceAccountName: aiagent - nodeSelector: - karpenter.sh/nodepool: workshop - containers: - - name: aiagent - image: ${ECR_URI}:latest - imagePullPolicy: Always - ports: - - containerPort: 8080 - env: - - name: SPRING_CONFIG_IMPORT - value: "optional:configtree:/mnt/secrets-store/" - - name: SPRING_AI_MCP_CLIENT_STREAMABLEHTTP_CONNECTIONS_SERVER1_URL - value: "${MCP_URL}" - - name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI - value: "${COGNITO_ISSUER_URI}" - resources: - requests: - cpu: "1" - memory: "2Gi" - limits: - cpu: "1" - memory: "2Gi" - livenessProbe: - httpGet: - path: /actuator/health/liveness - port: 8080 - failureThreshold: 6 - periodSeconds: 5 - readinessProbe: - httpGet: - path: /actuator/health/readiness - port: 8080 - failureThreshold: 6 - periodSeconds: 5 - initialDelaySeconds: 10 - startupProbe: - httpGet: - path: /actuator/health/liveness - port: 8080 - failureThreshold: 10 - periodSeconds: 5 - initialDelaySeconds: 20 - volumeMounts: - - name: secrets-store - mountPath: "/mnt/secrets-store" - readOnly: true - securityContext: - runAsNonRoot: true - runAsUser: 1000 - allowPrivilegeEscalation: false - lifecycle: - preStop: - exec: - command: ["sh", "-c", "sleep 10"] - volumes: - - name: secrets-store - csi: - driver: secrets-store.csi.k8s.io - readOnly: true - volumeAttributes: - secretProviderClass: aiagent-secrets -EOF -kubectl apply -f ~/environment/aiagent/k8s/deployment.yaml -log_success "Deployment created" - -# Service -log_info "Creating Service..." -cat < ~/environment/aiagent/k8s/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: aiagent - namespace: aiagent - labels: - app: aiagent -spec: - type: ClusterIP - selector: - app: aiagent - ports: - - port: 80 - targetPort: 8080 - protocol: TCP -EOF -kubectl apply -f ~/environment/aiagent/k8s/service.yaml -log_success "Service created" - -# Ingress -log_info "Creating Ingress..." -cat < ~/environment/aiagent/k8s/ingress.yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: aiagent - namespace: aiagent - annotations: - alb.ingress.kubernetes.io/scheme: internet-facing - alb.ingress.kubernetes.io/target-type: ip - alb.ingress.kubernetes.io/healthcheck-path: /actuator/health - labels: - app: aiagent -spec: - ingressClassName: alb - rules: - - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: aiagent - port: - number: 80 -EOF -kubectl apply -f ~/environment/aiagent/k8s/ingress.yaml -log_success "Ingress created" - -# ============================================================================ -# Wait for deployment and test -# ============================================================================ -log_info "Waiting for deployment to be ready..." -kubectl wait deployment aiagent -n aiagent \ - --for condition=Available=True --timeout=180s -kubectl get deployment aiagent -n aiagent -log_success "Deployment ready" - -log_info "Waiting for ALB to be provisioned (this may take 2-5 minutes)..." -SVC_URL=http://$(kubectl get ingress aiagent -n aiagent \ - -o jsonpath='{.status.loadBalancer.ingress[0].hostname}') - -while ! curl -s --max-time 5 "${SVC_URL}/actuator/health" | grep -q '"status":"UP"'; do - echo "Waiting for load balancer..." && sleep 15 -done - -log_success "EKS deployment completed" -echo "✅ Success: AI Agent deployed to EKS" -echo "URL: ${SVC_URL}" -echo "Username: alice" -echo "Password: ${IDE_PASSWORD}" diff --git a/infra/scripts/deploy/java-spring-ai-agents/6-ecs.sh b/infra/scripts/deploy/java-spring-ai-agents/6-ecs.sh deleted file mode 100755 index 213ded4e..00000000 --- a/infra/scripts/deploy/java-spring-ai-agents/6-ecs.sh +++ /dev/null @@ -1,130 +0,0 @@ -#!/bin/bash - -# Deploy AI Agent to Amazon ECS Express Mode -# Based on: java-spring-ai-agents/content/deploy/ecs/index.en.md - -# Source common utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../../lib/common.sh" - -# Source environment variables -source /etc/profile.d/workshop.sh - -APP_DIR=~/environment/aiagent -APP_NAME="aiagent" -ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${APP_NAME}" - -log_info "Deploying AI Agent to Amazon ECS..." -log_info "AWS Account: ${ACCOUNT_ID}" -log_info "AWS Region: ${AWS_REGION}" -log_info "ECR URI: ${ECR_URI}" - -# Verify application exists -if [[ ! -d "${APP_DIR}" ]]; then - log_error "AI Agent application not found at ${APP_DIR}. Run 3-app.sh first." - exit 1 -fi - -# ============================================================================ -# Add Jib plugin and build container image -# ============================================================================ -log_info "Adding Jib plugin to pom.xml..." -grep -q 'jib-maven-plugin' ~/environment/aiagent/pom.xml || \ -sed -i '/<\/plugins>/i\ - \ - com.google.cloud.tools\ - jib-maven-plugin\ - 3.5.1\ - \ - \ - public.ecr.aws/docker/library/amazoncorretto:25-alpine\ - \ - \ - 1000\ - \ - \ - ' ~/environment/aiagent/pom.xml -log_success "Jib plugin added" - -log_info "Logging in to ECR..." -aws ecr get-login-password --region ${AWS_REGION} \ - | docker login --username AWS --password-stdin ${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com -log_success "ECR login successful" - -log_info "Building and pushing container image with Jib..." -cd ~/environment/aiagent -mvn compile jib:build \ - -Dimage=${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/aiagent:latest \ - -DskipTests -log_success "Container image pushed" - -# ============================================================================ -# Configure ECS deployment -# ============================================================================ -log_info "Configuring faster deployment for workshop..." -aws ecs update-service \ - --cluster ${APP_NAME} \ - --service ${APP_NAME} \ - --deployment-configuration '{ - "maximumPercent": 200, - "minimumHealthyPercent": 0, - "bakeTimeInMinutes": 0, - "canaryConfiguration": {"canaryPercent": 100, "canaryBakeTimeInMinutes": 0} - }' \ - --no-cli-pager > /dev/null -log_success "Deployment configuration updated" - -# Get MCP URL and Cognito Issuer URI -log_info "Getting MCP Server URL and Cognito configuration..." -MCP_URL=http://$(kubectl get ingress mcpserver -n mcpserver \ - -o jsonpath='{.status.loadBalancer.ingress[0].hostname}') -echo "MCP URL: ${MCP_URL}" - -USER_POOL_ID=$(aws cognito-idp list-user-pools --max-results 60 --no-cli-pager \ - --query "UserPools[?Name=='aiagent-user-pool'].Id | [0]" --output text) -COGNITO_ISSUER_URI="https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}" -echo "Cognito Issuer URI: ${COGNITO_ISSUER_URI}" - -# Update task definition -log_info "Updating ECS task definition..." -AI_SERVICE_ARN=$(aws ecs describe-services --cluster aiagent --services aiagent \ - --query 'services[0].serviceArn' --output text --no-cli-pager) -IMAGE=$(aws ecs describe-express-gateway-service --service-arn ${AI_SERVICE_ARN} \ - --query 'service.activeConfigurations[0].primaryContainer.image' --output text --no-cli-pager) - -aws ecs update-express-gateway-service \ - --service-arn ${AI_SERVICE_ARN} \ - --primary-container \ - "{\"image\":\"${IMAGE}\",\"environment\":[{\"name\":\"SPRING_AI_MCP_CLIENT_STREAMABLEHTTP_CONNECTIONS_SERVER1_URL\",\"value\":\"${MCP_URL}\"},{\"name\":\"SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI\",\"value\":\"${COGNITO_ISSUER_URI}\"}]}" \ - --no-cli-pager > /dev/null -log_success "Task definition updated" - -# ============================================================================ -# Wait for deployment -# ============================================================================ -log_info "Waiting for deployment to complete (this may take 2-5 minutes)..." -while [[ $(aws ecs describe-services --cluster ${APP_NAME} --services ${APP_NAME} \ - --query 'services[0].deployments | length(@)' --output text --no-cli-pager) -gt 1 ]]; do - echo "Waiting for deployment to complete..." && sleep 15 -done -log_success "Deployment complete" - -# ============================================================================ -# Get Service URL and test -# ============================================================================ -log_info "Getting Service URL..." -SERVICE_ARN=$(aws ecs describe-services --cluster ${APP_NAME} --services ${APP_NAME} \ - --query 'services[0].serviceArn' --output text --no-cli-pager) -SVC_URL=https://$(aws ecs describe-express-gateway-service --service-arn ${SERVICE_ARN} \ - --query 'service.activeConfigurations[0].ingressPaths[0].endpoint' --output text --no-cli-pager) - -log_info "Waiting for service to be ready..." -while ! curl -s --max-time 5 "${SVC_URL}/actuator/health" | grep -q '"status":"UP"'; do - echo "Waiting for service..." && sleep 15 -done - -log_success "ECS deployment completed" -echo "✅ Success: AI Agent deployed to ECS" -echo "URL: ${SVC_URL}" -echo "Username: alice" -echo "Password: ${IDE_PASSWORD}" diff --git a/infra/scripts/deploy/java-spring-ai-agents/7-lambda.sh b/infra/scripts/deploy/java-spring-ai-agents/7-lambda.sh deleted file mode 100755 index 3e9bf52f..00000000 --- a/infra/scripts/deploy/java-spring-ai-agents/7-lambda.sh +++ /dev/null @@ -1,200 +0,0 @@ -#!/bin/bash - -# Deploy AI Agent to AWS Lambda with Lambda Web Adapter -# Based on: java-spring-ai-agents/content/deploy/lambda/index.en.md - -# Source common utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../../lib/common.sh" - -# Source environment variables -source /etc/profile.d/workshop.sh - -APP_DIR=~/environment/aiagent -APP_NAME="aiagent" - -log_info "Deploying AI Agent to AWS Lambda..." -log_info "AWS Account: ${ACCOUNT_ID}" -log_info "AWS Region: ${AWS_REGION}" - -# Verify application exists -if [[ ! -d "${APP_DIR}" ]]; then - log_error "AI Agent application not found at ${APP_DIR}. Run 3-app.sh first." - exit 1 -fi - -# ============================================================================ -# Create runtime script and build -# ============================================================================ -log_info "Creating Lambda runtime script..." -cat > ~/environment/aiagent/run.sh << 'EOF' -#!/bin/bash -java -jar agent-0.0.1-SNAPSHOT.jar -EOF -chmod +x ~/environment/aiagent/run.sh -log_success "Runtime script created" - -log_info "Building application..." -cd ~/environment/aiagent -mvn clean package -DskipTests -log_success "Application built" - -log_info "Creating deployment package..." -cd target -cp ../run.sh . -zip -r aiagent-deployment.zip agent-0.0.1-SNAPSHOT.jar run.sh -log_success "Deployment package created" - -# ============================================================================ -# Get configuration -# ============================================================================ -log_info "Getting MCP Server URL and Cognito configuration..." -MCP_URL=http://$(kubectl get ingress mcpserver -n mcpserver \ - -o jsonpath='{.status.loadBalancer.ingress[0].hostname}') -echo "MCP URL: ${MCP_URL}" - -ROLE_ARN=$(aws iam get-role --role-name aiagent-lambda-role \ - --query 'Role.Arn' --output text --no-cli-pager) - -USER_POOL_ID=$(aws cognito-idp list-user-pools --max-results 60 --no-cli-pager \ - --query "UserPools[?Name=='aiagent-user-pool'].Id | [0]" --output text) -COGNITO_ISSUER_URI="https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}" -echo "Cognito Issuer URI: ${COGNITO_ISSUER_URI}" - -log_info "Getting database credentials..." -export SPRING_DATASOURCE_URL=$(aws ssm get-parameter --name workshop-db-connection-string --no-cli-pager \ - | jq --raw-output '.Parameter.Value') -export SPRING_DATASOURCE_USERNAME=$(aws secretsmanager get-secret-value --secret-id workshop-db-secret --no-cli-pager \ - | jq --raw-output '.SecretString' | jq -r .username) -export SPRING_DATASOURCE_PASSWORD=$(aws secretsmanager get-secret-value --secret-id workshop-db-secret --no-cli-pager \ - | jq --raw-output '.SecretString' | jq -r .password) - -log_info "Creating environment variables file..." -cat > env-vars.json << EOF -{ - "Variables": { - "PORT": "8080", - "AWS_LWA_ENABLE_COMPRESSION": "false", - "SPRING_PROFILES_ACTIVE": "lambda", - "AWS_LAMBDA_EXEC_WRAPPER": "/opt/bootstrap", - "AWS_LWA_INVOKE_MODE": "response_stream", - "SPRING_DATASOURCE_URL": "${SPRING_DATASOURCE_URL}", - "SPRING_DATASOURCE_USERNAME": "${SPRING_DATASOURCE_USERNAME}", - "SPRING_DATASOURCE_PASSWORD": "${SPRING_DATASOURCE_PASSWORD}", - "SPRING_AI_MCP_CLIENT_STREAMABLEHTTP_CONNECTIONS_SERVER1_URL": "${MCP_URL}", - "SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI": "${COGNITO_ISSUER_URI}" - } -} -EOF -log_success "Environment variables configured" - -# ============================================================================ -# Get VPC configuration -# ============================================================================ -log_info "Getting VPC configuration..." -VPC_ID=$(aws ec2 describe-vpcs --filters "Name=tag:Name,Values=workshop-vpc" \ - --query 'Vpcs[0].VpcId' --output text --no-cli-pager) -echo "VPC ID: ${VPC_ID}" - -SUBNET_IDS=$(aws ec2 describe-subnets \ - --filters "Name=vpc-id,Values=${VPC_ID}" "Name=tag:Name,Values=*PrivateSubnet*" \ - --query 'Subnets[*].SubnetId' \ - --output text --no-cli-pager | tr '\t' ',') -echo "Private Subnets: ${SUBNET_IDS}" - -SECURITY_GROUP_ID=$(aws ec2 describe-security-groups \ - --filters "Name=vpc-id,Values=${VPC_ID}" "Name=group-name,Values=aiagent-lambda-sg" \ - --query 'SecurityGroups[0].GroupId' \ - --output text --no-cli-pager) - -if [ "${SECURITY_GROUP_ID}" = "None" ] || [ -z "${SECURITY_GROUP_ID}" ]; then - log_info "Creating security group..." - SECURITY_GROUP_ID=$(aws ec2 create-security-group \ - --group-name aiagent-lambda-sg \ - --description "Security group for AI Agent Lambda function" \ - --vpc-id ${VPC_ID} \ - --query 'GroupId' \ - --output text --no-cli-pager) - - aws ec2 authorize-security-group-egress \ - --group-id ${SECURITY_GROUP_ID} \ - --protocol all \ - --cidr 0.0.0.0/0 \ - --no-cli-pager > /dev/null 2>&1 || true -fi -echo "Security Group: ${SECURITY_GROUP_ID}" -log_success "VPC configuration ready" - -# ============================================================================ -# Create Lambda function -# ============================================================================ -log_info "Creating Lambda function..." -aws lambda create-function \ - --function-name aiagent \ - --runtime java25 \ - --role "${ROLE_ARN}" \ - --handler run.sh \ - --zip-file fileb://aiagent-deployment.zip \ - --timeout 60 \ - --memory-size 2048 \ - --layers arn:aws:lambda:${AWS_REGION}:753240598075:layer:LambdaAdapterLayerX86:25 \ - --environment file://env-vars.json \ - --vpc-config SubnetIds="${SUBNET_IDS}",SecurityGroupIds="${SECURITY_GROUP_ID}" \ - --no-cli-pager > /dev/null -log_success "Lambda function created" - -# ============================================================================ -# Create Function URL with streaming -# ============================================================================ -log_info "Creating Function URL with streaming support..." -aws lambda create-function-url-config \ - --function-name aiagent \ - --auth-type NONE \ - --invoke-mode RESPONSE_STREAM \ - --cors AllowOrigins="*",AllowMethods="*",AllowHeaders="date,keep-alive,x-custom-header,content-type",ExposeHeaders="date,keep-alive",MaxAge=86400 \ - --no-cli-pager > /dev/null -log_success "Function URL created" - -# Add permissions -log_info "Adding resource-based policies..." -aws lambda add-permission \ - --function-name aiagent \ - --statement-id FunctionURLAllowPublicAccess \ - --action lambda:InvokeFunctionUrl \ - --principal "*" \ - --function-url-auth-type NONE \ - --no-cli-pager > /dev/null - -aws lambda add-permission \ - --function-name aiagent \ - --statement-id FunctionURLPublicInvoke \ - --action lambda:InvokeFunction \ - --principal "*" \ - --invoked-via-function-url \ - --no-cli-pager > /dev/null -log_success "Permissions added" - -# ============================================================================ -# Wait and test -# ============================================================================ -log_info "Getting Function URL..." -FUNCTION_URL=$(aws lambda get-function-url-config \ - --function-name aiagent \ - --query 'FunctionUrl' \ - --output text \ - --no-cli-pager) -echo "Function URL: ${FUNCTION_URL}" - -log_info "Waiting for Lambda to become available (this may take 1-3 minutes)..." -while true; do - HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${FUNCTION_URL}" || echo "000") - echo "Lambda HTTP status: ${HTTP_STATUS}" - if [ "${HTTP_STATUS}" = "200" ]; then break; fi - sleep 15 -done - -log_success "Lambda deployment completed" -echo "✅ Success: AI Agent deployed to Lambda" -echo "URL: ${FUNCTION_URL}" -echo "Username: alice" -echo "Password: ${IDE_PASSWORD}" diff --git a/infra/scripts/deploy/java-spring-ai-agents/8-agentcore.sh b/infra/scripts/deploy/java-spring-ai-agents/8-agentcore.sh deleted file mode 100755 index 8e0eb221..00000000 --- a/infra/scripts/deploy/java-spring-ai-agents/8-agentcore.sh +++ /dev/null @@ -1,379 +0,0 @@ -#!/bin/bash - -# Deploy AI Agent to Amazon Bedrock AgentCore -# Based on: java-spring-ai-agents/content/deploy/agentcore/index.en.md - -# Source common utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/../../lib/common.sh" - -# Source environment variables -source /etc/profile.d/workshop.sh - -APP_DIR=~/environment/aiagent -APP_NAME="aiagent" -ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${APP_NAME}" - -log_info "Deploying AI Agent to Amazon Bedrock AgentCore..." -log_info "AWS Account: ${ACCOUNT_ID}" -log_info "AWS Region: ${AWS_REGION}" -log_info "ECR URI: ${ECR_URI}" - -# Verify application exists -if [[ ! -d "${APP_DIR}" ]]; then - log_error "AI Agent application not found at ${APP_DIR}. Run 3-app.sh first." - exit 1 -fi - -# ============================================================================ -# Add AgentCore dependencies -# ============================================================================ -log_info "Adding AgentCore dependencies to pom.xml..." -grep -q 'spring-ai-bedrock-agentcore-starter' ~/environment/aiagent/pom.xml || \ -sed -i '0,//{//a\ - \ - \ - org.springaicommunity\ - spring-ai-bedrock-agentcore-starter\ - 1.0.0-RC5\ - -}' ~/environment/aiagent/pom.xml -log_success "AgentCore dependencies added" - -# ============================================================================ -# Create InvocationService -# ============================================================================ -log_info "Creating InvocationService.java..." -cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/InvocationService.java -package com.example.agent; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.Base64; -import org.springaicommunity.agentcore.context.AgentCoreHeaders; -import org.springaicommunity.agentcore.annotation.AgentCoreInvocation; -import org.springaicommunity.agentcore.context.AgentCoreContext; -import org.springframework.stereotype.Service; -import reactor.core.publisher.Flux; - -@Service -public class InvocationService { - private final ChatService chatService; - private final ObjectMapper objectMapper = new ObjectMapper(); - - public InvocationService(ChatService chatService) { - this.chatService = chatService; - } - - @AgentCoreInvocation - public Flux handleInvocation(InvocationRequest request, AgentCoreContext context) throws Exception { - String jwt = context.getHeader(AgentCoreHeaders.AUTHORIZATION).replace("Bearer ", ""); - String payload = new String(Base64.getUrlDecoder().decode(jwt.split("\\.")[1])); - JsonNode claims = objectMapper.readTree(payload); - String visitorId = claims.get("sub").asText().replace("-", "").substring(0, 25); - String authTime = claims.get("auth_time").asText(); - String sessionId = visitorId + ":" + authTime; - return chatService.chat(request.prompt(), sessionId); - } -} -EOF -log_success "InvocationService.java created" - -# ============================================================================ -# Create Dockerfile -# ============================================================================ -log_info "Creating Dockerfile..." -cat <<'EOF' > ~/environment/aiagent/Dockerfile -FROM public.ecr.aws/docker/library/maven:3-amazoncorretto-25-al2023 AS builder - -COPY ./pom.xml ./pom.xml -COPY src ./src/ - -RUN rm -rf src/main/resources/static -RUN mvn clean package -DskipTests -ntp && mv target/*.jar app.jar - -FROM public.ecr.aws/docker/library/amazoncorretto:25-al2023 - -RUN yum install -y shadow-utils - -RUN groupadd --system spring -g 1000 -RUN adduser spring -u 1000 -g 1000 - -COPY --from=builder app.jar app.jar - -USER 1000:1000 -EXPOSE 8080 - -ENTRYPOINT ["java", "-jar", "/app.jar"] -EOF -log_success "Dockerfile created" - -# ============================================================================ -# Build and push container image -# ============================================================================ -log_info "Logging in to ECR..." -aws ecr get-login-password --region ${AWS_REGION} --no-cli-pager | \ - docker login --username AWS --password-stdin ${ECR_URI} -log_success "ECR login successful" - -log_info "Setting up Docker buildx for ARM64..." -docker run --privileged --rm tonistiigi/binfmt --install arm64 > /dev/null 2>&1 -docker buildx create --name arm64builder --use > /dev/null 2>&1 || docker buildx use arm64builder > /dev/null 2>&1 -docker buildx inspect --bootstrap > /dev/null 2>&1 -log_success "Docker buildx configured" - -log_info "Building and pushing Docker image (ARM64)..." -cd ~/environment/aiagent -docker buildx build --platform linux/arm64 -t ${ECR_URI}:agentcore --push . -log_success "Container image pushed" - -# ============================================================================ -# Get VPC and network configuration -# ============================================================================ -log_info "Getting VPC configuration..." -VPC_ID=$(aws ec2 describe-vpcs \ - --filters "Name=tag:Name,Values=workshop-vpc" \ - --query 'Vpcs[0].VpcId' --output text --no-cli-pager) - -SUBNET_IDS=$(aws ec2 describe-subnets \ - --filters "Name=vpc-id,Values=${VPC_ID}" \ - "Name=tag:aws-cdk:subnet-type,Values=Private" \ - "Name=availability-zone-id,Values=use1-az1,use1-az2,use1-az4" \ - --query 'Subnets[*].SubnetId' --output json --no-cli-pager) - -SG_ID=$(aws ec2 describe-security-groups \ - --filters "Name=group-name,Values=workshop-db-sg" \ - --query 'SecurityGroups[0].GroupId' --output text --no-cli-pager) - -echo "VPC: ${VPC_ID}" -echo "Subnets: ${SUBNET_IDS}" -echo "Security Group: ${SG_ID}" -log_success "VPC configuration ready" - -# ============================================================================ -# Get database and MCP configuration -# ============================================================================ -log_info "Getting database credentials and MCP Server URL..." -DB_URL=$(aws ssm get-parameter --name workshop-db-connection-string --no-cli-pager \ - | jq -r '.Parameter.Value') -DB_USER=$(aws secretsmanager get-secret-value --secret-id workshop-db-secret --no-cli-pager \ - | jq -r '.SecretString' | jq -r .username) -DB_PASS=$(aws secretsmanager get-secret-value --secret-id workshop-db-secret --no-cli-pager \ - | jq -r '.SecretString' | jq -r .password) - -MCP_URL=http://$(kubectl get ingress mcpserver -n mcpserver \ - -o jsonpath='{.status.loadBalancer.ingress[0].hostname}') - -echo "DB URL: ${DB_URL}" -echo "MCP URL: ${MCP_URL}" -log_success "Configuration ready" - -# ============================================================================ -# Create AgentCore Runtime -# ============================================================================ -log_info "Creating AgentCore Runtime..." -USER_POOL_ID=$(aws cognito-idp list-user-pools --max-results 60 --no-cli-pager \ - --query "UserPools[?Name=='aiagent-user-pool'].Id | [0]" --output text) -CLIENT_ID=$(aws cognito-idp list-user-pool-clients --user-pool-id "${USER_POOL_ID}" --no-cli-pager \ - --query "UserPoolClients[?ClientName=='aiagent-client'].ClientId | [0]" --output text) -COGNITO_DISCOVERY="https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}/.well-known/openid-configuration" - -ENV_VARS=$(jq -n \ - --arg db_url "${DB_URL}" \ - --arg db_user "${DB_USER}" \ - --arg db_pass "${DB_PASS}" \ - --arg mcp_url "${MCP_URL}" \ - '{SPRING_DATASOURCE_URL: $db_url, SPRING_DATASOURCE_USERNAME: $db_user, SPRING_DATASOURCE_PASSWORD: $db_pass, SPRING_AI_MCP_CLIENT_STREAMABLEHTTP_CONNECTIONS_SERVER1_URL: $mcp_url}') - -RUNTIME_RESPONSE=$(aws bedrock-agentcore-control create-agent-runtime \ - --agent-runtime-name aiagent \ - --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/aiagent-agentcore-runtime-role" \ - --agent-runtime-artifact "{\"containerConfiguration\":{\"containerUri\":\"${ECR_URI}:agentcore\"}}" \ - --network-configuration "{\"networkMode\":\"VPC\",\"networkModeConfig\":{\"subnets\":${SUBNET_IDS},\"securityGroups\":[\"${SG_ID}\"]}}" \ - --authorizer-configuration "{\"customJWTAuthorizer\":{\"discoveryUrl\":\"${COGNITO_DISCOVERY}\",\"allowedClients\":[\"${CLIENT_ID}\"]}}" \ - --request-header-configuration '{"requestHeaderAllowlist":["Authorization"]}' \ - --environment-variables "${ENV_VARS}" \ - --region ${AWS_REGION} \ - --no-cli-pager) - -RUNTIME_ID=$(echo "${RUNTIME_RESPONSE}" | jq -r '.agentRuntimeId') -echo "Runtime ID: ${RUNTIME_ID}" -log_success "AgentCore Runtime created" - -# ============================================================================ -# Wait for runtime to be ready -# ============================================================================ -log_info "Waiting for runtime to be ready (this may take 3-5 minutes)..." -while true; do - STATUS=$(aws bedrock-agentcore-control get-agent-runtime \ - --agent-runtime-id "${RUNTIME_ID}" \ - --region ${AWS_REGION} \ - --query 'status' --output text --no-cli-pager) - echo "Status: ${STATUS}" - if [ "${STATUS}" = "READY" ]; then break; fi - if [ "${STATUS}" = "FAILED" ]; then - log_error "Runtime failed" - exit 1 - fi - sleep 15 -done -log_success "Runtime ready" - -# ============================================================================ -# Test the deployment -# ============================================================================ -log_info "Getting AgentCore endpoint..." -RUNTIME_ARN="arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:runtime/${RUNTIME_ID}" -RUNTIME_ARN_ENCODED=$(echo -n "${RUNTIME_ARN}" | jq -sRr @uri) -API_ENDPOINT="https://bedrock-agentcore.${AWS_REGION}.amazonaws.com/runtimes/${RUNTIME_ARN_ENCODED}/invocations?qualifier=DEFAULT" - -log_info "Getting Cognito token..." -TOKEN=$(aws cognito-idp initiate-auth \ - --client-id ${CLIENT_ID} \ - --auth-flow USER_PASSWORD_AUTH \ - --auth-parameters USERNAME=alice,PASSWORD=${IDE_PASSWORD} \ - --region ${AWS_REGION} \ - --no-cli-pager \ - --query 'AuthenticationResult.AccessToken' --output text) - -log_info "Testing AgentCore endpoint..." -curl -N -X POST "${API_ENDPOINT}" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${TOKEN}" \ - -d '{"prompt": "Hello"}' | sed 's/^data://g' | tr -d '\n'; echo -log_success "AgentCore endpoint test completed" - -# ============================================================================ -# Deploy UI to S3 and CloudFront -# ============================================================================ -log_info "Creating S3 bucket for UI..." -UI_BUCKET="aiagent-ui-${ACCOUNT_ID}-$(date +%s)" - -if [ "${AWS_REGION}" = "us-east-1" ]; then - aws s3api create-bucket --bucket "${UI_BUCKET}" --no-cli-pager > /dev/null -else - aws s3api create-bucket --bucket "${UI_BUCKET}" \ - --create-bucket-configuration LocationConstraint="${AWS_REGION}" --no-cli-pager > /dev/null -fi -log_success "S3 bucket created: ${UI_BUCKET}" - -log_info "Creating CloudFront Origin Access Identity..." -OAI_RESPONSE=$(aws cloudfront create-cloud-front-origin-access-identity \ - --cloud-front-origin-access-identity-config \ - "{\"CallerReference\":\"aiagent-$(date +%s)\",\"Comment\":\"OAI for aiagent UI\"}" \ - --no-cli-pager) -OAI_ID=$(echo "${OAI_RESPONSE}" | jq -r '.CloudFrontOriginAccessIdentity.Id') -OAI_CANONICAL=$(aws cloudfront get-cloud-front-origin-access-identity --id "${OAI_ID}" \ - --no-cli-pager --query 'CloudFrontOriginAccessIdentity.S3CanonicalUserId' --output text) -log_success "OAI created: ${OAI_ID}" - -log_info "Updating S3 bucket policy..." -aws s3api put-bucket-policy --bucket "${UI_BUCKET}" --policy "{ - \"Version\": \"2012-10-17\", - \"Statement\": [{ - \"Effect\": \"Allow\", - \"Principal\": {\"CanonicalUser\": \"${OAI_CANONICAL}\"}, - \"Action\": \"s3:GetObject\", - \"Resource\": \"arn:aws:s3:::${UI_BUCKET}/*\" - }] -}" --no-cli-pager -log_success "Bucket policy updated" - -log_info "Creating CloudFront distribution..." -CF_RESPONSE=$(aws cloudfront create-distribution \ - --distribution-config "{ - \"CallerReference\": \"aiagent-$(date +%s)\", - \"Comment\": \"aiagent UI\", - \"Enabled\": true, - \"DefaultRootObject\": \"index.html\", - \"Origins\": { - \"Quantity\": 1, - \"Items\": [{ - \"Id\": \"S3-${UI_BUCKET}\", - \"DomainName\": \"${UI_BUCKET}.s3.${AWS_REGION}.amazonaws.com\", - \"S3OriginConfig\": { - \"OriginAccessIdentity\": \"origin-access-identity/cloudfront/${OAI_ID}\" - } - }] - }, - \"DefaultCacheBehavior\": { - \"TargetOriginId\": \"S3-${UI_BUCKET}\", - \"ViewerProtocolPolicy\": \"redirect-to-https\", - \"AllowedMethods\": { - \"Quantity\": 2, - \"Items\": [\"GET\", \"HEAD\"], - \"CachedMethods\": {\"Quantity\": 2, \"Items\": [\"GET\", \"HEAD\"]} - }, - \"ForwardedValues\": {\"QueryString\": false, \"Cookies\": {\"Forward\": \"none\"}}, - \"MinTTL\": 0, - \"DefaultTTL\": 86400, - \"MaxTTL\": 31536000, - \"Compress\": true - }, - \"CustomErrorResponses\": { - \"Quantity\": 1, - \"Items\": [{ - \"ErrorCode\": 403, - \"ResponsePagePath\": \"/index.html\", - \"ResponseCode\": \"200\", - \"ErrorCachingMinTTL\": 300 - }] - }, - \"PriceClass\": \"PriceClass_100\" - }" \ - --no-cli-pager) - -CF_DIST_ID=$(echo "${CF_RESPONSE}" | jq -r '.Distribution.Id') -CF_DOMAIN=$(echo "${CF_RESPONSE}" | jq -r '.Distribution.DomainName') -log_success "CloudFront distribution created: ${CF_DIST_ID}" - -log_info "Creating UI config.json..." -cat > ~/environment/aiagent/src/main/resources/static/config.json << EOF -{ - "userPoolId": "${USER_POOL_ID}", - "clientId": "${CLIENT_ID}", - "apiEndpoint": "${API_ENDPOINT}" -} -EOF -log_success "config.json created" - -log_info "Uploading UI files to S3..." -UI_DIR=~/environment/aiagent/src/main/resources/static -for file in ${UI_DIR}/*.html ${UI_DIR}/*.js ${UI_DIR}/*.css ${UI_DIR}/*.json ${UI_DIR}/*.svg; do - if [ -f "${file}" ]; then - filename=$(basename "${file}") - case "${filename}" in - *.html) CONTENT_TYPE="text/html" ;; - *.js) CONTENT_TYPE="application/javascript" ;; - *.css) CONTENT_TYPE="text/css" ;; - *.json) CONTENT_TYPE="application/json" ;; - *.svg) CONTENT_TYPE="image/svg+xml" ;; - esac - aws s3 cp "${file}" "s3://${UI_BUCKET}/${filename}" \ - --content-type "${CONTENT_TYPE}" --no-cli-pager > /dev/null - fi -done -log_success "UI files uploaded" - -log_info "Invalidating CloudFront cache..." -aws cloudfront create-invalidation \ - --distribution-id "${CF_DIST_ID}" \ - --paths "/*" \ - --no-cli-pager > /dev/null -log_success "Cache invalidated" - -log_info "Waiting for CloudFront to become available..." -while true; do - HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://${CF_DOMAIN}" || echo "000") - echo "CloudFront HTTP status: ${HTTP_STATUS}" - if [ "${HTTP_STATUS}" = "200" ]; then break; fi - sleep 15 -done - -log_success "AgentCore deployment completed" -echo "✅ Success: AI Agent deployed to AgentCore" -echo "Runtime ID: ${RUNTIME_ID}" -echo "API Endpoint: ${API_ENDPOINT}" -echo "UI URL: https://${CF_DOMAIN}" -echo "Username: alice" -echo "Password: ${IDE_PASSWORD}" diff --git a/infra/scripts/deploy/java-spring-ai-agents/90-diagnose.sh b/infra/scripts/deploy/java-spring-ai-agents/90-diagnose.sh new file mode 100755 index 00000000..19c4907b --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/90-diagnose.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=_suite-lib.sh +source "${SCRIPT_DIR}/_suite-lib.sh" + +case "${1:-}" in + "") ;; + -h|--help) echo "Usage: 90-diagnose.sh"; exit 0 ;; + *) die "Usage: 90-diagnose.sh" ;; +esac + +print_prerequisites "read-only AWS and optional kubectl access" +init_context_read_only +load_state +set +e + +printf '\n== Suite state ==\n' +printf 'State file: %s\nActive target: %s\nEndpoint: %s\n' "${STATE_FILE}" "${ACTIVE_TARGET:-not set}" "${AIAGENT_ENDPOINT:-not set}" +printf 'MCP endpoint: %s\nCognito pool: %s\n' "${MCP_URL:-not set}" "${COGNITO_USER_POOL_ID:-not set}" + +printf '\n== Prerequisites ==\n' +aws_cli rds describe-db-clusters --db-cluster-identifier workshop-db-cluster \ + --query 'DBClusters[0].{Status:Status,Engine:Engine,Version:EngineVersion,HttpEndpointEnabled:HttpEndpointEnabled}' --output table +aws_cli secretsmanager describe-secret --secret-id workshop-db-secret \ + --query '{Name:Name,ARN:ARN,LastChangedDate:LastChangedDate}' --output table +aws_cli ssm describe-parameters --parameter-filters Key=Name,Option=Equals,Values=workshop-db-connection-string \ + --query 'Parameters[0].{Name:Name,Type:Type,LastModifiedDate:LastModifiedDate}' --output table +aws_cli eks describe-cluster --name workshop-eks --query 'cluster.{Status:status,Version:version,Endpoint:endpoint}' --output table +aws_cli ecs describe-services --cluster aiagent --services aiagent \ + --query 'services[0].{Status:status,Desired:desiredCount,Running:runningCount,Deployments:length(deployments)}' --output table + +printf '\n== Suite resources ==\n' +aws_cli cognito-idp list-user-pools --max-results 60 --query "UserPools[?Name=='aiagent-user-pool'].{Name:Name,Id:Id,Updated:LastModifiedDate}" --output table +aws_cli lambda get-function-configuration --function-name aiagent \ + --query '{State:State,LastUpdateStatus:LastUpdateStatus,Runtime:Runtime,MemorySize:MemorySize,Timeout:Timeout}' --output table +aws_cli bedrock-agentcore-control list-agent-runtimes \ + --query "agentRuntimes[?agentRuntimeName=='aiagent'].{Name:agentRuntimeName,Id:agentRuntimeId,Status:status}" --output table +aws_cli bedrock get-model-invocation-logging-configuration \ + --query 'loggingConfig.{LogGroup:cloudWatchConfig.logGroupName,Bucket:s3Config.bucketName,Text:textDataDeliveryEnabled,Embedding:embeddingDataDeliveryEnabled}' --output table +if command -v kubectl >/dev/null 2>&1; then + printf '\n== Kubernetes ==\n' + kubectl get deployment,service,ingress -n mcpserver -o wide + kubectl get deployment,service,ingress -n aiagent -o wide +fi + +printf '\n== Log groups (names only) ==\n' +aws_cli logs describe-log-groups --log-group-name-prefix /aws/bedrock-agentcore/runtimes/ \ + --query 'logGroups[].logGroupName' --output text +aws_cli logs describe-log-groups --log-group-name-prefix /aws/bedrock/model-invocations \ + --query 'logGroups[].logGroupName' --output text +set -e +log "Read-only diagnostics complete. No secrets, tokens, or passwords were requested or printed." diff --git a/infra/scripts/deploy/java-spring-ai-agents/99-cleanup.sh b/infra/scripts/deploy/java-spring-ai-agents/99-cleanup.sh new file mode 100755 index 00000000..629eba5b --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/99-cleanup.sh @@ -0,0 +1,676 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=_suite-lib.sh +source "${SCRIPT_DIR}/_suite-lib.sh" + +APPLY=false +case "${1:-}" in + "") ;; + --apply) APPLY=true; shift ;; + -h|--help) echo "Usage: 99-cleanup.sh [--apply]"; exit 0 ;; + *) die "Usage: 99-cleanup.sh [--apply]" ;; +esac +(($# == 0)) || die "Usage: 99-cleanup.sh [--apply]" +print_prerequisites "AWS access and kubectl for EKS resources" +init_context_read_only +load_state + +print_plan() { + cat <&1); then + return 0 + fi + if grep -Eqi 'ResourceNotFoundException|NotFoundException|NoSuch[A-Za-z]+|InvalidGroup\.NotFound|UserNotFoundException|\(404\)|status code: 404|does not exist' <<<"${AWS_PROBE_OUTPUT}"; then + return 1 + fi + return 2 +} + +probe_aws_resource_with_retry() { + local attempts="$1" interval="$2" i probe_status + shift 2 + for ((i=1; i<=attempts; i++)); do + probe_status=0 + probe_aws_resource "$@" || probe_status=$? + [[ "${probe_status}" != 2 ]] && return "${probe_status}" + ((i == attempts)) || sleep "${interval}" + done + return 2 +} + +KUBECTL_PROBE_OUTPUT="" +probe_k8s_resource() { + local kind="$1" name="$2" namespace="${3:-}" + local namespace_args=() + [[ -z "${namespace}" ]] || namespace_args=(-n "${namespace}") + KUBECTL_PROBE_OUTPUT="" + if KUBECTL_PROBE_OUTPUT=$(kubectl get "${kind}" "${name}" "${namespace_args[@]}" -o json 2>&1); then + return 0 + fi + if grep -Eq '^Error from server \(NotFound\):' <<<"${KUBECTL_PROBE_OUTPUT}"; then + return 1 + fi + return 2 +} +if [[ -n "${EKS_NAMESPACE_CREATED:-}${EKS_SERVICE_ACCOUNT_CREATED:-}${EKS_POD_IDENTITY_ID:-}${EKS_SPC_BACKUP_PATH:-}${EKS_DEPLOYMENT_BACKUP_PATH:-}${EKS_SERVICE_BACKUP_PATH:-}${EKS_INGRESS_BACKUP_PATH:-}${MCP_NAMESPACE_CREATED:-}${MCP_SERVICE_ACCOUNT_CREATED:-}${MCP_POD_IDENTITY_ID:-}${MCP_SPC_BACKUP_PATH:-}${MCP_DEPLOYMENT_BACKUP_PATH:-}${MCP_SERVICE_BACKUP_PATH:-}${MCP_INGRESS_BACKUP_PATH:-}" ]]; then + ensure_eks_context +fi +log "Applying cleanup plan" + +restore_pod_identity() { + local prefix="$1" created_var="${1}_POD_IDENTITY_CREATED" id_var="${1}_POD_IDENTITY_ID" role_var="${1}_ORIGINAL_POD_ROLE_ARN" + local association_id="${!id_var:-}" created="${!created_var:-false}" original_role="${!role_var:-}" + [[ -n "${association_id}" ]] || return 0 + if [[ "${created}" == true ]]; then + aws_cli eks delete-pod-identity-association --cluster-name "${CLUSTER_NAME}" --association-id "${association_id}" >/dev/null 2>&1 || \ + record_failure "Could not delete suite-owned ${prefix} Pod Identity association ${association_id}" + elif [[ -n "${original_role}" ]]; then + aws_cli eks update-pod-identity-association --cluster-name "${CLUSTER_NAME}" --association-id "${association_id}" \ + --role-arn "${original_role}" >/dev/null 2>&1 || \ + record_failure "Could not restore ${prefix} Pod Identity association ${association_id} to ${original_role}" + fi +} + +delete_k8s_app() { + local namespace="$1" prefix="$2" service_account="$3" namespace_status + command -v kubectl >/dev/null 2>&1 || { record_failure "kubectl unavailable; could not clean up ${namespace} resources"; return; } + namespace_status=0 + probe_k8s_resource namespace "${namespace}" || namespace_status=$? + case "${namespace_status}" in + 0) ;; + 1) return 0 ;; + *) record_failure "Could not read namespace ${namespace} before cleanup: ${KUBECTL_PROBE_OUTPUT}"; return ;; + esac + kubectl delete ingress,service,deployment -n "${namespace}" -l "app.kubernetes.io/managed-by=${SUITE_OWNER}" \ + --ignore-not-found --wait=true --timeout=180s >/dev/null || \ + record_failure "Could not delete all suite-owned workload resources in namespace ${namespace}" + kubectl delete secretproviderclass -n "${namespace}" -l "app.kubernetes.io/managed-by=${SUITE_OWNER}" \ + --ignore-not-found --wait=true --timeout=120s >/dev/null || \ + record_failure "Could not delete all suite-owned SecretProviderClass resources in namespace ${namespace}" + + local backup_var backup_path + for backup_var in "${prefix}_SPC_BACKUP_PATH" "${prefix}_DEPLOYMENT_BACKUP_PATH" "${prefix}_SERVICE_BACKUP_PATH" "${prefix}_INGRESS_BACKUP_PATH"; do + backup_path="${!backup_var:-}" + if [[ -n "${backup_path}" ]]; then + if [[ -f "${backup_path}" ]]; then + kubectl apply -f "${backup_path}" >/dev/null || record_failure "Could not restore Kubernetes backup ${backup_path}" + else + record_failure "Kubernetes restore snapshot is missing: ${backup_path}" + fi + fi + done + + local sa_created_var="${prefix}_SERVICE_ACCOUNT_CREATED" namespace_created_var="${prefix}_NAMESPACE_CREATED" + if [[ "${!sa_created_var:-false}" == true ]]; then + kubectl delete serviceaccount "${service_account}" -n "${namespace}" --ignore-not-found --wait=true --timeout=60s >/dev/null || \ + record_failure "Could not delete suite-owned service account ${namespace}/${service_account}" + fi + if [[ "${!namespace_created_var:-false}" == true ]]; then + kubectl delete namespace "${namespace}" --ignore-not-found --wait=true --timeout=180s >/dev/null || \ + record_failure "Could not delete suite-owned namespace ${namespace}" + fi +} + +# AgentCore UI and Runtime +if [[ -n "${AGENTCORE_DISTRIBUTION_ID:-}" && "${AGENTCORE_DISTRIBUTION_CREATED:-false}" == true ]] && \ + aws_cli cloudfront get-distribution --id "${AGENTCORE_DISTRIBUTION_ID}" >/dev/null 2>&1; then + cf_file=$(mktemp "${WORK_DIR}/cf-delete.XXXXXX") + aws_cli cloudfront get-distribution-config --id "${AGENTCORE_DISTRIBUTION_ID}" > "${cf_file}" + etag=$(jq -r .ETag "${cf_file}") + if [[ "$(jq -r .DistributionConfig.Enabled "${cf_file}")" == true ]]; then + jq '.DistributionConfig | .Enabled=false' "${cf_file}" > "${cf_file}.disabled" + aws_cli cloudfront update-distribution --id "${AGENTCORE_DISTRIBUTION_ID}" --if-match "${etag}" \ + --distribution-config "file://${cf_file}.disabled" >/dev/null + fi + status="" + for i in {1..80}; do + status=$(aws_cli cloudfront get-distribution --id "${AGENTCORE_DISTRIBUTION_ID}" --query Distribution.Status --output text 2>/dev/null || true) + [[ "${status}" == Deployed ]] && break + ((i == 80)) || sleep 15 + done + [[ "${status}" == Deployed ]] || die "CloudFront distribution did not become deletable" + etag=$(aws_cli cloudfront get-distribution-config --id "${AGENTCORE_DISTRIBUTION_ID}" --query ETag --output text) + aws_cli cloudfront delete-distribution --id "${AGENTCORE_DISTRIBUTION_ID}" --if-match "${etag}" + rm -f "${cf_file}" "${cf_file}.disabled" +fi + +if [[ -n "${AGENTCORE_RUNTIME_ID:-}" && "${AGENTCORE_RUNTIME_CREATED:-false}" == true ]]; then + aws_cli bedrock-agentcore-control delete-agent-runtime --agent-runtime-id "${AGENTCORE_RUNTIME_ID}" >/dev/null +fi +if [[ -n "${AGENTCORE_LOG_GROUP:-}" ]]; then + if aws_cli logs describe-log-groups --log-group-name-prefix "${AGENTCORE_LOG_GROUP}" \ + --query "logGroups[?logGroupName=='${AGENTCORE_LOG_GROUP}'].logGroupName | [0]" --output text | grep -qx "${AGENTCORE_LOG_GROUP}"; then + aws_cli logs delete-log-group --log-group-name "${AGENTCORE_LOG_GROUP}" >/dev/null 2>&1 || \ + record_failure "Could not delete suite-owned AgentCore log group ${AGENTCORE_LOG_GROUP}" + fi +fi + +if [[ -n "${AGENTCORE_UI_BUCKET:-}" && "${AGENTCORE_UI_BUCKET_CREATED:-false}" == true ]]; then + aws_cli s3 rm "s3://${AGENTCORE_UI_BUCKET}" --recursive --only-show-errors || \ + record_failure "Could not empty suite-owned AgentCore UI bucket ${AGENTCORE_UI_BUCKET}" + aws_cli s3api delete-bucket --bucket "${AGENTCORE_UI_BUCKET}" || \ + record_failure "Could not delete suite-owned AgentCore UI bucket ${AGENTCORE_UI_BUCKET}" +fi +if [[ -n "${AGENTCORE_OAI_ID:-}" && "${AGENTCORE_OAI_CREATED:-false}" == true ]]; then + if oai_etag=$(aws_cli cloudfront get-cloud-front-origin-access-identity-config --id "${AGENTCORE_OAI_ID}" --query ETag --output text 2>/dev/null); then + aws_cli cloudfront delete-cloud-front-origin-access-identity --id "${AGENTCORE_OAI_ID}" --if-match "${oai_etag}" || \ + record_failure "Could not delete suite-owned CloudFront OAI ${AGENTCORE_OAI_ID}" + fi +fi + +# Lambda restore/delete, URL permissions, package, and suite-created security group. +lambda_cleanup_status=1 +lambda_function_tracked=false +if [[ -n "${LAMBDA_CREATED:-}${LAMBDA_BACKUP_VERSION:-}${LAMBDA_URL_CREATED:-}${LAMBDA_URL_ORIGINAL_B64:-}${LAMBDA_PERMISSION_URL_CREATED:-}${LAMBDA_PERMISSION_INVOKE_CREATED:-}" ]]; then + lambda_function_tracked=true + lambda_cleanup_status=0 + probe_aws_resource_with_retry 5 5 aws_cli lambda get-function-configuration --function-name aiagent || lambda_cleanup_status=$? + if [[ "${lambda_cleanup_status}" == 2 ]]; then + record_failure "Could not read tracked Lambda function aiagent before cleanup: ${AWS_PROBE_OUTPUT}" + elif [[ "${lambda_cleanup_status}" == 1 && "${LAMBDA_CREATED:-false}" != true ]]; then + record_failure "Tracked pre-existing Lambda function aiagent was not found; restore state was preserved" + fi +fi +if [[ "${lambda_cleanup_status}" == 0 ]]; then + if [[ "${LAMBDA_CREATED:-false}" == true ]]; then + aws_cli lambda delete-function --function-name aiagent + elif [[ -n "${LAMBDA_BACKUP_VERSION:-}" ]]; then + restore_dir=$(mktemp -d "${WORK_DIR}/lambda-restore.XXXXXX") + chmod 700 "${restore_dir}" + code_url=$(aws_cli lambda get-function --function-name aiagent --qualifier "${LAMBDA_BACKUP_VERSION}" --query Code.Location --output text) + curl --fail-with-body -sS --max-time 300 "${code_url}" -o "${restore_dir}/backup.zip" + chmod 600 "${restore_dir}/backup.zip" + workshop_bucket=$(aws_cli ssm get-parameter --name workshop-bucket-name --query Parameter.Value --output text) + restore_key="lambda/aiagent-suite-restore.zip" + aws_cli s3 cp "${restore_dir}/backup.zip" "s3://${workshop_bucket}/${restore_key}" --only-show-errors + aws_cli lambda update-function-code --function-name aiagent --s3-bucket "${workshop_bucket}" --s3-key "${restore_key}" >/dev/null + aws_cli lambda wait function-updated-v2 --function-name aiagent + backup=$(aws_cli lambda get-function-configuration --function-name aiagent --qualifier "${LAMBDA_BACKUP_VERSION}") + jq '{FunctionName:.FunctionName,Role:.Role,Handler:.Handler,Description:.Description,Timeout:.Timeout,MemorySize:.MemorySize,Runtime:.Runtime,Environment:{Variables:(.Environment.Variables // {})},VpcConfig:{SubnetIds:(.VpcConfig.SubnetIds // []),SecurityGroupIds:(.VpcConfig.SecurityGroupIds // []),Ipv6AllowedForDualStack:(.VpcConfig.Ipv6AllowedForDualStack // false)},DeadLetterConfig:{TargetArn:(.DeadLetterConfig.TargetArn // "")},KMSKeyArn:(.KMSKeyArn // ""),TracingConfig:{Mode:(.TracingConfig.Mode // "PassThrough")},Layers:[.Layers[]?.Arn],EphemeralStorage:{Size:(.EphemeralStorage.Size // 512)},SnapStart:{ApplyOn:(.SnapStart.ApplyOn // "None")},LoggingConfig:.LoggingConfig}' <<<"${backup}" > "${restore_dir}/config.json" + chmod 600 "${restore_dir}/config.json" + aws_cli lambda update-function-configuration --cli-input-json "file://${restore_dir}/config.json" >/dev/null + aws_cli lambda wait function-updated-v2 --function-name aiagent + current_lambda=$(aws_cli lambda get-function-configuration --function-name aiagent) + expected_lambda=$(jq -S '{CodeSha256,Role,Handler,Description,Timeout,MemorySize,Runtime,Environment:(.Environment.Variables // {}),VpcConfig:{SubnetIds:(.VpcConfig.SubnetIds // []),SecurityGroupIds:(.VpcConfig.SecurityGroupIds // []),Ipv6AllowedForDualStack:(.VpcConfig.Ipv6AllowedForDualStack // false)},DeadLetterConfig:(.DeadLetterConfig.TargetArn // ""),KMSKeyArn:(.KMSKeyArn // ""),TracingConfig:(.TracingConfig.Mode // "PassThrough"),Layers:[.Layers[]?.Arn],EphemeralStorage:(.EphemeralStorage.Size // 512),SnapStart:(.SnapStart.ApplyOn // "None"),LoggingConfig}' <<<"${backup}") + actual_lambda=$(jq -S '{CodeSha256,Role,Handler,Description,Timeout,MemorySize,Runtime,Environment:(.Environment.Variables // {}),VpcConfig:{SubnetIds:(.VpcConfig.SubnetIds // []),SecurityGroupIds:(.VpcConfig.SecurityGroupIds // []),Ipv6AllowedForDualStack:(.VpcConfig.Ipv6AllowedForDualStack // false)},DeadLetterConfig:(.DeadLetterConfig.TargetArn // ""),KMSKeyArn:(.KMSKeyArn // ""),TracingConfig:(.TracingConfig.Mode // "PassThrough"),Layers:[.Layers[]?.Arn],EphemeralStorage:(.EphemeralStorage.Size // 512),SnapStart:(.SnapStart.ApplyOn // "None"),LoggingConfig}' <<<"${current_lambda}") + aws_cli s3 rm "s3://${workshop_bucket}/${restore_key}" --only-show-errors + rm -rf "${restore_dir}" + if [[ "${actual_lambda}" == "${expected_lambda}" ]]; then + if aws_cli lambda delete-function --function-name aiagent --qualifier "${LAMBDA_BACKUP_VERSION}" >/dev/null; then + state_unset LAMBDA_BACKUP_VERSION + else + record_failure "Lambda was restored, but backup version ${LAMBDA_BACKUP_VERSION} could not be deleted" + fi + else + record_failure "Lambda aiagent did not match backup version ${LAMBDA_BACKUP_VERSION} after restore; backup version and state were preserved" + fi + fi +fi +if [[ "${LAMBDA_CREATED:-false}" != true && "${lambda_cleanup_status}" == 0 ]]; then + if [[ "${LAMBDA_URL_CREATED:-false}" == true ]]; then + aws_cli lambda delete-function-url-config --function-name aiagent >/dev/null 2>&1 || \ + record_failure "Could not delete suite-created Lambda function URL configuration" + elif [[ -n "${LAMBDA_URL_ORIGINAL_B64:-}" ]]; then + original_url=$(decode_b64 "${LAMBDA_URL_ORIGINAL_B64}") + jq --arg name aiagent '. + {FunctionName:$name}' <<<"${original_url}" > "${WORK_DIR}/lambda-url-restore.json" + chmod 600 "${WORK_DIR}/lambda-url-restore.json" + aws_cli lambda update-function-url-config --cli-input-json "file://${WORK_DIR}/lambda-url-restore.json" >/dev/null || \ + record_failure "Could not restore the original Lambda function URL configuration" + fi + if [[ "${LAMBDA_PERMISSION_URL_CREATED:-false}" == true ]]; then + aws_cli lambda remove-permission --function-name aiagent --statement-id FunctionURLAllowPublicAccess >/dev/null 2>&1 || \ + record_failure "Could not remove suite-created Lambda permission FunctionURLAllowPublicAccess" + fi + if [[ "${LAMBDA_PERMISSION_INVOKE_CREATED:-false}" == true ]]; then + aws_cli lambda remove-permission --function-name aiagent --statement-id FunctionURLPublicInvoke >/dev/null 2>&1 || \ + record_failure "Could not remove suite-created Lambda permission FunctionURLPublicInvoke" + fi +fi +if [[ "${LAMBDA_PACKAGE_UPLOADED:-false}" == true && -n "${LAMBDA_S3_KEY:-}" ]]; then + workshop_bucket=$(aws_cli ssm get-parameter --name workshop-bucket-name --query Parameter.Value --output text) + if [[ "${LAMBDA_PACKAGE_PREEXISTED:-false}" == true && -n "${LAMBDA_PACKAGE_BACKUP_KEY:-}" ]]; then + aws_cli s3api copy-object --bucket "${workshop_bucket}" --key "${LAMBDA_S3_KEY}" \ + --copy-source "${workshop_bucket}/${LAMBDA_PACKAGE_BACKUP_KEY}" >/dev/null + aws_cli s3 rm "s3://${workshop_bucket}/${LAMBDA_PACKAGE_BACKUP_KEY}" --only-show-errors + else + aws_cli s3 rm "s3://${workshop_bucket}/${LAMBDA_S3_KEY}" --only-show-errors + fi +fi +if [[ "${LAMBDA_SG_CREATED:-false}" == true && -n "${LAMBDA_SG_ID:-}" ]]; then + sg_deleted=false + for i in {1..20}; do + if aws_cli ec2 delete-security-group --group-id "${LAMBDA_SG_ID}" >/dev/null 2>&1; then + sg_deleted=true + break + fi + ((i == 20)) || sleep 15 + done + [[ "${sg_deleted}" == true ]] || record_failure "Could not delete suite-owned Lambda security group ${LAMBDA_SG_ID}" +fi + +# Restore the precreated ECS service rather than deleting it. +if [[ -n "${ECS_SERVICE_ARN:-}" ]]; then + ecs_snapshot_path="${ECS_ORIGINAL_PRIMARY_CONTAINER_PATH:-}" + if [[ -z "${ecs_snapshot_path}" && -n "${ECS_ORIGINAL_PRIMARY_CONTAINER_B64:-}" ]]; then + ecs_backup_dir="${WORK_DIR}/ecs-backups" + mkdir -p "${ecs_backup_dir}" + chmod 700 "${ecs_backup_dir}" + ecs_snapshot_path="${ecs_backup_dir}/original-primary-container.json" + decode_b64 "${ECS_ORIGINAL_PRIMARY_CONTAINER_B64}" > "${ecs_snapshot_path}" + chmod 600 "${ecs_snapshot_path}" + state_set ECS_ORIGINAL_PRIMARY_CONTAINER_PATH "${ecs_snapshot_path}" + state_unset ECS_ORIGINAL_PRIMARY_CONTAINER_B64 + fi + if [[ -n "${ecs_snapshot_path}" && -f "${ecs_snapshot_path}" ]]; then + aws_cli ecs update-express-gateway-service --service-arn "${ECS_SERVICE_ARN}" \ + --primary-container "$(jq -c . "${ecs_snapshot_path}")" >/dev/null || \ + record_failure "Could not restore the ECS Express primary container from ${ecs_snapshot_path}" + if [[ -n "${ECS_ORIGINAL_DEPLOYMENT_CONFIG_B64:-}" ]]; then + aws_cli ecs update-service --cluster aiagent --service aiagent \ + --deployment-configuration "$(decode_b64 "${ECS_ORIGINAL_DEPLOYMENT_CONFIG_B64}")" >/dev/null || \ + record_failure "Could not restore the ECS deployment configuration" + fi + else + record_failure "ECS restore snapshot is missing: ${ecs_snapshot_path:-not recorded}" + fi +fi + +# EKS AI-agent resources. +if [[ -n "${EKS_NAMESPACE_CREATED:-}${EKS_SERVICE_ACCOUNT_CREATED:-}${EKS_POD_IDENTITY_ID:-}${EKS_SPC_BACKUP_PATH:-}${EKS_DEPLOYMENT_BACKUP_PATH:-}${EKS_SERVICE_BACKUP_PATH:-}${EKS_INGRESS_BACKUP_PATH:-}" ]]; then + restore_pod_identity EKS + delete_k8s_app aiagent EKS aiagent +fi + +# Deterministic sample first, then MCP EKS resources. +if [[ "${MCP_SAMPLE_CREATED:-false}" == true && -n "${MCP_SAMPLE_ID:-}" && -n "${MCP_URL:-}" ]]; then + if curl --fail-with-body -sS --connect-timeout 10 --max-time 30 -X DELETE "${MCP_URL}/unicorns/${MCP_SAMPLE_ID}" >/dev/null; then + sample_status=$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 10 --max-time 30 \ + "${MCP_URL}/unicorns/${MCP_SAMPLE_ID}" || true) + [[ "${sample_status}" == 404 ]] || record_failure "Suite sample Unicorn ${MCP_SAMPLE_ID} is still retrievable after deletion (HTTP ${sample_status:-000})" + else + record_failure "Could not delete suite sample Unicorn ${MCP_SAMPLE_ID}" + fi +fi +if [[ -n "${MCP_NAMESPACE_CREATED:-}${MCP_SERVICE_ACCOUNT_CREATED:-}${MCP_POD_IDENTITY_ID:-}${MCP_SPC_BACKUP_PATH:-}${MCP_DEPLOYMENT_BACKUP_PATH:-}${MCP_SERVICE_BACKUP_PATH:-}${MCP_INGRESS_BACKUP_PATH:-}" ]]; then + restore_pod_identity MCP + delete_k8s_app mcpserver MCP mcpserver +fi + +# Cognito ownership-aware cleanup. +if [[ -n "${COGNITO_USER_POOL_ID:-}" ]]; then + if [[ "${COGNITO_POOL_CREATED:-false}" == true ]]; then + aws_cli cognito-idp delete-user-pool --user-pool-id "${COGNITO_USER_POOL_ID}" >/dev/null 2>&1 || \ + record_failure "Could not delete suite-owned Cognito user pool ${COGNITO_USER_POOL_ID}" + else + IFS=',' read -r -a created_users <<<"${COGNITO_CREATED_USERS:-}" + for user in "${created_users[@]}"; do + if [[ -n "${user}" ]]; then + aws_cli cognito-idp admin-delete-user --user-pool-id "${COGNITO_USER_POOL_ID}" --username "${user}" >/dev/null 2>&1 || \ + record_failure "Could not delete suite-created Cognito user ${user}" + fi + done + if [[ "${COGNITO_CLIENT_CREATED:-false}" == true && -n "${COGNITO_CLIENT_ID:-}" ]]; then + aws_cli cognito-idp delete-user-pool-client --user-pool-id "${COGNITO_USER_POOL_ID}" --client-id "${COGNITO_CLIENT_ID}" >/dev/null 2>&1 || \ + record_failure "Could not delete suite-created Cognito client ${COGNITO_CLIENT_ID}" + elif [[ -n "${COGNITO_CLIENT_ORIGINAL_CONFIG_B64:-}" ]]; then + original_client=$(decode_b64 "${COGNITO_CLIENT_ORIGINAL_CONFIG_B64}") + aws_cli cognito-idp update-user-pool-client --cli-input-json "${original_client}" >/dev/null || \ + record_failure "Could not restore Cognito client ${COGNITO_CLIENT_ID}" + fi + fi +fi + +# Restore account-level Bedrock logging, then remove only a suite-created log group. +if [[ -n "${BEDROCK_LOGGING_ORIGINAL_B64:-}" ]]; then + if [[ "${BEDROCK_LOGGING_ORIGINAL_B64}" == __NONE__ ]]; then + aws_cli bedrock delete-model-invocation-logging-configuration >/dev/null 2>&1 || \ + record_failure "Could not remove the suite-applied Bedrock model invocation logging configuration" + else + jq -n --argjson config "$(decode_b64 "${BEDROCK_LOGGING_ORIGINAL_B64}")" '{loggingConfig:$config}' > "${WORK_DIR}/bedrock-logging-restore.json" + chmod 600 "${WORK_DIR}/bedrock-logging-restore.json" + aws_cli bedrock put-model-invocation-logging-configuration \ + --cli-input-json "file://${WORK_DIR}/bedrock-logging-restore.json" >/dev/null || \ + record_failure "Could not restore the original Bedrock model invocation logging configuration" + fi +fi +if [[ "${BEDROCK_LOG_GROUP_CREATED:-false}" == true && -n "${BEDROCK_LOG_GROUP:-}" ]]; then + aws_cli logs delete-log-group --log-group-name "${BEDROCK_LOG_GROUP}" >/dev/null 2>&1 || \ + record_failure "Could not delete suite-owned Bedrock log group ${BEDROCK_LOG_GROUP}" +fi + +verify_aws_absent() { + local description="$1" + shift + local error_file + error_file=$(mktemp "${WORK_DIR}/verify-aws.XXXXXX") + chmod 600 "${error_file}" + if "$@" >/dev/null 2>"${error_file}"; then + record_failure "Verification failed: ${description} still exists" + elif ! grep -Eqi 'ResourceNotFoundException|NotFoundException|NoSuch[A-Za-z]+|InvalidGroup\.NotFound|UserNotFoundException|\(404\)|status code: 404|does not exist' "${error_file}"; then + record_failure "Verification failed for ${description}: $(tr '\n' ' ' < "${error_file}")" + fi + rm -f "${error_file}" +} + +verify_log_group_absent() { + local log_group="$1" description="$2" result + if ! result=$(aws_cli logs describe-log-groups --log-group-name-prefix "${log_group}" \ + --query "logGroups[?logGroupName=='${log_group}'].logGroupName | [0]" --output text 2>&1); then + record_failure "Verification failed for ${description}: ${result}" + elif [[ "${result}" == "${log_group}" ]]; then + record_failure "Verification failed: ${description} still exists" + fi +} + +verify_pod_identity() { + local prefix="$1" created_var="${1}_POD_IDENTITY_CREATED" id_var="${1}_POD_IDENTITY_ID" role_var="${1}_ORIGINAL_POD_ROLE_ARN" + local association_id="${!id_var:-}" original_role="${!role_var:-}" + [[ -n "${association_id}" ]] || return 0 + if [[ "${!created_var:-false}" == true ]]; then + verify_aws_absent "suite-owned ${prefix} Pod Identity association ${association_id}" \ + aws_cli eks describe-pod-identity-association --cluster-name "${CLUSTER_NAME}" --association-id "${association_id}" + elif [[ -n "${original_role}" ]]; then + restored_role=$(aws_cli eks describe-pod-identity-association --cluster-name "${CLUSTER_NAME}" \ + --association-id "${association_id}" --query association.roleArn --output text 2>/dev/null || true) + [[ "${restored_role}" == "${original_role}" ]] || \ + record_failure "Verification failed: ${prefix} Pod Identity role is ${restored_role:-unavailable}, expected ${original_role}" + fi +} + +verify_k8s_app() { + local namespace="$1" prefix="$2" service_account="$3" + local namespace_created_var="${prefix}_NAMESPACE_CREATED" sa_created_var="${prefix}_SERVICE_ACCOUNT_CREATED" + local probe_status kind name backup_var backup_path expected_json actual_json + + probe_status=0 + probe_k8s_resource namespace "${namespace}" || probe_status=$? + if [[ "${!namespace_created_var:-false}" == true ]]; then + case "${probe_status}" in + 0) record_failure "Verification failed: suite-owned namespace ${namespace} still exists" ;; + 1) ;; + *) record_failure "Verification failed while checking namespace ${namespace}: ${KUBECTL_PROBE_OUTPUT}" ;; + esac + return 0 + fi + case "${probe_status}" in + 0) ;; + 1) record_failure "Verification failed: pre-existing namespace ${namespace} no longer exists"; return 0 ;; + *) record_failure "Verification failed while checking pre-existing namespace ${namespace}: ${KUBECTL_PROBE_OUTPUT}"; return 0 ;; + esac + + probe_status=0 + probe_k8s_resource serviceaccount "${service_account}" "${namespace}" || probe_status=$? + if [[ "${!sa_created_var:-false}" == true ]]; then + case "${probe_status}" in + 0) record_failure "Verification failed: suite-owned service account ${namespace}/${service_account} still exists" ;; + 1) ;; + *) record_failure "Verification failed while checking service account ${namespace}/${service_account}: ${KUBECTL_PROBE_OUTPUT}" ;; + esac + else + case "${probe_status}" in + 0) ;; + 1) record_failure "Verification failed: pre-existing service account ${namespace}/${service_account} no longer exists" ;; + *) record_failure "Verification failed while checking pre-existing service account ${namespace}/${service_account}: ${KUBECTL_PROBE_OUTPUT}" ;; + esac + fi + + while IFS='|' read -r kind name backup_var; do + backup_path="${!backup_var:-}" + probe_status=0 + probe_k8s_resource "${kind}" "${name}" "${namespace}" || probe_status=$? + if [[ -n "${backup_path}" ]]; then + if [[ ! -f "${backup_path}" ]]; then + record_failure "Verification failed: Kubernetes restore snapshot is missing: ${backup_path}" + continue + fi + case "${probe_status}" in + 0) + expected_json=$(sanitize_k8s_resource_json < "${backup_path}" | jq -S .) + actual_json=$(sanitize_k8s_resource_json <<<"${KUBECTL_PROBE_OUTPUT}" | jq -S .) + [[ "${actual_json}" == "${expected_json}" ]] || \ + record_failure "Verification failed: restored ${kind} ${namespace}/${name} does not match ${backup_path}" + ;; + 1) record_failure "Verification failed: ${kind} ${namespace}/${name} was not restored from ${backup_path}" ;; + *) record_failure "Verification failed while checking restored ${kind} ${namespace}/${name}: ${KUBECTL_PROBE_OUTPUT}" ;; + esac + else + case "${probe_status}" in + 0) record_failure "Verification failed: suite-owned ${kind} ${namespace}/${name} still exists" ;; + 1) ;; + *) record_failure "Verification failed while checking suite-owned ${kind} ${namespace}/${name}: ${KUBECTL_PROBE_OUTPUT}" ;; + esac + fi + done </dev/null | \ + jq -S '{image,containerPort,awsLogsConfiguration,repositoryCredentials,command,environment,secrets} | with_entries(select(.value != null))' || true) + [[ "${actual_primary}" == "${expected_primary}" ]] && break + ((i == 40)) || sleep 15 + done + [[ "${actual_primary}" == "${expected_primary}" ]] || \ + record_failure "Verification failed: ECS primary container does not match ${snapshot_path}" + if [[ -n "${ECS_ORIGINAL_DEPLOYMENT_CONFIG_B64:-}" ]]; then + expected_deployment=$(decode_b64 "${ECS_ORIGINAL_DEPLOYMENT_CONFIG_B64}" | jq -S .) + actual_deployment=$(aws_cli ecs describe-services --cluster aiagent --services aiagent \ + --query 'services[0].deploymentConfiguration' --output json 2>/dev/null | jq -S . || true) + [[ "${actual_deployment}" == "${expected_deployment}" ]] || \ + record_failure "Verification failed: ECS deployment configuration was not restored" + fi +} + +log "Verifying cleanup results before clearing ownership state" +if [[ "${AGENTCORE_DISTRIBUTION_CREATED:-false}" == true && -n "${AGENTCORE_DISTRIBUTION_ID:-}" ]]; then + verify_aws_absent "suite-owned CloudFront distribution ${AGENTCORE_DISTRIBUTION_ID}" \ + aws_cli cloudfront get-distribution --id "${AGENTCORE_DISTRIBUTION_ID}" +fi +if [[ "${AGENTCORE_RUNTIME_CREATED:-false}" == true && -n "${AGENTCORE_RUNTIME_ID:-}" ]]; then + for i in {1..40}; do + if ! aws_cli bedrock-agentcore-control get-agent-runtime --agent-runtime-id "${AGENTCORE_RUNTIME_ID}" >/dev/null 2>&1; then + break + fi + ((i == 40)) || sleep 15 + done + verify_aws_absent "suite-owned AgentCore Runtime ${AGENTCORE_RUNTIME_ID}" \ + aws_cli bedrock-agentcore-control get-agent-runtime --agent-runtime-id "${AGENTCORE_RUNTIME_ID}" +fi +if [[ "${AGENTCORE_UI_BUCKET_CREATED:-false}" == true && -n "${AGENTCORE_UI_BUCKET:-}" ]]; then + verify_aws_absent "suite-owned AgentCore UI bucket ${AGENTCORE_UI_BUCKET}" \ + aws_cli s3api head-bucket --bucket "${AGENTCORE_UI_BUCKET}" +fi +if [[ "${AGENTCORE_OAI_CREATED:-false}" == true && -n "${AGENTCORE_OAI_ID:-}" ]]; then + verify_aws_absent "suite-owned CloudFront OAI ${AGENTCORE_OAI_ID}" \ + aws_cli cloudfront get-cloud-front-origin-access-identity --id "${AGENTCORE_OAI_ID}" +fi +if [[ -n "${AGENTCORE_LOG_GROUP:-}" ]]; then + verify_log_group_absent "${AGENTCORE_LOG_GROUP}" "suite-owned AgentCore log group ${AGENTCORE_LOG_GROUP}" +fi + +if [[ "${LAMBDA_CREATED:-false}" == true ]]; then + verify_aws_absent "suite-owned Lambda function aiagent" aws_cli lambda get-function --function-name aiagent +elif [[ "${lambda_function_tracked}" == true ]]; then + lambda_verify_status=0 + probe_aws_resource_with_retry 5 5 aws_cli lambda get-function-configuration --function-name aiagent || lambda_verify_status=$? + if [[ "${lambda_verify_status}" == 0 ]]; then + if [[ "${LAMBDA_URL_CREATED:-false}" == true ]]; then + verify_aws_absent "suite-created Lambda function URL configuration" aws_cli lambda get-function-url-config --function-name aiagent + elif [[ -n "${LAMBDA_URL_ORIGINAL_B64:-}" ]]; then + lambda_url_status=0 + probe_aws_resource_with_retry 5 5 aws_cli lambda get-function-url-config --function-name aiagent || lambda_url_status=$? + if [[ "${lambda_url_status}" == 0 ]]; then + expected_url=$(decode_b64 "${LAMBDA_URL_ORIGINAL_B64}" | jq -S '{AuthType,InvokeMode,Cors}') + actual_url=$(jq -S '{AuthType,InvokeMode,Cors}' <<<"${AWS_PROBE_OUTPUT}") + [[ "${actual_url}" == "${expected_url}" ]] || record_failure "Verification failed: Lambda function URL configuration was not restored" + elif [[ "${lambda_url_status}" == 1 ]]; then + record_failure "Verification failed: original Lambda function URL configuration is missing" + else + record_failure "Verification failed: could not read Lambda function URL configuration: ${AWS_PROBE_OUTPUT}" + fi + fi + lambda_policy_status=0 + probe_aws_resource_with_retry 5 5 aws_cli lambda get-policy --function-name aiagent --query Policy --output text || lambda_policy_status=$? + if [[ "${lambda_policy_status}" == 0 ]]; then + lambda_policy="${AWS_PROBE_OUTPUT}" + elif [[ "${lambda_policy_status}" == 1 ]]; then + lambda_policy='{"Statement":[]}' + else + lambda_policy='{"Statement":[]}' + record_failure "Verification failed: could not read Lambda resource policy: ${AWS_PROBE_OUTPUT}" + fi + if [[ "${LAMBDA_PERMISSION_URL_CREATED:-false}" == true ]] && jq -e '.Statement[]? | select(.Sid == "FunctionURLAllowPublicAccess")' >/dev/null <<<"${lambda_policy}"; then + record_failure "Verification failed: Lambda permission FunctionURLAllowPublicAccess still exists" + fi + if [[ "${LAMBDA_PERMISSION_INVOKE_CREATED:-false}" == true ]] && jq -e '.Statement[]? | select(.Sid == "FunctionURLPublicInvoke")' >/dev/null <<<"${lambda_policy}"; then + record_failure "Verification failed: Lambda permission FunctionURLPublicInvoke still exists" + fi + elif [[ "${lambda_verify_status}" == 1 ]]; then + record_failure "Verification failed: tracked pre-existing Lambda function aiagent no longer exists" + else + record_failure "Verification failed: could not read tracked Lambda function aiagent: ${AWS_PROBE_OUTPUT}" + fi +fi +if [[ "${LAMBDA_PACKAGE_UPLOADED:-false}" == true && -n "${LAMBDA_S3_KEY:-}" ]]; then + workshop_bucket=$(aws_cli ssm get-parameter --name workshop-bucket-name --query Parameter.Value --output text) + if [[ "${LAMBDA_PACKAGE_PREEXISTED:-false}" == true ]]; then + aws_cli s3api head-object --bucket "${workshop_bucket}" --key "${LAMBDA_S3_KEY}" >/dev/null 2>&1 || \ + record_failure "Verification failed: pre-existing Lambda package ${LAMBDA_S3_KEY} was not restored" + if [[ -n "${LAMBDA_PACKAGE_BACKUP_KEY:-}" ]] && aws_cli s3api head-object --bucket "${workshop_bucket}" --key "${LAMBDA_PACKAGE_BACKUP_KEY}" >/dev/null 2>&1; then + record_failure "Verification failed: temporary Lambda package backup ${LAMBDA_PACKAGE_BACKUP_KEY} still exists" + fi + else + verify_aws_absent "suite-uploaded Lambda package ${LAMBDA_S3_KEY}" \ + aws_cli s3api head-object --bucket "${workshop_bucket}" --key "${LAMBDA_S3_KEY}" + fi +fi +if [[ "${LAMBDA_SG_CREATED:-false}" == true && -n "${LAMBDA_SG_ID:-}" ]]; then + verify_aws_absent "suite-owned Lambda security group ${LAMBDA_SG_ID}" \ + aws_cli ec2 describe-security-groups --group-ids "${LAMBDA_SG_ID}" +fi + +verify_ecs_restore +if [[ -n "${EKS_NAMESPACE_CREATED:-}${EKS_SERVICE_ACCOUNT_CREATED:-}${EKS_POD_IDENTITY_ID:-}${EKS_SPC_BACKUP_PATH:-}${EKS_DEPLOYMENT_BACKUP_PATH:-}${EKS_SERVICE_BACKUP_PATH:-}${EKS_INGRESS_BACKUP_PATH:-}" ]]; then + verify_pod_identity EKS + verify_k8s_app aiagent EKS aiagent +fi +if [[ -n "${MCP_NAMESPACE_CREATED:-}${MCP_SERVICE_ACCOUNT_CREATED:-}${MCP_POD_IDENTITY_ID:-}${MCP_SPC_BACKUP_PATH:-}${MCP_DEPLOYMENT_BACKUP_PATH:-}${MCP_SERVICE_BACKUP_PATH:-}${MCP_INGRESS_BACKUP_PATH:-}" ]]; then + verify_pod_identity MCP + verify_k8s_app mcpserver MCP mcpserver +fi + +if [[ -n "${COGNITO_USER_POOL_ID:-}" ]]; then + if [[ "${COGNITO_POOL_CREATED:-false}" == true ]]; then + verify_aws_absent "suite-owned Cognito user pool ${COGNITO_USER_POOL_ID}" \ + aws_cli cognito-idp describe-user-pool --user-pool-id "${COGNITO_USER_POOL_ID}" + else + IFS=',' read -r -a created_users <<<"${COGNITO_CREATED_USERS:-}" + for user in "${created_users[@]}"; do + [[ -n "${user}" ]] && verify_aws_absent "suite-created Cognito user ${user}" \ + aws_cli cognito-idp admin-get-user --user-pool-id "${COGNITO_USER_POOL_ID}" --username "${user}" + done + if [[ "${COGNITO_CLIENT_CREATED:-false}" == true && -n "${COGNITO_CLIENT_ID:-}" ]]; then + verify_aws_absent "suite-created Cognito client ${COGNITO_CLIENT_ID}" \ + aws_cli cognito-idp describe-user-pool-client --user-pool-id "${COGNITO_USER_POOL_ID}" --client-id "${COGNITO_CLIENT_ID}" + elif [[ -n "${COGNITO_CLIENT_ORIGINAL_CONFIG_B64:-}" ]]; then + expected_client=$(decode_b64 "${COGNITO_CLIENT_ORIGINAL_CONFIG_B64}") + current_client=$(aws_cli cognito-idp describe-user-pool-client --user-pool-id "${COGNITO_USER_POOL_ID}" \ + --client-id "${COGNITO_CLIENT_ID}" --query UserPoolClient --output json 2>/dev/null || printf '{}') + jq -e --argjson expected "${expected_client}" --argjson current "${current_client}" \ + '$expected | to_entries | all(. as $entry | $current[$entry.key] == $entry.value)' >/dev/null || \ + record_failure "Verification failed: Cognito client ${COGNITO_CLIENT_ID} does not match its original configuration" + fi + fi +fi + +if [[ -n "${BEDROCK_LOGGING_ORIGINAL_B64:-}" ]]; then + if ! current_logging=$(aws_cli bedrock get-model-invocation-logging-configuration 2>&1); then + record_failure "Verification failed: could not read Bedrock model invocation logging configuration: ${current_logging}" + elif [[ "${BEDROCK_LOGGING_ORIGINAL_B64}" == __NONE__ ]]; then + [[ "$(jq -r '.loggingConfig // empty' <<<"${current_logging}")" == "" ]] || \ + record_failure "Verification failed: Bedrock model invocation logging remains configured" + else + expected_logging=$(decode_b64 "${BEDROCK_LOGGING_ORIGINAL_B64}" | jq -S .) + actual_logging=$(jq -S '.loggingConfig // {}' <<<"${current_logging}") + [[ "${actual_logging}" == "${expected_logging}" ]] || \ + record_failure "Verification failed: Bedrock model invocation logging was not restored" + fi +fi +if [[ "${BEDROCK_LOG_GROUP_CREATED:-false}" == true && -n "${BEDROCK_LOG_GROUP:-}" ]]; then + verify_log_group_absent "${BEDROCK_LOG_GROUP}" "suite-owned Bedrock log group ${BEDROCK_LOG_GROUP}" +fi + +if ((${#cleanup_failures[@]} > 0)); then + warn "Cleanup was incomplete; preserving ownership state in ${STATE_FILE}. Resolve these failures and rerun 99-cleanup.sh --apply:" + for failure in "${cleanup_failures[@]}"; do + printf ' - %s\n' "${failure}" >&2 + done + exit 1 +fi + +if ! rm -rf "${WORK_DIR}"; then + record_failure "Could not remove suite work directory ${WORK_DIR}" +fi +if [[ -e "${WORK_DIR}" || ${#cleanup_failures[@]} -gt 0 ]]; then + [[ -e "${WORK_DIR}" ]] && record_failure "Suite work directory still exists: ${WORK_DIR}" + warn "Cleanup was incomplete; preserving ownership state in ${STATE_FILE}" + exit 1 +fi +cleanup_timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ) +state_tmp=$(mktemp "${STATE_FILE}.cleanup.XXXXXX") +printf 'export SUITE_ACCOUNT_ID=%q\n' "${ACCOUNT_ID}" > "${state_tmp}" +printf 'export SUITE_AWS_REGION=%q\n' "${AWS_REGION}" >> "${state_tmp}" +printf 'export CLEANUP_LAST_APPLIED=%q\n' "${cleanup_timestamp}" >> "${state_tmp}" +chmod 600 "${state_tmp}" +mv "${state_tmp}" "${STATE_FILE}" +log "Cleanup verified and stale ownership state cleared. Prerequisite infrastructure, repositories, IAM roles, bucket, ECS service, and participant source were preserved." diff --git a/infra/scripts/deploy/java-spring-ai-agents/README.md b/infra/scripts/deploy/java-spring-ai-agents/README.md new file mode 100644 index 00000000..c57354fb --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/README.md @@ -0,0 +1,40 @@ +# Idempotent deployment suite + +This suite deploys and verifies the Unicorn Rentals Spring AI workshop. It replaces the removed legacy single-digit deployment scripts while preserving the manual-workshop `cleanup.sh` entry point. + +## Order and targets + +Run the complete flow with exactly one deployment target: + +```bash +./00-deploy-all.sh --target eks|ecs|lambda|agentcore +``` + +The orchestrator runs `01`–`05`, one of `10`–`13`, `20`, and `30`. Cleanup is never automatic. Every stage can also run independently and prints its prerequisites. + +Use `01-setup.sh --force` only to refresh existing `~/environment/aiagent` or `~/environment/mcpserver` source trees. `05-security.sh --rotate-passwords` is the only mode that changes passwords for existing workshop users. + +The EKS, ECS, Lambda, and AgentCore scripts are separate deployment targets. Re-running a stage discovers deterministic resource names and creates or updates the desired configuration. State is stored in `~/environment/.java-spring-ai-agents-suite.env`, is bound to one AWS account and Region, and contains no passwords, tokens, or database credentials. + +Workshop resource names match the workshop content: + +- ECR repositories: `aiagent` and `mcpserver` +- ECR image tag: `latest` +- AgentCore Runtime: `aiagent` +- Kubernetes namespaces and services: `aiagent` and `mcpserver` + +The generated application uses Spring Boot 4.1.0, Spring AI 2.0.1, `spring-ai-vector-store-advisor`, and Claude Sonnet 4.6. The AgentCore target adds the AgentCore 2.1.0 BOM and Runtime starter in an isolated build directory. AgentCore logs use `/aws/bedrock-agentcore/runtimes/-DEFAULT`. + +## Test scope + +`30-test.sh --target TARGET` obtains Cognito tokens and hard-fails on health, authentication, persona, conversation memory, PgVector RAG, date/time tools, and MCP Unicorn inventory checks. + +## Cleanup safety + +`99-cleanup.sh` is plan-only by default. `99-cleanup.sh --apply` removes only resources tracked by this suite or restores settings recorded before modification. It never deletes the prerequisite CloudFormation stack, VPC, Aurora cluster, EKS cluster, precreated ECS service, IAM roles, workshop bucket, ECR repositories, or participant source directories. + +Use `90-diagnose.sh` for read-only status collection before changing or cleaning resources. Participants who followed manual workshop commands use the separate `cleanup.sh` referenced by the workshop cleanup section. + +## Live AWS verification + +Before workshop use, run the stages in a disposable workshop account and verify EKS Pod Identity and Secrets Store CSI integration, internal MCP ALB reachability, ECS Express updates, Lambda Web Adapter behavior, AgentCore Runtime authorization, CloudFront propagation, and Bedrock model access. `30-test.sh` is the live acceptance gate. diff --git a/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh b/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh new file mode 100755 index 00000000..397cdd9a --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SUITE_NAME="java-spring-ai-agents" +SUITE_OWNER="java-spring-ai-agents-suite" +ENVIRONMENT_DIR="${ENVIRONMENT_DIR:-${HOME}/environment}" +STATE_FILE="${SUITE_STATE_FILE:-${ENVIRONMENT_DIR}/.java-spring-ai-agents-suite.env}" +WORK_DIR="${SUITE_WORK_DIR:-${ENVIRONMENT_DIR}/.java-spring-ai-agents-suite}" +AIAGENT_DIR="${ENVIRONMENT_DIR}/aiagent" +MCPSERVER_DIR="${ENVIRONMENT_DIR}/mcpserver" +CLUSTER_NAME="${CLUSTER_NAME:-workshop-eks}" + +log() { printf '[%s] %s\n' "${SUITE_NAME}" "$*"; } +warn() { printf '[%s] WARNING: %s\n' "${SUITE_NAME}" "$*" >&2; } +die() { printf '[%s] ERROR: %s\n' "${SUITE_NAME}" "$*" >&2; exit 1; } + +secure_work_dir() { + mkdir -p "${WORK_DIR}" + chmod 700 "${WORK_DIR}" +} + +sanitize_k8s_resource_json() { + jq ' + del( + .metadata.creationTimestamp, + .metadata.generation, + .metadata.managedFields, + .metadata.resourceVersion, + .metadata.uid, + .metadata.annotations."deployment.kubernetes.io/revision", + .metadata.annotations."kubectl.kubernetes.io/last-applied-configuration", + .status + ) + | if .kind == "Service" then + del(.spec.clusterIP,.spec.clusterIPs,.spec.ipFamilies,.spec.ipFamilyPolicy,.spec.internalTrafficPolicy,.spec.sessionAffinity) + else . end + ' +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" +} + +print_prerequisites() { + log "Prerequisites: configured AWS CLI credentials, jq, curl, and access to the predeployed workshop resources." + if (($#)); then log "This stage also requires: $*"; fi +} + +load_workshop_environment() { + if [[ -r /etc/profile.d/workshop.sh ]]; then + set +u + # shellcheck disable=SC1091 + source /etc/profile.d/workshop.sh + set -u + fi +} + +load_state() { + if [[ -r "${STATE_FILE}" ]]; then + set +u + # shellcheck disable=SC1090 + source "${STATE_FILE}" + set -u + fi +} + +state_set() { + local key="$1" value="$2" tmp + [[ "${key}" =~ ^[A-Z0-9_]+$ ]] || die "Invalid state key: ${key}" + [[ "${key}" != *PASSWORD* && "${key}" != *TOKEN* && "${key}" != *SECRET_VALUE* ]] || \ + die "Refusing to persist a secret-like state key: ${key}" + mkdir -p "${ENVIRONMENT_DIR}" + tmp=$(mktemp "${STATE_FILE}.tmp.XXXXXX") + if [[ -f "${STATE_FILE}" ]]; then + while IFS= read -r line || [[ -n "${line}" ]]; do + [[ "${line}" == "export ${key}="* ]] || printf '%s\n' "${line}" >> "${tmp}" + done < "${STATE_FILE}" + fi + printf 'export %s=%q\n' "${key}" "${value}" >> "${tmp}" + chmod 600 "${tmp}" + mv "${tmp}" "${STATE_FILE}" + export "${key}=${value}" +} + +state_unset() { + local key="$1" tmp + [[ -f "${STATE_FILE}" ]] || return 0 + tmp=$(mktemp "${STATE_FILE}.tmp.XXXXXX") + while IFS= read -r line || [[ -n "${line}" ]]; do + [[ "${line}" == "export ${key}="* ]] || printf '%s\n' "${line}" >> "${tmp}" + done < "${STATE_FILE}" + chmod 600 "${tmp}" + mv "${tmp}" "${STATE_FILE}" + unset "${key}" || true +} + +init_context() { + require_cmd aws + require_cmd jq + load_workshop_environment + load_state + + local discovered_account discovered_region + discovered_account=$(aws sts get-caller-identity --query Account --output text --no-cli-pager) + discovered_region="${AWS_REGION:-${AWS_DEFAULT_REGION:-}}" + if [[ -z "${discovered_region}" ]]; then + discovered_region=$(aws configure get region 2>/dev/null || true) + fi + [[ -n "${discovered_region}" && "${discovered_region}" != "None" ]] || die "AWS Region is not configured" + + if [[ -n "${SUITE_ACCOUNT_ID:-}" && "${SUITE_ACCOUNT_ID}" != "${discovered_account}" ]]; then + die "State belongs to account ${SUITE_ACCOUNT_ID}; current credentials use ${discovered_account}" + fi + if [[ -n "${SUITE_AWS_REGION:-}" && "${SUITE_AWS_REGION}" != "${discovered_region}" ]]; then + die "State belongs to Region ${SUITE_AWS_REGION}; current Region is ${discovered_region}" + fi + + ACCOUNT_ID="${discovered_account}" + AWS_REGION="${discovered_region}" + export ACCOUNT_ID AWS_REGION AWS_DEFAULT_REGION="${AWS_REGION}" + state_set SUITE_ACCOUNT_ID "${ACCOUNT_ID}" + state_set SUITE_AWS_REGION "${AWS_REGION}" + secure_work_dir + log "Account: ${ACCOUNT_ID}; Region: ${AWS_REGION}" +} + +init_context_read_only() { + require_cmd aws + require_cmd jq + load_workshop_environment + load_state + + local discovered_account discovered_region + discovered_account=$(aws sts get-caller-identity --query Account --output text --no-cli-pager) + discovered_region="${AWS_REGION:-${AWS_DEFAULT_REGION:-}}" + if [[ -z "${discovered_region}" ]]; then + discovered_region=$(aws configure get region 2>/dev/null || true) + fi + [[ -n "${discovered_region}" && "${discovered_region}" != "None" ]] || die "AWS Region is not configured" + [[ -z "${SUITE_ACCOUNT_ID:-}" || "${SUITE_ACCOUNT_ID}" == "${discovered_account}" ]] || \ + die "State belongs to account ${SUITE_ACCOUNT_ID}; current credentials use ${discovered_account}" + [[ -z "${SUITE_AWS_REGION:-}" || "${SUITE_AWS_REGION}" == "${discovered_region}" ]] || \ + die "State belongs to Region ${SUITE_AWS_REGION}; current Region is ${discovered_region}" + ACCOUNT_ID="${discovered_account}" + AWS_REGION="${discovered_region}" + export ACCOUNT_ID AWS_REGION AWS_DEFAULT_REGION="${AWS_REGION}" + log "Account: ${ACCOUNT_ID}; Region: ${AWS_REGION}" +} + +aws_cli() { + aws --region "${AWS_REGION}" --no-cli-pager "$@" +} + +require_state() { + local key + for key in "$@"; do + [[ -n "${!key:-}" ]] || die "Missing ${key}. Run the prerequisite stage first." + done +} + +is_none() { [[ -z "${1:-}" || "${1}" == "None" || "${1}" == "null" ]]; } + +wait_for_command() { + local description="$1" attempts="$2" interval="$3" + shift 3 + local i + for ((i=1; i<=attempts; i++)); do + if "$@"; then + log "${description}: ready" + return 0 + fi + ((i == attempts)) || sleep "${interval}" + done + die "Timed out waiting for ${description} after $((attempts * interval)) seconds" +} + +http_status() { + curl -s -o /dev/null -w '%{http_code}' --connect-timeout 10 --max-time 30 "$1" 2>/dev/null || true +} + +dns_resolves() { + local hostname="$1" + if command -v getent >/dev/null 2>&1; then + getent ahosts "${hostname}" >/dev/null 2>&1 + elif command -v python3 >/dev/null 2>&1; then + python3 -c 'import socket, sys; socket.getaddrinfo(sys.argv[1], None)' "${hostname}" >/dev/null 2>&1 + else + die "DNS readiness checks require getent or python3" + fi +} + +wait_for_ingress_hostname() { + local description="$1" namespace="$2" ingress="$3" attempts="${4:-40}" interval="${5:-15}" + local i + INGRESS_HOST="" + printf '[%s] Waiting for %s' "${SUITE_NAME}" "${description}" + for ((i=1; i<=attempts; i++)); do + INGRESS_HOST=$(kubectl get ingress "${ingress}" -n "${namespace}" \ + -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' 2>/dev/null || true) + if [[ -n "${INGRESS_HOST}" ]]; then + printf ' READY\n' + return 0 + fi + printf '.' + ((i == attempts)) || sleep "${interval}" + done + printf '\n' + die "Timed out waiting for ${description} after $((attempts * interval)) seconds" +} + +wait_for_dns() { + local description="$1" hostname="$2" attempts="${3:-30}" interval="${4:-10}" + local i + printf '[%s] Waiting for %s DNS' "${SUITE_NAME}" "${description}" + for ((i=1; i<=attempts; i++)); do + if dns_resolves "${hostname}"; then + printf ' READY\n' + return 0 + fi + printf '.' + ((i == attempts)) || sleep "${interval}" + done + printf '\n' + die "Timed out waiting for ${description} DNS after $((attempts * interval)) seconds" +} + +wait_for_http_status() { + local description="$1" url="$2" expected_regex="$3" attempts="${4:-40}" interval="${5:-15}" + local i http_code="" + printf '[%s] Waiting for %s' "${SUITE_NAME}" "${description}" + for ((i=1; i<=attempts; i++)); do + http_code=$(http_status "${url}") + if [[ "${http_code}" =~ ${expected_regex} ]]; then + printf ' HTTP %s\n' "${http_code}" + return 0 + fi + printf '.' + ((i == attempts)) || sleep "${interval}" + done + printf '\n' + die "Timed out waiting for ${description}; last HTTP status was ${http_code:-000}" +} + +require_workshop_role() { + local role_name="$1" + aws_cli iam get-role --role-name "${role_name}" >/dev/null || die "Required IAM role not found: ${role_name}" +} + +ensure_eks_context() { + require_cmd kubectl + local cluster_json expected_endpoint expected_ca context_cluster context_endpoint context_ca + cluster_json=$(aws_cli eks describe-cluster --name "${CLUSTER_NAME}" --query cluster) + expected_endpoint=$(jq -r '.endpoint' <<<"${cluster_json}") + expected_ca=$(jq -r '.certificateAuthority.data' <<<"${cluster_json}") + kubectl config current-context >/dev/null 2>&1 || die "kubectl has no current context" + context_cluster=$(kubectl config view --minify -o jsonpath='{.contexts[0].context.cluster}') + context_endpoint=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}') + context_ca=$(kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}') + [[ "${context_cluster}" == *"${CLUSTER_NAME}"* ]] || die "kubectl context does not target ${CLUSTER_NAME}: ${context_cluster}" + [[ "${context_endpoint}" == "${expected_endpoint}" ]] || \ + die "kubectl endpoint does not match ${CLUSTER_NAME} in account ${ACCOUNT_ID}, Region ${AWS_REGION}" + [[ -z "${context_ca}" || "${context_ca}" == "${expected_ca}" ]] || \ + die "kubectl certificate authority does not match ${CLUSTER_NAME} in account ${ACCOUNT_ID}, Region ${AWS_REGION}" +} + +ensure_ecr_repository() { + local repository="$1" templates matching + if aws_cli ecr describe-repositories --repository-names "${repository}" >/dev/null 2>&1; then + return 0 + fi + + templates=$(aws_cli ecr describe-repository-creation-templates) + matching=$(jq --arg repository "${repository}" '[ + .repositoryCreationTemplates[] + | .prefix as $prefix + | select((.appliedFor | index("CREATE_ON_PUSH")) != null) + | select($prefix == "ROOT" or ($repository | startswith($prefix))) + ] | length' <<<"${templates}") + if [[ "${matching}" -gt 0 ]]; then + log "ECR repository ${repository} will be created by the matching CREATE_ON_PUSH template on first push" + return 0 + fi + + die "ECR repository ${repository} does not exist and no matching CREATE_ON_PUSH template is configured" +} + +build_and_push_jib() { + local app_dir="$1" repository="$2" tag="${3:-latest}" + require_cmd docker + require_cmd mvn + ensure_ecr_repository "${repository}" + local registry="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + aws_cli ecr get-login-password | docker login --username AWS --password-stdin "${registry}" + (cd "${app_dir}" && mvn -ntp compile jib:build -Dimage="${registry}/${repository}:${tag}" -DskipTests) +} + +upsert_pod_identity() { + local namespace="$1" service_account="$2" role_arn="$3" prefix="$4" + local association_id current_role response + association_id=$(aws_cli eks list-pod-identity-associations --cluster-name "${CLUSTER_NAME}" \ + --query "associations[?namespace=='${namespace}' && serviceAccount=='${service_account}'].associationId | [0]" --output text) + if is_none "${association_id}"; then + response=$(aws_cli eks create-pod-identity-association --cluster-name "${CLUSTER_NAME}" \ + --namespace "${namespace}" --service-account "${service_account}" --role-arn "${role_arn}") + association_id=$(jq -r '.association.associationId' <<<"${response}") + state_set "${prefix}_POD_IDENTITY_CREATED" true + else + current_role=$(aws_cli eks describe-pod-identity-association --cluster-name "${CLUSTER_NAME}" \ + --association-id "${association_id}" --query 'association.roleArn' --output text) + local created_var="${prefix}_POD_IDENTITY_CREATED" original_role_var="${prefix}_ORIGINAL_POD_ROLE_ARN" + if [[ "${!created_var:-}" != true ]]; then + state_set "${prefix}_POD_IDENTITY_CREATED" false + [[ -n "${!original_role_var:-}" ]] || state_set "${prefix}_ORIGINAL_POD_ROLE_ARN" "${current_role}" + fi + if [[ "${current_role}" != "${role_arn}" ]]; then + aws_cli eks update-pod-identity-association --cluster-name "${CLUSTER_NAME}" \ + --association-id "${association_id}" --role-arn "${role_arn}" >/dev/null + fi + fi + state_set "${prefix}_POD_IDENTITY_ID" "${association_id}" +} + +backup_k8s_resource() { + local namespace="$1" kind="$2" name="$3" state_key="$4" backup_dir backup_file existing_json get_error + local existing_path="${!state_key:-}" + [[ -z "${existing_path}" ]] || return 0 + get_error=$(mktemp "${WORK_DIR}/k8s-read.XXXXXX") + chmod 600 "${get_error}" + if existing_json=$(kubectl get "${kind}" "${name}" -n "${namespace}" -o json 2>"${get_error}"); then + backup_dir="${WORK_DIR}/k8s-backups" + mkdir -p "${backup_dir}" + chmod 700 "${backup_dir}" + backup_file="${backup_dir}/${namespace}-${kind}-${name}.json" + sanitize_k8s_resource_json <<<"${existing_json}" > "${backup_file}" + chmod 600 "${backup_file}" + state_set "${state_key}" "${backup_file}" + elif ! grep -Eq '^Error from server \(NotFound\):' "${get_error}"; then + die "Could not inspect ${kind} ${namespace}/${name} before reconciliation: $(tr '\n' ' ' < "${get_error}")" + fi + rm -f "${get_error}" +} + +get_mcp_url() { + local host + host=$(kubectl get ingress mcpserver -n mcpserver -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' 2>/dev/null || true) + [[ -n "${host}" ]] || return 1 + printf 'http://%s' "${host}" +} + +get_cognito_issuer() { + require_state COGNITO_USER_POOL_ID + printf 'https://cognito-idp.%s.amazonaws.com/%s' "${AWS_REGION}" "${COGNITO_USER_POOL_ID}" +} + +encode_b64() { printf '%s' "$1" | base64 | tr -d '\n'; } +decode_b64() { + if printf '' | base64 --decode >/dev/null 2>&1; then + printf '%s' "$1" | base64 --decode + else + printf '%s' "$1" | base64 -D + fi +} diff --git a/infra/scripts/ide/bootstrap.sh b/infra/scripts/ide/bootstrap.sh index 49912486..6717cde0 100755 --- a/infra/scripts/ide/bootstrap.sh +++ b/infra/scripts/ide/bootstrap.sh @@ -68,6 +68,9 @@ export AWS_REGION="$AWS_REGION" export AWS_DEFAULT_REGION="$AWS_REGION" export ACCOUNT_ID="$ACCOUNT_ID" export AWS_ACCOUNT_ID="$ACCOUNT_ID" +export WORKSHOP_ID="$WORKSHOP_ID" +export WORKSHOP_STACK_NAME="$WORKSHOP_STACK_NAME" +export WORKSHOP_DEPLOYMENT_ID="$WORKSHOP_DEPLOYMENT_ID" export EC2_PRIVATE_IP="$EC2_PRIVATE_IP" export EC2_DOMAIN="$EC2_DOMAIN" export EC2_URL="$EC2_URL" diff --git a/infra/scripts/setup/analysis.sh b/infra/scripts/setup/analysis.sh index 6da24b78..21b7d620 100755 --- a/infra/scripts/setup/analysis.sh +++ b/infra/scripts/setup/analysis.sh @@ -77,13 +77,13 @@ FOLDER_UID=$(echo "$SHARED_FOLDER" | jq -r '.uid') FOLDER_ID=$(echo "$SHARED_FOLDER" | jq -r '.id') log_info "Using folder: $FOLDER_UID" -# Get Lambda Function URL for thread dump Lambda -FUNCTION_URL=$(aws lambda get-function-url-config --function-name "$LAMBDA_FUNCTION_NAME" --query "FunctionUrl" --output text 2>/dev/null || echo "") +# Get authenticated thread analysis endpoint +FUNCTION_URL=$(aws ssm get-parameter --name "${PREFIX}-thread-analysis-url" --query "Parameter.Value" --output text 2>/dev/null || echo "") if [[ -z "$FUNCTION_URL" ]]; then - log_error "Lambda Function URL not found. Ensure CDK stack is deployed." + log_error "Thread analysis endpoint not found. Ensure CDK stack is deployed." exit 1 fi -log_info "Using Lambda Function URL: $FUNCTION_URL" +log_info "Using thread analysis endpoint: $FUNCTION_URL" # ============================================================================= @@ -265,33 +265,38 @@ if [[ -n "$OLD_CONTACT_UID" ]]; then curl -s -X DELETE -u "$GRAFANA_USER:$GRAFANA_PASSWORD" "$GRAFANA_URL/api/v1/provisioning/contact-points/$OLD_CONTACT_UID" fi -EXISTING_THREAD_CONTACT=$(curl -s -u "$GRAFANA_USER:$GRAFANA_PASSWORD" "$GRAFANA_URL/api/v1/provisioning/contact-points" | jq -r ".[] | select(.name == \"$THREAD_CONTACT_POINT\") | .name // empty") - -if [[ -z "$EXISTING_THREAD_CONTACT" ]]; then - CONTACT_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" \ - -u "$GRAFANA_USER:$GRAFANA_PASSWORD" \ - -d "{ - \"name\": \"$THREAD_CONTACT_POINT\", - \"type\": \"webhook\", - \"settings\": { - \"url\": \"$FUNCTION_URL\", - \"httpMethod\": \"POST\", - \"username\": \"$WEBHOOK_USER\", - \"password\": \"$GRAFANA_PASSWORD\", - \"authorization_scheme\": \"basic\" - }, - \"disableResolveMessage\": false - }" \ - "$GRAFANA_URL/api/v1/provisioning/contact-points") - - if echo "$CONTACT_RESPONSE" | jq -e '.name' > /dev/null 2>&1; then - log_success "Thread analysis contact point created" - else - log_error "Thread analysis contact point creation failed:" - echo "$CONTACT_RESPONSE" | jq . - fi +EXISTING_THREAD_CONTACT_UID=$(curl -s -u "$GRAFANA_USER:$GRAFANA_PASSWORD" "$GRAFANA_URL/api/v1/provisioning/contact-points" | jq -r ".[] | select(.name == \"$THREAD_CONTACT_POINT\") | .uid // empty") + +CONTACT_METHOD="POST" +CONTACT_URL="$GRAFANA_URL/api/v1/provisioning/contact-points" +CONTACT_ACTION="created" +if [[ -n "$EXISTING_THREAD_CONTACT_UID" ]]; then + CONTACT_METHOD="PUT" + CONTACT_URL="$CONTACT_URL/$EXISTING_THREAD_CONTACT_UID" + CONTACT_ACTION="updated" +fi + +CONTACT_RESPONSE=$(curl -s -X "$CONTACT_METHOD" -H "Content-Type: application/json" \ + -u "$GRAFANA_USER:$GRAFANA_PASSWORD" \ + -d "{ + \"name\": \"$THREAD_CONTACT_POINT\", + \"type\": \"webhook\", + \"settings\": { + \"url\": \"$FUNCTION_URL\", + \"httpMethod\": \"POST\", + \"username\": \"$WEBHOOK_USER\", + \"password\": \"$GRAFANA_PASSWORD\", + \"authorization_scheme\": \"basic\" + }, + \"disableResolveMessage\": false + }" \ + "$CONTACT_URL") + +if echo "$CONTACT_RESPONSE" | jq -e '.name' > /dev/null 2>&1; then + log_success "Thread analysis contact point $CONTACT_ACTION" else - log_success "Thread analysis contact point already exists" + log_error "Thread analysis contact point update failed:" + echo "$CONTACT_RESPONSE" | jq . fi # Create thread analysis alert rule @@ -362,7 +367,7 @@ log_info "Testing Bedrock model access..." if aws bedrock-runtime invoke-model \ --model-id "global.anthropic.claude-sonnet-4-20250514-v1:0" \ --body "$(echo '{"anthropic_version": "bedrock-2023-05-31", "max_tokens": 10, "messages": [{"role": "user", "content": "Test"}]}' | base64)" \ - --region us-east-1 \ + --region "$AWS_REGION" \ /tmp/bedrock-test.json 2>/dev/null; then log_success "Bedrock model access verified" rm -f /tmp/bedrock-test.json diff --git a/infra/scripts/setup/perf-platform.sh b/infra/scripts/setup/perf-platform.sh index 122af395..97d2cfe8 100755 --- a/infra/scripts/setup/perf-platform.sh +++ b/infra/scripts/setup/perf-platform.sh @@ -359,14 +359,39 @@ else log_info "Grafana CloudWatch pod identity association already exists" fi -# Restart Grafana every time the script runs to guarantee the running pod -# has the Pod Identity credentials. The mutating webhook injects -# AWS_CONTAINER_CREDENTIALS_FULL_URI at pod-create time, so we always -# recreate the pod after this point. Idempotent — already-credentialled -# Grafana just gets a fresh pod with the same env. -log_info "Restarting Grafana to pick up Pod Identity credentials..." -kubectl rollout restart deployment/grafana -n "${NAMESPACE}" -kubectl rollout status deployment/grafana -n "${NAMESPACE}" --timeout=180s +# EKS Pod Identity associations are eventually consistent. Recreate Grafana +# until the current Running/Ready pod has the injected credential endpoint. +# This also repairs an existing pod that predates its association. +GRAFANA_POD="" +for i in {1..6}; do + if (( i > 1 )); then + log_info "Pod Identity credentials not injected yet; waiting before retry ${i}/6..." + sleep 10 + fi + + log_info "Restarting Grafana to pick up Pod Identity credentials (${i}/6)..." + kubectl rollout restart deployment/grafana -n "${NAMESPACE}" + kubectl rollout status deployment/grafana -n "${NAMESPACE}" --timeout=180s + + GRAFANA_POD=$(kubectl get pods -n "${NAMESPACE}" \ + -l app.kubernetes.io/name=grafana -o json \ + | jq -r '[.items[] + | select(.metadata.deletionTimestamp == null and .status.phase == "Running") + | select([.status.containerStatuses[]?.ready] | all) + | select([.spec.containers[].env[]?.name] + | index("AWS_CONTAINER_CREDENTIALS_FULL_URI")) + | .metadata.name] | first // empty') + if [[ -n "${GRAFANA_POD}" ]]; then + break + fi + +done +if [[ -z "${GRAFANA_POD}" ]]; then + log_error "Grafana did not receive EKS Pod Identity credentials after 6 restarts" + exit 1 +fi +log_success "Grafana pod ${GRAFANA_POD} received EKS Pod Identity credentials" + log_info "Waiting for Grafana API after pod restart..." for i in {1..40}; do STATUS=$(curl -s -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" "${GRAFANA_URL}/api/health" \ @@ -395,7 +420,35 @@ kubectl create configmap perf-platform-cloudwatch-datasource \ --dry-run=client -o yaml | kubectl apply -f - kubectl label configmap perf-platform-cloudwatch-datasource \ -n "${NAMESPACE}" grafana_datasource=1 --overwrite -log_success "Grafana CloudWatch datasource provisioned" + +log_info "Verifying Grafana CloudWatch datasource credentials..." +CLOUDWATCH_HEALTH="" +CLOUDWATCH_HTTP_STATUS="" +for i in {1..12}; do + CLOUDWATCH_RESPONSE=$(curl -sS -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" \ + -w $'\n%{http_code}' \ + "${GRAFANA_URL}/api/datasources/uid/cloudwatch/health" 2>&1 || true) + CLOUDWATCH_HTTP_STATUS="${CLOUDWATCH_RESPONSE##*$'\n'}" + CLOUDWATCH_HEALTH="${CLOUDWATCH_RESPONSE%$'\n'*}" + if [[ "${CLOUDWATCH_HTTP_STATUS}" == "200" ]] \ + && jq -e ' + .status == "OK" + or ((.message // "") + | contains("Successfully queried the CloudWatch metrics API.")) + ' <<<"${CLOUDWATCH_HEALTH}" >/dev/null 2>&1; then + break + fi + [[ $i -eq 12 ]] && { + CLOUDWATCH_MESSAGE=$(jq -r '.message // empty' <<<"${CLOUDWATCH_HEALTH}" 2>/dev/null || true) + if [[ -z "${CLOUDWATCH_MESSAGE}" ]]; then + CLOUDWATCH_MESSAGE="${CLOUDWATCH_HEALTH:-empty response}" + fi + log_error "Grafana CloudWatch datasource is unhealthy (HTTP ${CLOUDWATCH_HTTP_STATUS}): ${CLOUDWATCH_MESSAGE}" + exit 1 + } + sleep 5 +done +log_success "Grafana CloudWatch metrics access verified" # ============================================================================= # Latency Metrics dashboard — two rows, five panels: diff --git a/infra/scripts/test/benchmark.sh b/infra/scripts/test/benchmark.sh index 95496785..fc4de8c2 100755 --- a/infra/scripts/test/benchmark.sh +++ b/infra/scripts/test/benchmark.sh @@ -1,4 +1,4 @@ -#bin/sh +#!/bin/sh # Check if URL parameter is provided if [ -z "$1" ]; then @@ -19,9 +19,9 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" if [ -n "$2" ] && [ -n "$3" ] then - artillery run --overrides "{\"config\": { \"phases\": [{ \"duration\": $2, \"arrivalRate\": $3 }] } }" \ - -t $SVC_URL -v '{ "url": "/unicorns" }' "$SCRIPT_DIR/benchmark.yaml" + exec artillery run --overrides "{\"config\": { \"phases\": [{ \"duration\": $2, \"arrivalRate\": $3 }] } }" \ + -t "$SVC_URL" -v '{ "url": "/unicorns" }' "$SCRIPT_DIR/benchmark.yaml" else - artillery run \ - -t $SVC_URL -v '{ "url": "/unicorns" }' "$SCRIPT_DIR/benchmark.yaml" + exec artillery run \ + -t "$SVC_URL" -v '{ "url": "/unicorns" }' "$SCRIPT_DIR/benchmark.yaml" fi diff --git a/infra/scripts/test/ecs-redeploy.sh b/infra/scripts/test/ecs-redeploy.sh new file mode 100755 index 00000000..b1f3109a --- /dev/null +++ b/infra/scripts/test/ecs-redeploy.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +usage() { + echo "Usage: $0 [task-definition]" >&2 + exit 2 +} + +if (( $# < 2 || $# > 3 )); then + usage +fi + +ECS_CLUSTER="$1" +ECS_SERVICE="$2" +TASK_DEFINITION="${3:-}" +PROGRESS_INTERVAL_SECONDS="${ECS_REDEPLOY_PROGRESS_INTERVAL_SECONDS:-15}" +WAITER_PID="" + +show_service_status() { + aws ecs describe-services \ + --cluster "${ECS_CLUSTER}" \ + --services "${ECS_SERVICE}" \ + --query '{service:services[0].{deployments:deployments,events:events[:5]},failures:failures}' \ + --no-cli-pager || true +} + +stop_waiter() { + if [[ -n "${WAITER_PID}" ]] && kill -0 "${WAITER_PID}" 2>/dev/null; then + kill "${WAITER_PID}" 2>/dev/null || true + wait "${WAITER_PID}" 2>/dev/null || true + fi +} + +trap 'stop_waiter; exit 130' INT +trap 'stop_waiter; exit 143' TERM +trap stop_waiter EXIT + +update_arguments=( + --cluster "${ECS_CLUSTER}" + --service "${ECS_SERVICE}" + --force-new-deployment +) +if [[ -n "${TASK_DEFINITION}" ]]; then + update_arguments+=(--task-definition "${TASK_DEFINITION}") +fi + +if ! deployment_id=$(aws ecs update-service "${update_arguments[@]}" \ + --query "service.deployments[?status=='PRIMARY'].id | [0]" \ + --output text \ + --no-cli-pager); then + echo "Failed to start the ECS deployment." >&2 + show_service_status + exit 1 +fi + +if [[ -z "${deployment_id}" || "${deployment_id}" == "None" ]]; then + echo "ECS did not return the new deployment ID." >&2 + show_service_status + exit 1 +fi + +printf 'Waiting for ECS deployment' +aws ecs wait services-stable \ + --cluster "${ECS_CLUSTER}" \ + --services "${ECS_SERVICE}" \ + --no-cli-pager & +WAITER_PID=$! + +while kill -0 "${WAITER_PID}" 2>/dev/null; do + printf '.' + sleep "${PROGRESS_INTERVAL_SECONDS}" +done + +waiter_status=0 +wait "${WAITER_PID}" || waiter_status=$? +WAITER_PID="" + +active_deployment_id=$(aws ecs describe-services \ + --cluster "${ECS_CLUSTER}" \ + --services "${ECS_SERVICE}" \ + --query "services[0].deployments[?status=='PRIMARY'].id | [0]" \ + --output text \ + --no-cli-pager 2>/dev/null || true) + +if (( waiter_status != 0 )) || [[ "${active_deployment_id}" != "${deployment_id}" ]]; then + echo " FAILED" >&2 + show_service_status + exit 1 +fi + +trap - EXIT INT TERM +echo " COMPLETE" diff --git a/infra/scripts/ws-test/generate.mjs b/infra/scripts/ws-test/generate.mjs new file mode 100755 index 00000000..dc482577 --- /dev/null +++ b/infra/scripts/ws-test/generate.mjs @@ -0,0 +1,543 @@ +#!/usr/bin/env node + +import { + existsSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const INFRA_DIR = resolve(SCRIPT_DIR, '../..'); +const REPO_ROOT = resolve(INFRA_DIR, '..'); +const WORKSPACE_ROOT = dirname(REPO_ROOT); +const REGISTRY_PATH = join(INFRA_DIR, 'workshops.json'); + +function shellQuote(value) { + return `'${String(value).replaceAll("'", `'\"'\"'`)}'`; +} + +function parseAttributes(text) { + const attributes = {}; + const pattern = /([A-Za-z][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s}]+))/g; + let match; + while ((match = pattern.exec(text)) !== null) { + attributes[match[1]] = match[2] ?? match[3] ?? match[4] ?? ''; + } + return attributes; +} + +function parseBoolean(value, defaultValue) { + if (value === undefined) return defaultValue; + if (value === true || value === 'true') return true; + if (value === false || value === 'false') return false; + throw new Error(`Expected true or false, received: ${value}`); +} + +function listMarkdownFiles(root) { + const files = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) files.push(...listMarkdownFiles(path)); + if (entry.isFile() && entry.name.endsWith('.md')) files.push(path); + } + return files.sort(); +} + +function frontMatter(lines, sourcePath) { + if (lines[0]?.trim() !== '---') throw new Error(`${sourcePath}: missing YAML front matter`); + const end = lines.findIndex((line, index) => index > 0 && line.trim() === '---'); + if (end < 0) throw new Error(`${sourcePath}: unterminated YAML front matter`); + + const values = {}; + for (const line of lines.slice(1, end)) { + const match = line.match(/^\s*([\w-]+)\s*:\s*(.*?)\s*$/); + if (match) values[match[1]] = match[2].replace(/^['"]|['"]$/g, ''); + } + const weight = Number(values.weight); + if (!values.title || !Number.isFinite(weight)) { + throw new Error(`${sourcePath}: front matter must contain title and numeric weight`); + } + return { + title: values.title, + weight, + testEnabled: parseBoolean(values['ws-test'], true), + endLine: end + 1, + }; +} + +function blockMetadata( + attributes, + language, + defaultTimeout, + context, + copyActionEnabled = true, + defaultTestEnabled = true, + defaultDisabledReason = 'test disabled', +) { + const testEnabled = parseBoolean(attributes.test, defaultTestEnabled); + const enabled = copyActionEnabled && testEnabled; + let reason = ''; + if (!enabled) { + if (!copyActionEnabled) reason = 'copy action disabled'; + else if (attributes.test !== undefined) reason = 'test disabled'; + else reason = defaultDisabledReason; + } + reason = attributes.reason ?? reason; + const timeoutText = attributes.testTimeout ?? attributes.timeout; + const timeout = timeoutText === undefined ? defaultTimeout : Number(timeoutText); + if (enabled && (!Number.isInteger(timeout) || timeout <= 0)) { + throw new Error(`${context}: timeout must be a positive integer`); + } + return { + enabled, + language: language || attributes.language || '', + reason, + timeout: enabled ? timeout : 0, + }; +} + +function cleanInstruction(value) { + return value + .replace(/\[([^\]]+)]\([^)]*\)/g, '$1') + .replace(/[*_`]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function visibleHtmlContent(line, commentOpen) { + let visible = ''; + let cursor = 0; + let insideComment = commentOpen; + + while (cursor < line.length) { + if (insideComment) { + const commentEnd = line.indexOf('-->', cursor); + if (commentEnd < 0) return { visible, commentOpen: true }; + cursor = commentEnd + 3; + insideComment = false; + continue; + } + + const commentStart = line.indexOf('