From 48ee0ac8ce3c23e989c6c768875b0c71b62c1666 Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Thu, 20 Aug 2026 13:45:35 +0200 Subject: [PATCH 01/38] fix(infra): Address workshop security findings Scope workshop permissions and replace the public Lambda function URL with an authenticated API Gateway endpoint. Preserve dynamic account and region handling for Workshop Studio deployments. --- .../main/java/sample/com/WorkshopStack.java | 111 +++++++- .../com/constructs/CfnPreDeleteCleanup.java | 60 ++++- .../java/sample/com/constructs/CodeBuild.java | 85 +++++-- .../java/sample/com/constructs/Database.java | 1 + .../sample/com/constructs/EcrRegistry.java | 17 +- .../com/constructs/EcsExpressService.java | 2 +- .../main/java/sample/com/constructs/Ide.java | 71 ++++-- .../sample/com/constructs/ThreadAnalysis.java | 76 ++++-- .../java/sample/com/constructs/Unicorn.java | 5 +- .../sample/com/constructs/WorkshopBucket.java | 25 ++ infra/cdk/src/main/resources/iam-policy.json | 236 ++++++++++++------ .../lambda/cfn-pre-delete-cleanup.py | 26 +- .../lambda/thread-analysis-authorizer.py | 31 +++ .../src/main/resources/workshop-boundary.json | 75 ++++-- infra/scripts/setup/analysis.sh | 67 ++--- 15 files changed, 671 insertions(+), 217 deletions(-) create mode 100644 infra/cdk/src/main/resources/lambda/thread-analysis-authorizer.py diff --git a/infra/cdk/src/main/java/sample/com/WorkshopStack.java b/infra/cdk/src/main/java/sample/com/WorkshopStack.java index 16d54557..2fd996bf 100644 --- a/infra/cdk/src/main/java/sample/com/WorkshopStack.java +++ b/infra/cdk/src/main/java/sample/com/WorkshopStack.java @@ -4,10 +4,13 @@ import software.amazon.awscdk.Stack; import software.amazon.awscdk.StackProps; 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 { @@ -84,6 +87,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 +108,17 @@ 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) + .repositoryNames(ecrRepositoryNames) .build()); // Bedrock logging role (for model invocation logging to CloudWatch) @@ -200,18 +222,81 @@ 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", + "bedrock-agentcore:StartBrowserSession", + "bedrock-agentcore:GetBrowserSession", + "bedrock-agentcore:StopBrowserSession", + "bedrock-agentcore:UpdateBrowserStream", + "bedrock-agentcore:StartCodeInterpreterSession", + "bedrock-agentcore:GetCodeInterpreterSession", + "bedrock-agentcore:InvokeCodeInterpreter", + "bedrock-agentcore:StopCodeInterpreterSession" + )) + .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("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("ecr:*", "logs:*", "xray:*", "cloudwatch:*")) + .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("aws-marketplace:Subscribe", "aws-marketplace:Unsubscribe", "aws-marketplace:ViewSubscriptions")) + .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 +387,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 +432,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..9bbfbbf6 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/CodeBuild.java +++ b/infra/cdk/src/main/java/sample/com/constructs/CodeBuild.java @@ -1,7 +1,9 @@ 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.codebuild.*; import software.amazon.awscdk.services.events.*; import software.amazon.awscdk.services.events.targets.LambdaFunction; @@ -12,6 +14,7 @@ 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; @@ -33,6 +36,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 +52,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 +67,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 +84,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 +95,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,6 +123,69 @@ 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", networkInterfaceArn + ), + Map.of( + "Effect", "Allow", + "Action", List.of( + "ec2:DescribeDhcpOptions", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeVpcs" + ), + "Resource", "*" + ) + )); + + 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); 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..de283902 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,7 @@ public class EcrRegistry extends Construct { public static class EcrRegistryProps { private String prefix = "workshop"; + private List repositoryNames = List.of(); public static Builder builder() { return new Builder(); } @@ -27,10 +30,12 @@ public static class Builder { private EcrRegistryProps props = new EcrRegistryProps(); public Builder prefix(String prefix) { props.prefix = prefix; 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 List getRepositoryNames() { return repositoryNames; } } public EcrRegistry(final Construct scope, final String id, final EcrRegistryProps props) { @@ -84,12 +89,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) 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..dfa79382 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/Ide.java +++ b/infra/cdk/src/main/java/sample/com/constructs/Ide.java @@ -179,22 +179,12 @@ 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) @@ -202,7 +192,8 @@ public Ide(final Construct scope, final String id, final IdeProps props) { this.ideRole.addManagedPolicy(policy); // 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 +210,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(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(lambdaPermissions); + 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") 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/iam-policy.json b/infra/cdk/src/main/resources/iam-policy.json index 223c5d73..839af7e8 100644 --- a/infra/cdk/src/main/resources/iam-policy.json +++ b/infra/cdk/src/main/resources/iam-policy.json @@ -2,16 +2,11 @@ "Version": "2012-10-17", "Statement": [ { - "Sid": "MarketplaceSubscribeClaude", "Effect": "Allow", - "Action": [ - "aws-marketplace:Subscribe" - ], + "Action": "aws-marketplace:Subscribe", "Resource": "*", "Condition": { - "Null": { - "aws-marketplace:ProductId": "false" - }, + "Null": { "aws-marketplace:ProductId": "false" }, "ForAllValues:StringEquals": { "aws-marketplace:ProductId": [ "prod-xdkflymybwmvi", @@ -24,28 +19,19 @@ } }, { - "Sid": "AllowedServices", "Effect": "Allow", "Action": [ - "aws-marketplace:Unsubscribe", - "aws-marketplace:ViewSubscriptions", "acm:*", - "bedrock:*", - "bedrock-agentcore:*", - "apigateway:*", - "cloudfront:*", - "cognito-idp:*", "application-autoscaling:*", "application-signals:*", + "bedrock:*", + "bedrock-agentcore:*", "cloudformation:*", "cloudtrail:*", "cloudwatch:*", - "codewhisperer:*", - "dbqms:*", - "dynamodb:*", + "cognito-idp:*", "ec2:*", "ecr:*", - "ecs:*", "eks:*", "elasticloadbalancing:*", "events:*", @@ -53,52 +39,135 @@ "logs:*", "rds:*", "rds-data:*", - "s3:*", "s3vectors:*", "secretsmanager:*", "ssm:*", - "sts:*", - "tag:*", - "xray:*", - "q:*" + "xray:*" ], - "Resource": "*" + "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-*" + ] }, { - "Sid": "PassRole", "Effect": "Allow", "Action": [ - "iam:PassRole" + "aws-marketplace:Unsubscribe", + "aws-marketplace:ViewSubscriptions" ], - "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*" - ] + "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}}" + } + } }, { - "Sid": "CreateServiceLinkedRole", "Effect": "Allow", "Action": [ - "iam:CreateServiceLinkedRole" + "apigateway:*", + "cloudfront:*", + "dynamodb:*", + "ecs:*", + "s3:*" ], "Resource": [ - "arn:aws:iam::*:role/aws-service-role/application-signals.cloudwatch.amazonaws.com/*", - "arn:aws:iam::*:role/aws-service-role/cloudtrail.amazonaws.com/*" + "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-*" ] }, { - "Sid": "GetRole", "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" @@ -106,33 +175,62 @@ "Resource": "*" }, { - "Sid": "AiAgentCreateRoles", "Effect": "Allow", - "Action": [ - "iam:CreateRole", - "iam:DeleteRole", - "iam:PutRolePolicy", - "iam:DeleteRolePolicy", - "iam:AttachRolePolicy", - "iam:DetachRolePolicy", - "iam:UpdateAssumeRolePolicy" - ], + "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:PermissionsBoundary": "arn:aws:iam::{{.AccountId}}:policy/workshop-boundary" + "iam:PassedToService": [ + "bedrock.amazonaws.com", + "bedrock-agentcore.amazonaws.com", + "codebuild.amazonaws.com", + "ec2.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" + ] } } }, { - "Sid": "AiAgentPassRole", "Effect": "Allow", "Action": [ - "iam:PassRole" + "iam:CreateRole", + "iam:DeleteRole", + "iam:PutRolePolicy", + "iam:DeleteRolePolicy", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "iam:UpdateAssumeRolePolicy" ], "Resource": [ "arn:aws:iam::{{.AccountId}}:role/aiagent*", @@ -141,41 +239,25 @@ ], "Condition": { "StringEquals": { - "iam:PassedToService": [ - "bedrock.amazonaws.com", - "bedrock-agentcore.amazonaws.com", - "lambda.amazonaws.com" - ] + "iam:PermissionsBoundary": "arn:aws:iam::{{.AccountId}}:policy/workshop-boundary" } } }, { - "Sid": "DenyXXLInstances", "Effect": "Deny", "Action": "ec2:RunInstances", "Condition": { "StringLike": { "ec2:InstanceType": [ - "*4xlarge", - "*6xlarge", - "*8xlarge", - "*9xlarge", - "*10xlarge", + "*4xlarge", "*6xlarge", "*8xlarge", "*9xlarge", "*10xlarge", "*12xlarge", - "*16xlarge", - "*18xlarge", - "*24xlarge", - "f1*", - "x1*", - "z1*", - "*metal" + "f1*", "x1*", "z1*", "*metal" ] } }, - "Resource": ["arn:aws:ec2:*:*:instance/*"] + "Resource": "arn:aws:ec2:*:*:instance/*" }, { - "Sid": "DenyReservations", "Effect": "Deny", "Action": [ "ec2:ModifyReservedInstances", 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/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/workshop-boundary.json b/infra/cdk/src/main/resources/workshop-boundary.json index ef30a042..93434a7b 100644 --- a/infra/cdk/src/main/resources/workshop-boundary.json +++ b/infra/cdk/src/main/resources/workshop-boundary.json @@ -2,31 +2,72 @@ "Version": "2012-10-17", "Statement": [ { - "Sid": "AllowedServicesForRoles", + "Sid": "BedrockRuntime", + "Effect": "Allow", + "Action": "bedrock:*", + "Resource": [ + "arn:aws:bedrock:*::foundation-model/*", + "arn:aws:bedrock:*:{{.AccountId}}:*" + ] + }, + { + "Sid": "AgentCoreRuntime", + "Effect": "Allow", + "Action": "bedrock-agentcore:*", + "Resource": "arn:aws:bedrock-agentcore:*:{{.AccountId}}:*" + }, + { + "Sid": "WorkshopData", "Effect": "Allow", "Action": [ - "aws-marketplace:Subscribe", - "aws-marketplace:Unsubscribe", - "aws-marketplace:ViewSubscriptions", - "bedrock:*", - "bedrock-agentcore:*", - "cognito-idp:*", - "cloudfront:*", - "cloudwatch:*", "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:s3:::workshop-*", + "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-*" + ] + }, + { + "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/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 From e5a86df07463d06c7bf2d3ec59d1e5085a75445f Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Thu, 20 Aug 2026 13:51:25 +0200 Subject: [PATCH 02/38] chore(infra): Regenerate java-on-aws template Regenerate the java-on-aws CloudFormation template from the committed security remediation and embed the feat/holmes-remediation branch for workshop bootstrap. --- infra/cfn/java-on-aws-stack.yaml | 1233 +++++++++++++++++++++++++----- 1 file changed, 1036 insertions(+), 197 deletions(-) diff --git a/infra/cfn/java-on-aws-stack.yaml b/infra/cfn/java-on-aws-stack.yaml index d44fce94..9e13397c 100644 --- a/infra/cfn/java-on-aws-stack.yaml +++ b/infra/cfn/java-on-aws-stack.yaml @@ -189,6 +189,9 @@ Resources: CfnPreDeleteCleanupFA953B95: DeletionPolicy: Delete Properties: + BucketNames: + - Ref: WorkshopBucketFD5BC43F + - Ref: WorkshopBucketAccessLogs476BAB88 ServiceToken: Fn::GetAtt: - CfnPreDeleteCleanupFunction580FB700 @@ -217,13 +220,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 +235,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 +322,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") @@ -399,17 +402,89 @@ Resources: 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: @@ -426,7 +501,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1783086703971" + ContentHash: "1787226502073" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -495,7 +570,10 @@ Resources: - codebuild:BatchGetBuilds - codebuild:StartBuild Effect: Allow - Resource: "*" + Resource: + Fn::GetAtt: + - CodeBuildProjectA0FF5539 + - Arn Version: "2012-10-17" PolicyName: CodeBuildLambdaRoleDefaultPolicyFB35F0AF Roles: @@ -515,7 +593,7 @@ Resources: EnvironmentVariables: - Name: GIT_BRANCH Type: PLAINTEXT - Value: main + Value: feat/holmes-remediation - Name: TEMPLATE_TYPE Type: PLAINTEXT Value: java-on-aws @@ -568,7 +646,101 @@ Resources: 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: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":ec2:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - :network-interface/* + - Action: - ec2:DescribeDhcpOptions - ec2:DescribeNetworkInterfaces - ec2:DescribeSecurityGroups @@ -661,6 +833,16 @@ Resources: 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 +939,6 @@ Resources: Principal: Service: codebuild.amazonaws.com Version: "2012-10-17" - ManagedPolicyArns: - - Fn::Join: - - "" - - - "arn:" - - Ref: AWS::Partition - - :iam::aws:policy/PowerUserAccess Type: AWS::IAM::Role CodeBuildStartLambdaFunction8349284F: DependsOn: @@ -845,8 +1021,9 @@ Resources: Ref: DatabaseClusterSubnets5540150D DatabaseName: workshop EnableHttpEndpoint: true + EnableIAMDatabaseAuthentication: true Engine: aurora-postgresql - EngineVersion: "16.13 + EngineVersion: "16.13" MasterUserPassword: Fn::Join: - "" @@ -1020,7 +1197,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: @@ -1363,7 +1567,7 @@ 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 - |- @@ -1709,21 +1913,43 @@ Resources: 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: @@ -1964,9 +2190,6 @@ Resources: Properties: PolicyDocument: Statement: - - Action: cloudformation:SignalResource - Effect: Allow - Resource: "*" - Action: - secretsmanager:DescribeSecret - secretsmanager:GetSecretValue @@ -2023,123 +2246,414 @@ 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: "*" - 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/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 - 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:ListAttachedRolePolicies - - iam:ListRolePolicies - - iam:ListRoles - 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: - ec2:InstanceType: - - "*4xlarge" - - "*6xlarge" - - "*8xlarge" - - "*9xlarge" - - "*10xlarge" - - "*12xlarge" - - "*16xlarge" - - "*18xlarge" - - "*24xlarge" - - f1* + - 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: iam:PassRole + Condition: + StringEquals: + iam:PassedToService: + - bedrock.amazonaws.com + - bedrock-agentcore.amazonaws.com + - codebuild.amazonaws.com + - ec2.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:AttachRolePolicy + - iam:CreateRole + - iam:DeleteRole + - iam:DeleteRolePolicy + - iam:DetachRolePolicy + - iam:PutRolePolicy + - iam:UpdateAssumeRolePolicy + 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: ec2:RunInstances + Condition: + StringLike: + ec2:InstanceType: + - "*4xlarge" + - "*6xlarge" + - "*8xlarge" + - "*9xlarge" + - "*10xlarge" + - "*12xlarge" + - f1* - x1* - z1* - "*metal" Effect: Deny Resource: arn:aws:ec2:*:*:instance/* - Sid: DenyXXLInstances - Action: - dynamodb:PurchaseReservedCapacityOfferings - ec2:ModifyReservedInstances @@ -2149,7 +2663,6 @@ Resources: - rds:PurchaseReservedDBInstancesOffering Effect: Deny Resource: "*" - Sid: DenyReservations Version: "2012-10-17" Type: AWS::IAM::ManagedPolicy IdeWaitConditionCC35C186: @@ -2170,32 +2683,119 @@ 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: bedrock-agentcore:* + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:aws:bedrock-agentcore:*:" + - Ref: AWS::AccountId + - :* + Sid: AgentCoreRuntime - Action: - - aws-marketplace:Subscribe - - aws-marketplace:Unsubscribe - - aws-marketplace:ViewSubscriptions - - bedrock-agentcore:* - - bedrock:* - - cloudfront:* - - cloudwatch:* - - cognito-idp:* - 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:::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: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: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:* @@ -2377,6 +2977,197 @@ 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 + 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 + 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 + Type: String + Value: + Fn::GetAtt: + - ThreadAnalysisHttpApi2CC57DDB + - ApiEndpoint + Type: AWS::SSM::Parameter + ThreadAnalysisHttpApi2CC57DDB: + Properties: + Name: workshop-thread-analysis + ProtocolType: HTTP + Type: AWS::ApiGatewayV2::Api + ThreadAnalysisHttpApiDefaultStage13C90718: + Properties: + ApiId: + Ref: ThreadAnalysisHttpApi2CC57DDB + AutoDeploy: true + StageName: $default + 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 @@ -2473,19 +3264,6 @@ Resources: - Arn 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: @@ -2524,17 +3302,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,36 +3363,6 @@ 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: @@ -2968,7 +3729,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 @@ -3367,6 +4130,78 @@ Resources: 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 + - "-20260820134822" + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + 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 @@ -3385,7 +4220,11 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260703155143" + - "-20260820134822" + LoggingConfiguration: + DestinationBucketName: + Ref: WorkshopBucketAccessLogs476BAB88 + LogFilePrefix: workshop-data/ PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true From 5897e809d693dc7b72c4a584e0e91169492619e0 Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Thu, 20 Aug 2026 15:54:49 +0200 Subject: [PATCH 03/38] fix(infra): Add ECS service principal to IAM trust policy --- infra/cdk/src/main/resources/iam-policy.json | 1 + infra/cfn/java-on-aws-stack.yaml | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/infra/cdk/src/main/resources/iam-policy.json b/infra/cdk/src/main/resources/iam-policy.json index 839af7e8..4470ce3c 100644 --- a/infra/cdk/src/main/resources/iam-policy.json +++ b/infra/cdk/src/main/resources/iam-policy.json @@ -197,6 +197,7 @@ "bedrock-agentcore.amazonaws.com", "codebuild.amazonaws.com", "ec2.amazonaws.com", + "ecs.amazonaws.com", "ecs-tasks.amazonaws.com", "lambda.amazonaws.com", "pods.eks.amazonaws.com" diff --git a/infra/cfn/java-on-aws-stack.yaml b/infra/cfn/java-on-aws-stack.yaml index 9e13397c..12e625a5 100644 --- a/infra/cfn/java-on-aws-stack.yaml +++ b/infra/cfn/java-on-aws-stack.yaml @@ -501,7 +501,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787226502073" + ContentHash: "1787230220256" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -2533,6 +2533,7 @@ Resources: - bedrock-agentcore.amazonaws.com - codebuild.amazonaws.com - ec2.amazonaws.com + - ecs.amazonaws.com - ecs-tasks.amazonaws.com - lambda.amazonaws.com - pods.eks.amazonaws.com @@ -4149,7 +4150,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260820134822" + - "-20260820145020" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -4220,7 +4221,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260820134822" + - "-20260820145020" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 From e631f47ebcfcd9a1ca6ed980bdfe2ed3f2c56f93 Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Fri, 21 Aug 2026 12:36:46 +0200 Subject: [PATCH 04/38] feat(test): Add workshop script generator Generate deterministic unattended Bash tests from workshop content using a shared runtime and central workshop registry. Reuse the registry for CloudFormation generation and synchronization, and preserve benchmark process cleanup. --- .gitignore | 1 + infra/package.json | 3 +- infra/scripts/cfn/generate.sh | 94 +- infra/scripts/cfn/sync.sh | 137 +- infra/scripts/test/benchmark.sh | 10 +- infra/scripts/ws-test/generate.mjs | 408 ++ .../ws-test/java-on-aws-immersion-day.sh | 3396 +++++++++++++++++ infra/scripts/ws-test/runtime.sh | 447 +++ infra/workshops.json | 35 + 9 files changed, 4401 insertions(+), 130 deletions(-) create mode 100755 infra/scripts/ws-test/generate.mjs create mode 100755 infra/scripts/ws-test/java-on-aws-immersion-day.sh create mode 100755 infra/scripts/ws-test/runtime.sh create mode 100644 infra/workshops.json diff --git a/.gitignore b/.gitignore index a0b0be3a..42ddcccd 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,6 @@ build/ infrastructure/cdk/output* dependency-reduced-pom.xml +infra/scripts/ws-test/reports/ .env diff --git a/infra/package.json b/infra/package.json index 8617ade4..cc0aafb0 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" }, "author": "", "license": "ISC" diff --git a/infra/scripts/cfn/generate.sh b/infra/scripts/cfn/generate.sh index 7c6b4684..b3b64b54 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 --yaml --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..5c08e1be 100755 --- a/infra/scripts/cfn/sync.sh +++ b/infra/scripts/cfn/sync.sh @@ -1,101 +1,90 @@ #!/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 the shared IAM policy +# 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" - exit 1 -} +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" -WORKSHOPS=("java-on-aws" "java-on-amazon-eks" "java-spring-ai-agents" "java-ai-agents" "java-ai-agents-advanced") +if [[ ! -f "$CONFIG_FILE" ]]; then + log_error "Workshop registry not found: $CONFIG_FILE" + exit 1 +fi +if [[ ! -f "$SHARED_POLICY_FILE" ]]; then + log_error "Shared policy file not found: $SHARED_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 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" -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 + 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 - # Copy shared IAM policy -> iam-policy.json - cp "$SHARED_POLICY_FILE" "$target_dir/iam-policy.json" || { - log_error "Failed to copy policy for $workshop" - exit 1 - } - log_success "Synced $SHARED_POLICY_FILE to $folder_name/static/iam-policy.json" + 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" - ((synced_count++)) - else - log_info "Directory $target_dir not found, skipping $workshop ($folder_name)" - fi + 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" + 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/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/ws-test/generate.mjs b/infra/scripts/ws-test/generate.mjs new file mode 100755 index 00000000..4c587e90 --- /dev/null +++ b/infra/scripts/ws-test/generate.mjs @@ -0,0 +1,408 @@ +#!/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 { 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, + explicitId: attributes.testId ?? attributes.id ?? '', + }; +} + +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(' + + org.springaicommunity + spring-ai-agentcore-runtime-starter + +WS_TEST_BLOCK_60_8 + +ws_run_block 9 'Creating the ChatService' 'Create src/main/java/com/example/agent/ChatService.java:' 143 168 'java' '' <<'WS_TEST_BLOCK_60_9' +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.springaicommunity.agentcore.annotation.AgentCoreInvocation; + +record ChatRequest(String prompt) {} + +@Service +public class ChatService { + private final ChatClient chatClient; + + public ChatService(ChatClient.Builder chatClientBuilder) { + this.chatClient = chatClientBuilder + .build(); + } + + @AgentCoreInvocation + public Flux chat(ChatRequest request) { + return chatClient.prompt().user(request.prompt()).stream().content(); + } +} +EOF +code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java +WS_TEST_BLOCK_60_9 + +ws_run_block 10 'Adding the Web UI' 'Copy the static files (HTML, CSS, JavaScript) to the project:' 186 187 'bash' '' <<'WS_TEST_BLOCK_60_10' +cp ~/java-on-aws/apps/java-spring-ai-agents/aiagent/src/main/resources/static/* \ + ~/environment/aiagent/src/main/resources/static/ +WS_TEST_BLOCK_60_10 + +ws_run_block 11 'Running the AI agent' '1. Start the application:' 195 196 'bash' '' <<'WS_TEST_BLOCK_60_11' +cd ~/environment/aiagent +./mvnw spring-boot:run +WS_TEST_BLOCK_60_11 + +ws_run_block 12 'Running the AI agent' '2. Test with REST API (in a new terminal):' 204 206 'bash' '' <<'WS_TEST_BLOCK_60_12' +curl -N -X POST localhost:8080/invocations \ + -H "Content-Type: application/json" \ + -d '{"prompt": "Create a 100-word article about the most important Java 25 features."}'; echo +WS_TEST_BLOCK_60_12 + +ws_run_block 13 'Committing changes' 'Initialize a Git repository and commit the initial code:' 232 237 'bash' '' <<'WS_TEST_BLOCK_60_13' +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 -m "Create the AI agent" +WS_TEST_BLOCK_60_13 + +ws_end_page + +ws_begin_page 'Agent persona' 80 'persona/index.en.md' + +ws_run_block 1 'Agent persona' '> If you closed the application, start it with:' 9 10 'bash' '' <<'WS_TEST_BLOCK_80_1' +cd ~/environment/aiagent +./mvnw spring-boot:run +WS_TEST_BLOCK_80_1 + +ws_skip_block 2 'Choosing the right model' 'The model is configured in application.properties:' 44 44 '' '' 'informational block without language' + +ws_run_block 3 'Configuring temperature' '- 1.0 - More creative, varied responses (good for brainstorming)' 60 60 'bash' '' <<'WS_TEST_BLOCK_80_3' +code ~/environment/aiagent/src/main/resources/application.properties +WS_TEST_BLOCK_80_3 + +ws_run_block 4 'Configuring temperature' 'Add the temperature setting:' 66 66 'properties' '' <<'WS_TEST_BLOCK_80_4' +spring.ai.bedrock.converse.chat.temperature=0.7 +WS_TEST_BLOCK_80_4 + +ws_run_block 5 'Updating the code' '1. Open ChatService.java:' 86 86 'bash' '' <<'WS_TEST_BLOCK_80_5' +code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java +WS_TEST_BLOCK_80_5 + +ws_run_block 6 'Updating the code' '2. Add the system prompt constant after private final ChatClient chatClient;:' 92 95 'java' '' <<'WS_TEST_BLOCK_80_6' + private static final String SYSTEM_PROMPT = """ + You are a helpful AI agent for travel and expense management. + Be friendly, helpful, and concise in your responses. + """; +WS_TEST_BLOCK_80_6 + +ws_run_block 7 'Updating the code' '3. Update the constructor to apply the system prompt:' 101 105 'java' '' <<'WS_TEST_BLOCK_80_7' + public ChatService(ChatClient.Builder chatClientBuilder) { + this.chatClient = chatClientBuilder + .defaultSystem(SYSTEM_PROMPT) + .build(); + } +WS_TEST_BLOCK_80_7 + +ws_run_block 8 'Testing the application' '1. Start the application:' 115 116 'bash' '' <<'WS_TEST_BLOCK_80_8' +cd ~/environment/aiagent +./mvnw spring-boot:run +WS_TEST_BLOCK_80_8 + +ws_run_block 9 'Committing changes' 'Committing changes' 136 138 'bash' '' <<'WS_TEST_BLOCK_80_9' +cd ~/environment/aiagent +git add . +git commit -m "Add persona" +WS_TEST_BLOCK_80_9 + +ws_end_page + +ws_begin_page 'Conversation memory' 100 'memory/index.en.md' + +ws_run_block 1 'Creating the memory resource' 'Run the setup script to create the AgentCore Memory resource with LTM strategies:' 79 79 'bash' 'script' <<'WS_TEST_BLOCK_100_1' +~/java-on-aws/apps/java-spring-ai-agents/scripts/02-memory.sh +WS_TEST_BLOCK_100_1 + +ws_run_block 2 'Creating the memory resource' '1. Create an AgentCore Memory resource and wait for it to become active (2-5 minutes):' 90 105 'bash' 'manual' <<'WS_TEST_BLOCK_100_2' +AGENTCORE_MEMORY_MEMORY_ID=$(aws bedrock-agentcore-control create-memory \ + --name "aiagent_memory" --event-expiry-duration 7 \ + --no-cli-pager --query "memory.id" --output text) + +echo -n "Waiting for memory" +while [ "$(aws bedrock-agentcore-control get-memory --memory-id "${AGENTCORE_MEMORY_MEMORY_ID}" \ + --no-cli-pager --query 'memory.status' --output text)" != "ACTIVE" ]; do + echo -n "."; sleep 5 +done && echo " ACTIVE" + +cat >> ~/environment/aiagent/src/main/resources/application.properties << EOF + +# AgentCore Memory +agentcore.memory.memory-id=${AGENTCORE_MEMORY_MEMORY_ID} +agentcore.memory.long-term.auto-discovery=true +EOF +WS_TEST_BLOCK_100_2 + +ws_run_block 3 'Creating the memory resource' '2. Add LTM strategies and wait for them to become active:' 116 128 'bash' 'manual' <<'WS_TEST_BLOCK_100_3' +aws bedrock-agentcore-control update-memory --memory-id "${AGENTCORE_MEMORY_MEMORY_ID}" --no-cli-pager \ + --memory-strategies '{ + "addMemoryStrategies": [ + {"semanticMemoryStrategy": {"name": "SemanticFacts", "namespaces": ["/strategies/{memoryStrategyId}/actors/{actorId}"]}}, + {"userPreferenceMemoryStrategy": {"name": "UserPreferences", "namespaces": ["/strategies/{memoryStrategyId}/actors/{actorId}"]}} + ] + }' + +echo -n "Waiting for strategies" +while aws bedrock-agentcore-control get-memory --memory-id "${AGENTCORE_MEMORY_MEMORY_ID}" \ + --no-cli-pager --query 'memory.strategies[].status' --output text | grep -q "CREATING"; do + echo -n "."; sleep 5 +done && echo " ACTIVE" +WS_TEST_BLOCK_100_3 + +ws_run_block 4 'Adding dependencies' '1. Open pom.xml:' 141 141 'bash' '' <<'WS_TEST_BLOCK_100_4' +code ~/environment/aiagent/pom.xml +WS_TEST_BLOCK_100_4 + +ws_run_block 5 'Adding dependencies' '2. Add the AgentCore Memory starter to the section:' 147 151 'xml' '' <<'WS_TEST_BLOCK_100_5' + + + org.springaicommunity + spring-ai-agentcore-memory + +WS_TEST_BLOCK_100_5 + +ws_run_block 6 'Updating the code' '1. Open ChatService.java:' 159 159 'bash' '' <<'WS_TEST_BLOCK_100_6' +code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java +WS_TEST_BLOCK_100_6 + +ws_run_block 7 'Updating the code' '2. Replace the file content:' 165 225 'java' '' <<'WS_TEST_BLOCK_100_7' +package com.example.agent; + +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.agentcore.annotation.AgentCoreInvocation; +import org.springaicommunity.agentcore.context.AgentCoreContext; +import org.springaicommunity.agentcore.context.AgentCoreHeaders; +import org.springaicommunity.agentcore.memory.longterm.AgentCoreMemory; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.api.Advisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; + +record ChatRequest(String prompt) {} + +@Service +public class ChatService { + + private static final Logger logger = LoggerFactory.getLogger(ChatService.class); + + private final ChatClient chatClient; + + private static final String SYSTEM_PROMPT = """ + You are a helpful AI agent for travel and expense management. + Be friendly, helpful, and concise in your responses. + """; + + public ChatService(AgentCoreMemory agentCoreMemory, + ChatClient.Builder chatClientBuilder) { + + List advisors = new ArrayList<>(); + + // Memory (STM + LTM) + advisors.addAll(agentCoreMemory.advisors); + logger.info("Memory enabled: {} advisors", agentCoreMemory.advisors.size()); + + this.chatClient = chatClientBuilder + .defaultSystem(SYSTEM_PROMPT) + .defaultAdvisors(advisors.toArray(new Advisor[0])) + .build(); + } + + @AgentCoreInvocation + public Flux chat(ChatRequest request, AgentCoreContext context) { + return chat(request.prompt(), getConversationId(context)); + } + + private Flux chat(String prompt, String sessionId) { + return chatClient.prompt().user(prompt) + .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId)) + .stream().content(); + } + + private String getConversationId(AgentCoreContext context) { + return context.getHeader(AgentCoreHeaders.SESSION_ID); + } +} +WS_TEST_BLOCK_100_7 + +ws_run_block 8 'Testing the application' '1. Start the application:' 240 241 'bash' '' <<'WS_TEST_BLOCK_100_8' +cd ~/environment/aiagent +./mvnw spring-boot:run +WS_TEST_BLOCK_100_8 + +ws_skip_block 9 'Testing the application' 'Testing the application' 245 249 '' '' 'informational block without language' + +ws_run_block 10 'Committing changes' 'Committing changes' 271 273 'bash' '' <<'WS_TEST_BLOCK_100_10' +cd ~/environment/aiagent +git add . +git commit -m "Add memory" +WS_TEST_BLOCK_100_10 + +ws_end_page + +ws_begin_page 'Knowledge base' 200 'knowledge/index.en.md' + +ws_run_block 1 'Creating the Knowledge Base' 'Run the setup script to create the Knowledge Base with S3 Vectors storage:' 65 65 'bash' 'script' <<'WS_TEST_BLOCK_200_1' +~/java-on-aws/apps/java-spring-ai-agents/scripts/03-knowledgebase.sh +WS_TEST_BLOCK_200_1 + +ws_run_block 2 'Creating the Knowledge Base' '1. Create S3 buckets and vector index:' 76 83 'bash' 'manual' <<'WS_TEST_BLOCK_200_2' +DATA_BUCKET="aiagent-kb-data-${ACCOUNT_ID}" +VECTOR_BUCKET="aiagent-kb-vectors-${ACCOUNT_ID}" + +aws s3api create-bucket --bucket "${DATA_BUCKET}" --no-cli-pager +aws s3vectors create-vector-bucket --vector-bucket-name "${VECTOR_BUCKET}" --no-cli-pager +aws s3vectors create-index --vector-bucket-name "${VECTOR_BUCKET}" \ + --index-name "aiagent-index" --data-type "float32" \ + --dimension 1024 --distance-metric "cosine" --no-cli-pager +WS_TEST_BLOCK_200_2 + +ws_run_block 3 'Creating the Knowledge Base' '2. Create IAM role for the Knowledge Base:' 89 120 'bash' 'manual' <<'WS_TEST_BLOCK_200_3' +KB_ROLE="aiagent-kb-role" + +aws iam create-role --role-name "${KB_ROLE}" \ + --permissions-boundary "arn:aws:iam::${ACCOUNT_ID}:policy/workshop-boundary" \ + --assume-role-policy-document '{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "bedrock.amazonaws.com"}, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": {"aws:SourceAccount": "'${ACCOUNT_ID}'"}, + "ArnLike": {"aws:SourceArn": "arn:aws:bedrock:'${AWS_REGION}':'${ACCOUNT_ID}':knowledge-base/*"} + } + }] + }' --no-cli-pager + +aws iam put-role-policy --role-name "${KB_ROLE}" --policy-name "aiagent-kb-policy" \ + --policy-document '{ + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": ["s3:GetObject", "s3:ListBucket"], + "Resource": ["arn:aws:s3:::'${DATA_BUCKET}'", "arn:aws:s3:::'${DATA_BUCKET}'/*"]}, + {"Effect": "Allow", "Action": ["bedrock:InvokeModel"], + "Resource": ["arn:aws:bedrock:'${AWS_REGION}'::foundation-model/amazon.titan-embed-text-v2:0"]}, + {"Effect": "Allow", "Action": ["s3vectors:*"], + "Resource": ["arn:aws:s3vectors:'${AWS_REGION}':'${ACCOUNT_ID}':bucket/'${VECTOR_BUCKET}'", + "arn:aws:s3vectors:'${AWS_REGION}':'${ACCOUNT_ID}':bucket/'${VECTOR_BUCKET}'/*"]} + ] + }' --no-cli-pager + +echo -n "Waiting for role propagation" && sleep 10 && echo " done" +WS_TEST_BLOCK_200_3 + +ws_run_block 4 'Creating the Knowledge Base' '3. Create the Knowledge Base:' 126 156 'bash' 'manual' <<'WS_TEST_BLOCK_200_4' +VECTOR_BUCKET="aiagent-kb-vectors-${ACCOUNT_ID}" +KB_ROLE="aiagent-kb-role" + +KB_ID=$(aws bedrock-agent create-knowledge-base --name "aiagent-kb" \ + --description "Knowledge base for AI agent policies" \ + --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/${KB_ROLE}" \ + --knowledge-base-configuration '{ + "type": "VECTOR", + "vectorKnowledgeBaseConfiguration": { + "embeddingModelArn": "arn:aws:bedrock:'${AWS_REGION}'::foundation-model/amazon.titan-embed-text-v2:0" + } + }' \ + --storage-configuration '{ + "type": "S3_VECTORS", + "s3VectorsConfiguration": { + "vectorBucketArn": "arn:aws:s3vectors:'${AWS_REGION}':'${ACCOUNT_ID}':bucket/'${VECTOR_BUCKET}'", + "indexName": "aiagent-index" + } + }' --no-cli-pager --query 'knowledgeBase.knowledgeBaseId' --output text) + +echo -n "Waiting for knowledge base" +while [ "$(aws bedrock-agent get-knowledge-base --knowledge-base-id ${KB_ID} \ + --no-cli-pager --query 'knowledgeBase.status' --output text)" != "ACTIVE" ]; do + echo -n "."; sleep 5 +done && echo " ACTIVE" + +cat >> ~/environment/aiagent/src/main/resources/application.properties << EOF + +# Knowledge Base +spring.ai.vectorstore.bedrock-knowledge-base.knowledge-base-id=${KB_ID} +EOF +WS_TEST_BLOCK_200_4 + +ws_run_block 5 'Creating the Knowledge Base' '4. Create data source, upload documents, and start ingestion:' 165 192 'bash' 'manual' <<'WS_TEST_BLOCK_200_5' +DATA_BUCKET="aiagent-kb-data-${ACCOUNT_ID}" + +DS_ID=$(aws bedrock-agent create-data-source \ + --knowledge-base-id "${KB_ID}" \ + --name "aiagent-policies" \ + --data-source-configuration '{ + "type": "S3", + "s3Configuration": { + "bucketArn": "arn:aws:s3:::'${DATA_BUCKET}'", + "inclusionPrefixes": ["policies/"] + } + }' --no-cli-pager --query 'dataSource.dataSourceId' --output text) + +aws s3 cp ~/java-on-aws/apps/java-spring-ai-agents/aiagent/samples/policy-travel.md \ + s3://${DATA_BUCKET}/policies/policy-travel.md --no-cli-pager +aws s3 cp ~/java-on-aws/apps/java-spring-ai-agents/aiagent/samples/policy-expense.md \ + s3://${DATA_BUCKET}/policies/policy-expense.md --no-cli-pager + +JOB_ID=$(aws bedrock-agent start-ingestion-job \ + --knowledge-base-id "${KB_ID}" --data-source-id "${DS_ID}" \ + --no-cli-pager --query 'ingestionJob.ingestionJobId' --output text) + +echo -n "Waiting for ingestion" +while [ "$(aws bedrock-agent get-ingestion-job --knowledge-base-id "${KB_ID}" \ + --data-source-id "${DS_ID}" --ingestion-job-id "${JOB_ID}" \ + --no-cli-pager --query 'ingestionJob.status' --output text)" = "IN_PROGRESS" ]; do + echo -n "."; sleep 5 +done && echo " COMPLETE" +WS_TEST_BLOCK_200_5 + +ws_run_block 6 'Adding dependencies' '1. Open pom.xml:' 205 205 'bash' '' <<'WS_TEST_BLOCK_200_6' +code ~/environment/aiagent/pom.xml +WS_TEST_BLOCK_200_6 + +ws_run_block 7 'Adding dependencies' '2. Add the Bedrock Knowledge Base dependency to the section:' 211 224 'xml' '' <<'WS_TEST_BLOCK_200_7' + + + org.springframework.ai + spring-ai-starter-vector-store-bedrock-knowledgebase + + + org.springframework.ai + spring-ai-vector-store-advisor + + + + org.springframework.boot + spring-boot-starter-validation + +WS_TEST_BLOCK_200_7 + +ws_run_block 8 'Updating the code' '1. Open ChatService.java:' 232 232 'bash' '' <<'WS_TEST_BLOCK_200_8' +code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java +WS_TEST_BLOCK_200_8 + +ws_run_block 9 'Updating the code' '2. Add the imports after the existing imports:' 238 240 'java' '' <<'WS_TEST_BLOCK_200_9' +import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.vectorstore.VectorStore; +WS_TEST_BLOCK_200_9 + +ws_run_block 10 'Updating the code' '3. Add VectorStore to the constructor parameters:' 246 248 'java' '' <<'WS_TEST_BLOCK_200_10' + public ChatService(AgentCoreMemory agentCoreMemory, + VectorStore kbVectorStore, + ChatClient.Builder chatClientBuilder) { +WS_TEST_BLOCK_200_10 + +ws_run_block 11 'Updating the code' '4. Add the Knowledge Base section after the LTM section:' 256 265 'java' '' <<'WS_TEST_BLOCK_200_11' + // Knowledge Base (RAG) + advisors.add(QuestionAnswerAdvisor.builder(kbVectorStore) + .promptTemplate(PromptTemplate.builder().template(""" + {query} + + The following documents may be relevant as reference material: + {question_answer_context} + """).build()) + .build()); + logger.info("KB RAG enabled"); +WS_TEST_BLOCK_200_11 + +ws_run_block 12 'Testing the application' '1. Start the application:' 275 276 'bash' '' <<'WS_TEST_BLOCK_200_12' +cd ~/environment/aiagent +./mvnw spring-boot:run +WS_TEST_BLOCK_200_12 + +ws_run_block 13 'Committing changes' 'Committing changes' 294 296 'bash' '' <<'WS_TEST_BLOCK_200_13' +cd ~/environment/aiagent +git add . +git commit -m "Add knowledge base" +WS_TEST_BLOCK_200_13 + +ws_end_page + +ws_begin_page 'Tool calling and web grounding' 400 'tools/index.en.md' + +ws_run_block 1 'Adding dependencies' '1. Open pom.xml:' 54 54 'bash' '' <<'WS_TEST_BLOCK_400_1' +code ~/environment/aiagent/pom.xml +WS_TEST_BLOCK_400_1 + +ws_run_block 2 'Adding dependencies' '2. Add the AWS Java SDK BOM to the section, alongside the existing Spring AI BOM:' 60 66 'xml' '' <<'WS_TEST_BLOCK_400_2' + + software.amazon.awssdk + bom + 2.46.7 + pom + import + +WS_TEST_BLOCK_400_2 + +ws_run_block 3 'Adding dependencies' '3. Add the Bedrock Runtime SDK dependency to the section. The web grounding tool uses the Bedrock Converse API directly with SystemTool and CitationLocation, which require a recent SDK version:' 72 76 'xml' '' <<'WS_TEST_BLOCK_400_3' + + + software.amazon.awssdk + bedrockruntime + +WS_TEST_BLOCK_400_3 + +ws_run_block 4 'Creating ContextAdvisor' 'Advisors can augment user prompts with contextual information. Create ContextAdvisor.java:' 84 133 'java' '' <<'WS_TEST_BLOCK_400_4' +cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/ContextAdvisor.java +package com.example.agent; + +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.client.advisor.api.AdvisorChain; +import org.springframework.ai.chat.client.advisor.api.BaseAdvisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.stereotype.Component; + +@Component +class ContextAdvisor implements BaseAdvisor { + + @Override + public ChatClientRequest before(ChatClientRequest request, AdvisorChain advisorChain) { + Prompt original = request.prompt(); + String timestamp = ZonedDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + String conversationId = (String) request.context().get(ChatMemory.CONVERSATION_ID); + String userId = conversationId != null ? conversationId.split(":")[0] : "unknown"; + + List messages = new ArrayList<>(original.getInstructions()); + UserMessage userMsg = original.getUserMessage(); + if (userMsg != null) { + int idx = messages.lastIndexOf(userMsg); + messages.set(idx, new UserMessage( + "[Current date and time: " + timestamp + "] [UserId: " + userId + "]\n" + userMsg.getText())); + } + + Prompt augmented = new Prompt(messages, original.getOptions()); + return request.mutate().prompt(augmented).build(); + } + + @Override + public ChatClientResponse after(ChatClientResponse response, AdvisorChain advisorChain) { + return response; + } + + @Override + public int getOrder() { + return 0; + } +} +EOF +WS_TEST_BLOCK_400_4 + +ws_run_block 5 'Web grounding with Amazon Nova 2' '- Cannot be predicted or cached' 153 243 'java' '' <<'WS_TEST_BLOCK_400_5' +cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/WebGroundingTools.java +package com.example.agent; + +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.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import jakarta.annotation.PreDestroy; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; +import software.amazon.awssdk.services.bedrockruntime.model.*; + +@Service +public class WebGroundingTools { + + private static final Logger logger = LoggerFactory.getLogger(WebGroundingTools.class); + + private final BedrockRuntimeClient bedrockClient; + + private final String modelId; + + public WebGroundingTools(@Value("${app.ai.web-grounding.model:us.amazon.nova-2-lite-v1:0}") String modelId) { + this.modelId = modelId; + this.bedrockClient = BedrockRuntimeClient.builder().build(); + logger.info("WebGroundingTools: model={}", modelId); + } + + @PreDestroy + public void close() { + if (bedrockClient != null) { + bedrockClient.close(); + } + } + + @Tool(description = "Search the web for current information. Use for news, real-time data, or facts needing verification.") + public String searchWeb(@ToolParam(description = "Search query") String query) { + logger.info("Web search: {}", query); + try { + var response = bedrockClient.converse(ConverseRequest.builder() + .modelId(modelId) + .messages(Message.builder().role(ConversationRole.USER).content(ContentBlock.fromText(query)).build()) + .toolConfig(ToolConfiguration.builder() + .tools(software.amazon.awssdk.services.bedrockruntime.model.Tool + .fromSystemTool(SystemTool.builder().name("nova_grounding").build())) + .build()) + .build()); + + return extractResponse(response); + } + catch (Exception e) { + logger.error("Web search failed: {}", e.getMessage(), e); + return "Web search failed. Try again later."; + } + } + + private String extractResponse(ConverseResponse response) { + var result = new StringBuilder(); + var citations = new StringBuilder(); + + logger.debug("Raw response: {}", response); + + if (response.output() != null && response.output().message() != null) { + for (var block : response.output().message().content()) { + if (block.text() != null) { + result.append(block.text()); + } + if (block.citationsContent() != null && block.citationsContent().citations() != null) { + for (var citation : block.citationsContent().citations()) { + if (citation.location() != null && citation.location().web() != null) { + var url = citation.location().web().url(); + if (url != null && !url.isEmpty()) { + citations.append("\n- ").append(url); + } + } + } + } + } + } + + if (result.isEmpty()) { + return "No results found."; + } + if (!citations.isEmpty()) { + result.append("\n\nSources:").append(citations); + } + return result.toString(); + } + +} +EOF +WS_TEST_BLOCK_400_5 + +ws_run_block 6 'Updating the code' '1. Open ChatService.java:' 282 282 'bash' '' <<'WS_TEST_BLOCK_400_6' +code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java +WS_TEST_BLOCK_400_6 + +ws_run_block 7 'Updating the code' '2. Add the new dependencies to the constructor parameters:' 288 292 'java' '' <<'WS_TEST_BLOCK_400_7' + public ChatService(AgentCoreMemory agentCoreMemory, + VectorStore kbVectorStore, + WebGroundingTools webGroundingTools, + ContextAdvisor contextAdvisor, + ChatClient.Builder chatClientBuilder) { +WS_TEST_BLOCK_400_7 + +ws_run_block 8 'Updating the code' '3. Add the advisor and tools after the Knowledge Base (RAG) section:' 300 307 'java' '' <<'WS_TEST_BLOCK_400_8' + // ContextAdvisor + advisors.add(contextAdvisor); + logger.info("Context Advisor enabled"); + + // Tools + List localTools = new ArrayList<>(); + localTools.add(webGroundingTools); + logger.info("Web Grounding enabled"); +WS_TEST_BLOCK_400_8 + +ws_run_block 9 'Updating the code' '4. Add .defaultTools() to the ChatClient builder:' 316 320 'java' '' <<'WS_TEST_BLOCK_400_9' + this.chatClient = chatClientBuilder + .defaultSystem(SYSTEM_PROMPT) + .defaultAdvisors(advisors.toArray(new Advisor[0])) + .defaultTools(localTools.toArray()) + .build(); +WS_TEST_BLOCK_400_9 + +ws_run_block 10 'Testing the application' '1. Start the application:' 330 331 'bash' '' <<'WS_TEST_BLOCK_400_10' +cd ~/environment/aiagent +./mvnw spring-boot:run +WS_TEST_BLOCK_400_10 + +ws_skip_block 11 'Testing the application' 'Testing the application' 335 339 '' '' 'informational block without language' + +ws_run_block 12 'Committing changes' 'Committing changes' 357 359 'bash' '' <<'WS_TEST_BLOCK_400_12' +cd ~/environment/aiagent +git add . +git commit -m "Add tools" +WS_TEST_BLOCK_400_12 + +ws_end_page + +ws_begin_page 'Web browsing' 440 'browser/index.en.md' + +ws_skip_block 1 'Introduction to ToolCallbackProvider' '.defaultTools(Object...) accepts both kinds, so all tools are registered the same way:' 53 58 'java' '' 'copy action disabled' + +ws_skip_block 2 'Introduction to ToolCallReactiveContextHolder' '5. Spring AI clears the ThreadLocal in a finally block before the thread returns to the pool' 82 87 'java' '' 'copy action disabled' + +ws_run_block 3 'Adding dependencies' '1. Open pom.xml:' 97 97 'bash' '' <<'WS_TEST_BLOCK_440_3' +code ~/environment/aiagent/pom.xml +WS_TEST_BLOCK_440_3 + +ws_run_block 4 'Adding dependencies' '2. Add the AgentCore Browser starter dependency to the section:' 103 107 'xml' '' <<'WS_TEST_BLOCK_440_4' + + + org.springaicommunity + spring-ai-agentcore-browser + +WS_TEST_BLOCK_440_4 + +ws_run_block 5 'Adding dependencies' '3. Override tool descriptions in application.properties for better results:' 124 124 'bash' '' <<'WS_TEST_BLOCK_440_5' +code ~/environment/aiagent/src/main/resources/application.properties +WS_TEST_BLOCK_440_5 + +ws_run_block 6 'Adding dependencies' 'Adding dependencies' 128 130 'properties' '' <<'WS_TEST_BLOCK_440_6' +# AgentCore Browser - tool descriptions +agentcore.browser.browse-url-description=Browse a web page and extract its text content. Returns the page title and body text. Use this to read and extract data from websites. For interactive sites, combine with fillForm and clickElement to navigate, then call browseUrl again to read the results. +agentcore.browser.screenshot-description=Take a screenshot of a web page for the user to see. Does NOT return page content to you. Use browseUrl to extract data first, then takeScreenshot for visual evidence. +WS_TEST_BLOCK_440_6 + +ws_run_block 7 'Updating the code' '1. Open ChatService.java:' 138 138 'bash' '' <<'WS_TEST_BLOCK_440_7' +code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java +WS_TEST_BLOCK_440_7 + +ws_run_block 8 'Updating the code' '2. Add the imports after the existing imports:' 144 149 'java' '' <<'WS_TEST_BLOCK_440_8' +import org.springaicommunity.agentcore.artifacts.ArtifactStore; +import org.springaicommunity.agentcore.artifacts.GeneratedFile; +import org.springaicommunity.agentcore.artifacts.SessionConstants; +import org.springaicommunity.agentcore.browser.BrowserArtifacts; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.beans.factory.annotation.Qualifier; +WS_TEST_BLOCK_440_8 + +ws_run_block 9 'Updating the code' '3. Add the browserArtifactStore field after the chatClient field:' 155 155 'java' '' <<'WS_TEST_BLOCK_440_9' + private final ArtifactStore browserArtifactStore; +WS_TEST_BLOCK_440_9 + +ws_run_block 10 'Updating the code' '4. Add the browser parameters to the constructor:' 161 163 'java' '' <<'WS_TEST_BLOCK_440_10' + @Qualifier("browserToolCallbackProvider") ToolCallbackProvider browserTools, + @Qualifier("browserArtifactStore") ArtifactStore browserArtifactStore, + ChatClient.Builder chatClientBuilder) { +WS_TEST_BLOCK_440_10 + +ws_run_block 11 'Updating the code' '5. Store the artifact store reference and build the tool callback providers list after the local tools section:' 172 178 'java' '' <<'WS_TEST_BLOCK_440_11' + // Browser + this.browserArtifactStore = browserArtifactStore; + + // Tool Callback Providers + List toolCallbackProviders = new ArrayList<>(); + toolCallbackProviders.add(browserTools); + logger.info("Browser enabled"); +WS_TEST_BLOCK_440_11 + +ws_run_block 12 'Updating the code' '6. Add a second .defaultTools() call to register the tool callback providers:' 187 191 'java' '' <<'WS_TEST_BLOCK_440_12' + this.chatClient = chatClientBuilder.defaultSystem(SYSTEM_PROMPT) + .defaultAdvisors(advisors.toArray(new Advisor[0])) + .defaultTools(localTools.toArray()) + .defaultTools(toolCallbackProviders.toArray()) + .build(); +WS_TEST_BLOCK_440_12 + +ws_run_block 13 'Updating the code' '7. Update the chat() method to append screenshots and propagate the session ID:' 199 205 'java' '' <<'WS_TEST_BLOCK_440_13' + private Flux chat(String prompt, String sessionId) { + return chatClient.prompt().user(prompt) + .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId)) + .stream().content() + .concatWith(Flux.defer(() -> appendScreenshots(sessionId))) + .contextWrite(ctx -> ctx.put(SessionConstants.SESSION_ID_KEY, sessionId)); + } +WS_TEST_BLOCK_440_13 + +ws_run_block 14 'Updating the code' '8. Add the appendScreenshots() and formatScreenshotsAsMarkdown() methods to ChatService:' 213 231 'java' '' <<'WS_TEST_BLOCK_440_14' + private Flux appendScreenshots(String sessionId) { + List screenshots = browserArtifactStore.retrieve(sessionId); + if (screenshots == null || screenshots.isEmpty()) { + return Flux.empty(); + } + return Flux.just(formatScreenshotsAsMarkdown(screenshots)); + } + + private String formatScreenshotsAsMarkdown(List screenshots) { + StringBuilder sb = new StringBuilder(); + for (GeneratedFile screenshot : screenshots) { + sb.append("\n\n![Screenshot of ") + .append(BrowserArtifacts.url(screenshot).orElse("unknown")) + .append("](") + .append(screenshot.toDataUrl()) + .append(")"); + } + return sb.toString(); + } +WS_TEST_BLOCK_440_14 + +ws_run_block 15 'Testing the application' '1. Start the application:' 242 244 'bash' '' <<'WS_TEST_BLOCK_440_15' +echo "export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" >> ~/environment/.envrc +cd ~/environment/aiagent +./mvnw spring-boot:run +WS_TEST_BLOCK_440_15 + +ws_skip_block 16 'Testing the application' '2. Interact with the AI agent:' 259 268 '' '' 'informational block without language' + +ws_run_block 17 'Committing changes' 'Committing changes' 278 280 'bash' '' <<'WS_TEST_BLOCK_440_17' +cd ~/environment/aiagent +git add . +git commit -m "Add browser" +WS_TEST_BLOCK_440_17 + +ws_end_page + +ws_begin_page 'Code interpreter' 460 'code-interpreter/index.en.md' + +ws_run_block 1 'Adding dependencies' '1. Open pom.xml:' 53 53 'bash' '' <<'WS_TEST_BLOCK_460_1' +code ~/environment/aiagent/pom.xml +WS_TEST_BLOCK_460_1 + +ws_run_block 2 'Adding dependencies' '2. Add the AgentCore Code Interpreter starter dependency to the section:' 59 63 'xml' '' <<'WS_TEST_BLOCK_460_2' + + + org.springaicommunity + spring-ai-agentcore-code-interpreter + +WS_TEST_BLOCK_460_2 + +ws_run_block 3 'Updating the code' '1. Open ChatService.java:' 79 79 'bash' '' <<'WS_TEST_BLOCK_460_3' +code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java +WS_TEST_BLOCK_460_3 + +ws_run_block 4 'Updating the code' '2. Add the imports after the existing imports:' 85 86 'java' '' <<'WS_TEST_BLOCK_460_4' +import org.springaicommunity.agentcore.artifacts.ArtifactStore; +import org.springaicommunity.agentcore.artifacts.GeneratedFile; +WS_TEST_BLOCK_460_4 + +ws_run_block 5 'Updating the code' '3. Add the codeInterpreterArtifactStore field after the browserArtifactStore field:' 94 94 'java' '' <<'WS_TEST_BLOCK_460_5' + private final ArtifactStore codeInterpreterArtifactStore; +WS_TEST_BLOCK_460_5 + +ws_run_block 6 'Updating the code' '4. Add the code interpreter parameters to the constructor:' 100 102 'java' '' <<'WS_TEST_BLOCK_460_6' + @Qualifier("codeInterpreterToolCallbackProvider") ToolCallbackProvider codeInterpreterTools, + @Qualifier("codeInterpreterArtifactStore") ArtifactStore codeInterpreterArtifactStore, + ChatClient.Builder chatClientBuilder) { +WS_TEST_BLOCK_460_6 + +ws_run_block 7 'Updating the code' '5. Store the artifact store reference and add code interpreter to the tool callback providers list after Browser:' 111 115 'java' '' <<'WS_TEST_BLOCK_460_7' + // Code Interpreter + this.codeInterpreterArtifactStore = codeInterpreterArtifactStore; + + toolCallbackProviders.add(codeInterpreterTools); + logger.info("Code Interpreter enabled"); +WS_TEST_BLOCK_460_7 + +ws_run_block 8 'Updating the code' '6. Update the chat() method to append generated files and unify the session context key:' 124 131 'java' '' <<'WS_TEST_BLOCK_460_8' + private Flux chat(String prompt, String sessionId) { + return chatClient.prompt().user(prompt) + .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId)) + .stream().content() + .concatWith(Flux.defer(() -> appendGeneratedFiles(sessionId))) + .concatWith(Flux.defer(() -> appendScreenshots(sessionId))) + .contextWrite(ctx -> ctx.put(SessionConstants.SESSION_ID_KEY, sessionId)); + } +WS_TEST_BLOCK_460_8 + +ws_run_block 9 'Updating the code' '7. Add the appendGeneratedFiles() and formatFilesAsMarkdown() methods to ChatService:' 140 167 'java' '' <<'WS_TEST_BLOCK_460_9' + private Flux appendGeneratedFiles(String sessionId) { + List files = codeInterpreterArtifactStore.retrieve(sessionId); + if (files == null || files.isEmpty()) { + return Flux.empty(); + } + String markdown = formatFilesAsMarkdown(files); + if (markdown.isEmpty()) { + return Flux.empty(); + } + return Flux.just(markdown); + } + + private String formatFilesAsMarkdown(List files) { + StringBuilder sb = new StringBuilder(); + for (GeneratedFile file : files) { + if (file.name().equals("package.json") || file.name().equals("package-lock.json")) { + continue; + } + if (file.isImage()) { + sb.append("\n\n![").append(file.name()).append("](") + .append(file.toDataUrl()).append(")"); + } else { + sb.append("\n\n[Download ").append(file.name()).append("](") + .append(file.toDataUrl()).append(")"); + } + } + return sb.toString(); + } +WS_TEST_BLOCK_460_9 + +ws_run_block 10 'Testing the application' '1. Start the application:' 178 179 'bash' '' <<'WS_TEST_BLOCK_460_10' +cd ~/environment/aiagent +./mvnw spring-boot:run +WS_TEST_BLOCK_460_10 + +ws_run_block 11 'Committing changes' 'Committing changes' 198 200 'bash' '' <<'WS_TEST_BLOCK_460_11' +cd ~/environment/aiagent +git add . +git commit -m "Add code interpreter" +WS_TEST_BLOCK_460_11 + +ws_end_page + +ws_begin_page 'MCP Server' 610 'mcp/mcp-server/index.en.md' + +ws_run_block 1 'Copying the application' 'Copy the backoffice application from the reference repository:' 24 29 'bash' '' <<'WS_TEST_BLOCK_610_1' +cp -r ~/java-on-aws/apps/java-spring-ai-agents/backoffice/trip ~/environment/backoffice + +cd ~/environment/backoffice +git init -b main +git add . +git commit -q -m "Initial commit" +WS_TEST_BLOCK_610_1 + +ws_run_block 2 'Exploring the application' 'The application uses Spring Cloud AWS DynamoDB for data access. Open TripService.java to review the existing service layer:' 37 37 'bash' '' <<'WS_TEST_BLOCK_610_2' +code ~/environment/backoffice/src/main/java/com/example/backoffice/trip/TripService.java +WS_TEST_BLOCK_610_2 + +ws_skip_block 3 'Exploring the application' 'Exploring the application' 41 54 'java' '' 'copy action disabled' + +ws_run_block 4 'Adding dependencies' '1. Open pom.xml:' 66 66 'bash' '' <<'WS_TEST_BLOCK_610_4' +code ~/environment/backoffice/pom.xml +WS_TEST_BLOCK_610_4 + +ws_run_block 5 'Adding dependencies' '2. Add the Spring AI BOM to the section, alongside the existing Spring Cloud AWS BOM:' 72 78 'xml' '' <<'WS_TEST_BLOCK_610_5' + + org.springframework.ai + spring-ai-bom + 2.0.0 + pom + import + +WS_TEST_BLOCK_610_5 + +ws_run_block 6 'Adding dependencies' '3. Add the MCP server starter to the section:' 86 90 'xml' '' <<'WS_TEST_BLOCK_610_6' + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + +WS_TEST_BLOCK_610_6 + +ws_run_block 7 'Adding dependencies' '4. Verify the dependencies resolve:' 98 99 'bash' '' <<'WS_TEST_BLOCK_610_7' +cd ~/environment/backoffice +mvn dependency:resolve -q +WS_TEST_BLOCK_610_7 + +ws_run_block 8 'Updating the configuration' 'Add the MCP server configuration to application.properties:' 107 107 'bash' '' <<'WS_TEST_BLOCK_610_8' +code ~/environment/backoffice/src/main/resources/application.properties +WS_TEST_BLOCK_610_8 + +ws_run_block 9 'Updating the configuration' 'Add the server port, MCP server settings, and debug logging:' 113 122 'properties' '' <<'WS_TEST_BLOCK_610_9' +server.port=8000 + +# MCP Server +spring.ai.mcp.server.name=backoffice +spring.ai.mcp.server.version=1.0.0 +spring.ai.mcp.server.protocol=STATELESS + +# Logging +logging.level.org.springframework.ai=DEBUG +logging.level.io.modelcontextprotocol=DEBUG +WS_TEST_BLOCK_610_9 + +ws_run_block 10 'Creating the tools' 'Create TripTools.java to expose trip operations as MCP tools:' 133 188 'java' '' <<'WS_TEST_BLOCK_610_10' +cat <<'EOF' > ~/environment/backoffice/src/main/java/com/example/backoffice/trip/TripTools.java +package com.example.backoffice.trip; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +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.time.LocalDate; +import java.util.List; + +@Component +public class TripTools { + + private final TripService service; + + public TripTools(TripService service) { + this.service = service; + } + + @Bean + public ToolCallbackProvider tripToolsProvider(TripTools tripTools) { + return MethodToolCallbackProvider.builder() + .toolObjects(tripTools) + .build(); + } + + @Tool(description = "Register a new business trip. Returns trip reference for tracking.") + public Trip registerTrip( + @ToolParam(description = "User ID") String userId, + @ToolParam(description = "Departure date (YYYY-MM-DD)") LocalDate departureDate, + @ToolParam(description = "Return date (YYYY-MM-DD)") LocalDate returnDate, + @ToolParam(description = "Origin city") String origin, + @ToolParam(description = "Destination city") String destination, + @ToolParam(description = "Trip purpose") String purpose) { + return service.registerTrip(userId, departureDate, returnDate, origin, destination, purpose); + } + + @Tool(description = "Get all business trips registered by a user") + public List getTrips(@ToolParam(description = "User ID") String userId) { + return service.getTrips(userId); + } + + @Tool(description = "Get trip details by reference number") + public Trip getTrip(@ToolParam(description = "Trip reference (TRP-XXXXXXXX)") String tripReference) { + return service.getTrip(tripReference); + } + + @Tool(description = "Cancel a planned trip") + public Trip cancelTrip(@ToolParam(description = "Trip reference (TRP-XXXXXXXX)") String tripReference) { + return service.cancelTrip(tripReference); + } +} +EOF +WS_TEST_BLOCK_610_10 + +ws_run_block 11 'Creating DynamoDB tables' 'Create the DynamoDB table with the indexes the backoffice application needs:' 203 217 'bash' '' <<'WS_TEST_BLOCK_610_11' +aws dynamodb create-table \ + --table-name "backoffice-trip" \ + --attribute-definitions \ + AttributeName=pk,AttributeType=S \ + AttributeName=sk,AttributeType=S \ + AttributeName=tripReference,AttributeType=S \ + --key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \ + --global-secondary-indexes \ + "IndexName=tripReference-index,KeySchema=[{AttributeName=tripReference,KeyType=HASH}],Projection={ProjectionType=ALL}" \ + --billing-mode PAY_PER_REQUEST \ + --region ${AWS_REGION} \ + --no-cli-pager + +aws dynamodb wait table-exists --table-name "backoffice-trip" --region ${AWS_REGION} +echo "Table created: backoffice-trip" +WS_TEST_BLOCK_610_11 + +ws_run_block 12 'Starting the MCP server' 'Open a new terminal and start the MCP server:' 225 226 'bash' '' <<'WS_TEST_BLOCK_610_12' +cd ~/environment/backoffice +mvn spring-boot:run +WS_TEST_BLOCK_610_12 + +ws_skip_block 13 'Starting the MCP server' 'The startup log shows the registered MCP tools:' 232 232 '' '' 'informational block without language' + +ws_run_block 14 'Testing the MCP server' '1. Initialize the MCP session:' 242 254 'bash' '' <<'WS_TEST_BLOCK_610_14' +curl -s -X POST http://localhost:8000/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1.0"} + } + }' | jq . +WS_TEST_BLOCK_610_14 + +ws_skip_block 15 'Testing the MCP server' 'Testing the MCP server' 258 281 '' '' 'informational block without language' + +ws_run_block 16 'Testing the MCP server' '2. List available tools:' 287 290 'bash' '' <<'WS_TEST_BLOCK_610_16' +curl -s -X POST http://localhost:8000/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | jq '.result.tools[] | {name}' +WS_TEST_BLOCK_610_16 + +ws_skip_block 17 'Testing the MCP server' 'Testing the MCP server' 294 305 '' '' 'informational block without language' + +ws_run_block 18 'Testing the MCP server' '3. Register a trip:' 311 332 'bash' '' <<'WS_TEST_BLOCK_610_18' +DEPARTURE=$(date -d "+7 days" +%Y-%m-%d 2>/dev/null || date -v+7d +%Y-%m-%d) +RETURN=$(date -d "+11 days" +%Y-%m-%d 2>/dev/null || date -v+11d +%Y-%m-%d) + +curl -s -X POST http://localhost:8000/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d "{ + \"jsonrpc\": \"2.0\", + \"id\": 3, + \"method\": \"tools/call\", + \"params\": { + \"name\": \"registerTrip\", + \"arguments\": { + \"userId\": \"testuser\", + \"departureDate\": \"${DEPARTURE}\", + \"returnDate\": \"${RETURN}\", + \"origin\": \"Berlin\", + \"destination\": \"Tokyo\", + \"purpose\": \"Customer meeting\" + } + } + }" | jq '.result.content[0].text' +WS_TEST_BLOCK_610_18 + +ws_skip_block 19 'Testing the MCP server' 'Testing the MCP server' 336 336 '' '' 'informational block without language' + +ws_skip_block 20 'Testing the MCP server' 'Testing the MCP server' 340 342 '' '' 'informational block without language' + +ws_run_block 21 'Committing changes' 'Committing changes' 350 352 'bash' '' <<'WS_TEST_BLOCK_610_21' +cd ~/environment/backoffice +git add . +git commit -m "Add MCP server" +WS_TEST_BLOCK_610_21 + +ws_end_page + +ws_begin_page 'MCP on AgentCore' 620 'mcp/mcp-on-agentcore/index.en.md' + +ws_run_block 1 'Creating M2M authentication' 'Run the setup script to create the Cognito resources for M2M authentication:' 39 39 'bash' 'script' <<'WS_TEST_BLOCK_620_1' +~/java-on-aws/apps/java-spring-ai-agents/scripts/04-mcp-cognito.sh +WS_TEST_BLOCK_620_1 + +ws_run_block 2 'Creating the M2M Cognito pool' 'Create a dedicated Cognito User Pool for M2M authentication:' 52 57 'bash' 'manual' <<'WS_TEST_BLOCK_620_2' +GATEWAY_POOL_ID=$(aws cognito-idp create-user-pool \ + --pool-name "mcp-gateway-pool" \ + --region ${AWS_REGION} \ + --no-cli-pager \ + --query 'UserPool.Id' --output text) +echo "export GATEWAY_POOL_ID=${GATEWAY_POOL_ID}" >> ~/environment/.envrc +WS_TEST_BLOCK_620_2 + +ws_run_block 3 'Creating the resource server' 'A resource server defines the API and its scopes. The gateway/invoke scope authorizes clients to call MCP tools:' 67 73 'bash' 'manual' <<'WS_TEST_BLOCK_620_3' +aws cognito-idp create-resource-server \ + --user-pool-id "${GATEWAY_POOL_ID}" \ + --identifier "gateway" \ + --name "Gateway API" \ + --scopes '[{"ScopeName":"invoke","ScopeDescription":"Invoke gateway tools"}]' \ + --region ${AWS_REGION} \ + --no-cli-pager +WS_TEST_BLOCK_620_3 + +ws_run_block 4 'Creating the Cognito domain' 'The clientcredentials flow requires a Cognito domain for the token endpoint:' 84 88 'bash' 'manual' <<'WS_TEST_BLOCK_620_4' +aws cognito-idp create-user-pool-domain \ + --domain "mcp-gateway-${ACCOUNT_ID}" \ + --user-pool-id "${GATEWAY_POOL_ID}" \ + --region ${AWS_REGION} \ + --no-cli-pager +WS_TEST_BLOCK_620_4 + +ws_run_block 5 'Creating the app client' 'Create an app client that uses the clientcredentials grant type:' 98 112 'bash' 'manual' <<'WS_TEST_BLOCK_620_5' +GATEWAY_CLIENT_ID=$(aws cognito-idp create-user-pool-client \ + --user-pool-id "${GATEWAY_POOL_ID}" \ + --client-name "mcp-gateway-client" \ + --generate-secret \ + --allowed-o-auth-flows "client_credentials" \ + --allowed-o-auth-scopes "gateway/invoke" \ + --allowed-o-auth-flows-user-pool-client \ + --region ${AWS_REGION} \ + --no-cli-pager \ + --query 'UserPoolClient.ClientId' --output text) + +GATEWAY_DISCOVERY_URL="https://cognito-idp.${AWS_REGION}.amazonaws.com/${GATEWAY_POOL_ID}/.well-known/openid-configuration" + +echo "export GATEWAY_CLIENT_ID=${GATEWAY_CLIENT_ID}" >> ~/environment/.envrc +echo "export GATEWAY_DISCOVERY_URL=${GATEWAY_DISCOVERY_URL}" >> ~/environment/.envrc +WS_TEST_BLOCK_620_5 + +ws_run_block 6 'Deploying the MCP server' 'Run the setup script to build and deploy the MCP server to AgentCore Runtime:' 149 149 'bash' 'script' <<'WS_TEST_BLOCK_620_6' +~/java-on-aws/apps/java-spring-ai-agents/scripts/05-mcp-runtime.sh +WS_TEST_BLOCK_620_6 + +ws_run_block 7 'Creating the ECR repository' 'Create an Amazon Elastic Container Registry (Amazon ECR) repository to store the container image:' 162 165 'bash' 'manual' <<'WS_TEST_BLOCK_620_7' +aws ecr create-repository \ + --repository-name "backoffice" \ + --region ${AWS_REGION} \ + --no-cli-pager +WS_TEST_BLOCK_620_7 + +ws_run_block 8 'Creating the IAM role' '1. Create the trust policy and role:' 175 194 'bash' 'manual' <<'WS_TEST_BLOCK_620_8' +cat > /tmp/trust-policy.json << EOF +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "bedrock-agentcore.amazonaws.com"}, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": {"aws:SourceAccount": "${ACCOUNT_ID}"}, + "ArnLike": {"aws:SourceArn": "arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:*"} + } + }] +} +EOF + +aws iam create-role \ + --role-name "backoffice-role" \ + --permissions-boundary "arn:aws:iam::${ACCOUNT_ID}:policy/workshop-boundary" \ + --assume-role-policy-document file:///tmp/trust-policy.json \ + --no-cli-pager +WS_TEST_BLOCK_620_8 + +ws_run_block 9 'Creating the IAM role' '2. Attach the permissions policy:' 203 228 'bash' 'manual' <<'WS_TEST_BLOCK_620_9' +cat > /tmp/backoffice-policy.json << EOF +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["ecr:*", "logs:*", "cloudwatch:*"], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": ["dynamodb:*"], + "Resource": [ + "arn:aws:dynamodb:${AWS_REGION}:${ACCOUNT_ID}:table/backoffice-*", + "arn:aws:dynamodb:${AWS_REGION}:${ACCOUNT_ID}:table/backoffice-*/index/*" + ] + } + ] +} +EOF + +aws iam put-role-policy \ + --role-name "backoffice-role" \ + --policy-name "AgentCorePolicy" \ + --policy-document file:///tmp/backoffice-policy.json \ + --no-cli-pager +WS_TEST_BLOCK_620_9 + +ws_run_block 10 'Building and pushing the container image' 'Build the container image using Spring Boot Buildpacks and push it to ECR:' 239 250 'bash' 'manual' <<'WS_TEST_BLOCK_620_10' +ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/backoffice" + +aws ecr get-login-password --region ${AWS_REGION} --no-cli-pager | \ + docker login --username AWS --password-stdin "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + +cd ~/environment/backoffice +mvn -ntp spring-boot:build-image \ + -DskipTests \ + -Dspring-boot.build-image.imageName="${ECR_URI}:latest" \ + -Dspring-boot.build-image.imagePlatform=linux/arm64 + +docker push "${ECR_URI}:latest" +WS_TEST_BLOCK_620_10 + +ws_run_block 11 'Creating the AgentCore Runtime' 'Creating the AgentCore Runtime' 263 293 'bash' 'manual' <<'WS_TEST_BLOCK_620_11' +cd ~/environment +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} +SUBNET_ID=$(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[0].SubnetId' --output text --no-cli-pager) && echo ${SUBNET_ID} +SG_ID=$(aws ec2 describe-security-groups \ + --filters "Name=vpc-id,Values=${VPC_ID}" "Name=group-name,Values=default" \ + --query 'SecurityGroups[0].GroupId' --output text --no-cli-pager) && echo ${SG_ID} + +echo "export VPC_ID=${VPC_ID}" >> ~/environment/.envrc +echo "export SUBNET_ID=${SUBNET_ID}" >> ~/environment/.envrc +echo "export SG_ID=${SG_ID}" >> ~/environment/.envrc + +ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/backoffice" +ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/backoffice-role" + +MCP_RUNTIME_ID=$(aws bedrock-agentcore-control create-agent-runtime \ + --agent-runtime-name "backoffice" \ + --role-arn "${ROLE_ARN}" \ + --agent-runtime-artifact "{\"containerConfiguration\":{\"containerUri\":\"${ECR_URI}:latest\"}}" \ + --protocol-configuration '{"serverProtocol":"MCP"}' \ + --network-configuration "{\"networkMode\":\"VPC\",\"networkModeConfig\":{\"subnets\":[\"${SUBNET_ID}\"],\"securityGroups\":[\"${SG_ID}\"]}}" \ + --authorizer-configuration "{\"customJWTAuthorizer\":{\"discoveryUrl\":\"${GATEWAY_DISCOVERY_URL}\",\"allowedClients\":[\"${GATEWAY_CLIENT_ID}\"]}}" \ + --region ${AWS_REGION} \ + --no-cli-pager \ + --query 'agentRuntimeId' --output text) +echo "export MCP_RUNTIME_ID=${MCP_RUNTIME_ID}" >> ~/environment/.envrc +WS_TEST_BLOCK_620_11 + +ws_run_block 12 'Creating the AgentCore Runtime' '- 27: JWT authorizer validates tokens from the M2M Cognito pool — only the gateway client ID is allowed' 306 311 'bash' 'manual' <<'WS_TEST_BLOCK_620_12' +echo -n "Waiting for runtime" +while [ "$(aws bedrock-agentcore-control get-agent-runtime \ + --agent-runtime-id "${MCP_RUNTIME_ID}" --region ${AWS_REGION} \ + --no-cli-pager --query 'status' --output text)" != "READY" ]; do + echo -n "."; sleep 5 +done && echo " READY" +WS_TEST_BLOCK_620_12 + +ws_run_block 13 'Creating the AgentCore Runtime' 'Save the AgentCore Runtime endpoint and token URI for later use:' 317 325 'bash' 'manual' <<'WS_TEST_BLOCK_620_13' +RUNTIME_ARN="arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:runtime/${MCP_RUNTIME_ID}" +MCP_ENDPOINT="https://bedrock-agentcore.${AWS_REGION}.amazonaws.com/runtimes/$(echo -n "${RUNTIME_ARN}" | jq -sRr @uri)/invocations?qualifier=DEFAULT" +COGNITO_DOMAIN=$(aws cognito-idp describe-user-pool \ + --user-pool-id "${GATEWAY_POOL_ID}" --region ${AWS_REGION} \ + --no-cli-pager --query 'UserPool.Domain' --output text) +M2M_TOKEN_URI="https://${COGNITO_DOMAIN}.auth.${AWS_REGION}.amazoncognito.com/oauth2/token" + +echo "export MCP_ENDPOINT=${MCP_ENDPOINT}" >> ~/environment/.envrc +echo "export M2M_TOKEN_URI=${M2M_TOKEN_URI}" >> ~/environment/.envrc +WS_TEST_BLOCK_620_13 + +ws_run_block 14 'Testing the MCP Server on AgentCore Runtime' 'Verify the deployed MCP server by listing its tools:' 336 353 'bash' '' <<'WS_TEST_BLOCK_620_14' +cd ~/environment + +GATEWAY_CLIENT_SECRET=$(aws cognito-idp describe-user-pool-client \ + --user-pool-id "${GATEWAY_POOL_ID}" --client-id "${GATEWAY_CLIENT_ID}" \ + --region ${AWS_REGION} --no-cli-pager \ + --query 'UserPoolClient.ClientSecret' --output text) + +TOKEN=$(curl -s -X POST "${M2M_TOKEN_URI}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials&client_id=${GATEWAY_CLIENT_ID}&client_secret=${GATEWAY_CLIENT_SECRET}&scope=gateway/invoke" \ + | jq -r '.access_token') + +curl -s -X POST "${MCP_ENDPOINT}" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \ + | jq '.result.tools[] | {name}' +WS_TEST_BLOCK_620_14 + +ws_skip_block 15 'Testing the MCP Server on AgentCore Runtime' 'Expected output:' 359 370 '' '' 'informational block without language' + +ws_run_block 16 'Creating the Gateway' 'Run the setup script to create the Gateway with targets:' 412 412 'bash' 'script' <<'WS_TEST_BLOCK_620_16' +~/java-on-aws/apps/java-spring-ai-agents/scripts/06-mcp-gateway.sh +WS_TEST_BLOCK_620_16 + +ws_run_block 17 'Creating the Gateway IAM role' '1. Create the trust policy and role:' 427 445 'bash' 'manual' <<'WS_TEST_BLOCK_620_17' +cat > /tmp/trust-policy.json << EOF +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "bedrock-agentcore.amazonaws.com"}, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": {"aws:SourceAccount": "${ACCOUNT_ID}"} + } + }] +} +EOF + +aws iam create-role \ + --role-name "mcp-gateway-role" \ + --permissions-boundary "arn:aws:iam::${ACCOUNT_ID}:policy/workshop-boundary" \ + --assume-role-policy-document file:///tmp/trust-policy.json \ + --no-cli-pager +WS_TEST_BLOCK_620_17 + +ws_run_block 18 'Creating the Gateway IAM role' '2. Attach the permissions policy:' 453 487 'bash' 'manual' <<'WS_TEST_BLOCK_620_18' +cat > /tmp/gateway-policy.json << EOF +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["bedrock-agentcore:InvokeAgentRuntime"], + "Resource": "arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:runtime/*" + }, + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetResourceApiKey", + "bedrock-agentcore:GetResourceOauth2Token" + ], + "Resource": [ + "arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:workload-identity-directory/*", + "arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:token-vault/*" + ] + }, + { + "Effect": "Allow", + "Action": ["secretsmanager:GetSecretValue"], + "Resource": "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:bedrock-agentcore-identity!*" + } + ] +} +EOF + +aws iam put-role-policy \ + --role-name "mcp-gateway-role" \ + --policy-name "GatewayPolicy" \ + --policy-document file:///tmp/gateway-policy.json \ + --no-cli-pager +WS_TEST_BLOCK_620_18 + +ws_run_block 19 'Creating the Gateway' 'Creating the Gateway' 497 520 'bash' 'manual' <<'WS_TEST_BLOCK_620_19' +GATEWAY_ID=$(aws bedrock-agentcore-control create-gateway \ + --name "mcp-gateway" \ + --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/mcp-gateway-role" \ + --protocol-type "MCP" \ + --protocol-configuration '{"mcp":{"searchType":"SEMANTIC"}}' \ + --authorizer-type "AWS_IAM" \ + --region ${AWS_REGION} \ + --no-cli-pager \ + --query 'gatewayId' --output text) + +echo "export GATEWAY_ID=${GATEWAY_ID}" >> ~/environment/.envrc + +echo -n "Waiting for gateway" +while [ "$(aws bedrock-agentcore-control get-gateway \ + --gateway-identifier "${GATEWAY_ID}" --region ${AWS_REGION} \ + --no-cli-pager --query 'status' --output text)" != "READY" ]; do + echo -n "."; sleep 5 +done && echo " READY" + +GATEWAY_URL=$(aws bedrock-agentcore-control get-gateway \ + --gateway-identifier "${GATEWAY_ID}" \ + --region ${AWS_REGION} \ + --no-cli-pager --query 'gatewayUrl' --output text) +echo "export GATEWAY_URL=${GATEWAY_URL}" >> ~/environment/.envrc +WS_TEST_BLOCK_620_19 + +ws_run_block 20 'Adding the backoffice target' '1. Create an OAuth2 credential provider:' 534 551 'bash' 'manual' <<'WS_TEST_BLOCK_620_20' +cd ~/environment +GATEWAY_CLIENT_SECRET=$(aws cognito-idp describe-user-pool-client \ + --user-pool-id "${GATEWAY_POOL_ID}" --client-id "${GATEWAY_CLIENT_ID}" \ + --region ${AWS_REGION} --no-cli-pager \ + --query 'UserPoolClient.ClientSecret' --output text) + +OAUTH_CONFIG=$(jq -n \ + --arg clientId "${GATEWAY_CLIENT_ID}" \ + --arg clientSecret "${GATEWAY_CLIENT_SECRET}" \ + --arg discoveryUrl "${GATEWAY_DISCOVERY_URL}" \ + '{customOauth2ProviderConfig: {clientId: $clientId, clientSecret: $clientSecret, oauthDiscovery: {discoveryUrl: $discoveryUrl}}}') + +aws bedrock-agentcore-control create-oauth2-credential-provider \ + --name "mcp-backoffice-oauth" \ + --credential-provider-vendor "CustomOauth2" \ + --oauth2-provider-config-input "${OAUTH_CONFIG}" \ + --region ${AWS_REGION} \ + --no-cli-pager +WS_TEST_BLOCK_620_20 + +ws_run_block 21 'Adding the backoffice target' '2. Add the backoffice target:' 560 577 'bash' 'manual' <<'WS_TEST_BLOCK_620_21' +RUNTIME_ARN="arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:runtime/${MCP_RUNTIME_ID}" +MCP_ENDPOINT="https://bedrock-agentcore.${AWS_REGION}.amazonaws.com/runtimes/$(echo -n "${RUNTIME_ARN}" | jq -sRr @uri)/invocations?qualifier=DEFAULT" +TARGET_CONFIG=$(jq -n --arg endpoint "${MCP_ENDPOINT}" '{mcp: {mcpServer: {endpoint: $endpoint}}}') + +OAUTH_PROVIDER_ARN=$(aws bedrock-agentcore-control list-oauth2-credential-providers \ + --region ${AWS_REGION} --no-cli-pager \ + --query "credentialProviders[?name=='mcp-backoffice-oauth'].credentialProviderArn | [0]" --output text) + +CREDENTIAL_CONFIG=$(jq -n --arg providerArn "${OAUTH_PROVIDER_ARN}" \ + '[{credentialProviderType: "OAUTH", credentialProvider: {oauthCredentialProvider: {providerArn: $providerArn, grantType: "CLIENT_CREDENTIALS", scopes: ["gateway/invoke"]}}}]') + +aws bedrock-agentcore-control create-gateway-target \ + --gateway-identifier "${GATEWAY_ID}" \ + --name "backoffice" \ + --target-configuration "${TARGET_CONFIG}" \ + --credential-provider-configurations "${CREDENTIAL_CONFIG}" \ + --region ${AWS_REGION} \ + --no-cli-pager +WS_TEST_BLOCK_620_21 + +ws_run_block 22 'Adding the holidays target' '> Copy and run the block as-is — the jq filter is a one-time conversion and does not need to be understood.' 593 605 'bash' 'manual' <<'WS_TEST_BLOCK_620_22' +OPENAPI_SPEC=$(curl -s -L "https://nagerholidays.com/openapi/community-v4.json" | jq -c ' + .openapi = "3.0.0" | + . + {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]) + ) + ) | + walk(if type == "object" and .type == ["null", "string"] then .type = "string" | .nullable = true + elif type == "object" and .type == ["null", "array"] then .type = "array" | .nullable = true + elif type == "object" and .type == ["null", "integer"] then .type = "integer" | .nullable = true + else . end) +') +WS_TEST_BLOCK_620_22 + +ws_run_block 23 'Adding the holidays target' 'Create an API key credential provider and add the target:' 613 635 'bash' 'manual' <<'WS_TEST_BLOCK_620_23' +aws bedrock-agentcore-control create-api-key-credential-provider \ + --name "mcp-holidays-apikey-provider" \ + --api-key "public-api-no-key-required" \ + --region ${AWS_REGION} \ + --no-cli-pager + +APIKEY_PROVIDER_ARN=$(aws bedrock-agentcore-control list-api-key-credential-providers \ + --region ${AWS_REGION} --no-cli-pager \ + --query "credentialProviders[?name=='mcp-holidays-apikey-provider'].credentialProviderArn | [0]" --output text) + +TARGET_CONFIG=$(jq -n --arg spec "${OPENAPI_SPEC}" \ + '{mcp: {openApiSchema: {inlinePayload: $spec}}}') + +CREDENTIAL_CONFIG=$(jq -n --arg providerArn "${APIKEY_PROVIDER_ARN}" \ + '[{credentialProviderType: "API_KEY", credentialProvider: {apiKeyCredentialProvider: {providerArn: $providerArn, credentialLocation: "HEADER", credentialParameterName: "X-Api-Key"}}}]') + +aws bedrock-agentcore-control create-gateway-target \ + --gateway-identifier "${GATEWAY_ID}" \ + --name "holidays" \ + --target-configuration "${TARGET_CONFIG}" \ + --credential-provider-configurations "${CREDENTIAL_CONFIG}" \ + --region ${AWS_REGION} \ + --no-cli-pager +WS_TEST_BLOCK_620_23 + +ws_run_block 24 'Adding the holidays target' '- 7-8: Look up the credential provider ARN' 644 655 'bash' 'manual' <<'WS_TEST_BLOCK_620_24' +for TARGET_NAME in backoffice holidays; do + TARGET_ID=$(aws bedrock-agentcore-control list-gateway-targets \ + --gateway-identifier "${GATEWAY_ID}" --region ${AWS_REGION} --no-cli-pager \ + --query "items[?name=='${TARGET_NAME}'].targetId | [0]" --output text) + echo -n "Waiting for ${TARGET_NAME}" + while [ "$(aws bedrock-agentcore-control get-gateway-target \ + --gateway-identifier "${GATEWAY_ID}" --target-id "${TARGET_ID}" \ + --region ${AWS_REGION} --no-cli-pager \ + --query 'status' --output text)" != "READY" ]; do + echo -n "."; sleep 5 + done && echo " READY" +done +WS_TEST_BLOCK_620_24 + +ws_run_block 25 'Testing the MCP Server on the AgentCore Gateway' '1. List all tools across both targets:' 668 679 'bash' '' <<'WS_TEST_BLOCK_620_25' +cd ~/environment + +eval "$(aws configure export-credentials --format env --no-cli-pager)" + +curl -s -X POST "${GATEWAY_URL}" \ + --aws-sigv4 aws:amz:${AWS_REGION}:bedrock-agentcore \ + --user ${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY} \ + -H x-amz-security-token:${AWS_SESSION_TOKEN} \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \ + | jq '.result.tools[] | {name}' +WS_TEST_BLOCK_620_25 + +ws_run_block 26 'Testing the MCP Server on the AgentCore Gateway' '2. Register a trip through the Gateway:' 687 696 'bash' '' <<'WS_TEST_BLOCK_620_26' +eval "$(aws configure export-credentials --format env --no-cli-pager)" + +curl -s -X POST "${GATEWAY_URL}" \ + --aws-sigv4 aws:amz:${AWS_REGION}:bedrock-agentcore \ + --user ${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY} \ + -H x-amz-security-token:${AWS_SESSION_TOKEN} \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"backoffice___registerTrip","arguments":{"userId":"testuser","departureDate":"2026-03-15","returnDate":"2026-03-20","origin":"Berlin","destination":"Amsterdam","purpose":"Java conference"}}}' \ + | jq '.result.content[0].text' -r +WS_TEST_BLOCK_620_26 + +ws_end_page + +ws_begin_page 'MCP Client' 640 'mcp/mcp-client/index.en.md' + +ws_run_block 1 'Adding dependencies' '1. Open pom.xml:' 31 31 'bash' '' <<'WS_TEST_BLOCK_640_1' +code ~/environment/aiagent/pom.xml +WS_TEST_BLOCK_640_1 + +ws_run_block 2 'Adding dependencies' '2. Add the MCP client starter and AWS SDK signing dependencies to the section:' 37 50 'xml' '' <<'WS_TEST_BLOCK_640_2' + + + org.springframework.ai + spring-ai-starter-mcp-client + + + + software.amazon.awssdk + auth + + + software.amazon.awssdk + regions + +WS_TEST_BLOCK_640_2 + +ws_run_block 3 'Creating the SigV4 configuration' 'Create src/main/java/com/example/agent/SigV4McpConfig.java:' 60 123 'java' '' <<'WS_TEST_BLOCK_640_3' +cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/SigV4McpConfig.java +package com.example.agent; + +import java.util.Set; + +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.customizer.McpClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.http.ContentStreamProvider; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner; +import software.amazon.awssdk.http.auth.spi.signer.SignedRequest; +import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; + +@Configuration +public class SigV4McpConfig { + + private static final Logger log = LoggerFactory.getLogger(SigV4McpConfig.class); + private static final Set RESTRICTED_HEADERS = Set.of("content-length", "host", "expect"); + + @Bean + McpClientCustomizer sigV4RequestCustomizer() { + var signer = AwsV4HttpSigner.create(); + var credentialsProvider = DefaultCredentialsProvider.builder().build(); + var region = new DefaultAwsRegionProviderChain().getRegion(); + log.info("SigV4 MCP request customizer: region={}, service=bedrock-agentcore", region); + + McpSyncHttpClientRequestCustomizer requestCustomizer = (builder, method, endpoint, body, context) -> { + var httpRequest = SdkHttpRequest.builder() + .uri(endpoint) + .method(SdkHttpMethod.valueOf(method)) + .putHeader("Content-Type", "application/json") + .build(); + + ContentStreamProvider payload = (body != null && !body.isEmpty()) + ? ContentStreamProvider.fromUtf8String(body) + : null; + + SignedRequest signedRequest = signer.sign(r -> r + .identity(credentialsProvider.resolveIdentity().join()) + .request(httpRequest) + .payload(payload) + .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, "bedrock-agentcore") + .putProperty(AwsV4HttpSigner.REGION_NAME, region.id())); + + signedRequest.request().headers().forEach((name, values) -> { + if (!RESTRICTED_HEADERS.contains(name.toLowerCase())) { + values.forEach(value -> builder.setHeader(name, value)); + } + }); + }; + + return (name, transportBuilder) -> { + transportBuilder.httpRequestCustomizer(requestCustomizer); + }; + } +} +EOF +WS_TEST_BLOCK_640_3 + +ws_run_block 4 'Updating the code' '1. Open ChatService.java:' 141 141 'bash' '' <<'WS_TEST_BLOCK_640_4' +code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java +WS_TEST_BLOCK_640_4 + +ws_run_block 5 'Updating the code' '2. Add the MCP tools parameter to the constructor, after Code Interpreter:' 147 148 'java' '' <<'WS_TEST_BLOCK_640_5' + @Qualifier("mcpToolCallbacks") ToolCallbackProvider mcpTools, + ChatClient.Builder chatClientBuilder) { +WS_TEST_BLOCK_640_5 + +ws_run_block 6 'Updating the code' '3. Add MCP tools to the tool callback providers list after the Code Interpreter:' 156 158 'java' '' <<'WS_TEST_BLOCK_640_6' + // MCP Tools + toolCallbackProviders.add(mcpTools); + logger.info("MCP tools enabled"); +WS_TEST_BLOCK_640_6 + +ws_run_block 7 'Testing the application' 'Write the MCP client configuration to application.properties:' 168 175 'bash' '' <<'WS_TEST_BLOCK_640_7' +grep -q "spring.ai.mcp.client" ~/environment/aiagent/src/main/resources/application.properties 2>/dev/null || \ +cat >> ~/environment/aiagent/src/main/resources/application.properties << EOF + +# MCP Client +spring.ai.mcp.client.toolcallback.enabled=true +spring.ai.mcp.client.initialized=false +spring.ai.mcp.client.streamable-http.connections.gateway.url=${GATEWAY_URL} +EOF +WS_TEST_BLOCK_640_7 + +ws_run_block 8 'Testing the application' '- connections.gateway.url points to the AgentCore Gateway MCP endpoint' 183 184 'bash' '' <<'WS_TEST_BLOCK_640_8' +cd ~/environment/aiagent +./mvnw spring-boot:run +WS_TEST_BLOCK_640_8 + +ws_skip_block 9 'Testing the application' 'Testing the application' 188 188 '' '' 'informational block without language' + +ws_run_block 10 'Committing changes' 'Committing changes' 216 218 'bash' '' <<'WS_TEST_BLOCK_640_10' +cd ~/environment/aiagent +git add . +git commit -m "Add MCP client" +WS_TEST_BLOCK_640_10 + +ws_end_page + +ws_begin_page 'Deploy the AI agent' 700 'deploy/index.en.md' + +ws_run_block 1 'Creating user authentication' 'Run the setup script to create the Cognito User Pool and test users:' 31 31 'bash' 'script' <<'WS_TEST_BLOCK_700_1' +~/java-on-aws/apps/java-spring-ai-agents/scripts/07-aiagent-cognito.sh +WS_TEST_BLOCK_700_1 + +ws_run_block 2 'Creating the Cognito User Pool' '1. Create an Amazon Cognito User Pool:' 44 62 'bash' 'manual' <<'WS_TEST_BLOCK_700_2' +AIAGENT_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) + +echo "export AIAGENT_USER_POOL_ID=${AIAGENT_USER_POOL_ID}" >> ~/environment/.envrc +echo "export AIAGENT_DISCOVERY_URL=https://cognito-idp.${AWS_REGION}.amazonaws.com/${AIAGENT_USER_POOL_ID}/.well-known/openid-configuration" >> ~/environment/.envrc +WS_TEST_BLOCK_700_2 + +ws_run_block 3 'Creating the Cognito User Pool' '2. Create an app client for the AI agent:' 72 81 'bash' 'manual' <<'WS_TEST_BLOCK_700_3' +AIAGENT_CLIENT_ID=$(aws cognito-idp create-user-pool-client \ + --user-pool-id "${AIAGENT_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) + +echo "export AIAGENT_CLIENT_ID=${AIAGENT_CLIENT_ID}" >> ~/environment/.envrc +WS_TEST_BLOCK_700_3 + +ws_run_block 4 'Creating the Cognito User Pool' '3. Create test users:' 90 107 'bash' 'manual' <<'WS_TEST_BLOCK_700_4' +for USER in admin alice bob; do + aws cognito-idp admin-create-user \ + --user-pool-id "${AIAGENT_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 "${AIAGENT_USER_POOL_ID}" \ + --username "${USER}" \ + --password "${IDE_PASSWORD}" \ + --permanent \ + --region ${AWS_REGION} \ + --no-cli-pager +done +echo "Test users created: admin, alice, bob" +WS_TEST_BLOCK_700_4 + +ws_run_block 5 'Adding dependencies' '1. Open pom.xml:' 121 121 'bash' '' <<'WS_TEST_BLOCK_700_5' +code ~/environment/aiagent/pom.xml +WS_TEST_BLOCK_700_5 + +ws_run_block 6 'Adding dependencies' '2. Add the Spring Security OAuth2 Resource Server to the section:' 127 131 'xml' '' <<'WS_TEST_BLOCK_700_6' + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + +WS_TEST_BLOCK_700_6 + +ws_run_block 7 'Updating the code' '1. Create SecurityConfig.java:' 139 177 'java' '' <<'WS_TEST_BLOCK_700_7' +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 +WS_TEST_BLOCK_700_7 + +ws_run_block 8 'Updating the code' '2. Save the Cognito issuer URI to application.properties:' 191 196 'bash' '' <<'WS_TEST_BLOCK_700_8' +grep -q "spring.security.oauth2.resourceserver.jwt.issuer-uri" ~/environment/aiagent/src/main/resources/application.properties 2>/dev/null || \ +cat >> ~/environment/aiagent/src/main/resources/application.properties << EOF + +# Security +spring.security.oauth2.resourceserver.jwt.issuer-uri=https://cognito-idp.${AWS_REGION}.amazonaws.com/${AIAGENT_USER_POOL_ID} +EOF +WS_TEST_BLOCK_700_8 + +ws_run_block 9 'Updating the code' '3. Create ConversationIdResolver.java to extract user identity from JWT tokens:' 202 248 'java' '' <<'WS_TEST_BLOCK_700_9' +cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/ConversationIdResolver.java +package com.example.agent; + +import org.springaicommunity.agentcore.context.AgentCoreContext; +import org.springaicommunity.agentcore.context.AgentCoreHeaders; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import java.util.Base64; +import java.util.UUID; + +/** + * Utility for extracting conversation ID from AgentCore context. + * Format: userId:sessionId (authenticated) or sessionId (anonymous) + */ +public final class ConversationIdResolver { + + private static final Logger logger = LoggerFactory.getLogger(ConversationIdResolver.class); + private static final JsonMapper jsonMapper = JsonMapper.builder().build(); + + private ConversationIdResolver() {} + + public static String resolve(AgentCoreContext context) { + String sessionId = context.getHeader(AgentCoreHeaders.SESSION_ID); + if (sessionId == null || sessionId.isBlank()) { + sessionId = UUID.randomUUID().toString(); + } + + String authHeader = context.getHeader(AgentCoreHeaders.AUTHORIZATION); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + try { + String jwt = authHeader.substring(7); + String payload = new String(Base64.getUrlDecoder().decode(jwt.split("\\.")[1])); + JsonNode claims = jsonMapper.readTree(payload); + String userId = claims.get("sub").asString(); + return userId + ":" + sessionId; + } catch (Exception e) { + logger.debug("JWT parsing failed, using sessionId only", e); + } + } + + return sessionId; + } +} +EOF +WS_TEST_BLOCK_700_9 + +ws_run_block 10 'Updating the code' '4. Open ChatService.java:' 258 258 'bash' '' <<'WS_TEST_BLOCK_700_10' +code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java +WS_TEST_BLOCK_700_10 + +ws_run_block 11 'Updating the code' '5. Update getConversationId() to use the resolver:' 264 266 'java' '' <<'WS_TEST_BLOCK_700_11' + private String getConversationId(AgentCoreContext context) { + return ConversationIdResolver.resolve(context); + } +WS_TEST_BLOCK_700_11 + +ws_run_block 12 'Testing authentication' '1. Start the application:' 274 275 'bash' '' <<'WS_TEST_BLOCK_700_12' +cd ~/environment/aiagent +./mvnw spring-boot:run +WS_TEST_BLOCK_700_12 + +ws_run_block 13 'Testing authentication' '2. Get a JWT token for alice and send an authenticated request:' 281 294 'bash' '' <<'WS_TEST_BLOCK_700_13' +cd ~/environment + +TOKEN=$(aws cognito-idp initiate-auth \ + --client-id "${AIAGENT_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) + +curl -N -s -X POST http://localhost:8080/invocations \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${TOKEN}" \ + -d '{"prompt":"Hi, I am Alice"}' +WS_TEST_BLOCK_700_13 + +ws_run_block 14 'Testing authentication' 'The AI agent responds with a streamed reply. Without the token, the request is rejected:' 300 302 'bash' '' <<'WS_TEST_BLOCK_700_14' +curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8080/invocations \ + -H "Content-Type: application/json" \ + -d '{"prompt":"Hi"}' +WS_TEST_BLOCK_700_14 + +ws_run_block 15 'Excluding static files from the container' '1. Open pom.xml:' 335 335 'bash' '' <<'WS_TEST_BLOCK_700_15' +code ~/environment/aiagent/pom.xml +WS_TEST_BLOCK_700_15 + +ws_run_block 16 'Excluding static files from the container' '2. Add a Maven profile before the closing tag that excludes the static/ directory from the build:' 341 355 'xml' '' <<'WS_TEST_BLOCK_700_16' + + + headless + + + + src/main/resources + + static/** + + + + + + +WS_TEST_BLOCK_700_16 + +ws_run_block 17 'Deploying to AgentCore Runtime' 'Run the setup script to build and deploy the AI agent to AgentCore Runtime:' 368 368 'bash' 'script' <<'WS_TEST_BLOCK_700_17' +~/java-on-aws/apps/java-spring-ai-agents/scripts/08-aiagent-runtime.sh +WS_TEST_BLOCK_700_17 + +ws_run_block 18 'Creating the ECR repository' 'Creating the ECR repository' 379 382 'bash' 'manual' <<'WS_TEST_BLOCK_700_18' +aws ecr create-repository \ + --repository-name "aiagent" \ + --region ${AWS_REGION} \ + --no-cli-pager +WS_TEST_BLOCK_700_18 + +ws_run_block 19 'Creating the IAM role' '1. Create the trust policy and role:' 392 411 'bash' 'manual' <<'WS_TEST_BLOCK_700_19' +cat > /tmp/trust-policy.json << EOF +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "bedrock-agentcore.amazonaws.com"}, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": {"aws:SourceAccount": "${ACCOUNT_ID}"}, + "ArnLike": {"aws:SourceArn": "arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:*"} + } + }] +} +EOF + +aws iam create-role \ + --role-name "aiagent-runtime-role" \ + --permissions-boundary "arn:aws:iam::${ACCOUNT_ID}:policy/workshop-boundary" \ + --assume-role-policy-document file:///tmp/trust-policy.json \ + --no-cli-pager +WS_TEST_BLOCK_700_19 + +ws_run_block 20 'Creating the IAM role' '2. Attach the permissions policy:' 420 442 'bash' 'manual' <<'WS_TEST_BLOCK_700_20' +cat > /tmp/aiagent-policy.json << EOF +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["bedrock:*", "bedrock-agentcore:*", "aws-marketplace:*"], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": ["ecr:*", "logs:*", "xray:*", "cloudwatch:*"], + "Resource": "*" + } + ] +} +EOF + +aws iam put-role-policy \ + --role-name "aiagent-runtime-role" \ + --policy-name "AgentCoreExecutionPolicy" \ + --policy-document file:///tmp/aiagent-policy.json \ + --no-cli-pager +WS_TEST_BLOCK_700_20 + +ws_run_block 21 'Building and pushing the container image' 'Building and pushing the container image' 453 465 'bash' 'manual' <<'WS_TEST_BLOCK_700_21' +ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/aiagent" + +aws ecr get-login-password --region ${AWS_REGION} --no-cli-pager | \ + docker login --username AWS --password-stdin "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + +cd ~/environment/aiagent +mvn -ntp spring-boot:build-image \ + -Pheadless \ + -DskipTests \ + -Dspring-boot.build-image.imageName="${ECR_URI}:latest" \ + -Dspring-boot.build-image.imagePlatform=linux/arm64 + +docker push "${ECR_URI}:latest" +WS_TEST_BLOCK_700_21 + +ws_run_block 22 'Creating the AgentCore Runtime' 'Creating the AgentCore Runtime' 478 493 'bash' 'manual' <<'WS_TEST_BLOCK_700_22' +cd ~/environment + +ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/aiagent" + +AIAGENT_RUNTIME_ID=$(aws bedrock-agentcore-control create-agent-runtime \ + --agent-runtime-name "aiagent" \ + --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/aiagent-runtime-role" \ + --agent-runtime-artifact "{\"containerConfiguration\":{\"containerUri\":\"${ECR_URI}:latest\"}}" \ + --network-configuration "{\"networkMode\":\"VPC\",\"networkModeConfig\":{\"subnets\":[\"${SUBNET_ID}\"],\"securityGroups\":[\"${SG_ID}\"]}}" \ + --authorizer-configuration "{\"customJWTAuthorizer\":{\"discoveryUrl\":\"${AIAGENT_DISCOVERY_URL}\",\"allowedClients\":[\"${AIAGENT_CLIENT_ID}\"]}}" \ + --request-header-configuration '{"requestHeaderAllowlist":["Authorization"]}' \ + --environment-variables PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ + --region ${AWS_REGION} \ + --no-cli-pager \ + --query 'agentRuntimeId' --output text) +echo "export AIAGENT_RUNTIME_ID=${AIAGENT_RUNTIME_ID}" >> ~/environment/.envrc +WS_TEST_BLOCK_700_22 + +ws_run_block 23 'Creating the AgentCore Runtime' '- 12: Skip Playwright'"'"'s local browser download — AgentCore Browser runs remotely' 503 508 'bash' 'manual' <<'WS_TEST_BLOCK_700_23' +echo -n "Waiting for runtime" +while [ "$(aws bedrock-agentcore-control get-agent-runtime \ + --agent-runtime-id "${AIAGENT_RUNTIME_ID}" --region ${AWS_REGION} \ + --no-cli-pager --query 'status' --output text)" != "READY" ]; do + echo -n "."; sleep 5 +done && echo " READY" +WS_TEST_BLOCK_700_23 + +ws_run_block 24 'Creating the AgentCore Runtime' 'Save the AgentCore Runtime endpoint:' 514 517 'bash' 'manual' <<'WS_TEST_BLOCK_700_24' +RUNTIME_ARN="arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:runtime/${AIAGENT_RUNTIME_ID}" +AIAGENT_ENDPOINT="https://bedrock-agentcore.${AWS_REGION}.amazonaws.com/runtimes/$(echo -n "${RUNTIME_ARN}" | jq -sRr @uri)/invocations?qualifier=DEFAULT" + +echo "export AIAGENT_ENDPOINT=${AIAGENT_ENDPOINT}" >> ~/environment/.envrc +WS_TEST_BLOCK_700_24 + +ws_run_block 25 'Testing the AI Agent on the AgentCore Runtime' 'Get a JWT token for alice and send a request to the deployed AI agent:' 528 541 'bash' '' <<'WS_TEST_BLOCK_700_25' +cd ~/environment + +TOKEN=$(aws cognito-idp initiate-auth \ + --client-id "${AIAGENT_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) + +curl -N -s -X POST "${AIAGENT_ENDPOINT}" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${TOKEN}" \ + -d '{"prompt":"Hi, I am alice"}' +WS_TEST_BLOCK_700_25 + +ws_run_block 26 'Accessing the logs' 'View the AgentCore Runtime status:' 549 553 'bash' '' <<'WS_TEST_BLOCK_700_26' +aws bedrock-agentcore-control get-agent-runtime \ + --agent-runtime-id "${AIAGENT_RUNTIME_ID}" \ + --region ${AWS_REGION} \ + --no-cli-pager \ + --query '{status:status,lastUpdated:lastUpdatedAt}' +WS_TEST_BLOCK_700_26 + +ws_run_block 27 'Accessing the logs' 'CloudWatch log group for the AgentCore Runtime:' 559 562 'bash' '' <<'WS_TEST_BLOCK_700_27' +aws logs tail "/aws/bedrock-agentcore/runtimes/${AIAGENT_RUNTIME_ID}-DEFAULT" \ + --region ${AWS_REGION} \ + --since 1h \ + --no-cli-pager +WS_TEST_BLOCK_700_27 + +ws_run_block 28 'Deploying the UI' 'Run the setup script to create the S3 bucket, CloudFront distribution, and upload the UI files:' 584 584 'bash' 'script' <<'WS_TEST_BLOCK_700_28' +~/java-on-aws/apps/java-spring-ai-agents/scripts/09-aiagent-ui.sh +WS_TEST_BLOCK_700_28 + +ws_run_block 29 'Creating the S3 bucket' 'Creating the S3 bucket' 595 603 'bash' 'manual' <<'WS_TEST_BLOCK_700_29' +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 +else + aws s3api create-bucket --bucket "${UI_BUCKET}" \ + --create-bucket-configuration LocationConstraint="${AWS_REGION}" --no-cli-pager +fi +echo "export UI_BUCKET=${UI_BUCKET}" >> ~/environment/.envrc +WS_TEST_BLOCK_700_29 + +ws_run_block 30 'Creating the CloudFront distribution' 'Create an OAI so CloudFront can read from the private S3 bucket, set the bucket policy, and create the distribution:' 613 683 'bash' 'manual' <<'WS_TEST_BLOCK_700_30' +OAI_ID=$(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 --query 'CloudFrontOriginAccessIdentity.Id' --output text) + +OAI_CANONICAL=$(aws cloudfront get-cloud-front-origin-access-identity --id "${OAI_ID}" \ + --no-cli-pager --query 'CloudFrontOriginAccessIdentity.S3CanonicalUserId' --output text) + +cat > /tmp/bucket-policy.json << EOF +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"CanonicalUser": "${OAI_CANONICAL}"}, + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::${UI_BUCKET}/*" + }] +} +EOF + +aws s3api put-bucket-policy --bucket "${UI_BUCKET}" \ + --policy file:///tmp/bucket-policy.json --no-cli-pager + +cat > /tmp/cf-distribution.json << EOF +{ + "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" +} +EOF + +UI_DOMAIN=$(aws cloudfront create-distribution \ + --distribution-config file:///tmp/cf-distribution.json \ + --no-cli-pager --query 'Distribution.DomainName' --output text) + +echo "export UI_DOMAIN=${UI_DOMAIN}" >> ~/environment/.envrc +WS_TEST_BLOCK_700_30 + +ws_run_block 31 'Uploading the files' 'Generate the UI configuration and upload all static files:' 694 718 'bash' 'manual' <<'WS_TEST_BLOCK_700_31' +cd ~/environment +cat > ~/environment/aiagent/src/main/resources/static/config.json << EOF +{ + "userPoolId": "${AIAGENT_USER_POOL_ID}", + "clientId": "${AIAGENT_CLIENT_ID}", + "apiEndpoint": "${AIAGENT_ENDPOINT}", + "enableAttachments": true +} +EOF + +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 + fi +done +WS_TEST_BLOCK_700_31 + +ws_run_block 32 'Uploading the files' '- 7: enableAttachments will be used in next modules' 727 731 'bash' 'manual' <<'WS_TEST_BLOCK_700_32' +echo -n "Waiting for CloudFront" +while [ "$(curl -s -o /dev/null -w "%{http_code}" "https://${UI_DOMAIN}" 2>/dev/null)" != "200" ]; do + echo -n "."; sleep 15 +done && echo " READY" +echo "UI URL: https://${UI_DOMAIN}" +WS_TEST_BLOCK_700_32 + +ws_run_block 33 'Testing the AI agent' 'Open the UI at https://${UIDOMAIN} and log in with the test credentials from the authentication section.' 742 744 'bash' '' <<'WS_TEST_BLOCK_700_33' +echo "UI URL: https://${UI_DOMAIN}" +echo "username: Alice" +echo "password: ${IDE_PASSWORD}" +WS_TEST_BLOCK_700_33 + +ws_run_block 34 'Committing changes' 'Committing changes' 760 762 'bash' '' <<'WS_TEST_BLOCK_700_34' +cd ~/environment/aiagent +git add . +git commit -m "Deploy the AI agent" +WS_TEST_BLOCK_700_34 + +ws_end_page + +ws_begin_page 'Document processing' 740 'document-processing/index.en.md' + +ws_run_block 1 'Document processing' 'Copy sample receipts and invoices to your environment for testing:' 9 10 'bash' '' <<'WS_TEST_BLOCK_740_1' +mkdir -p ~/environment/aiagent/samples/ +cp ~/java-on-aws/apps/java-spring-ai-agents/aiagent/samples/*.png ~/environment/aiagent/samples/ +WS_TEST_BLOCK_740_1 + +ws_end_page + +ws_begin_page 'Gateway plug-and-play' 741 'document-processing/gateway-plug-and-play/index.en.md' + +ws_run_block 1 'Adding expense tools to the MCP server' '1. Copy the expense package from the reference repository:' 13 14 'bash' '' <<'WS_TEST_BLOCK_741_1' +cp -r ~/java-on-aws/apps/java-spring-ai-agents/backoffice/expense \ + ~/environment/backoffice/src/main/java/com/example/backoffice/expense +WS_TEST_BLOCK_741_1 + +ws_run_block 2 'Adding expense tools to the MCP server' '2. Create ExpenseTools.java — the same pattern as TripTools.java from the MCP Server module:' 20 85 'java' '' <<'WS_TEST_BLOCK_741_2' +cat <<'EOF' > ~/environment/backoffice/src/main/java/com/example/backoffice/expense/ExpenseTools.java +package com.example.backoffice.expense; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +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.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +@Component +public class ExpenseTools { + + private final ExpenseService service; + + public ExpenseTools(ExpenseService service) { + this.service = service; + } + + @Bean + public ToolCallbackProvider expenseToolsProvider(ExpenseTools expenseTools) { + return MethodToolCallbackProvider.builder() + .toolObjects(expenseTools) + .build(); + } + + @Tool(description = "Create a new expense report. Optionally link to a trip.") + public Expense createExpense( + @ToolParam(description = "User ID") String userId, + @ToolParam(description = "Amount") BigDecimal amount, + @ToolParam(description = "Currency code (USD, EUR, etc.)") String currency, + @ToolParam(description = "Expense date (YYYY-MM-DD)") LocalDate date, + @ToolParam(description = "Description of expense") String description, + @ToolParam(description = "Type: FLIGHT, HOTEL, MEALS, TRANSPORT, OTHER") Expense.ExpenseType type, + @ToolParam(description = "Trip reference to link (optional, TRP-XXXXXXXX)") String tripReference) { + return service.createExpense(userId, amount, currency, date, description, type, tripReference); + } + + @Tool(description = "Get all expenses for a user") + public List getExpenses(@ToolParam(description = "User ID") String userId) { + return service.getExpenses(userId); + } + + @Tool(description = "Get expense details by reference number") + public Expense getExpense( + @ToolParam(description = "Expense reference (EXP-XXXXXXXX)") String expenseReference) { + return service.getExpense(expenseReference); + } + + @Tool(description = "Get all expenses linked to a specific trip") + public List getExpensesForTrip( + @ToolParam(description = "Trip reference (TRP-XXXXXXXX)") String tripReference) { + return service.getExpensesForTrip(tripReference); + } + + @Tool(description = "Submit a draft expense for approval") + public Expense submitExpense( + @ToolParam(description = "Expense reference (EXP-XXXXXXXX)") String expenseReference) { + return service.submitExpense(expenseReference); + } +} +EOF +WS_TEST_BLOCK_741_2 + +ws_run_block 3 'Adding expense tools to the MCP server' '3. Create the DynamoDB table and indexes for expenses:' 94 110 'bash' '' <<'WS_TEST_BLOCK_741_3' +aws dynamodb create-table \ + --table-name "backoffice-expense" \ + --attribute-definitions \ + AttributeName=pk,AttributeType=S \ + AttributeName=sk,AttributeType=S \ + AttributeName=expenseReference,AttributeType=S \ + AttributeName=tripReference,AttributeType=S \ + --key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \ + --global-secondary-indexes \ + "IndexName=expenseReference-index,KeySchema=[{AttributeName=expenseReference,KeyType=HASH}],Projection={ProjectionType=ALL}" \ + "IndexName=tripReference-index,KeySchema=[{AttributeName=tripReference,KeyType=HASH}],Projection={ProjectionType=ALL}" \ + --billing-mode PAY_PER_REQUEST \ + --region ${AWS_REGION} \ + --no-cli-pager + +aws dynamodb wait table-exists --table-name "backoffice-expense" --region ${AWS_REGION} +echo "Table created: backoffice-expense" +WS_TEST_BLOCK_741_3 + +ws_run_block 4 'Redeploying the MCP server' 'Run the setup script to rebuild and redeploy the MCP server to AgentCore Runtime:' 121 121 'bash' 'script' <<'WS_TEST_BLOCK_741_4' +~/java-on-aws/apps/java-spring-ai-agents/scripts/10-mcp-runtime-redeploy.sh +WS_TEST_BLOCK_741_4 + +ws_run_block 5 'Redeploying the MCP server' 'Rebuild the container image and update the AgentCore Runtime:' 132 155 'bash' 'manual' <<'WS_TEST_BLOCK_741_5' +cd ~/environment + +ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/backoffice" + +aws ecr get-login-password --region ${AWS_REGION} --no-cli-pager | \ + docker login --username AWS --password-stdin "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + +cd ~/environment/backoffice +mvn -ntp spring-boot:build-image \ + -DskipTests \ + -Dspring-boot.build-image.imageName="${ECR_URI}:latest" \ + -Dspring-boot.build-image.imagePlatform=linux/arm64 + +docker push "${ECR_URI}:latest" + +aws bedrock-agentcore-control update-agent-runtime \ + --agent-runtime-id "${MCP_RUNTIME_ID}" \ + --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/backoffice-role" \ + --agent-runtime-artifact "{\"containerConfiguration\":{\"containerUri\":\"${ECR_URI}:latest\"}}" \ + --protocol-configuration '{"serverProtocol":"MCP"}' \ + --network-configuration "{\"networkMode\":\"VPC\",\"networkModeConfig\":{\"subnets\":[\"${SUBNET_ID}\"],\"securityGroups\":[\"${SG_ID}\"]}}" \ + --authorizer-configuration "{\"customJWTAuthorizer\":{\"discoveryUrl\":\"${GATEWAY_DISCOVERY_URL}\",\"allowedClients\":[\"${GATEWAY_CLIENT_ID}\"]}}" \ + --region ${AWS_REGION} \ + --no-cli-pager +WS_TEST_BLOCK_741_5 + +ws_run_block 6 'Redeploying the MCP server' '- 16-24: Update the AgentCore Runtime — AgentCore pulls the new image and restarts the container' 164 169 'bash' 'manual' <<'WS_TEST_BLOCK_741_6' +echo -n "Waiting for runtime" +while [ "$(aws bedrock-agentcore-control get-agent-runtime \ + --agent-runtime-id "${MCP_RUNTIME_ID}" --region ${AWS_REGION} \ + --no-cli-pager --query 'status' --output text)" != "READY" ]; do + echo -n "."; sleep 5 +done && echo " READY" +WS_TEST_BLOCK_741_6 + +ws_run_block 7 'Redeploying the MCP server' 'Synchronize the Gateway target so it discovers the new expense tools. The Gateway pre-computes vector embeddings for semantic search, so it needs an explicit sync when the MCP server'"'"'s tool catalog changes:' 175 185 'bash' 'manual' <<'WS_TEST_BLOCK_741_7' +cd ~/environment + +BACKOFFICE_TARGET_ID=$(aws bedrock-agentcore-control list-gateway-targets \ + --gateway-identifier "${GATEWAY_ID}" --region ${AWS_REGION} --no-cli-pager \ + --query "items[?name=='backoffice'].targetId | [0]" --output text) + +aws bedrock-agentcore-control synchronize-gateway-targets \ + --gateway-identifier "${GATEWAY_ID}" \ + --target-id-list "${BACKOFFICE_TARGET_ID}" \ + --region ${AWS_REGION} \ + --no-cli-pager +WS_TEST_BLOCK_741_7 + +ws_run_block 8 'Committing changes' 'Committing changes' 194 196 'bash' '' <<'WS_TEST_BLOCK_741_8' +cd ~/environment/backoffice +git add . +git commit -m "Add expense tools" +WS_TEST_BLOCK_741_8 + +ws_run_block 9 'Adding a currency converter Lambda to the Gateway' 'Run the setup script to deploy the currency converter Lambda and add it as a Gateway target:' 212 212 'bash' 'script' <<'WS_TEST_BLOCK_741_9' +~/java-on-aws/apps/java-spring-ai-agents/scripts/11-mcp-currency.sh +WS_TEST_BLOCK_741_9 + +ws_run_block 10 'Adding a currency converter Lambda to the Gateway' '1. Copy the currency converter application from the reference repository:' 223 223 'bash' 'manual' <<'WS_TEST_BLOCK_741_10' +cp -r ~/java-on-aws/apps/java-spring-ai-agents/currency ~/environment/currency +WS_TEST_BLOCK_741_10 + +ws_run_block 11 'Adding a currency converter Lambda to the Gateway' '2. Build the Lambda package:' 229 230 'bash' 'manual' <<'WS_TEST_BLOCK_741_11' +cd ~/environment/currency +mvn clean package -DskipTests -ntp +WS_TEST_BLOCK_741_11 + +ws_run_block 12 'Adding a currency converter Lambda to the Gateway' '3. Create the IAM role and deploy the Lambda function:' 236 272 'bash' 'manual' <<'WS_TEST_BLOCK_741_12' +aws iam create-role \ + --role-name "mcp-currency-role" \ + --permissions-boundary "arn:aws:iam::${ACCOUNT_ID}:policy/workshop-boundary" \ + --assume-role-policy-document '{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }' \ + --no-cli-pager + +aws iam attach-role-policy \ + --role-name "mcp-currency-role" \ + --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole \ + --no-cli-pager + +sleep 10 + +JAR_FILE=$(ls ~/environment/currency/target/*.jar | head -1) +aws lambda create-function \ + --function-name "mcp-currency" \ + --runtime java25 \ + --role "arn:aws:iam::${ACCOUNT_ID}:role/mcp-currency-role" \ + --handler "com.example.currency.CurrencyHandler::handleRequest" \ + --zip-file "fileb://${JAR_FILE}" \ + --timeout 30 \ + --memory-size 512 \ + --region ${AWS_REGION} \ + --no-cli-pager + +aws lambda wait function-active-v2 \ + --function-name "mcp-currency" \ + --region ${AWS_REGION} \ + --no-cli-pager +echo "Lambda ready: mcp-currency" +WS_TEST_BLOCK_741_12 + +ws_run_block 13 'Adding a currency converter Lambda to the Gateway' '4. Add Lambda invoke permission to the Gateway role:' 282 295 'bash' 'manual' <<'WS_TEST_BLOCK_741_13' +aws iam put-role-policy \ + --role-name "mcp-gateway-role" \ + --policy-name "CurrencyLambdaInvoke" \ + --policy-document "{ + \"Version\": \"2012-10-17\", + \"Statement\": [{ + \"Effect\": \"Allow\", + \"Action\": \"lambda:InvokeFunction\", + \"Resource\": \"arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:mcp-currency\" + }] + }" \ + --no-cli-pager + +sleep 10 +WS_TEST_BLOCK_741_13 + +ws_run_block 14 'Adding a currency converter Lambda to the Gateway' '5. Create the Lambda target on the Gateway:' 301 334 'bash' 'manual' <<'WS_TEST_BLOCK_741_14' +cd ~/environment +LAMBDA_TOOLS='[ + { + "name": "convertCurrency", + "description": "Convert amount between currencies using real-time exchange rates", + "inputSchema": { + "type": "object", + "properties": { + "fromCurrency": {"type": "string", "description": "Source currency code (USD, EUR, GBP, etc.)"}, + "toCurrency": {"type": "string", "description": "Target currency code"}, + "amount": {"type": "number", "description": "Amount to convert"} + }, + "required": ["fromCurrency", "toCurrency", "amount"] + } + }, + { + "name": "getSupportedCurrencies", + "description": "Get list of all supported currency codes for conversion", + "inputSchema": {"type": "object", "properties": {}} + } +]' + +TARGET_CONFIG=$(jq -n \ + --arg arn "arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:mcp-currency" \ + --argjson tools "${LAMBDA_TOOLS}" \ + '{mcp: {lambda: {lambdaArn: $arn, toolSchema: {inlinePayload: $tools}}}}') + +aws bedrock-agentcore-control create-gateway-target \ + --gateway-identifier "${GATEWAY_ID}" \ + --name "currency" \ + --target-configuration "${TARGET_CONFIG}" \ + --credential-provider-configurations '[{"credentialProviderType":"GATEWAY_IAM_ROLE"}]' \ + --region ${AWS_REGION} \ + --no-cli-pager +WS_TEST_BLOCK_741_14 + +ws_run_block 15 'Adding a currency converter Lambda to the Gateway' '- 32: GATEWAYIAMROLE — Gateway uses its own IAM role to invoke the function, no separate credential provider needed' 343 352 'bash' 'manual' <<'WS_TEST_BLOCK_741_15' +TARGET_ID=$(aws bedrock-agentcore-control list-gateway-targets \ + --gateway-identifier "${GATEWAY_ID}" --region ${AWS_REGION} --no-cli-pager \ + --query "items[?name=='currency'].targetId | [0]" --output text) +echo -n "Waiting for currency target" +while [ "$(aws bedrock-agentcore-control get-gateway-target \ + --gateway-identifier "${GATEWAY_ID}" --target-id "${TARGET_ID}" \ + --region ${AWS_REGION} --no-cli-pager \ + --query 'status' --output text)" != "READY" ]; do + echo -n "."; sleep 5 +done && echo " READY" +WS_TEST_BLOCK_741_15 + +ws_end_page + +ws_begin_page 'Multi-modal chat' 742 'document-processing/multi-modal-chat/index.en.md' + +ws_run_block 1 'Updating the ChatRequest record' '1. Open ChatService.java:' 15 15 'bash' '' <<'WS_TEST_BLOCK_742_1' +code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java +WS_TEST_BLOCK_742_1 + +ws_run_block 2 'Updating the ChatRequest record' '2. Replace the ChatRequest record with a version that includes file fields:' 21 25 'java' '' <<'WS_TEST_BLOCK_742_2' +record ChatRequest(String prompt, String fileBase64, String fileName) { + public boolean hasFile() { + return fileBase64 != null && !fileBase64.isEmpty() && fileName != null && !fileName.isEmpty(); + } +} +WS_TEST_BLOCK_742_2 + +ws_run_block 3 'Updating the ChatService' '1. Add the multimodal imports after the existing imports:' 35 42 'java' '' <<'WS_TEST_BLOCK_742_3' +import java.util.Base64; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; +WS_TEST_BLOCK_742_3 + +ws_run_block 4 'Updating the ChatService' '2. Add two fields after the chatClient field:' 48 49 'java' '' <<'WS_TEST_BLOCK_742_4' + private final ChatClient documentClient; + private final String documentModel; +WS_TEST_BLOCK_742_4 + +ws_run_block 5 'Updating the ChatService' '3. Add two constructor parameters after the existing ones:' 55 57 'java' '' <<'WS_TEST_BLOCK_742_5' + ChatModel chatModel, + @Value("${app.ai.document.model:global.anthropic.claude-opus-4-6-v1}") String documentModel, + ChatClient.Builder chatClientBuilder) { +WS_TEST_BLOCK_742_5 + +ws_run_block 6 'Updating the ChatService' '4. Initialize the fields inside the constructor body, before the chatClient build:' 63 64 'java' '' <<'WS_TEST_BLOCK_742_6' + this.documentModel = documentModel; + this.documentClient = ChatClient.builder(chatModel).build(); +WS_TEST_BLOCK_742_6 + +ws_run_block 7 'Updating the ChatService' '5. Update the @AgentCoreInvocation method to route file uploads to document processing:' 72 86 'java' '' <<'WS_TEST_BLOCK_742_7' + @AgentCoreInvocation + public Flux chat(ChatRequest request, AgentCoreContext context) { + if (request.hasFile()) { + return processDocument(request.prompt(), request.fileBase64(), request.fileName()) + .collectList() + .map(chunks -> String.join("", chunks)) + .flatMapMany(documentAnalysis -> { + String userPrompt = (request.prompt() != null && !request.prompt().trim().isEmpty()) + ? request.prompt() : "Process this document"; + String combinedPrompt = userPrompt + "\n\nDocument analysis:\n" + documentAnalysis; + return chat(combinedPrompt, getConversationId(context)); + }); + } + return chat(request.prompt(), getConversationId(context)); + } +WS_TEST_BLOCK_742_7 + +ws_run_block 8 'Updating the ChatService' '6. Add the processDocument and determineMimeType methods at the end of the class:' 94 122 'java' '' <<'WS_TEST_BLOCK_742_8' + private Flux processDocument(String prompt, String fileBase64, String fileName) { + logger.info("Processing document: {}", fileName); + + MimeType mimeType = determineMimeType(fileName); + byte[] fileData = Base64.getDecoder().decode(fileBase64); + ByteArrayResource resource = new ByteArrayResource(fileData); + String userPrompt = (prompt != null && !prompt.trim().isEmpty()) ? prompt : "Analyze this document"; + + return documentClient.prompt() + .options(ToolCallingChatOptions.builder().model(documentModel)) + .user(userSpec -> { + userSpec.text(userPrompt); + userSpec.media(mimeType, resource); + }) + .stream() + .content() + .onErrorResume(error -> { + logger.error("Error processing document", error); + return Flux.just("Error analyzing document: " + error.getMessage()); + }); + } + + private MimeType determineMimeType(String fileName) { + if (fileName != null && !fileName.trim().isEmpty()) { + MediaType mediaType = MediaTypeFactory.getMediaType(fileName).orElse(MediaType.APPLICATION_OCTET_STREAM); + return new MimeType(mediaType.getType(), mediaType.getSubtype()); + } + return MimeTypeUtils.APPLICATION_OCTET_STREAM; + } +WS_TEST_BLOCK_742_8 + +ws_run_block 9 'Redeploying the AI agent' 'Run the setup script to rebuild and redeploy the AI agent to AgentCore Runtime:' 138 138 'bash' 'script' <<'WS_TEST_BLOCK_742_9' +~/java-on-aws/apps/java-spring-ai-agents/scripts/12-aiagent-redeploy.sh +WS_TEST_BLOCK_742_9 + +ws_run_block 10 'Redeploying the AI agent' 'Rebuild the container image and update the AgentCore Runtime:' 149 174 'bash' 'manual' <<'WS_TEST_BLOCK_742_10' +cd ~/environment + +ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/aiagent" + +aws ecr get-login-password --region ${AWS_REGION} --no-cli-pager | \ + docker login --username AWS --password-stdin "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + +cd ~/environment/aiagent +mvn -ntp spring-boot:build-image \ + -Pheadless \ + -DskipTests \ + -Dspring-boot.build-image.imageName="${ECR_URI}:latest" \ + -Dspring-boot.build-image.imagePlatform=linux/arm64 + +docker push "${ECR_URI}:latest" + +aws bedrock-agentcore-control update-agent-runtime \ + --agent-runtime-id "${AIAGENT_RUNTIME_ID}" \ + --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/aiagent-runtime-role" \ + --agent-runtime-artifact "{\"containerConfiguration\":{\"containerUri\":\"${ECR_URI}:latest\"}}" \ + --network-configuration "{\"networkMode\":\"VPC\",\"networkModeConfig\":{\"subnets\":[\"${SUBNET_ID}\"],\"securityGroups\":[\"${SG_ID}\"]}}" \ + --authorizer-configuration "{\"customJWTAuthorizer\":{\"discoveryUrl\":\"${AIAGENT_DISCOVERY_URL}\",\"allowedClients\":[\"${AIAGENT_CLIENT_ID}\"]}}" \ + --request-header-configuration '{"requestHeaderAllowlist":["Authorization"]}' \ + --environment-variables PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ + --region ${AWS_REGION} \ + --no-cli-pager +WS_TEST_BLOCK_742_10 + +ws_run_block 11 'Redeploying the AI agent' 'Wait for the AgentCore Runtime to be ready:' 180 185 'bash' 'manual' <<'WS_TEST_BLOCK_742_11' +echo -n "Waiting for runtime" +while [ "$(aws bedrock-agentcore-control get-agent-runtime \ + --agent-runtime-id "${AIAGENT_RUNTIME_ID}" --region ${AWS_REGION} \ + --no-cli-pager --query 'status' --output text)" != "READY" ]; do + echo -n "."; sleep 5 +done && echo " READY" +WS_TEST_BLOCK_742_11 + +ws_run_block 12 'Testing the AI agent' 'Open the UI at https://${UIDOMAIN}, log off and log into the new section.' 196 198 'bash' '' <<'WS_TEST_BLOCK_742_12' +echo "UI URL: https://${UI_DOMAIN}" +echo "username: Alice" +echo "password: ${IDE_PASSWORD}" +WS_TEST_BLOCK_742_12 + +ws_run_block 13 'Committing changes' 'Committing changes' 214 216 'bash' '' <<'WS_TEST_BLOCK_742_13' +cd ~/environment/aiagent +git add . +git commit -m "Add multimodal support" +WS_TEST_BLOCK_742_13 + +ws_end_page + +ws_begin_page 'Observability' 800 'observability/index.en.md' + +ws_run_block 1 'Enabling Bedrock model invocation logging' 'Enable logging to both CloudWatch Logs and Amazon S3:' 21 52 'bash' '' <<'WS_TEST_BLOCK_800_1' +BUCKET_NAME=$(aws ssm get-parameter --name workshop-bucket-name \ + --query 'Parameter.Value' --output text --no-cli-pager) +ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/workshop-bedrock-logging-role" + +aws logs create-log-group \ + --log-group-name /aws/bedrock/model-invocations --no-cli-pager || true + +cat > /tmp/bedrock-logging-config.json << EOF +{ + "loggingConfig": { + "cloudWatchConfig": { + "logGroupName": "/aws/bedrock/model-invocations", + "roleArn": "${ROLE_ARN}", + "largeDataDeliveryS3Config": { + "bucketName": "${BUCKET_NAME}", + "keyPrefix": "bedrock-logs" + } + }, + "s3Config": { + "bucketName": "${BUCKET_NAME}", + "keyPrefix": "bedrock-logs" + }, + "textDataDeliveryEnabled": true, + "imageDataDeliveryEnabled": true, + "embeddingDataDeliveryEnabled": true + } +} +EOF + +aws bedrock put-model-invocation-logging-configuration \ + --cli-input-json file:///tmp/bedrock-logging-config.json \ + --no-cli-pager +WS_TEST_BLOCK_800_1 + +ws_run_block 2 'Enabling Bedrock model invocation logging' '- 14-16: Amazon S3 configuration for long-term log retention' 61 61 'bash' '' <<'WS_TEST_BLOCK_800_2' +aws bedrock get-model-invocation-logging-configuration --no-cli-pager +WS_TEST_BLOCK_800_2 + +ws_end_page + +ws_begin_page 'Clean up' 1000 'cleanup/index.en.md' + +ws_run_block 1 'Cleaning up workshop resources' 'Run the cleanup script to delete all resources created during the workshop:' 18 19 'bash' 'own' <<'WS_TEST_BLOCK_1000_1' + +~/java-on-aws/apps/java-spring-ai-agents/scripts/99-cleanup.sh +WS_TEST_BLOCK_1000_1 + +ws_run_block 2 'Deleting the workshop infrastructure' '2. Delete AWS CloudFormation template:' 35 38 'bash' 'own' <<'WS_TEST_BLOCK_1000_2' +aws cloudformation delete-stack --stack-name workshop-stack +aws cloudformation wait stack-delete-complete --stack-name workshop-stack +CFN_S3=$(aws s3api list-buckets --query "Buckets[?starts_with(Name, 'cfn-')].Name" --output text) +aws s3 rb s3://${CFN_S3} --force +WS_TEST_BLOCK_1000_2 + +ws_end_page + +ws_finish_run diff --git a/infra/workshops.json b/infra/workshops.json index da77a568..d18ddd97 100644 --- a/infra/workshops.json +++ b/infra/workshops.json @@ -25,7 +25,10 @@ }, { "template": "java-ai-agents", - "repository": "java-ai-agents" + "repository": "java-ai-agents", + "test": { + "enabled": true + } }, { "template": "java-ai-agents-advanced", From ea9bec84523a2bd900b3054e67969b3642275cb3 Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Mon, 24 Aug 2026 18:48:23 +0200 Subject: [PATCH 28/38] fix(iam): Allow AgentCore identity secrets --- .../main/java/sample/com/constructs/Ide.java | 12 +++++++++ .../resources/agentcore-identity-policy.json | 16 +++++++++++ .../src/main/resources/workshop-boundary.json | 3 ++- infra/cfn/java-ai-agents-advanced-stack.yaml | 27 +++++++++++++++++++ infra/cfn/java-ai-agents-stack.yaml | 27 +++++++++++++++++++ infra/scripts/cfn/sync.sh | 16 ++++++++++- 6 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 infra/cdk/src/main/resources/agentcore-identity-policy.json 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 f9b6d4c0..236bd634 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/Ide.java +++ b/infra/cdk/src/main/java/sample/com/constructs/Ide.java @@ -195,6 +195,18 @@ public Ide(final Construct scope, final String id, final IdeProps props) { .build(); this.ideRole.addManagedPolicy(policy); + 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") .replace("{{.AccountId}}", Aws.ACCOUNT_ID); 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/workshop-boundary.json b/infra/cdk/src/main/resources/workshop-boundary.json index 93434a7b..237c8efd 100644 --- a/infra/cdk/src/main/resources/workshop-boundary.json +++ b/infra/cdk/src/main/resources/workshop-boundary.json @@ -41,7 +41,8 @@ "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:mcp-*", + "arn:aws:secretsmanager:*:{{.AccountId}}:secret:bedrock-agentcore-identity!*" ] }, { diff --git a/infra/cfn/java-ai-agents-advanced-stack.yaml b/infra/cfn/java-ai-agents-advanced-stack.yaml index 275ce666..7ee8e19f 100644 --- a/infra/cfn/java-ai-agents-advanced-stack.yaml +++ b/infra/cfn/java-ai-agents-advanced-stack.yaml @@ -1155,6 +1155,27 @@ Resources: Roles: - Ref: EcrRegistryTemplateRole9295BC5C Type: AWS::IAM::Policy + IdeAgentCoreIdentityPolicy5C973EFA: + Properties: + 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 IdeDistribution042A6660: DeletionPolicy: Delete DependsOn: @@ -1965,6 +1986,7 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeAgentCoreIdentityPolicy5C973EFA RoleName: workshop-ide-user Tags: - Key: WorkshopDeploymentId @@ -2557,6 +2579,11 @@ Resources: - - "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:*:" diff --git a/infra/cfn/java-ai-agents-stack.yaml b/infra/cfn/java-ai-agents-stack.yaml index ee0749ae..08e5186d 100644 --- a/infra/cfn/java-ai-agents-stack.yaml +++ b/infra/cfn/java-ai-agents-stack.yaml @@ -1155,6 +1155,27 @@ Resources: Roles: - Ref: EcrRegistryTemplateRole9295BC5C Type: AWS::IAM::Policy + IdeAgentCoreIdentityPolicy5C973EFA: + Properties: + 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 IdeDistribution042A6660: DeletionPolicy: Delete DependsOn: @@ -1965,6 +1986,7 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeAgentCoreIdentityPolicy5C973EFA RoleName: workshop-ide-user Tags: - Key: WorkshopDeploymentId @@ -2557,6 +2579,11 @@ Resources: - - "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:*:" diff --git a/infra/scripts/cfn/sync.sh b/infra/scripts/cfn/sync.sh index 5c08e1be..61e50d9b 100755 --- a/infra/scripts/cfn/sync.sh +++ b/infra/scripts/cfn/sync.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Copies workshop-specific CloudFormation templates and the shared IAM policy +# 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" @@ -10,6 +10,7 @@ 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" +AGENTCORE_IDENTITY_POLICY_FILE="$INFRA_DIR/cdk/src/main/resources/agentcore-identity-policy.json" if [[ ! -f "$CONFIG_FILE" ]]; then log_error "Workshop registry not found: $CONFIG_FILE" @@ -19,6 +20,10 @@ if [[ ! -f "$SHARED_POLICY_FILE" ]]; then log_error "Shared policy file not found: $SHARED_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 all_templates=() all_repositories=() @@ -80,6 +85,15 @@ for index in "${selected_indexes[@]}"; do exit 1 } log_success "Synced $SHARED_POLICY_FILE to $repository/static/iam-policy.json" + + 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 From a44563a898ebe237a892c96aeed8de5a0d1163d3 Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Tue, 25 Aug 2026 09:38:31 +0200 Subject: [PATCH 29/38] fix(iam): Refactor AgentCore IAM policies with granular resource scoping - Split monolithic bedrock-agentcore policy into granular statements for browser automation, code interpreter, and identity operations - Add new agentcore-managed-tools-policy.json with scoped permissions for browser and code interpreter resources - Update WorkshopStack to apply resource-specific ARNs (aws.browser.v1 and aws.codeinterpreter.v1) instead of account-wide wildcards - Add AgentCoreManagedToolsPolicy to IDE role for java-spring-ai-agents, java-ai-agents, and java-ai-agents-advanced templates - Update workshop-boundary.json with matching granular policy statements for permission boundaries - Sync CloudFormation templates (java-ai-agents-stack.yaml, java-ai-agents-advanced-stack.yaml, java-spring-ai-agents-stack.yaml) with CDK changes - Improves least-privilege security posture by scoping bedrock-agentcore permissions to specific AWS-managed resources --- .../main/java/sample/com/WorkshopStack.java | 30 ++++- .../main/java/sample/com/constructs/Ide.java | 12 ++ .../agentcore-managed-tools-policy.json | 36 ++++++ .../src/main/resources/workshop-boundary.json | 31 +++++ infra/cfn/java-ai-agents-advanced-stack.yaml | 59 +++++++++- infra/cfn/java-ai-agents-stack.yaml | 65 ++++++++++- infra/cfn/java-spring-ai-agents-stack.yaml | 108 ++++++++++++++---- infra/scripts/cfn/sync.sh | 13 +++ 8 files changed, 319 insertions(+), 35 deletions(-) create mode 100644 infra/cdk/src/main/resources/agentcore-managed-tools-policy.json diff --git a/infra/cdk/src/main/java/sample/com/WorkshopStack.java b/infra/cdk/src/main/java/sample/com/WorkshopStack.java index 3e116218..9dfd285b 100644 --- a/infra/cdk/src/main/java/sample/com/WorkshopStack.java +++ b/infra/cdk/src/main/java/sample/com/WorkshopStack.java @@ -255,17 +255,37 @@ public WorkshopStack(final Construct scope, final String id, final StackProps pr "bedrock-agentcore:GetWorkloadAccessTokenForJWT", "bedrock-agentcore:GetWorkloadAccessTokenForUserId", "bedrock-agentcore:InvokeAgentRuntime", - "bedrock-agentcore:InvokeGateway", - "bedrock-agentcore:StartBrowserSession", + "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( "bedrock-agentcore:GetBrowserSession", + "bedrock-agentcore:StartBrowserSession", "bedrock-agentcore:StopBrowserSession", - "bedrock-agentcore:UpdateBrowserStream", - "bedrock-agentcore:StartCodeInterpreterSession", + "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:*:" + this.getAccount() + ":*")) + .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) 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 236bd634..870a2c8e 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/Ide.java +++ b/infra/cdk/src/main/java/sample/com/constructs/Ide.java @@ -195,6 +195,18 @@ public Ide(final Construct scope, final String id, final IdeProps props) { .build(); this.ideRole.addManagedPolicy(policy); + 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") 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/workshop-boundary.json b/infra/cdk/src/main/resources/workshop-boundary.json index 237c8efd..76fa2096 100644 --- a/infra/cdk/src/main/resources/workshop-boundary.json +++ b/infra/cdk/src/main/resources/workshop-boundary.json @@ -16,6 +16,37 @@ "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", diff --git a/infra/cfn/java-ai-agents-advanced-stack.yaml b/infra/cfn/java-ai-agents-advanced-stack.yaml index 7ee8e19f..b482f5c1 100644 --- a/infra/cfn/java-ai-agents-advanced-stack.yaml +++ b/infra/cfn/java-ai-agents-advanced-stack.yaml @@ -481,7 +481,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787491873386" + ContentHash: "1787598686641" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -1176,6 +1176,36 @@ Resources: 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: @@ -1986,6 +2016,7 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeAgentCoreManagedToolsPolicy33EC19D9 - Ref: IdeAgentCoreIdentityPolicy5C973EFA RoleName: workshop-ide-user Tags: @@ -2522,6 +2553,28 @@ Resources: - 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:* @@ -2959,7 +3012,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260823153113" + - "-20260824211126" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -3043,7 +3096,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260823153113" + - "-20260824211126" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-ai-agents-stack.yaml b/infra/cfn/java-ai-agents-stack.yaml index 08e5186d..088c4659 100644 --- a/infra/cfn/java-ai-agents-stack.yaml +++ b/infra/cfn/java-ai-agents-stack.yaml @@ -481,7 +481,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787491870573" + ContentHash: "1787598679815" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -587,12 +587,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: feat/holmes-remediation Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 ImagePullCredentialsType: CODEBUILD PrivilegedMode: false @@ -1176,6 +1176,36 @@ Resources: 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: @@ -1986,6 +2016,7 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeAgentCoreManagedToolsPolicy33EC19D9 - Ref: IdeAgentCoreIdentityPolicy5C973EFA RoleName: workshop-ide-user Tags: @@ -2522,6 +2553,28 @@ Resources: - 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:* @@ -2959,7 +3012,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260823153110" + - "-20260824211119" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -3043,7 +3096,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260823153110" + - "-20260824211119" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-spring-ai-agents-stack.yaml b/infra/cfn/java-spring-ai-agents-stack.yaml index 88cf3fc5..f36c13b4 100644 --- a/infra/cfn/java-spring-ai-agents-stack.yaml +++ b/infra/cfn/java-spring-ai-agents-stack.yaml @@ -408,22 +408,14 @@ Resources: - :knowledge-base/* - Action: - bedrock-agentcore:CreateEvent - - bedrock-agentcore:GetBrowserSession - - bedrock-agentcore:GetCodeInterpreterSession - bedrock-agentcore:GetEvent - bedrock-agentcore:GetWorkloadAccessToken - bedrock-agentcore:GetWorkloadAccessTokenForJWT - bedrock-agentcore:GetWorkloadAccessTokenForUserId - bedrock-agentcore:InvokeAgentRuntime - - bedrock-agentcore:InvokeCodeInterpreter - bedrock-agentcore:InvokeGateway - bedrock-agentcore:ListEvents - bedrock-agentcore:RetrieveMemoryRecords - - bedrock-agentcore:StartBrowserSession - - bedrock-agentcore:StartCodeInterpreterSession - - bedrock-agentcore:StopBrowserSession - - bedrock-agentcore:StopCodeInterpreterSession - - bedrock-agentcore:UpdateBrowserStream Effect: Allow Resource: Fn::Join: @@ -431,6 +423,31 @@ Resources: - - "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 @@ -441,15 +458,6 @@ Resources: - - "arn:aws:ecr:*:" - Ref: AWS::AccountId - :repository/aiagent - - Action: - - ecr:GetAuthorizationToken - - logs:DescribeLogGroups - - xray:GetSamplingRules - - xray:GetSamplingTargets - - xray:PutTelemetryRecords - - xray:PutTraceSegments - Effect: Allow - Resource: "*" - Action: - logs:CreateLogGroup - logs:CreateLogStream @@ -961,7 +969,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787491867441" + ContentHash: "1787598673018" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -2079,6 +2087,36 @@ Resources: - 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: @@ -2889,6 +2927,7 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeAgentCoreManagedToolsPolicy33EC19D9 RoleName: workshop-ide-user Tags: - Key: WorkshopDeploymentId @@ -3424,6 +3463,28 @@ Resources: - 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:* @@ -3481,6 +3542,11 @@ Resources: - - "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:*:" @@ -3560,7 +3626,7 @@ Resources: Fn::GetAtt: - PlaceholderImageBuildRole66BA72FE - Arn - ContentHash: "1787491867598" + ContentHash: "1787598673208" ProjectName: Ref: PlaceholderImageBuildProjectC08F4D66 ServiceToken: @@ -5043,7 +5109,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260823153107" + - "-20260824211113" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -5127,7 +5193,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260823153107" + - "-20260824211113" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/scripts/cfn/sync.sh b/infra/scripts/cfn/sync.sh index 61e50d9b..8379b869 100755 --- a/infra/scripts/cfn/sync.sh +++ b/infra/scripts/cfn/sync.sh @@ -11,6 +11,7 @@ WORKSPACE_ROOT="$(dirname "$REPO_ROOT")" CONFIG_FILE="$INFRA_DIR/workshops.json" SHARED_POLICY_FILE="$INFRA_DIR/cdk/src/main/resources/iam-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" @@ -24,6 +25,10 @@ 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 all_templates=() all_repositories=() @@ -86,6 +91,14 @@ for index in "${selected_indexes[@]}"; do } log_success "Synced $SHARED_POLICY_FILE to $repository/static/iam-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 $AGENTCORE_MANAGED_TOOLS_POLICY_FILE to $repository/static/agentcore-managed-tools-policy.json" + fi + 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" From 732d32c31c8274bde662c6add4858bcf965c3fdc Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Tue, 25 Aug 2026 14:35:59 +0200 Subject: [PATCH 30/38] feat(java-spring-ai-agents): Restructure deployment scripts and update dependencies --- .gitignore | 1 + apps/java-spring-ai-agents/aiagent/pom.xml | 4 +- .../backoffice/tools/pom.xml | 2 +- .../com/example/currency/CurrencyHandler.java | 2 +- .../demo-scripts/01-create.sh | 2 +- .../demo-scripts/README.md | 2 +- infra/cdk/src/main/resources/iam-policy.json | 32 +- .../src/main/resources/workshop-boundary.json | 13 + infra/cfn/java-ai-agents-advanced-stack.yaml | 75 +- infra/cfn/java-ai-agents-stack.yaml | 69 +- infra/cfn/java-on-amazon-eks-stack.yaml | 96 ++- infra/cfn/java-on-aws-stack.yaml | 96 ++- infra/cfn/java-spring-ai-agents-stack.yaml | 77 +- .../java-spring-ai-agents/00-deploy-all.sh | 56 ++ .../deploy/java-spring-ai-agents/01-setup.sh | 303 ++++++++ .../deploy/java-spring-ai-agents/02-memory.sh | 30 + .../java-spring-ai-agents/03-knowledge.sh | 20 + .../java-spring-ai-agents/04-mcp-server.sh | 175 +++++ .../java-spring-ai-agents/05-security.sh | 93 +++ .../java-spring-ai-agents/1-mcp-server.sh | 383 ---------- .../java-spring-ai-agents/10-deploy-eks.sh | 150 ++++ .../java-spring-ai-agents/11-deploy-ecs.sh | 76 ++ .../java-spring-ai-agents/12-deploy-lambda.sh | 127 ++++ .../13-deploy-agentcore.sh | 252 +++++++ .../deploy/java-spring-ai-agents/2-cognito.sh | 104 --- .../java-spring-ai-agents/20-observability.sh | 39 + .../deploy/java-spring-ai-agents/3-app.sh | 426 ----------- .../deploy/java-spring-ai-agents/30-test.sh | 109 +++ .../java-spring-ai-agents/4-app-local.sh | 91 --- .../deploy/java-spring-ai-agents/5-eks.sh | 311 -------- .../deploy/java-spring-ai-agents/6-ecs.sh | 130 ---- .../deploy/java-spring-ai-agents/7-lambda.sh | 200 ------ .../java-spring-ai-agents/8-agentcore.sh | 379 ---------- .../java-spring-ai-agents/90-diagnose.sh | 53 ++ .../java-spring-ai-agents/99-cleanup.sh | 676 ++++++++++++++++++ .../README-alternative.md | 33 + .../java-spring-ai-agents/_suite-lib.sh | 299 ++++++++ infra/scripts/ws-test/java-ai-agents.sh | 4 +- 38 files changed, 2886 insertions(+), 2104 deletions(-) create mode 100755 infra/scripts/deploy/java-spring-ai-agents/00-deploy-all.sh create mode 100755 infra/scripts/deploy/java-spring-ai-agents/01-setup.sh create mode 100755 infra/scripts/deploy/java-spring-ai-agents/02-memory.sh create mode 100755 infra/scripts/deploy/java-spring-ai-agents/03-knowledge.sh create mode 100755 infra/scripts/deploy/java-spring-ai-agents/04-mcp-server.sh create mode 100755 infra/scripts/deploy/java-spring-ai-agents/05-security.sh delete mode 100755 infra/scripts/deploy/java-spring-ai-agents/1-mcp-server.sh create mode 100755 infra/scripts/deploy/java-spring-ai-agents/10-deploy-eks.sh create mode 100755 infra/scripts/deploy/java-spring-ai-agents/11-deploy-ecs.sh create mode 100755 infra/scripts/deploy/java-spring-ai-agents/12-deploy-lambda.sh create mode 100755 infra/scripts/deploy/java-spring-ai-agents/13-deploy-agentcore.sh delete mode 100755 infra/scripts/deploy/java-spring-ai-agents/2-cognito.sh create mode 100755 infra/scripts/deploy/java-spring-ai-agents/20-observability.sh delete mode 100755 infra/scripts/deploy/java-spring-ai-agents/3-app.sh create mode 100755 infra/scripts/deploy/java-spring-ai-agents/30-test.sh delete mode 100755 infra/scripts/deploy/java-spring-ai-agents/4-app-local.sh delete mode 100755 infra/scripts/deploy/java-spring-ai-agents/5-eks.sh delete mode 100755 infra/scripts/deploy/java-spring-ai-agents/6-ecs.sh delete mode 100755 infra/scripts/deploy/java-spring-ai-agents/7-lambda.sh delete mode 100755 infra/scripts/deploy/java-spring-ai-agents/8-agentcore.sh create mode 100755 infra/scripts/deploy/java-spring-ai-agents/90-diagnose.sh create mode 100755 infra/scripts/deploy/java-spring-ai-agents/99-cleanup.sh create mode 100644 infra/scripts/deploy/java-spring-ai-agents/README-alternative.md create mode 100755 infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh diff --git a/.gitignore b/.gitignore index 42ddcccd..260d9d1b 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ build/ ### Kiro ### .kiro/debug/ +semantic-review/ ### Other 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/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/infra/cdk/src/main/resources/iam-policy.json b/infra/cdk/src/main/resources/iam-policy.json index 4470ce3c..229067d3 100644 --- a/infra/cdk/src/main/resources/iam-policy.json +++ b/infra/cdk/src/main/resources/iam-policy.json @@ -130,7 +130,9 @@ "arn:aws:ecs:*:{{.AccountId}}:task-definition/unicorn*:*", "arn:aws:ecs:*:{{.AccountId}}:task-definition/aiagent*:*", "arn:aws:s3:::workshop-*", - "arn:aws:s3:::aiagent-*" + "arn:aws:s3:::workshop-*/*", + "arn:aws:s3:::aiagent-*", + "arn:aws:s3:::aiagent-*/*" ] }, { @@ -224,15 +226,7 @@ }, { "Effect": "Allow", - "Action": [ - "iam:CreateRole", - "iam:DeleteRole", - "iam:PutRolePolicy", - "iam:DeleteRolePolicy", - "iam:AttachRolePolicy", - "iam:DetachRolePolicy", - "iam:UpdateAssumeRolePolicy" - ], + "Action": "iam:CreateRole", "Resource": [ "arn:aws:iam::{{.AccountId}}:role/aiagent*", "arn:aws:iam::{{.AccountId}}:role/mcp*", @@ -244,6 +238,24 @@ } } }, + { + "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" + ] + }, { "Effect": "Deny", "Action": "ec2:RunInstances", diff --git a/infra/cdk/src/main/resources/workshop-boundary.json b/infra/cdk/src/main/resources/workshop-boundary.json index 76fa2096..5c978494 100644 --- a/infra/cdk/src/main/resources/workshop-boundary.json +++ b/infra/cdk/src/main/resources/workshop-boundary.json @@ -10,6 +10,15 @@ "arn:aws:bedrock:*:{{.AccountId}}:*" ] }, + { + "Sid": "BedrockMarketplaceModelAccess", + "Effect": "Allow", + "Action": [ + "aws-marketplace:Subscribe", + "aws-marketplace:ViewSubscriptions" + ], + "Resource": "*" + }, { "Sid": "AgentCoreRuntime", "Effect": "Allow", @@ -67,8 +76,12 @@ "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-*", diff --git a/infra/cfn/java-ai-agents-advanced-stack.yaml b/infra/cfn/java-ai-agents-advanced-stack.yaml index b482f5c1..6477fc35 100644 --- a/infra/cfn/java-ai-agents-advanced-stack.yaml +++ b/infra/cfn/java-ai-agents-advanced-stack.yaml @@ -481,7 +481,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787598686641" + ContentHash: "1787660058890" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -587,12 +587,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-advanced + - Name: GIT_BRANCH + Type: PLAINTEXT + Value: feat/holmes-remediation Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 ImagePullCredentialsType: CODEBUILD PrivilegedMode: false @@ -2286,7 +2286,9 @@ Resources: - arn:aws:apigateway:*::/apis/* - arn:aws:apigateway:*::/restapis/* - arn:aws:s3:::aiagent-* + - arn:aws:s3:::aiagent-*/* - arn:aws:s3:::workshop-* + - arn:aws:s3:::workshop-*/* - Fn::Join: - "" - - "arn:aws:cloudfront::" @@ -2456,14 +2458,7 @@ Resources: - runtime-identity.bedrock-agentcore.amazonaws.com Effect: Allow Resource: arn:aws:iam::*:role/aws-service-role/* - - Action: - - iam:AttachRolePolicy - - iam:CreateRole - - iam:DeleteRole - - iam:DeleteRolePolicy - - iam:DetachRolePolicy - - iam:PutRolePolicy - - iam:UpdateAssumeRolePolicy + - Action: iam:CreateRole Condition: StringEquals: iam:PermissionsBoundary: @@ -2489,6 +2484,40 @@ Resources: - - "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 - Action: ec2:RunInstances Condition: StringLike: @@ -2544,6 +2573,12 @@ Resources: - Ref: AWS::AccountId - :* Sid: BedrockRuntime + - Action: + - aws-marketplace:Subscribe + - aws-marketplace:ViewSubscriptions + Effect: Allow + Resource: "*" + Sid: BedrockMarketplaceModelAccess - Action: bedrock-agentcore:* Effect: Allow Resource: @@ -2586,7 +2621,9 @@ Resources: 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:*:" @@ -2622,6 +2659,16 @@ Resources: - - "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:*:" @@ -3012,7 +3059,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260824211126" + - "-20260825141418" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -3096,7 +3143,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260824211126" + - "-20260825141418" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-ai-agents-stack.yaml b/infra/cfn/java-ai-agents-stack.yaml index 088c4659..7e74af3b 100644 --- a/infra/cfn/java-ai-agents-stack.yaml +++ b/infra/cfn/java-ai-agents-stack.yaml @@ -481,7 +481,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787598679815" + ContentHash: "1787660055592" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -2286,7 +2286,9 @@ Resources: - arn:aws:apigateway:*::/apis/* - arn:aws:apigateway:*::/restapis/* - arn:aws:s3:::aiagent-* + - arn:aws:s3:::aiagent-*/* - arn:aws:s3:::workshop-* + - arn:aws:s3:::workshop-*/* - Fn::Join: - "" - - "arn:aws:cloudfront::" @@ -2456,14 +2458,7 @@ Resources: - runtime-identity.bedrock-agentcore.amazonaws.com Effect: Allow Resource: arn:aws:iam::*:role/aws-service-role/* - - Action: - - iam:AttachRolePolicy - - iam:CreateRole - - iam:DeleteRole - - iam:DeleteRolePolicy - - iam:DetachRolePolicy - - iam:PutRolePolicy - - iam:UpdateAssumeRolePolicy + - Action: iam:CreateRole Condition: StringEquals: iam:PermissionsBoundary: @@ -2489,6 +2484,40 @@ Resources: - - "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 - Action: ec2:RunInstances Condition: StringLike: @@ -2544,6 +2573,12 @@ Resources: - Ref: AWS::AccountId - :* Sid: BedrockRuntime + - Action: + - aws-marketplace:Subscribe + - aws-marketplace:ViewSubscriptions + Effect: Allow + Resource: "*" + Sid: BedrockMarketplaceModelAccess - Action: bedrock-agentcore:* Effect: Allow Resource: @@ -2586,7 +2621,9 @@ Resources: 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:*:" @@ -2622,6 +2659,16 @@ Resources: - - "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:*:" @@ -3012,7 +3059,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260824211119" + - "-20260825141415" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -3096,7 +3143,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260824211119" + - "-20260825141415" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-on-amazon-eks-stack.yaml b/infra/cfn/java-on-amazon-eks-stack.yaml index c543833e..c912f8e2 100644 --- a/infra/cfn/java-on-amazon-eks-stack.yaml +++ b/infra/cfn/java-on-amazon-eks-stack.yaml @@ -533,7 +533,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787492839985" + ContentHash: "1787660048585" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -2753,7 +2753,9 @@ Resources: - arn:aws:apigateway:*::/apis/* - arn:aws:apigateway:*::/restapis/* - arn:aws:s3:::aiagent-* + - arn:aws:s3:::aiagent-*/* - arn:aws:s3:::workshop-* + - arn:aws:s3:::workshop-*/* - Fn::Join: - "" - - "arn:aws:cloudfront::" @@ -2923,14 +2925,7 @@ Resources: - runtime-identity.bedrock-agentcore.amazonaws.com Effect: Allow Resource: arn:aws:iam::*:role/aws-service-role/* - - Action: - - iam:AttachRolePolicy - - iam:CreateRole - - iam:DeleteRole - - iam:DeleteRolePolicy - - iam:DetachRolePolicy - - iam:PutRolePolicy - - iam:UpdateAssumeRolePolicy + - Action: iam:CreateRole Condition: StringEquals: iam:PermissionsBoundary: @@ -2956,6 +2951,40 @@ Resources: - - "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 - Action: ec2:RunInstances Condition: StringLike: @@ -3011,6 +3040,12 @@ Resources: - Ref: AWS::AccountId - :* Sid: BedrockRuntime + - Action: + - aws-marketplace:Subscribe + - aws-marketplace:ViewSubscriptions + Effect: Allow + Resource: "*" + Sid: BedrockMarketplaceModelAccess - Action: bedrock-agentcore:* Effect: Allow Resource: @@ -3020,6 +3055,28 @@ Resources: - 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:* @@ -3031,7 +3088,9 @@ Resources: 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:*:" @@ -3067,6 +3126,16 @@ Resources: - - "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:*:" @@ -3077,6 +3146,11 @@ Resources: - - "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:*:" @@ -4723,7 +4797,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260823154719" + - "-20260825141408" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -4807,7 +4881,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260823154719" + - "-20260825141408" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-on-aws-stack.yaml b/infra/cfn/java-on-aws-stack.yaml index 1847c12a..0e01b20f 100644 --- a/infra/cfn/java-on-aws-stack.yaml +++ b/infra/cfn/java-on-aws-stack.yaml @@ -533,7 +533,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787491860886" + ContentHash: "1787660044379" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -2753,7 +2753,9 @@ Resources: - arn:aws:apigateway:*::/apis/* - arn:aws:apigateway:*::/restapis/* - arn:aws:s3:::aiagent-* + - arn:aws:s3:::aiagent-*/* - arn:aws:s3:::workshop-* + - arn:aws:s3:::workshop-*/* - Fn::Join: - "" - - "arn:aws:cloudfront::" @@ -2923,14 +2925,7 @@ Resources: - runtime-identity.bedrock-agentcore.amazonaws.com Effect: Allow Resource: arn:aws:iam::*:role/aws-service-role/* - - Action: - - iam:AttachRolePolicy - - iam:CreateRole - - iam:DeleteRole - - iam:DeleteRolePolicy - - iam:DetachRolePolicy - - iam:PutRolePolicy - - iam:UpdateAssumeRolePolicy + - Action: iam:CreateRole Condition: StringEquals: iam:PermissionsBoundary: @@ -2956,6 +2951,40 @@ Resources: - - "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 - Action: ec2:RunInstances Condition: StringLike: @@ -3011,6 +3040,12 @@ Resources: - Ref: AWS::AccountId - :* Sid: BedrockRuntime + - Action: + - aws-marketplace:Subscribe + - aws-marketplace:ViewSubscriptions + Effect: Allow + Resource: "*" + Sid: BedrockMarketplaceModelAccess - Action: bedrock-agentcore:* Effect: Allow Resource: @@ -3020,6 +3055,28 @@ Resources: - 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:* @@ -3031,7 +3088,9 @@ Resources: 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:*:" @@ -3067,6 +3126,16 @@ Resources: - - "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:*:" @@ -3077,6 +3146,11 @@ Resources: - - "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:*:" @@ -4723,7 +4797,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260823153100" + - "-20260825141404" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -4807,7 +4881,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260823153100" + - "-20260825141404" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-spring-ai-agents-stack.yaml b/infra/cfn/java-spring-ai-agents-stack.yaml index f36c13b4..2dd17553 100644 --- a/infra/cfn/java-spring-ai-agents-stack.yaml +++ b/infra/cfn/java-spring-ai-agents-stack.yaml @@ -969,7 +969,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787598673018" + ContentHash: "1787660052113" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -1075,12 +1075,12 @@ Resources: Environment: ComputeType: BUILD_GENERAL1_MEDIUM EnvironmentVariables: - - Name: TEMPLATE_TYPE - Type: PLAINTEXT - Value: java-spring-ai-agents - Name: GIT_BRANCH Type: PLAINTEXT Value: feat/holmes-remediation + - Name: TEMPLATE_TYPE + Type: PLAINTEXT + Value: java-spring-ai-agents Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 ImagePullCredentialsType: CODEBUILD PrivilegedMode: false @@ -3196,7 +3196,9 @@ Resources: - arn:aws:apigateway:*::/apis/* - arn:aws:apigateway:*::/restapis/* - arn:aws:s3:::aiagent-* + - arn:aws:s3:::aiagent-*/* - arn:aws:s3:::workshop-* + - arn:aws:s3:::workshop-*/* - Fn::Join: - "" - - "arn:aws:cloudfront::" @@ -3366,14 +3368,7 @@ Resources: - runtime-identity.bedrock-agentcore.amazonaws.com Effect: Allow Resource: arn:aws:iam::*:role/aws-service-role/* - - Action: - - iam:AttachRolePolicy - - iam:CreateRole - - iam:DeleteRole - - iam:DeleteRolePolicy - - iam:DetachRolePolicy - - iam:PutRolePolicy - - iam:UpdateAssumeRolePolicy + - Action: iam:CreateRole Condition: StringEquals: iam:PermissionsBoundary: @@ -3399,6 +3394,40 @@ Resources: - - "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 - Action: ec2:RunInstances Condition: StringLike: @@ -3454,6 +3483,12 @@ Resources: - Ref: AWS::AccountId - :* Sid: BedrockRuntime + - Action: + - aws-marketplace:Subscribe + - aws-marketplace:ViewSubscriptions + Effect: Allow + Resource: "*" + Sid: BedrockMarketplaceModelAccess - Action: bedrock-agentcore:* Effect: Allow Resource: @@ -3496,7 +3531,9 @@ Resources: 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:*:" @@ -3532,6 +3569,16 @@ Resources: - - "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:*:" @@ -3626,7 +3673,7 @@ Resources: Fn::GetAtt: - PlaceholderImageBuildRole66BA72FE - Arn - ContentHash: "1787598673208" + ContentHash: "1787660052266" ProjectName: Ref: PlaceholderImageBuildProjectC08F4D66 ServiceToken: @@ -5109,7 +5156,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260824211113" + - "-20260825141412" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -5193,7 +5240,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260824211113" + - "-20260825141412" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 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..c87fbf11 --- /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}; the suite uses Aurora/PgVector RAG and does not create a managed Bedrock Knowledge Base." 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..84d51242 --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/04-mcp-server.sh @@ -0,0 +1,175 @@ +#!/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 alternative +IMAGE_DIGEST=$(aws_cli ecr describe-images --repository-name mcpserver --image-ids imageTag=alternative \ + --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..a8acddb4 --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/10-deploy-eks.sh @@ -0,0 +1,150 @@ +#!/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 alternative +IMAGE_DIGEST=$(aws_cli ecr describe-images --repository-name aiagent --image-ids imageTag=alternative \ + --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" </dev/null || true) + [[ -n "${host}" ]] && break + log "Waiting for AI-agent ingress hostname (${i}/40)" + ((i == 40)) || sleep 15 +done +[[ -n "${host}" ]] || die "AI-agent ingress did not receive a hostname" +AIAGENT_ENDPOINT="http://${host}" +wait_for_http_status "EKS AI-agent health" "${AIAGENT_ENDPOINT}/actuator/health" '^(200)$' 30 10 +state_set ACTIVE_TARGET eks +state_set AIAGENT_ENDPOINT "${AIAGENT_ENDPOINT}" +log "AI agent reconciled on EKS: ${AIAGENT_ENDPOINT}" diff --git a/infra/scripts/deploy/java-spring-ai-agents/11-deploy-ecs.sh b/infra/scripts/deploy/java-spring-ai-agents/11-deploy-ecs.sh new file mode 100755 index 00000000..0d1c404b --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/11-deploy-ecs.sh @@ -0,0 +1,76 @@ +#!/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, Maven, and the precreated aiagent ECS Express service" +init_context +load_state +require_state MCP_URL COGNITO_ISSUER_URI +[[ -f "${AIAGENT_DIR}/pom.xml" ]] || die "AI-agent source not found. Run 01-setup.sh first." + +build_and_push_jib "${AIAGENT_DIR}" aiagent alternative +IMAGE_DIGEST=$(aws_cli ecr describe-images --repository-name aiagent --image-ids imageTag=alternative \ + --query 'imageDetails[0].imageDigest' --output text) +IMAGE_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/aiagent@${IMAGE_DIGEST}" + +SERVICE=$(aws_cli ecs describe-services --cluster aiagent --services aiagent --query 'services[0]') +SERVICE_ARN=$(jq -r '.serviceArn // empty' <<<"${SERVICE}") +[[ -n "${SERVICE_ARN}" ]] || die "Precreated ECS service aiagent was not found" +EXPRESS=$(aws_cli ecs describe-express-gateway-service --service-arn "${SERVICE_ARN}" --query service) +CURRENT_CONTAINER=$(jq -c '.activeConfigurations[0].primaryContainer' <<<"${EXPRESS}") +CURRENT_ENV=$(jq -c '.environment // []' <<<"${CURRENT_CONTAINER}") + +if [[ -z "${ECS_ORIGINAL_DEPLOYMENT_CONFIG_B64:-}" ]]; then + state_set ECS_ORIGINAL_DEPLOYMENT_CONFIG_B64 "$(encode_b64 "$(jq -c '.deploymentConfiguration' <<<"${SERVICE}")")" +fi +if [[ -z "${ECS_ORIGINAL_PRIMARY_CONTAINER_PATH:-}" ]]; then + backup_dir="${WORK_DIR}/ecs-backups" + mkdir -p "${backup_dir}" + chmod 700 "${backup_dir}" + backup_file="${backup_dir}/original-primary-container.json" + if [[ -n "${ECS_ORIGINAL_PRIMARY_CONTAINER_B64:-}" ]]; then + decode_b64 "${ECS_ORIGINAL_PRIMARY_CONTAINER_B64}" > "${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..a1747666 --- /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:alternative-agentcore" +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=alternative-agentcore \ + --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-alternative" +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..cd9d0772 --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/30-test.sh @@ -0,0 +1,109 @@ +#!/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" + +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" + +tmp_dir=$(mktemp -d "${WORK_DIR}/tests.XXXXXX") +trap 'rm -rf "${tmp_dir}"' EXIT +invoke() { + local name="$1" prompt="$2" 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}" \ + --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" + grep -Eiq "${regex}" "${file}" || die "${description} response lacked expected capability evidence" +} + +file=$(invoke persona "Briefly identify the company you assist and what service it provides.") +assert_matches "${file}" 'unicorn|rental' "Persona" + +# The database-backed chat advisor intentionally retains chat history. Use one stable +# marker per suite/account/Region and avoid adding another store turn when it is already +# retrievable; the recall checks themselves still add unavoidable chat-memory rows. +MEMORY_MARKER="memory-${SUITE_OWNER}-${ACCOUNT_ID}-${AWS_REGION}" +file=$(invoke memory_existing "What verification marker did I ask you to remember? Reply with the exact marker if known.") +if ! grep -Fqi -- "${MEMORY_MARKER}" "${file}"; then + invoke memory_store "Remember this verification marker for our conversation: ${MEMORY_MARKER}." >/dev/null + file=$(invoke memory_recall "What verification marker did I ask you to remember?") +fi +assert_matches "${file}" "${MEMORY_MARKER}" "Conversation memory" +log "Memory check uses a stable marker; chat prompts/responses remain retained by the workshop memory store." + +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}" \ + --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" + +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" + +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 "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..9594c40b --- /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-alternative'].{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-alternative.md b/infra/scripts/deploy/java-spring-ai-agents/README-alternative.md new file mode 100644 index 00000000..149bfe44 --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/README-alternative.md @@ -0,0 +1,33 @@ +# Alternative idempotent deployment suite +# Alternative idempotent deployment suite + +This suite deploys the Unicorn Rentals Spring AI workshop and replaces the removed legacy `1-mcp-server.sh` through `8-agentcore.sh` scripts. The separately linked legacy `cleanup.sh` remains unchanged and is never called by this suite. + +## Order and targets + +Run the complete flow with exactly one 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 be run independently; each prints its prerequisites. Use `01-setup.sh --force` only when existing `~/environment/aiagent` or `~/environment/mcpserver` files should be refreshed. Existing directories are otherwise left untouched. `05-security.sh --rotate-passwords` is the only mode that changes passwords for existing users. + +The EKS, ECS, Lambda, and AgentCore scripts are alternatives. Re-running a stage discovers deterministic resource names and applies 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. The generated application uses Spring Boot 4.1.0, Spring AI 2.0.1, `spring-ai-vector-store-advisor`, and the modern Bedrock Converse properties with Claude Sonnet 4.6. The AgentCore target adds the AgentCore 2.1.0 BOM and runtime starter in an isolated build directory; its Runtime log group is `/aws/bedrock-agentcore/runtimes/-DEFAULT`. UI configuration always includes the selected AWS Region. + +## Test scope + +`30-test.sh --target TARGET` obtains user and administrator Cognito tokens and hard-fails on health/readiness, unauthenticated access, authenticated invocation, persona, conversation memory, PgVector-backed RAG using an exact dynamic retrieval marker, a representative date/time tool call, and MCP Unicorn inventory checks. Knowledge loading is restricted to the `admin` Cognito user and bounded to 4,096 characters; normal assertions use dynamic markers and broad capability evidence rather than exact model prose. + +## Cleanup safety + +`99-cleanup.sh` is plan-only by default. `99-cleanup.sh --apply` removes only suite-created resources and data or restores settings that the suite recorded before modifying. 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. The Lambda ZIP object is restored when it predated the suite and removed otherwise; the deterministic sample Unicorn is removed only when this suite created it. + +Use `90-diagnose.sh` for read-only status collection before changing or cleaning up resources. + + +## Live AWS verification still required + +The scripts are syntax-checked without invoking AWS or Kubernetes mutations. Before workshop use, run the stages in a disposable workshop account and verify the predeployed resource names and IAM permissions, EKS Pod Identity/Secrets Store CSI integration, internal MCP ALB reachability from every target, ECS Express Gateway update behavior, Java 25 Lambda Web Adapter layer availability, AgentCore-supported private Availability Zones, Runtime custom-JWT authorization, and CloudFront propagation. Also confirm Bedrock access to Claude Sonnet 4.6 and Titan Text Embeddings V2 in the selected Region. `30-test.sh` is the required live acceptance gate; a deployment is not considered successful until all of its health, authentication, persona, memory, RAG, tool, and MCP assertions pass. + +Run `90-diagnose.sh` for read-only discovery before a live deployment. Review `99-cleanup.sh` without arguments first; only `99-cleanup.sh --apply` performs the ownership-scoped cleanup plan. \ No newline at end of file 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..3eb3ff75 --- /dev/null +++ b/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh @@ -0,0 +1,299 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SUITE_NAME="java-spring-ai-agents-alternative" +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 -sS -o /dev/null -w '%{http_code}' --connect-timeout 10 --max-time 30 "$1" || true +} + +wait_for_http_status() { + local description="$1" url="$2" expected_regex="$3" attempts="${4:-40}" interval="${5:-15}" + local i status + for ((i=1; i<=attempts; i++)); do + status=$(http_status "${url}") + if [[ "${status}" =~ ${expected_regex} ]]; then + log "${description}: HTTP ${status}" + return 0 + fi + log "${description}: HTTP ${status:-000} (${i}/${attempts})" + ((i == attempts)) || sleep "${interval}" + done + die "Timed out waiting for ${description}" +} + +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" + aws_cli ecr describe-repositories --repository-names "${repository}" >/dev/null || \ + die "Predeployed ECR repository not found: ${repository}" +} + +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/ws-test/java-ai-agents.sh b/infra/scripts/ws-test/java-ai-agents.sh index a8973248..40d23250 100755 --- a/infra/scripts/ws-test/java-ai-agents.sh +++ b/infra/scripts/ws-test/java-ai-agents.sh @@ -83,7 +83,7 @@ ws_run_block 7 'Adding dependencies' '2. Add the AgentCore BOM to the org.springaicommunity spring-ai-agentcore-bom - 1.0.0 + 2.1.0 pom import @@ -973,7 +973,7 @@ ws_run_block 5 'Adding dependencies' '2. Add the Spring AI BOM to the org.springframework.ai spring-ai-bom - 2.0.0 + 2.0.1 pom import From b75f9e48dff34efdd7e0fcbb12f5936161037a29 Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Tue, 25 Aug 2026 14:51:27 +0200 Subject: [PATCH 31/38] fix(infra): Refactor IAM policies and consolidate role management --- .../main/java/sample/com/constructs/Ide.java | 9 + infra/cdk/src/main/resources/iam-policy.json | 287 +--------------- .../resources/iam-role-management-policy.json | 85 +++++ infra/cfn/java-ai-agents-advanced-stack.yaml | 305 ++++++++--------- infra/cfn/java-ai-agents-stack.yaml | 305 ++++++++--------- infra/cfn/java-on-amazon-eks-stack.yaml | 305 ++++++++--------- infra/cfn/java-on-aws-stack.yaml | 299 ++++++++--------- infra/cfn/java-spring-ai-agents-stack.yaml | 307 +++++++++--------- infra/scripts/cfn/sync.sh | 11 + 9 files changed, 884 insertions(+), 1029 deletions(-) create mode 100644 infra/cdk/src/main/resources/iam-role-management-policy.json 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 870a2c8e..b0a9d293 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/Ide.java +++ b/infra/cdk/src/main/java/sample/com/constructs/Ide.java @@ -195,6 +195,15 @@ public Ide(final Construct scope, final String id, final IdeProps props) { .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())) { diff --git a/infra/cdk/src/main/resources/iam-policy.json b/infra/cdk/src/main/resources/iam-policy.json index 229067d3..c799ddd0 100644 --- a/infra/cdk/src/main/resources/iam-policy.json +++ b/infra/cdk/src/main/resources/iam-policy.json @@ -1,286 +1 @@ -{ - "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:::workshop-*/*", - "arn:aws:s3:::aiagent-*", - "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": "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" - ] - }, - { - "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": "*" - } - ] -} +{"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/cfn/java-ai-agents-advanced-stack.yaml b/infra/cfn/java-ai-agents-advanced-stack.yaml index 6477fc35..a2220a2a 100644 --- a/infra/cfn/java-ai-agents-advanced-stack.yaml +++ b/infra/cfn/java-ai-agents-advanced-stack.yaml @@ -481,7 +481,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787660058890" + ContentHash: "1787662084758" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -587,12 +587,12 @@ Resources: Environment: ComputeType: BUILD_GENERAL1_MEDIUM EnvironmentVariables: - - Name: TEMPLATE_TYPE - Type: PLAINTEXT - Value: java-ai-agents-advanced - Name: GIT_BRANCH Type: PLAINTEXT Value: feat/holmes-remediation + - Name: TEMPLATE_TYPE + Type: PLAINTEXT + Value: java-ai-agents-advanced Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 ImagePullCredentialsType: CODEBUILD PrivilegedMode: false @@ -2016,6 +2016,7 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeRoleManagementPolicyFE7F8500 - Ref: IdeAgentCoreManagedToolsPolicy33EC19D9 - Ref: IdeAgentCoreIdentityPolicy5C973EFA RoleName: workshop-ide-user @@ -2043,6 +2044,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 @@ -2286,9 +2436,7 @@ Resources: - arn:aws:apigateway:*::/apis/* - arn:aws:apigateway:*::/restapis/* - arn:aws:s3:::aiagent-* - - arn:aws:s3:::aiagent-*/* - arn:aws:s3:::workshop-* - - arn:aws:s3:::workshop-*/* - Fn::Join: - "" - - "arn:aws:cloudfront::" @@ -2377,147 +2525,6 @@ Resources: - tag:GetResources Effect: Allow Resource: "*" - - 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 - Action: ec2:RunInstances Condition: StringLike: @@ -3059,7 +3066,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825141418" + - "-20260825144804" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -3143,7 +3150,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825141418" + - "-20260825144804" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-ai-agents-stack.yaml b/infra/cfn/java-ai-agents-stack.yaml index 7e74af3b..567ba1c8 100644 --- a/infra/cfn/java-ai-agents-stack.yaml +++ b/infra/cfn/java-ai-agents-stack.yaml @@ -481,7 +481,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787660055592" + ContentHash: "1787662081336" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -587,12 +587,12 @@ Resources: Environment: ComputeType: BUILD_GENERAL1_MEDIUM EnvironmentVariables: - - Name: TEMPLATE_TYPE - Type: PLAINTEXT - Value: java-ai-agents - Name: GIT_BRANCH Type: PLAINTEXT Value: feat/holmes-remediation + - Name: TEMPLATE_TYPE + Type: PLAINTEXT + Value: java-ai-agents Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 ImagePullCredentialsType: CODEBUILD PrivilegedMode: false @@ -2016,6 +2016,7 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeRoleManagementPolicyFE7F8500 - Ref: IdeAgentCoreManagedToolsPolicy33EC19D9 - Ref: IdeAgentCoreIdentityPolicy5C973EFA RoleName: workshop-ide-user @@ -2043,6 +2044,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 @@ -2286,9 +2436,7 @@ Resources: - arn:aws:apigateway:*::/apis/* - arn:aws:apigateway:*::/restapis/* - arn:aws:s3:::aiagent-* - - arn:aws:s3:::aiagent-*/* - arn:aws:s3:::workshop-* - - arn:aws:s3:::workshop-*/* - Fn::Join: - "" - - "arn:aws:cloudfront::" @@ -2377,147 +2525,6 @@ Resources: - tag:GetResources Effect: Allow Resource: "*" - - 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 - Action: ec2:RunInstances Condition: StringLike: @@ -3059,7 +3066,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825141415" + - "-20260825144801" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -3143,7 +3150,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825141415" + - "-20260825144801" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-on-amazon-eks-stack.yaml b/infra/cfn/java-on-amazon-eks-stack.yaml index c912f8e2..05ac7f00 100644 --- a/infra/cfn/java-on-amazon-eks-stack.yaml +++ b/infra/cfn/java-on-amazon-eks-stack.yaml @@ -533,7 +533,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787660048585" + ContentHash: "1787662073993" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -639,12 +639,12 @@ Resources: Environment: ComputeType: BUILD_GENERAL1_MEDIUM EnvironmentVariables: - - Name: TEMPLATE_TYPE - Type: PLAINTEXT - Value: java-on-amazon-eks - Name: GIT_BRANCH Type: PLAINTEXT Value: feat/holmes-remediation + - Name: TEMPLATE_TYPE + Type: PLAINTEXT + Value: java-on-amazon-eks Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 ImagePullCredentialsType: CODEBUILD PrivilegedMode: false @@ -2485,6 +2485,7 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeRoleManagementPolicyFE7F8500 RoleName: workshop-ide-user Tags: - Key: WorkshopDeploymentId @@ -2510,6 +2511,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 @@ -2753,9 +2903,7 @@ Resources: - arn:aws:apigateway:*::/apis/* - arn:aws:apigateway:*::/restapis/* - arn:aws:s3:::aiagent-* - - arn:aws:s3:::aiagent-*/* - arn:aws:s3:::workshop-* - - arn:aws:s3:::workshop-*/* - Fn::Join: - "" - - "arn:aws:cloudfront::" @@ -2844,147 +2992,6 @@ Resources: - tag:GetResources Effect: Allow Resource: "*" - - 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 - Action: ec2:RunInstances Condition: StringLike: @@ -4797,7 +4804,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825141408" + - "-20260825144753" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -4881,7 +4888,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825141408" + - "-20260825144753" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-on-aws-stack.yaml b/infra/cfn/java-on-aws-stack.yaml index 0e01b20f..ac4c00e8 100644 --- a/infra/cfn/java-on-aws-stack.yaml +++ b/infra/cfn/java-on-aws-stack.yaml @@ -533,7 +533,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787660044379" + ContentHash: "1787662070138" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -2485,6 +2485,7 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeRoleManagementPolicyFE7F8500 RoleName: workshop-ide-user Tags: - Key: WorkshopDeploymentId @@ -2510,6 +2511,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 @@ -2753,9 +2903,7 @@ Resources: - arn:aws:apigateway:*::/apis/* - arn:aws:apigateway:*::/restapis/* - arn:aws:s3:::aiagent-* - - arn:aws:s3:::aiagent-*/* - arn:aws:s3:::workshop-* - - arn:aws:s3:::workshop-*/* - Fn::Join: - "" - - "arn:aws:cloudfront::" @@ -2844,147 +2992,6 @@ Resources: - tag:GetResources Effect: Allow Resource: "*" - - 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 - Action: ec2:RunInstances Condition: StringLike: @@ -4797,7 +4804,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825141404" + - "-20260825144750" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -4881,7 +4888,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825141404" + - "-20260825144750" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-spring-ai-agents-stack.yaml b/infra/cfn/java-spring-ai-agents-stack.yaml index 2dd17553..9bb93d3a 100644 --- a/infra/cfn/java-spring-ai-agents-stack.yaml +++ b/infra/cfn/java-spring-ai-agents-stack.yaml @@ -969,7 +969,7 @@ Resources: Fn::GetAtt: - CodeBuildRoleE9A44575 - Arn - ContentHash: "1787660052113" + ContentHash: "1787662077560" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -1075,12 +1075,12 @@ Resources: Environment: ComputeType: BUILD_GENERAL1_MEDIUM EnvironmentVariables: - - Name: GIT_BRANCH - Type: PLAINTEXT - Value: feat/holmes-remediation - Name: TEMPLATE_TYPE Type: PLAINTEXT Value: java-spring-ai-agents + - Name: GIT_BRANCH + Type: PLAINTEXT + Value: feat/holmes-remediation Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 ImagePullCredentialsType: CODEBUILD PrivilegedMode: false @@ -2927,6 +2927,7 @@ Resources: - Ref: AWS::Partition - :iam::aws:policy/CloudWatchAgentServerPolicy - Ref: IdeUserPolicy2460FC7D + - Ref: IdeRoleManagementPolicyFE7F8500 - Ref: IdeAgentCoreManagedToolsPolicy33EC19D9 RoleName: workshop-ide-user Tags: @@ -2953,6 +2954,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 @@ -3196,9 +3346,7 @@ Resources: - arn:aws:apigateway:*::/apis/* - arn:aws:apigateway:*::/restapis/* - arn:aws:s3:::aiagent-* - - arn:aws:s3:::aiagent-*/* - arn:aws:s3:::workshop-* - - arn:aws:s3:::workshop-*/* - Fn::Join: - "" - - "arn:aws:cloudfront::" @@ -3287,147 +3435,6 @@ Resources: - tag:GetResources Effect: Allow Resource: "*" - - 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 - Action: ec2:RunInstances Condition: StringLike: @@ -3673,7 +3680,7 @@ Resources: Fn::GetAtt: - PlaceholderImageBuildRole66BA72FE - Arn - ContentHash: "1787660052266" + ContentHash: "1787662077712" ProjectName: Ref: PlaceholderImageBuildProjectC08F4D66 ServiceToken: @@ -5156,7 +5163,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825141412" + - "-20260825144757" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -5240,7 +5247,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825141412" + - "-20260825144757" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/scripts/cfn/sync.sh b/infra/scripts/cfn/sync.sh index 8379b869..342d9b07 100755 --- a/infra/scripts/cfn/sync.sh +++ b/infra/scripts/cfn/sync.sh @@ -10,6 +10,7 @@ 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" @@ -21,6 +22,10 @@ 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 @@ -91,6 +96,12 @@ for index in "${selected_indexes[@]}"; do } 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" From f4e279546e87c2fd29addf9889003cd1f9b7ee87 Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Tue, 25 Aug 2026 16:21:59 +0200 Subject: [PATCH 32/38] fix(infra): Resolve AWS SDK version conflicts and enhance CodeBuild workflow - Add AWS SDK BOM import to resolve dependency conflicts between AgentCore Memory 2.1.0 and Spring AI 2.0.1 - Introduce DynamoDB table to persist CloudFormation callback state during CodeBuild execution - Refactor CodeBuild Lambda functions to use DynamoDB for tracking pending builds and coordinating responses - Update EventBridge rule to capture additional CodeBuild terminal states (FAULT, TIMED_OUT) - Replace Arrays with List for consistency in EventBridge event patterns - Fix EC2 network interface deletion policy to use wildcard resource instead of specific ARN - Add CDK Nag suppression for DynamoDB point-in-time recovery requirement - Add explicit construct dependencies to ensure proper deployment ordering - Remove unused CodeBuildIamRoleArn property from custom resource - Simplify Lambda function environment variable management through table grants - Update demo script to handle AWS SDK version coherence for Java Spring AI agents --- .../demo-scripts/02-memory.sh | 11 + .../java/sample/com/constructs/CodeBuild.java | 52 +- .../main/resources/lambda/codebuild-report.py | 142 +- .../main/resources/lambda/codebuild-start.py | 104 +- infra/cfn/java-ai-agents-advanced-stack.yaml | 349 ++- infra/cfn/java-ai-agents-stack.yaml | 343 ++- infra/cfn/java-on-amazon-eks-stack.yaml | 349 ++- infra/cfn/java-on-aws-stack.yaml | 343 ++- infra/cfn/java-spring-ai-agents-stack.yaml | 682 ++-- infra/scripts/ws-test/java-ai-agents.sh | 2729 ----------------- infra/workshops.json | 5 +- 11 files changed, 1609 insertions(+), 3500 deletions(-) delete mode 100755 infra/scripts/ws-test/java-ai-agents.sh 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/infra/cdk/src/main/java/sample/com/constructs/CodeBuild.java b/infra/cdk/src/main/java/sample/com/constructs/CodeBuild.java index 9bbfbbf6..b85830c4 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/CodeBuild.java +++ b/infra/cdk/src/main/java/sample/com/constructs/CodeBuild.java @@ -1,10 +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.*; @@ -17,7 +21,6 @@ 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 { @@ -165,7 +168,7 @@ public CodeBuild(final Construct scope, final String id, final CodeBuildProps pr Map.of( "Effect", "Allow", "Action", List.of("ec2:DeleteNetworkInterface"), - "Resource", networkInterfaceArn + "Resource", "*" ), Map.of( "Effect", "Allow", @@ -191,36 +194,61 @@ public CodeBuild(final Construct scope, final String id, final CodeBuildProps pr "/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/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/cfn/java-ai-agents-advanced-stack.yaml b/infra/cfn/java-ai-agents-advanced-stack.yaml index a2220a2a..a359cb63 100644 --- a/infra/cfn/java-ai-agents-advanced-stack.yaml +++ b/infra/cfn/java-ai-agents-advanced-stack.yaml @@ -475,13 +475,14 @@ Resources: DependsOn: - CodeBuildCompleteRuleAllowEventRuleWorkshopStackCodeBuildReportLambdaFunctionD77C60919E0B0C89 - CodeBuildCompleteRuleEE9277E8 + - CodeBuildPendingBuilds19869454 + - CodeBuildProjectPolicyDocument567377F5 + - CodeBuildProjectA0FF5539 + - CodeBuildProjectSecurityGroup7CE557B3 - CodeBuildReportLambdaFunctionA3C396F7 + - CodeBuildStartLambdaFunction8349284F Properties: - CodeBuildIamRoleArn: - Fn::GetAtt: - - CodeBuildRoleE9A44575 - - Arn - ContentHash: "1787662084758" + ContentHash: "1787666977653" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -511,7 +512,9 @@ Resources: build-status: - SUCCEEDED - FAILED + - FAULT - STOPPED + - TIMED_OUT project-name: - Ref: CodeBuildProjectA0FF5539 detail-type: @@ -570,11 +573,68 @@ Resources: 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 @@ -587,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-advanced + - Name: GIT_BRANCH + Type: PLAINTEXT + Value: feat/holmes-remediation Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 ImagePullCredentialsType: CODEBUILD PrivilegedMode: false @@ -734,16 +794,7 @@ Resources: - Action: - ec2:DeleteNetworkInterface Effect: Allow - Resource: - Fn::Join: - - "" - - - "arn:" - - Ref: AWS::Partition - - ":ec2:" - - Ref: AWS::Region - - ":" - - Ref: AWS::AccountId - - :network-interface/* + Resource: "*" - Action: - ec2:DescribeDhcpOptions - ec2:DescribeNetworkInterfaces @@ -781,57 +832,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: @@ -974,62 +1081,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: @@ -3066,7 +3189,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825144804" + - "-20260825160937" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -3150,7 +3273,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825144804" + - "-20260825160937" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-ai-agents-stack.yaml b/infra/cfn/java-ai-agents-stack.yaml index 567ba1c8..2ec0136e 100644 --- a/infra/cfn/java-ai-agents-stack.yaml +++ b/infra/cfn/java-ai-agents-stack.yaml @@ -475,13 +475,14 @@ Resources: DependsOn: - CodeBuildCompleteRuleAllowEventRuleWorkshopStackCodeBuildReportLambdaFunctionD77C60919E0B0C89 - CodeBuildCompleteRuleEE9277E8 + - CodeBuildPendingBuilds19869454 + - CodeBuildProjectPolicyDocument567377F5 + - CodeBuildProjectA0FF5539 + - CodeBuildProjectSecurityGroup7CE557B3 - CodeBuildReportLambdaFunctionA3C396F7 + - CodeBuildStartLambdaFunction8349284F Properties: - CodeBuildIamRoleArn: - Fn::GetAtt: - - CodeBuildRoleE9A44575 - - Arn - ContentHash: "1787662081336" + ContentHash: "1787666974232" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -511,7 +512,9 @@ Resources: build-status: - SUCCEEDED - FAILED + - FAULT - STOPPED + - TIMED_OUT project-name: - Ref: CodeBuildProjectA0FF5539 detail-type: @@ -570,11 +573,68 @@ Resources: 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 @@ -734,16 +794,7 @@ Resources: - Action: - ec2:DeleteNetworkInterface Effect: Allow - Resource: - Fn::Join: - - "" - - - "arn:" - - Ref: AWS::Partition - - ":ec2:" - - Ref: AWS::Region - - ":" - - Ref: AWS::AccountId - - :network-interface/* + Resource: "*" - Action: - ec2:DescribeDhcpOptions - ec2:DescribeNetworkInterfaces @@ -781,57 +832,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: @@ -974,62 +1081,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: @@ -3066,7 +3189,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825144801" + - "-20260825160934" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -3150,7 +3273,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825144801" + - "-20260825160934" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-on-amazon-eks-stack.yaml b/infra/cfn/java-on-amazon-eks-stack.yaml index 05ac7f00..416a8269 100644 --- a/infra/cfn/java-on-amazon-eks-stack.yaml +++ b/infra/cfn/java-on-amazon-eks-stack.yaml @@ -527,13 +527,14 @@ Resources: DependsOn: - CodeBuildCompleteRuleAllowEventRuleWorkshopStackCodeBuildReportLambdaFunctionD77C60919E0B0C89 - CodeBuildCompleteRuleEE9277E8 + - CodeBuildPendingBuilds19869454 + - CodeBuildProjectPolicyDocument567377F5 + - CodeBuildProjectA0FF5539 + - CodeBuildProjectSecurityGroup7CE557B3 - CodeBuildReportLambdaFunctionA3C396F7 + - CodeBuildStartLambdaFunction8349284F Properties: - CodeBuildIamRoleArn: - Fn::GetAtt: - - CodeBuildRoleE9A44575 - - Arn - ContentHash: "1787662073993" + ContentHash: "1787666966801" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -563,7 +564,9 @@ Resources: build-status: - SUCCEEDED - FAILED + - FAULT - STOPPED + - TIMED_OUT project-name: - Ref: CodeBuildProjectA0FF5539 detail-type: @@ -622,11 +625,68 @@ Resources: 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 @@ -639,12 +699,12 @@ Resources: Environment: ComputeType: BUILD_GENERAL1_MEDIUM EnvironmentVariables: - - Name: GIT_BRANCH - Type: PLAINTEXT - Value: feat/holmes-remediation - 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 @@ -786,16 +846,7 @@ Resources: - Action: - ec2:DeleteNetworkInterface Effect: Allow - Resource: - Fn::Join: - - "" - - - "arn:" - - Ref: AWS::Partition - - ":ec2:" - - Ref: AWS::Region - - ":" - - Ref: AWS::AccountId - - :network-interface/* + Resource: "*" - Action: - ec2:DescribeDhcpOptions - ec2:DescribeNetworkInterfaces @@ -833,57 +884,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: @@ -1026,62 +1133,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: @@ -4804,7 +4927,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825144753" + - "-20260825160926" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -4888,7 +5011,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825144753" + - "-20260825160926" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-on-aws-stack.yaml b/infra/cfn/java-on-aws-stack.yaml index ac4c00e8..d9636af4 100644 --- a/infra/cfn/java-on-aws-stack.yaml +++ b/infra/cfn/java-on-aws-stack.yaml @@ -527,13 +527,14 @@ Resources: DependsOn: - CodeBuildCompleteRuleAllowEventRuleWorkshopStackCodeBuildReportLambdaFunctionD77C60919E0B0C89 - CodeBuildCompleteRuleEE9277E8 + - CodeBuildPendingBuilds19869454 + - CodeBuildProjectPolicyDocument567377F5 + - CodeBuildProjectA0FF5539 + - CodeBuildProjectSecurityGroup7CE557B3 - CodeBuildReportLambdaFunctionA3C396F7 + - CodeBuildStartLambdaFunction8349284F Properties: - CodeBuildIamRoleArn: - Fn::GetAtt: - - CodeBuildRoleE9A44575 - - Arn - ContentHash: "1787662070138" + ContentHash: "1787666963070" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -563,7 +564,9 @@ Resources: build-status: - SUCCEEDED - FAILED + - FAULT - STOPPED + - TIMED_OUT project-name: - Ref: CodeBuildProjectA0FF5539 detail-type: @@ -622,11 +625,68 @@ Resources: 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 @@ -786,16 +846,7 @@ Resources: - Action: - ec2:DeleteNetworkInterface Effect: Allow - Resource: - Fn::Join: - - "" - - - "arn:" - - Ref: AWS::Partition - - ":ec2:" - - Ref: AWS::Region - - ":" - - Ref: AWS::AccountId - - :network-interface/* + Resource: "*" - Action: - ec2:DescribeDhcpOptions - ec2:DescribeNetworkInterfaces @@ -833,57 +884,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: @@ -1026,62 +1133,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: @@ -4804,7 +4927,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825144750" + - "-20260825160923" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -4888,7 +5011,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825144750" + - "-20260825160923" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-spring-ai-agents-stack.yaml b/infra/cfn/java-spring-ai-agents-stack.yaml index 9bb93d3a..585d0c2f 100644 --- a/infra/cfn/java-spring-ai-agents-stack.yaml +++ b/infra/cfn/java-spring-ai-agents-stack.yaml @@ -963,13 +963,14 @@ Resources: DependsOn: - CodeBuildCompleteRuleAllowEventRuleWorkshopStackCodeBuildReportLambdaFunctionD77C60919E0B0C89 - CodeBuildCompleteRuleEE9277E8 + - CodeBuildPendingBuilds19869454 + - CodeBuildProjectPolicyDocument567377F5 + - CodeBuildProjectA0FF5539 + - CodeBuildProjectSecurityGroup7CE557B3 - CodeBuildReportLambdaFunctionA3C396F7 + - CodeBuildStartLambdaFunction8349284F Properties: - CodeBuildIamRoleArn: - Fn::GetAtt: - - CodeBuildRoleE9A44575 - - Arn - ContentHash: "1787662077560" + ContentHash: "1787666970419" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -999,7 +1000,9 @@ Resources: build-status: - SUCCEEDED - FAILED + - FAULT - STOPPED + - TIMED_OUT project-name: - Ref: CodeBuildProjectA0FF5539 detail-type: @@ -1058,11 +1061,68 @@ Resources: 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 @@ -1222,16 +1282,7 @@ Resources: - Action: - ec2:DeleteNetworkInterface Effect: Allow - Resource: - Fn::Join: - - "" - - - "arn:" - - Ref: AWS::Partition - - ":ec2:" - - Ref: AWS::Region - - ":" - - Ref: AWS::AccountId - - :network-interface/* + Resource: "*" - Action: - ec2:DescribeDhcpOptions - ec2:DescribeNetworkInterfaces @@ -1269,57 +1320,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: @@ -1462,62 +1569,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: @@ -3653,7 +3776,12 @@ Resources: - EcrRegistryTemplateD54113AB - PlaceholderImageBuildCompleteRuleAllowEventRuleWorkshopStackPlaceholderImageBuildReportLambdaFunction17D5E0E6696B4706 - PlaceholderImageBuildCompleteRuleD3DA254B + - PlaceholderImageBuildPendingBuilds86736129 + - PlaceholderImageBuildProjectPolicyDocument31093CFB + - PlaceholderImageBuildProjectC08F4D66 + - PlaceholderImageBuildProjectSecurityGroupA7FE7BBC - PlaceholderImageBuildReportLambdaFunctionD1A0B620 + - PlaceholderImageBuildStartLambdaFunctionF132A6DF - VpcIGW488B0FEB - VpcPrivateSubnet1DefaultRouteF704DE9F - VpcPrivateSubnet1RouteTable901BAEEE @@ -3676,11 +3804,7 @@ Resources: - VpcC3027511 - VpcVPCGW42EC8516 Properties: - CodeBuildIamRoleArn: - Fn::GetAtt: - - PlaceholderImageBuildRole66BA72FE - - Arn - ContentHash: "1787662077712" + ContentHash: "1787666970573" ProjectName: Ref: PlaceholderImageBuildProjectC08F4D66 ServiceToken: @@ -3710,7 +3834,9 @@ Resources: build-status: - SUCCEEDED - FAILED + - FAULT - STOPPED + - TIMED_OUT project-name: - Ref: PlaceholderImageBuildProjectC08F4D66 detail-type: @@ -3769,11 +3895,68 @@ Resources: 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 @@ -3949,16 +4132,7 @@ Resources: - Action: - ec2:DeleteNetworkInterface Effect: Allow - Resource: - Fn::Join: - - "" - - - "arn:" - - Ref: AWS::Partition - - ":ec2:" - - Ref: AWS::Region - - ":" - - Ref: AWS::AccountId - - :network-interface/* + Resource: "*" - Action: - ec2:DescribeDhcpOptions - ec2:DescribeNetworkInterfaces @@ -3996,57 +4170,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: @@ -4200,62 +4430,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: @@ -5163,7 +5409,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825144757" + - "-20260825160930" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -5247,7 +5493,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825144757" + - "-20260825160930" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/scripts/ws-test/java-ai-agents.sh b/infra/scripts/ws-test/java-ai-agents.sh deleted file mode 100755 index 40d23250..00000000 --- a/infra/scripts/ws-test/java-ai-agents.sh +++ /dev/null @@ -1,2729 +0,0 @@ -#!/usr/bin/env bash - -# Generated by infra/scripts/ws-test/generate.mjs. Do not edit. - -set -Eeuo pipefail -WS_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${WS_SCRIPT_DIR}/runtime.sh" -ws_begin_run 'java-ai-agents' "${WS_SCRIPT_DIR}/reports/java-ai-agents" 5 "$@" -ws_set_executable_total 196 -ws_set_default_timeout 600 -ws_source_environment '/etc/profile.d/workshop.sh' - -ws_begin_page 'Workshop setup' 20 'setup/index.en.md' - -ws_run_block 1 'Deploying Code Editor' '2. Deploy the CloudFormation stack:' 35 42 'bash' 'own-account' <<'WS_TEST_BLOCK_20_1' -curl -sL https://raw.githubusercontent.com/aws-samples/java-on-aws/main/infra/cfn/java-ai-agents-stack.yaml \ - -o workshop-stack.yaml -CFN_S3=cfn-$(uuidgen | tr -d - | tr '[:upper:]' '[:lower:]') -aws s3 mb s3://${CFN_S3} -aws cloudformation deploy --stack-name workshop-stack \ - --template-file ./workshop-stack.yaml \ - --s3-bucket ${CFN_S3} \ - --capabilities CAPABILITY_NAMED_IAM -WS_TEST_BLOCK_20_1 - -ws_run_block 2 'Deploying Code Editor' '3. Get the Code Editor URL:' 50 51 'bash' 'own-account' <<'WS_TEST_BLOCK_20_2' -aws cloudformation describe-stacks --stack-name workshop-stack --no-cli-pager \ - --query "Stacks[0].Outputs[?OutputKey=='IdeUrl'].OutputValue" --output text -WS_TEST_BLOCK_20_2 - -ws_end_page - -ws_begin_page 'Create the AI agent' 60 'create/index.en.md' - -ws_run_block 1 'Using Spring Initializr' 'Spring Initializr is a web-based tool that helps bootstrap Spring Boot applications. Run the following command to generate a project with the required dependencies:' 11 27 'bash' '' <<'WS_TEST_BLOCK_60_1' -cd ~/environment/ -curl https://start.spring.io/starter.zip \ - -d type=maven-project \ - -d language=java \ - -d packaging=jar \ - -d javaVersion=25 \ - -d bootVersion=4.1.0 \ - -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 aiagent.zip -rm aiagent.zip -WS_TEST_BLOCK_60_1 - -ws_skip_block 2 'Using Spring Initializr' '- Line 13: spring-ai-bedrock-converse for Amazon Bedrock, web for REST API, webflux for streaming, actuator for health checks and metrics' 38 45 '' '' 'informational block without language' - -ws_run_block 3 'Setting up environment variables' 'Create a .envrc file to store environment variables for the workshop. direnv automatically loads these variables when you enter the ~/environment directory.' 53 54 'bash' '' <<'WS_TEST_BLOCK_60_3' -touch ~/environment/.envrc -direnv allow ~/environment -WS_TEST_BLOCK_60_3 - -ws_run_block 4 'Configuring Amazon Bedrock' '1. Open application.properties:' 79 79 'bash' '' <<'WS_TEST_BLOCK_60_4' -code ~/environment/aiagent/src/main/resources/application.properties -WS_TEST_BLOCK_60_4 - -ws_run_block 5 'Configuring Amazon Bedrock' '2. Add the configuration:' 85 93 'properties' '' <<'WS_TEST_BLOCK_60_5' -# Logging -logging.level.org.springframework.ai=DEBUG -logging.level.org.springaicommunity.agentcore=DEBUG -logging.level.com.example.agent=DEBUG -logging.pattern.console=%msg%n -# Amazon Bedrock Configuration -spring.ai.bedrock.aws.timeout=120s -spring.ai.bedrock.converse.chat.max-tokens=4096 -spring.ai.bedrock.converse.chat.model=global.anthropic.claude-sonnet-4-6 -WS_TEST_BLOCK_60_5 - -ws_run_block 6 'Adding dependencies' '1. Open pom.xml:' 107 107 'bash' '' <<'WS_TEST_BLOCK_60_6' -code ~/environment/aiagent/pom.xml -WS_TEST_BLOCK_60_6 - -ws_run_block 7 'Adding dependencies' '2. Add the AgentCore BOM to the section, alongside the existing Spring AI BOM:' 113 119 'xml' '' <<'WS_TEST_BLOCK_60_7' - - org.springaicommunity - spring-ai-agentcore-bom - 2.1.0 - pom - import - -WS_TEST_BLOCK_60_7 - -ws_run_block 8 'Adding dependencies' '3. Add the AgentCore runtime starter to the section:' 127 131 'xml' '' <<'WS_TEST_BLOCK_60_8' - - - org.springaicommunity - spring-ai-agentcore-runtime-starter - -WS_TEST_BLOCK_60_8 - -ws_run_block 9 'Creating the ChatService' 'Create src/main/java/com/example/agent/ChatService.java:' 143 168 'java' '' <<'WS_TEST_BLOCK_60_9' -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.springaicommunity.agentcore.annotation.AgentCoreInvocation; - -record ChatRequest(String prompt) {} - -@Service -public class ChatService { - private final ChatClient chatClient; - - public ChatService(ChatClient.Builder chatClientBuilder) { - this.chatClient = chatClientBuilder - .build(); - } - - @AgentCoreInvocation - public Flux chat(ChatRequest request) { - return chatClient.prompt().user(request.prompt()).stream().content(); - } -} -EOF -code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java -WS_TEST_BLOCK_60_9 - -ws_run_block 10 'Adding the Web UI' 'Copy the static files (HTML, CSS, JavaScript) to the project:' 186 187 'bash' '' <<'WS_TEST_BLOCK_60_10' -cp ~/java-on-aws/apps/java-spring-ai-agents/aiagent/src/main/resources/static/* \ - ~/environment/aiagent/src/main/resources/static/ -WS_TEST_BLOCK_60_10 - -ws_run_block 11 'Running the AI agent' '1. Start the application:' 195 196 'bash' '' <<'WS_TEST_BLOCK_60_11' -cd ~/environment/aiagent -./mvnw spring-boot:run -WS_TEST_BLOCK_60_11 - -ws_run_block 12 'Running the AI agent' '2. Test with REST API (in a new terminal):' 204 206 'bash' '' <<'WS_TEST_BLOCK_60_12' -curl -N -X POST localhost:8080/invocations \ - -H "Content-Type: application/json" \ - -d '{"prompt": "Create a 100-word article about the most important Java 25 features."}'; echo -WS_TEST_BLOCK_60_12 - -ws_run_block 13 'Committing changes' 'Initialize a Git repository and commit the initial code:' 232 237 'bash' '' <<'WS_TEST_BLOCK_60_13' -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 -m "Create the AI agent" -WS_TEST_BLOCK_60_13 - -ws_end_page - -ws_begin_page 'Agent persona' 80 'persona/index.en.md' - -ws_run_block 1 'Agent persona' '> If you closed the application, start it with:' 9 10 'bash' '' <<'WS_TEST_BLOCK_80_1' -cd ~/environment/aiagent -./mvnw spring-boot:run -WS_TEST_BLOCK_80_1 - -ws_skip_block 2 'Choosing the right model' 'The model is configured in application.properties:' 44 44 '' '' 'informational block without language' - -ws_run_block 3 'Configuring temperature' '- 1.0 - More creative, varied responses (good for brainstorming)' 60 60 'bash' '' <<'WS_TEST_BLOCK_80_3' -code ~/environment/aiagent/src/main/resources/application.properties -WS_TEST_BLOCK_80_3 - -ws_run_block 4 'Configuring temperature' 'Add the temperature setting:' 66 66 'properties' '' <<'WS_TEST_BLOCK_80_4' -spring.ai.bedrock.converse.chat.temperature=0.7 -WS_TEST_BLOCK_80_4 - -ws_run_block 5 'Updating the code' '1. Open ChatService.java:' 86 86 'bash' '' <<'WS_TEST_BLOCK_80_5' -code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java -WS_TEST_BLOCK_80_5 - -ws_run_block 6 'Updating the code' '2. Add the system prompt constant after private final ChatClient chatClient;:' 92 95 'java' '' <<'WS_TEST_BLOCK_80_6' - private static final String SYSTEM_PROMPT = """ - You are a helpful AI agent for travel and expense management. - Be friendly, helpful, and concise in your responses. - """; -WS_TEST_BLOCK_80_6 - -ws_run_block 7 'Updating the code' '3. Update the constructor to apply the system prompt:' 101 105 'java' '' <<'WS_TEST_BLOCK_80_7' - public ChatService(ChatClient.Builder chatClientBuilder) { - this.chatClient = chatClientBuilder - .defaultSystem(SYSTEM_PROMPT) - .build(); - } -WS_TEST_BLOCK_80_7 - -ws_run_block 8 'Testing the application' '1. Start the application:' 115 116 'bash' '' <<'WS_TEST_BLOCK_80_8' -cd ~/environment/aiagent -./mvnw spring-boot:run -WS_TEST_BLOCK_80_8 - -ws_run_block 9 'Committing changes' 'Committing changes' 136 138 'bash' '' <<'WS_TEST_BLOCK_80_9' -cd ~/environment/aiagent -git add . -git commit -m "Add persona" -WS_TEST_BLOCK_80_9 - -ws_end_page - -ws_begin_page 'Conversation memory' 100 'memory/index.en.md' - -ws_run_block 1 'Creating the memory resource' 'Run the setup script to create the AgentCore Memory resource with LTM strategies:' 79 79 'bash' 'script' <<'WS_TEST_BLOCK_100_1' -~/java-on-aws/apps/java-spring-ai-agents/scripts/02-memory.sh -WS_TEST_BLOCK_100_1 - -ws_run_block 2 'Creating the memory resource' '1. Create an AgentCore Memory resource and wait for it to become active (2-5 minutes):' 90 105 'bash' 'manual' <<'WS_TEST_BLOCK_100_2' -AGENTCORE_MEMORY_MEMORY_ID=$(aws bedrock-agentcore-control create-memory \ - --name "aiagent_memory" --event-expiry-duration 7 \ - --no-cli-pager --query "memory.id" --output text) - -echo -n "Waiting for memory" -while [ "$(aws bedrock-agentcore-control get-memory --memory-id "${AGENTCORE_MEMORY_MEMORY_ID}" \ - --no-cli-pager --query 'memory.status' --output text)" != "ACTIVE" ]; do - echo -n "."; sleep 5 -done && echo " ACTIVE" - -cat >> ~/environment/aiagent/src/main/resources/application.properties << EOF - -# AgentCore Memory -agentcore.memory.memory-id=${AGENTCORE_MEMORY_MEMORY_ID} -agentcore.memory.long-term.auto-discovery=true -EOF -WS_TEST_BLOCK_100_2 - -ws_run_block 3 'Creating the memory resource' '2. Add LTM strategies and wait for them to become active:' 116 128 'bash' 'manual' <<'WS_TEST_BLOCK_100_3' -aws bedrock-agentcore-control update-memory --memory-id "${AGENTCORE_MEMORY_MEMORY_ID}" --no-cli-pager \ - --memory-strategies '{ - "addMemoryStrategies": [ - {"semanticMemoryStrategy": {"name": "SemanticFacts", "namespaces": ["/strategies/{memoryStrategyId}/actors/{actorId}"]}}, - {"userPreferenceMemoryStrategy": {"name": "UserPreferences", "namespaces": ["/strategies/{memoryStrategyId}/actors/{actorId}"]}} - ] - }' - -echo -n "Waiting for strategies" -while aws bedrock-agentcore-control get-memory --memory-id "${AGENTCORE_MEMORY_MEMORY_ID}" \ - --no-cli-pager --query 'memory.strategies[].status' --output text | grep -q "CREATING"; do - echo -n "."; sleep 5 -done && echo " ACTIVE" -WS_TEST_BLOCK_100_3 - -ws_run_block 4 'Adding dependencies' '1. Open pom.xml:' 141 141 'bash' '' <<'WS_TEST_BLOCK_100_4' -code ~/environment/aiagent/pom.xml -WS_TEST_BLOCK_100_4 - -ws_run_block 5 'Adding dependencies' '2. Add the AgentCore Memory starter to the section:' 147 151 'xml' '' <<'WS_TEST_BLOCK_100_5' - - - org.springaicommunity - spring-ai-agentcore-memory - -WS_TEST_BLOCK_100_5 - -ws_run_block 6 'Updating the code' '1. Open ChatService.java:' 159 159 'bash' '' <<'WS_TEST_BLOCK_100_6' -code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java -WS_TEST_BLOCK_100_6 - -ws_run_block 7 'Updating the code' '2. Replace the file content:' 165 225 'java' '' <<'WS_TEST_BLOCK_100_7' -package com.example.agent; - -import java.util.ArrayList; -import java.util.List; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springaicommunity.agentcore.annotation.AgentCoreInvocation; -import org.springaicommunity.agentcore.context.AgentCoreContext; -import org.springaicommunity.agentcore.context.AgentCoreHeaders; -import org.springaicommunity.agentcore.memory.longterm.AgentCoreMemory; -import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.client.advisor.api.Advisor; -import org.springframework.ai.chat.memory.ChatMemory; -import org.springframework.stereotype.Service; -import reactor.core.publisher.Flux; - -record ChatRequest(String prompt) {} - -@Service -public class ChatService { - - private static final Logger logger = LoggerFactory.getLogger(ChatService.class); - - private final ChatClient chatClient; - - private static final String SYSTEM_PROMPT = """ - You are a helpful AI agent for travel and expense management. - Be friendly, helpful, and concise in your responses. - """; - - public ChatService(AgentCoreMemory agentCoreMemory, - ChatClient.Builder chatClientBuilder) { - - List advisors = new ArrayList<>(); - - // Memory (STM + LTM) - advisors.addAll(agentCoreMemory.advisors); - logger.info("Memory enabled: {} advisors", agentCoreMemory.advisors.size()); - - this.chatClient = chatClientBuilder - .defaultSystem(SYSTEM_PROMPT) - .defaultAdvisors(advisors.toArray(new Advisor[0])) - .build(); - } - - @AgentCoreInvocation - public Flux chat(ChatRequest request, AgentCoreContext context) { - return chat(request.prompt(), getConversationId(context)); - } - - private Flux chat(String prompt, String sessionId) { - return chatClient.prompt().user(prompt) - .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId)) - .stream().content(); - } - - private String getConversationId(AgentCoreContext context) { - return context.getHeader(AgentCoreHeaders.SESSION_ID); - } -} -WS_TEST_BLOCK_100_7 - -ws_run_block 8 'Testing the application' '1. Start the application:' 240 241 'bash' '' <<'WS_TEST_BLOCK_100_8' -cd ~/environment/aiagent -./mvnw spring-boot:run -WS_TEST_BLOCK_100_8 - -ws_skip_block 9 'Testing the application' 'Testing the application' 245 249 '' '' 'informational block without language' - -ws_run_block 10 'Committing changes' 'Committing changes' 271 273 'bash' '' <<'WS_TEST_BLOCK_100_10' -cd ~/environment/aiagent -git add . -git commit -m "Add memory" -WS_TEST_BLOCK_100_10 - -ws_end_page - -ws_begin_page 'Knowledge base' 200 'knowledge/index.en.md' - -ws_run_block 1 'Creating the Knowledge Base' 'Run the setup script to create the Knowledge Base with S3 Vectors storage:' 65 65 'bash' 'script' <<'WS_TEST_BLOCK_200_1' -~/java-on-aws/apps/java-spring-ai-agents/scripts/03-knowledgebase.sh -WS_TEST_BLOCK_200_1 - -ws_run_block 2 'Creating the Knowledge Base' '1. Create S3 buckets and vector index:' 76 83 'bash' 'manual' <<'WS_TEST_BLOCK_200_2' -DATA_BUCKET="aiagent-kb-data-${ACCOUNT_ID}" -VECTOR_BUCKET="aiagent-kb-vectors-${ACCOUNT_ID}" - -aws s3api create-bucket --bucket "${DATA_BUCKET}" --no-cli-pager -aws s3vectors create-vector-bucket --vector-bucket-name "${VECTOR_BUCKET}" --no-cli-pager -aws s3vectors create-index --vector-bucket-name "${VECTOR_BUCKET}" \ - --index-name "aiagent-index" --data-type "float32" \ - --dimension 1024 --distance-metric "cosine" --no-cli-pager -WS_TEST_BLOCK_200_2 - -ws_run_block 3 'Creating the Knowledge Base' '2. Create IAM role for the Knowledge Base:' 89 120 'bash' 'manual' <<'WS_TEST_BLOCK_200_3' -KB_ROLE="aiagent-kb-role" - -aws iam create-role --role-name "${KB_ROLE}" \ - --permissions-boundary "arn:aws:iam::${ACCOUNT_ID}:policy/workshop-boundary" \ - --assume-role-policy-document '{ - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": {"Service": "bedrock.amazonaws.com"}, - "Action": "sts:AssumeRole", - "Condition": { - "StringEquals": {"aws:SourceAccount": "'${ACCOUNT_ID}'"}, - "ArnLike": {"aws:SourceArn": "arn:aws:bedrock:'${AWS_REGION}':'${ACCOUNT_ID}':knowledge-base/*"} - } - }] - }' --no-cli-pager - -aws iam put-role-policy --role-name "${KB_ROLE}" --policy-name "aiagent-kb-policy" \ - --policy-document '{ - "Version": "2012-10-17", - "Statement": [ - {"Effect": "Allow", "Action": ["s3:GetObject", "s3:ListBucket"], - "Resource": ["arn:aws:s3:::'${DATA_BUCKET}'", "arn:aws:s3:::'${DATA_BUCKET}'/*"]}, - {"Effect": "Allow", "Action": ["bedrock:InvokeModel"], - "Resource": ["arn:aws:bedrock:'${AWS_REGION}'::foundation-model/amazon.titan-embed-text-v2:0"]}, - {"Effect": "Allow", "Action": ["s3vectors:*"], - "Resource": ["arn:aws:s3vectors:'${AWS_REGION}':'${ACCOUNT_ID}':bucket/'${VECTOR_BUCKET}'", - "arn:aws:s3vectors:'${AWS_REGION}':'${ACCOUNT_ID}':bucket/'${VECTOR_BUCKET}'/*"]} - ] - }' --no-cli-pager - -echo -n "Waiting for role propagation" && sleep 10 && echo " done" -WS_TEST_BLOCK_200_3 - -ws_run_block 4 'Creating the Knowledge Base' '3. Create the Knowledge Base:' 126 156 'bash' 'manual' <<'WS_TEST_BLOCK_200_4' -VECTOR_BUCKET="aiagent-kb-vectors-${ACCOUNT_ID}" -KB_ROLE="aiagent-kb-role" - -KB_ID=$(aws bedrock-agent create-knowledge-base --name "aiagent-kb" \ - --description "Knowledge base for AI agent policies" \ - --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/${KB_ROLE}" \ - --knowledge-base-configuration '{ - "type": "VECTOR", - "vectorKnowledgeBaseConfiguration": { - "embeddingModelArn": "arn:aws:bedrock:'${AWS_REGION}'::foundation-model/amazon.titan-embed-text-v2:0" - } - }' \ - --storage-configuration '{ - "type": "S3_VECTORS", - "s3VectorsConfiguration": { - "vectorBucketArn": "arn:aws:s3vectors:'${AWS_REGION}':'${ACCOUNT_ID}':bucket/'${VECTOR_BUCKET}'", - "indexName": "aiagent-index" - } - }' --no-cli-pager --query 'knowledgeBase.knowledgeBaseId' --output text) - -echo -n "Waiting for knowledge base" -while [ "$(aws bedrock-agent get-knowledge-base --knowledge-base-id ${KB_ID} \ - --no-cli-pager --query 'knowledgeBase.status' --output text)" != "ACTIVE" ]; do - echo -n "."; sleep 5 -done && echo " ACTIVE" - -cat >> ~/environment/aiagent/src/main/resources/application.properties << EOF - -# Knowledge Base -spring.ai.vectorstore.bedrock-knowledge-base.knowledge-base-id=${KB_ID} -EOF -WS_TEST_BLOCK_200_4 - -ws_run_block 5 'Creating the Knowledge Base' '4. Create data source, upload documents, and start ingestion:' 165 192 'bash' 'manual' <<'WS_TEST_BLOCK_200_5' -DATA_BUCKET="aiagent-kb-data-${ACCOUNT_ID}" - -DS_ID=$(aws bedrock-agent create-data-source \ - --knowledge-base-id "${KB_ID}" \ - --name "aiagent-policies" \ - --data-source-configuration '{ - "type": "S3", - "s3Configuration": { - "bucketArn": "arn:aws:s3:::'${DATA_BUCKET}'", - "inclusionPrefixes": ["policies/"] - } - }' --no-cli-pager --query 'dataSource.dataSourceId' --output text) - -aws s3 cp ~/java-on-aws/apps/java-spring-ai-agents/aiagent/samples/policy-travel.md \ - s3://${DATA_BUCKET}/policies/policy-travel.md --no-cli-pager -aws s3 cp ~/java-on-aws/apps/java-spring-ai-agents/aiagent/samples/policy-expense.md \ - s3://${DATA_BUCKET}/policies/policy-expense.md --no-cli-pager - -JOB_ID=$(aws bedrock-agent start-ingestion-job \ - --knowledge-base-id "${KB_ID}" --data-source-id "${DS_ID}" \ - --no-cli-pager --query 'ingestionJob.ingestionJobId' --output text) - -echo -n "Waiting for ingestion" -while [ "$(aws bedrock-agent get-ingestion-job --knowledge-base-id "${KB_ID}" \ - --data-source-id "${DS_ID}" --ingestion-job-id "${JOB_ID}" \ - --no-cli-pager --query 'ingestionJob.status' --output text)" = "IN_PROGRESS" ]; do - echo -n "."; sleep 5 -done && echo " COMPLETE" -WS_TEST_BLOCK_200_5 - -ws_run_block 6 'Adding dependencies' '1. Open pom.xml:' 205 205 'bash' '' <<'WS_TEST_BLOCK_200_6' -code ~/environment/aiagent/pom.xml -WS_TEST_BLOCK_200_6 - -ws_run_block 7 'Adding dependencies' '2. Add the Bedrock Knowledge Base dependency to the section:' 211 224 'xml' '' <<'WS_TEST_BLOCK_200_7' - - - org.springframework.ai - spring-ai-starter-vector-store-bedrock-knowledgebase - - - org.springframework.ai - spring-ai-vector-store-advisor - - - - org.springframework.boot - spring-boot-starter-validation - -WS_TEST_BLOCK_200_7 - -ws_run_block 8 'Updating the code' '1. Open ChatService.java:' 232 232 'bash' '' <<'WS_TEST_BLOCK_200_8' -code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java -WS_TEST_BLOCK_200_8 - -ws_run_block 9 'Updating the code' '2. Add the imports after the existing imports:' 238 240 'java' '' <<'WS_TEST_BLOCK_200_9' -import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; -import org.springframework.ai.chat.prompt.PromptTemplate; -import org.springframework.ai.vectorstore.VectorStore; -WS_TEST_BLOCK_200_9 - -ws_run_block 10 'Updating the code' '3. Add VectorStore to the constructor parameters:' 246 248 'java' '' <<'WS_TEST_BLOCK_200_10' - public ChatService(AgentCoreMemory agentCoreMemory, - VectorStore kbVectorStore, - ChatClient.Builder chatClientBuilder) { -WS_TEST_BLOCK_200_10 - -ws_run_block 11 'Updating the code' '4. Add the Knowledge Base section after the LTM section:' 256 265 'java' '' <<'WS_TEST_BLOCK_200_11' - // Knowledge Base (RAG) - advisors.add(QuestionAnswerAdvisor.builder(kbVectorStore) - .promptTemplate(PromptTemplate.builder().template(""" - {query} - - The following documents may be relevant as reference material: - {question_answer_context} - """).build()) - .build()); - logger.info("KB RAG enabled"); -WS_TEST_BLOCK_200_11 - -ws_run_block 12 'Testing the application' '1. Start the application:' 275 276 'bash' '' <<'WS_TEST_BLOCK_200_12' -cd ~/environment/aiagent -./mvnw spring-boot:run -WS_TEST_BLOCK_200_12 - -ws_run_block 13 'Committing changes' 'Committing changes' 294 296 'bash' '' <<'WS_TEST_BLOCK_200_13' -cd ~/environment/aiagent -git add . -git commit -m "Add knowledge base" -WS_TEST_BLOCK_200_13 - -ws_end_page - -ws_begin_page 'Tool calling and web grounding' 400 'tools/index.en.md' - -ws_run_block 1 'Adding dependencies' '1. Open pom.xml:' 54 54 'bash' '' <<'WS_TEST_BLOCK_400_1' -code ~/environment/aiagent/pom.xml -WS_TEST_BLOCK_400_1 - -ws_run_block 2 'Adding dependencies' '2. Add the AWS Java SDK BOM to the section, alongside the existing Spring AI BOM:' 60 66 'xml' '' <<'WS_TEST_BLOCK_400_2' - - software.amazon.awssdk - bom - 2.46.7 - pom - import - -WS_TEST_BLOCK_400_2 - -ws_run_block 3 'Adding dependencies' '3. Add the Bedrock Runtime SDK dependency to the section. The web grounding tool uses the Bedrock Converse API directly with SystemTool and CitationLocation, which require a recent SDK version:' 72 76 'xml' '' <<'WS_TEST_BLOCK_400_3' - - - software.amazon.awssdk - bedrockruntime - -WS_TEST_BLOCK_400_3 - -ws_run_block 4 'Creating ContextAdvisor' 'Advisors can augment user prompts with contextual information. Create ContextAdvisor.java:' 84 133 'java' '' <<'WS_TEST_BLOCK_400_4' -cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/ContextAdvisor.java -package com.example.agent; - -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.List; -import org.springframework.ai.chat.client.ChatClientRequest; -import org.springframework.ai.chat.client.ChatClientResponse; -import org.springframework.ai.chat.client.advisor.api.AdvisorChain; -import org.springframework.ai.chat.client.advisor.api.BaseAdvisor; -import org.springframework.ai.chat.memory.ChatMemory; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.stereotype.Component; - -@Component -class ContextAdvisor implements BaseAdvisor { - - @Override - public ChatClientRequest before(ChatClientRequest request, AdvisorChain advisorChain) { - Prompt original = request.prompt(); - String timestamp = ZonedDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); - String conversationId = (String) request.context().get(ChatMemory.CONVERSATION_ID); - String userId = conversationId != null ? conversationId.split(":")[0] : "unknown"; - - List messages = new ArrayList<>(original.getInstructions()); - UserMessage userMsg = original.getUserMessage(); - if (userMsg != null) { - int idx = messages.lastIndexOf(userMsg); - messages.set(idx, new UserMessage( - "[Current date and time: " + timestamp + "] [UserId: " + userId + "]\n" + userMsg.getText())); - } - - Prompt augmented = new Prompt(messages, original.getOptions()); - return request.mutate().prompt(augmented).build(); - } - - @Override - public ChatClientResponse after(ChatClientResponse response, AdvisorChain advisorChain) { - return response; - } - - @Override - public int getOrder() { - return 0; - } -} -EOF -WS_TEST_BLOCK_400_4 - -ws_run_block 5 'Web grounding with Amazon Nova 2' '- Cannot be predicted or cached' 153 243 'java' '' <<'WS_TEST_BLOCK_400_5' -cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/WebGroundingTools.java -package com.example.agent; - -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.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import jakarta.annotation.PreDestroy; -import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; -import software.amazon.awssdk.services.bedrockruntime.model.*; - -@Service -public class WebGroundingTools { - - private static final Logger logger = LoggerFactory.getLogger(WebGroundingTools.class); - - private final BedrockRuntimeClient bedrockClient; - - private final String modelId; - - public WebGroundingTools(@Value("${app.ai.web-grounding.model:us.amazon.nova-2-lite-v1:0}") String modelId) { - this.modelId = modelId; - this.bedrockClient = BedrockRuntimeClient.builder().build(); - logger.info("WebGroundingTools: model={}", modelId); - } - - @PreDestroy - public void close() { - if (bedrockClient != null) { - bedrockClient.close(); - } - } - - @Tool(description = "Search the web for current information. Use for news, real-time data, or facts needing verification.") - public String searchWeb(@ToolParam(description = "Search query") String query) { - logger.info("Web search: {}", query); - try { - var response = bedrockClient.converse(ConverseRequest.builder() - .modelId(modelId) - .messages(Message.builder().role(ConversationRole.USER).content(ContentBlock.fromText(query)).build()) - .toolConfig(ToolConfiguration.builder() - .tools(software.amazon.awssdk.services.bedrockruntime.model.Tool - .fromSystemTool(SystemTool.builder().name("nova_grounding").build())) - .build()) - .build()); - - return extractResponse(response); - } - catch (Exception e) { - logger.error("Web search failed: {}", e.getMessage(), e); - return "Web search failed. Try again later."; - } - } - - private String extractResponse(ConverseResponse response) { - var result = new StringBuilder(); - var citations = new StringBuilder(); - - logger.debug("Raw response: {}", response); - - if (response.output() != null && response.output().message() != null) { - for (var block : response.output().message().content()) { - if (block.text() != null) { - result.append(block.text()); - } - if (block.citationsContent() != null && block.citationsContent().citations() != null) { - for (var citation : block.citationsContent().citations()) { - if (citation.location() != null && citation.location().web() != null) { - var url = citation.location().web().url(); - if (url != null && !url.isEmpty()) { - citations.append("\n- ").append(url); - } - } - } - } - } - } - - if (result.isEmpty()) { - return "No results found."; - } - if (!citations.isEmpty()) { - result.append("\n\nSources:").append(citations); - } - return result.toString(); - } - -} -EOF -WS_TEST_BLOCK_400_5 - -ws_run_block 6 'Updating the code' '1. Open ChatService.java:' 282 282 'bash' '' <<'WS_TEST_BLOCK_400_6' -code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java -WS_TEST_BLOCK_400_6 - -ws_run_block 7 'Updating the code' '2. Add the new dependencies to the constructor parameters:' 288 292 'java' '' <<'WS_TEST_BLOCK_400_7' - public ChatService(AgentCoreMemory agentCoreMemory, - VectorStore kbVectorStore, - WebGroundingTools webGroundingTools, - ContextAdvisor contextAdvisor, - ChatClient.Builder chatClientBuilder) { -WS_TEST_BLOCK_400_7 - -ws_run_block 8 'Updating the code' '3. Add the advisor and tools after the Knowledge Base (RAG) section:' 300 307 'java' '' <<'WS_TEST_BLOCK_400_8' - // ContextAdvisor - advisors.add(contextAdvisor); - logger.info("Context Advisor enabled"); - - // Tools - List localTools = new ArrayList<>(); - localTools.add(webGroundingTools); - logger.info("Web Grounding enabled"); -WS_TEST_BLOCK_400_8 - -ws_run_block 9 'Updating the code' '4. Add .defaultTools() to the ChatClient builder:' 316 320 'java' '' <<'WS_TEST_BLOCK_400_9' - this.chatClient = chatClientBuilder - .defaultSystem(SYSTEM_PROMPT) - .defaultAdvisors(advisors.toArray(new Advisor[0])) - .defaultTools(localTools.toArray()) - .build(); -WS_TEST_BLOCK_400_9 - -ws_run_block 10 'Testing the application' '1. Start the application:' 330 331 'bash' '' <<'WS_TEST_BLOCK_400_10' -cd ~/environment/aiagent -./mvnw spring-boot:run -WS_TEST_BLOCK_400_10 - -ws_skip_block 11 'Testing the application' 'Testing the application' 335 339 '' '' 'informational block without language' - -ws_run_block 12 'Committing changes' 'Committing changes' 357 359 'bash' '' <<'WS_TEST_BLOCK_400_12' -cd ~/environment/aiagent -git add . -git commit -m "Add tools" -WS_TEST_BLOCK_400_12 - -ws_end_page - -ws_begin_page 'Web browsing' 440 'browser/index.en.md' - -ws_skip_block 1 'Introduction to ToolCallbackProvider' '.defaultTools(Object...) accepts both kinds, so all tools are registered the same way:' 53 58 'java' '' 'copy action disabled' - -ws_skip_block 2 'Introduction to ToolCallReactiveContextHolder' '5. Spring AI clears the ThreadLocal in a finally block before the thread returns to the pool' 82 87 'java' '' 'copy action disabled' - -ws_run_block 3 'Adding dependencies' '1. Open pom.xml:' 97 97 'bash' '' <<'WS_TEST_BLOCK_440_3' -code ~/environment/aiagent/pom.xml -WS_TEST_BLOCK_440_3 - -ws_run_block 4 'Adding dependencies' '2. Add the AgentCore Browser starter dependency to the section:' 103 107 'xml' '' <<'WS_TEST_BLOCK_440_4' - - - org.springaicommunity - spring-ai-agentcore-browser - -WS_TEST_BLOCK_440_4 - -ws_run_block 5 'Adding dependencies' '3. Override tool descriptions in application.properties for better results:' 124 124 'bash' '' <<'WS_TEST_BLOCK_440_5' -code ~/environment/aiagent/src/main/resources/application.properties -WS_TEST_BLOCK_440_5 - -ws_run_block 6 'Adding dependencies' 'Adding dependencies' 128 130 'properties' '' <<'WS_TEST_BLOCK_440_6' -# AgentCore Browser - tool descriptions -agentcore.browser.browse-url-description=Browse a web page and extract its text content. Returns the page title and body text. Use this to read and extract data from websites. For interactive sites, combine with fillForm and clickElement to navigate, then call browseUrl again to read the results. -agentcore.browser.screenshot-description=Take a screenshot of a web page for the user to see. Does NOT return page content to you. Use browseUrl to extract data first, then takeScreenshot for visual evidence. -WS_TEST_BLOCK_440_6 - -ws_run_block 7 'Updating the code' '1. Open ChatService.java:' 138 138 'bash' '' <<'WS_TEST_BLOCK_440_7' -code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java -WS_TEST_BLOCK_440_7 - -ws_run_block 8 'Updating the code' '2. Add the imports after the existing imports:' 144 149 'java' '' <<'WS_TEST_BLOCK_440_8' -import org.springaicommunity.agentcore.artifacts.ArtifactStore; -import org.springaicommunity.agentcore.artifacts.GeneratedFile; -import org.springaicommunity.agentcore.artifacts.SessionConstants; -import org.springaicommunity.agentcore.browser.BrowserArtifacts; -import org.springframework.ai.tool.ToolCallbackProvider; -import org.springframework.beans.factory.annotation.Qualifier; -WS_TEST_BLOCK_440_8 - -ws_run_block 9 'Updating the code' '3. Add the browserArtifactStore field after the chatClient field:' 155 155 'java' '' <<'WS_TEST_BLOCK_440_9' - private final ArtifactStore browserArtifactStore; -WS_TEST_BLOCK_440_9 - -ws_run_block 10 'Updating the code' '4. Add the browser parameters to the constructor:' 161 163 'java' '' <<'WS_TEST_BLOCK_440_10' - @Qualifier("browserToolCallbackProvider") ToolCallbackProvider browserTools, - @Qualifier("browserArtifactStore") ArtifactStore browserArtifactStore, - ChatClient.Builder chatClientBuilder) { -WS_TEST_BLOCK_440_10 - -ws_run_block 11 'Updating the code' '5. Store the artifact store reference and build the tool callback providers list after the local tools section:' 172 178 'java' '' <<'WS_TEST_BLOCK_440_11' - // Browser - this.browserArtifactStore = browserArtifactStore; - - // Tool Callback Providers - List toolCallbackProviders = new ArrayList<>(); - toolCallbackProviders.add(browserTools); - logger.info("Browser enabled"); -WS_TEST_BLOCK_440_11 - -ws_run_block 12 'Updating the code' '6. Add a second .defaultTools() call to register the tool callback providers:' 187 191 'java' '' <<'WS_TEST_BLOCK_440_12' - this.chatClient = chatClientBuilder.defaultSystem(SYSTEM_PROMPT) - .defaultAdvisors(advisors.toArray(new Advisor[0])) - .defaultTools(localTools.toArray()) - .defaultTools(toolCallbackProviders.toArray()) - .build(); -WS_TEST_BLOCK_440_12 - -ws_run_block 13 'Updating the code' '7. Update the chat() method to append screenshots and propagate the session ID:' 199 205 'java' '' <<'WS_TEST_BLOCK_440_13' - private Flux chat(String prompt, String sessionId) { - return chatClient.prompt().user(prompt) - .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId)) - .stream().content() - .concatWith(Flux.defer(() -> appendScreenshots(sessionId))) - .contextWrite(ctx -> ctx.put(SessionConstants.SESSION_ID_KEY, sessionId)); - } -WS_TEST_BLOCK_440_13 - -ws_run_block 14 'Updating the code' '8. Add the appendScreenshots() and formatScreenshotsAsMarkdown() methods to ChatService:' 213 231 'java' '' <<'WS_TEST_BLOCK_440_14' - private Flux appendScreenshots(String sessionId) { - List screenshots = browserArtifactStore.retrieve(sessionId); - if (screenshots == null || screenshots.isEmpty()) { - return Flux.empty(); - } - return Flux.just(formatScreenshotsAsMarkdown(screenshots)); - } - - private String formatScreenshotsAsMarkdown(List screenshots) { - StringBuilder sb = new StringBuilder(); - for (GeneratedFile screenshot : screenshots) { - sb.append("\n\n![Screenshot of ") - .append(BrowserArtifacts.url(screenshot).orElse("unknown")) - .append("](") - .append(screenshot.toDataUrl()) - .append(")"); - } - return sb.toString(); - } -WS_TEST_BLOCK_440_14 - -ws_run_block 15 'Testing the application' '1. Start the application:' 242 244 'bash' '' <<'WS_TEST_BLOCK_440_15' -echo "export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" >> ~/environment/.envrc -cd ~/environment/aiagent -./mvnw spring-boot:run -WS_TEST_BLOCK_440_15 - -ws_skip_block 16 'Testing the application' '2. Interact with the AI agent:' 259 268 '' '' 'informational block without language' - -ws_run_block 17 'Committing changes' 'Committing changes' 278 280 'bash' '' <<'WS_TEST_BLOCK_440_17' -cd ~/environment/aiagent -git add . -git commit -m "Add browser" -WS_TEST_BLOCK_440_17 - -ws_end_page - -ws_begin_page 'Code interpreter' 460 'code-interpreter/index.en.md' - -ws_run_block 1 'Adding dependencies' '1. Open pom.xml:' 53 53 'bash' '' <<'WS_TEST_BLOCK_460_1' -code ~/environment/aiagent/pom.xml -WS_TEST_BLOCK_460_1 - -ws_run_block 2 'Adding dependencies' '2. Add the AgentCore Code Interpreter starter dependency to the section:' 59 63 'xml' '' <<'WS_TEST_BLOCK_460_2' - - - org.springaicommunity - spring-ai-agentcore-code-interpreter - -WS_TEST_BLOCK_460_2 - -ws_run_block 3 'Updating the code' '1. Open ChatService.java:' 79 79 'bash' '' <<'WS_TEST_BLOCK_460_3' -code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java -WS_TEST_BLOCK_460_3 - -ws_run_block 4 'Updating the code' '2. Add the imports after the existing imports:' 85 86 'java' '' <<'WS_TEST_BLOCK_460_4' -import org.springaicommunity.agentcore.artifacts.ArtifactStore; -import org.springaicommunity.agentcore.artifacts.GeneratedFile; -WS_TEST_BLOCK_460_4 - -ws_run_block 5 'Updating the code' '3. Add the codeInterpreterArtifactStore field after the browserArtifactStore field:' 94 94 'java' '' <<'WS_TEST_BLOCK_460_5' - private final ArtifactStore codeInterpreterArtifactStore; -WS_TEST_BLOCK_460_5 - -ws_run_block 6 'Updating the code' '4. Add the code interpreter parameters to the constructor:' 100 102 'java' '' <<'WS_TEST_BLOCK_460_6' - @Qualifier("codeInterpreterToolCallbackProvider") ToolCallbackProvider codeInterpreterTools, - @Qualifier("codeInterpreterArtifactStore") ArtifactStore codeInterpreterArtifactStore, - ChatClient.Builder chatClientBuilder) { -WS_TEST_BLOCK_460_6 - -ws_run_block 7 'Updating the code' '5. Store the artifact store reference and add code interpreter to the tool callback providers list after Browser:' 111 115 'java' '' <<'WS_TEST_BLOCK_460_7' - // Code Interpreter - this.codeInterpreterArtifactStore = codeInterpreterArtifactStore; - - toolCallbackProviders.add(codeInterpreterTools); - logger.info("Code Interpreter enabled"); -WS_TEST_BLOCK_460_7 - -ws_run_block 8 'Updating the code' '6. Update the chat() method to append generated files and unify the session context key:' 124 131 'java' '' <<'WS_TEST_BLOCK_460_8' - private Flux chat(String prompt, String sessionId) { - return chatClient.prompt().user(prompt) - .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId)) - .stream().content() - .concatWith(Flux.defer(() -> appendGeneratedFiles(sessionId))) - .concatWith(Flux.defer(() -> appendScreenshots(sessionId))) - .contextWrite(ctx -> ctx.put(SessionConstants.SESSION_ID_KEY, sessionId)); - } -WS_TEST_BLOCK_460_8 - -ws_run_block 9 'Updating the code' '7. Add the appendGeneratedFiles() and formatFilesAsMarkdown() methods to ChatService:' 140 167 'java' '' <<'WS_TEST_BLOCK_460_9' - private Flux appendGeneratedFiles(String sessionId) { - List files = codeInterpreterArtifactStore.retrieve(sessionId); - if (files == null || files.isEmpty()) { - return Flux.empty(); - } - String markdown = formatFilesAsMarkdown(files); - if (markdown.isEmpty()) { - return Flux.empty(); - } - return Flux.just(markdown); - } - - private String formatFilesAsMarkdown(List files) { - StringBuilder sb = new StringBuilder(); - for (GeneratedFile file : files) { - if (file.name().equals("package.json") || file.name().equals("package-lock.json")) { - continue; - } - if (file.isImage()) { - sb.append("\n\n![").append(file.name()).append("](") - .append(file.toDataUrl()).append(")"); - } else { - sb.append("\n\n[Download ").append(file.name()).append("](") - .append(file.toDataUrl()).append(")"); - } - } - return sb.toString(); - } -WS_TEST_BLOCK_460_9 - -ws_run_block 10 'Testing the application' '1. Start the application:' 178 179 'bash' '' <<'WS_TEST_BLOCK_460_10' -cd ~/environment/aiagent -./mvnw spring-boot:run -WS_TEST_BLOCK_460_10 - -ws_run_block 11 'Committing changes' 'Committing changes' 198 200 'bash' '' <<'WS_TEST_BLOCK_460_11' -cd ~/environment/aiagent -git add . -git commit -m "Add code interpreter" -WS_TEST_BLOCK_460_11 - -ws_end_page - -ws_begin_page 'MCP Server' 610 'mcp/mcp-server/index.en.md' - -ws_run_block 1 'Copying the application' 'Copy the backoffice application from the reference repository:' 24 29 'bash' '' <<'WS_TEST_BLOCK_610_1' -cp -r ~/java-on-aws/apps/java-spring-ai-agents/backoffice/trip ~/environment/backoffice - -cd ~/environment/backoffice -git init -b main -git add . -git commit -q -m "Initial commit" -WS_TEST_BLOCK_610_1 - -ws_run_block 2 'Exploring the application' 'The application uses Spring Cloud AWS DynamoDB for data access. Open TripService.java to review the existing service layer:' 37 37 'bash' '' <<'WS_TEST_BLOCK_610_2' -code ~/environment/backoffice/src/main/java/com/example/backoffice/trip/TripService.java -WS_TEST_BLOCK_610_2 - -ws_skip_block 3 'Exploring the application' 'Exploring the application' 41 54 'java' '' 'copy action disabled' - -ws_run_block 4 'Adding dependencies' '1. Open pom.xml:' 66 66 'bash' '' <<'WS_TEST_BLOCK_610_4' -code ~/environment/backoffice/pom.xml -WS_TEST_BLOCK_610_4 - -ws_run_block 5 'Adding dependencies' '2. Add the Spring AI BOM to the section, alongside the existing Spring Cloud AWS BOM:' 72 78 'xml' '' <<'WS_TEST_BLOCK_610_5' - - org.springframework.ai - spring-ai-bom - 2.0.1 - pom - import - -WS_TEST_BLOCK_610_5 - -ws_run_block 6 'Adding dependencies' '3. Add the MCP server starter to the section:' 86 90 'xml' '' <<'WS_TEST_BLOCK_610_6' - - - org.springframework.ai - spring-ai-starter-mcp-server-webmvc - -WS_TEST_BLOCK_610_6 - -ws_run_block 7 'Adding dependencies' '4. Verify the dependencies resolve:' 98 99 'bash' '' <<'WS_TEST_BLOCK_610_7' -cd ~/environment/backoffice -mvn dependency:resolve -q -WS_TEST_BLOCK_610_7 - -ws_run_block 8 'Updating the configuration' 'Add the MCP server configuration to application.properties:' 107 107 'bash' '' <<'WS_TEST_BLOCK_610_8' -code ~/environment/backoffice/src/main/resources/application.properties -WS_TEST_BLOCK_610_8 - -ws_run_block 9 'Updating the configuration' 'Add the server port, MCP server settings, and debug logging:' 113 122 'properties' '' <<'WS_TEST_BLOCK_610_9' -server.port=8000 - -# MCP Server -spring.ai.mcp.server.name=backoffice -spring.ai.mcp.server.version=1.0.0 -spring.ai.mcp.server.protocol=STATELESS - -# Logging -logging.level.org.springframework.ai=DEBUG -logging.level.io.modelcontextprotocol=DEBUG -WS_TEST_BLOCK_610_9 - -ws_run_block 10 'Creating the tools' 'Create TripTools.java to expose trip operations as MCP tools:' 133 188 'java' '' <<'WS_TEST_BLOCK_610_10' -cat <<'EOF' > ~/environment/backoffice/src/main/java/com/example/backoffice/trip/TripTools.java -package com.example.backoffice.trip; - -import org.springframework.ai.tool.annotation.Tool; -import org.springframework.ai.tool.annotation.ToolParam; -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.time.LocalDate; -import java.util.List; - -@Component -public class TripTools { - - private final TripService service; - - public TripTools(TripService service) { - this.service = service; - } - - @Bean - public ToolCallbackProvider tripToolsProvider(TripTools tripTools) { - return MethodToolCallbackProvider.builder() - .toolObjects(tripTools) - .build(); - } - - @Tool(description = "Register a new business trip. Returns trip reference for tracking.") - public Trip registerTrip( - @ToolParam(description = "User ID") String userId, - @ToolParam(description = "Departure date (YYYY-MM-DD)") LocalDate departureDate, - @ToolParam(description = "Return date (YYYY-MM-DD)") LocalDate returnDate, - @ToolParam(description = "Origin city") String origin, - @ToolParam(description = "Destination city") String destination, - @ToolParam(description = "Trip purpose") String purpose) { - return service.registerTrip(userId, departureDate, returnDate, origin, destination, purpose); - } - - @Tool(description = "Get all business trips registered by a user") - public List getTrips(@ToolParam(description = "User ID") String userId) { - return service.getTrips(userId); - } - - @Tool(description = "Get trip details by reference number") - public Trip getTrip(@ToolParam(description = "Trip reference (TRP-XXXXXXXX)") String tripReference) { - return service.getTrip(tripReference); - } - - @Tool(description = "Cancel a planned trip") - public Trip cancelTrip(@ToolParam(description = "Trip reference (TRP-XXXXXXXX)") String tripReference) { - return service.cancelTrip(tripReference); - } -} -EOF -WS_TEST_BLOCK_610_10 - -ws_run_block 11 'Creating DynamoDB tables' 'Create the DynamoDB table with the indexes the backoffice application needs:' 203 217 'bash' '' <<'WS_TEST_BLOCK_610_11' -aws dynamodb create-table \ - --table-name "backoffice-trip" \ - --attribute-definitions \ - AttributeName=pk,AttributeType=S \ - AttributeName=sk,AttributeType=S \ - AttributeName=tripReference,AttributeType=S \ - --key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \ - --global-secondary-indexes \ - "IndexName=tripReference-index,KeySchema=[{AttributeName=tripReference,KeyType=HASH}],Projection={ProjectionType=ALL}" \ - --billing-mode PAY_PER_REQUEST \ - --region ${AWS_REGION} \ - --no-cli-pager - -aws dynamodb wait table-exists --table-name "backoffice-trip" --region ${AWS_REGION} -echo "Table created: backoffice-trip" -WS_TEST_BLOCK_610_11 - -ws_run_block 12 'Starting the MCP server' 'Open a new terminal and start the MCP server:' 225 226 'bash' '' <<'WS_TEST_BLOCK_610_12' -cd ~/environment/backoffice -mvn spring-boot:run -WS_TEST_BLOCK_610_12 - -ws_skip_block 13 'Starting the MCP server' 'The startup log shows the registered MCP tools:' 232 232 '' '' 'informational block without language' - -ws_run_block 14 'Testing the MCP server' '1. Initialize the MCP session:' 242 254 'bash' '' <<'WS_TEST_BLOCK_610_14' -curl -s -X POST http://localhost:8000/mcp \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "1.0"} - } - }' | jq . -WS_TEST_BLOCK_610_14 - -ws_skip_block 15 'Testing the MCP server' 'Testing the MCP server' 258 281 '' '' 'informational block without language' - -ws_run_block 16 'Testing the MCP server' '2. List available tools:' 287 290 'bash' '' <<'WS_TEST_BLOCK_610_16' -curl -s -X POST http://localhost:8000/mcp \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | jq '.result.tools[] | {name}' -WS_TEST_BLOCK_610_16 - -ws_skip_block 17 'Testing the MCP server' 'Testing the MCP server' 294 305 '' '' 'informational block without language' - -ws_run_block 18 'Testing the MCP server' '3. Register a trip:' 311 332 'bash' '' <<'WS_TEST_BLOCK_610_18' -DEPARTURE=$(date -d "+7 days" +%Y-%m-%d 2>/dev/null || date -v+7d +%Y-%m-%d) -RETURN=$(date -d "+11 days" +%Y-%m-%d 2>/dev/null || date -v+11d +%Y-%m-%d) - -curl -s -X POST http://localhost:8000/mcp \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d "{ - \"jsonrpc\": \"2.0\", - \"id\": 3, - \"method\": \"tools/call\", - \"params\": { - \"name\": \"registerTrip\", - \"arguments\": { - \"userId\": \"testuser\", - \"departureDate\": \"${DEPARTURE}\", - \"returnDate\": \"${RETURN}\", - \"origin\": \"Berlin\", - \"destination\": \"Tokyo\", - \"purpose\": \"Customer meeting\" - } - } - }" | jq '.result.content[0].text' -WS_TEST_BLOCK_610_18 - -ws_skip_block 19 'Testing the MCP server' 'Testing the MCP server' 336 336 '' '' 'informational block without language' - -ws_skip_block 20 'Testing the MCP server' 'Testing the MCP server' 340 342 '' '' 'informational block without language' - -ws_run_block 21 'Committing changes' 'Committing changes' 350 352 'bash' '' <<'WS_TEST_BLOCK_610_21' -cd ~/environment/backoffice -git add . -git commit -m "Add MCP server" -WS_TEST_BLOCK_610_21 - -ws_end_page - -ws_begin_page 'MCP on AgentCore' 620 'mcp/mcp-on-agentcore/index.en.md' - -ws_run_block 1 'Creating M2M authentication' 'Run the setup script to create the Cognito resources for M2M authentication:' 39 39 'bash' 'script' <<'WS_TEST_BLOCK_620_1' -~/java-on-aws/apps/java-spring-ai-agents/scripts/04-mcp-cognito.sh -WS_TEST_BLOCK_620_1 - -ws_run_block 2 'Creating the M2M Cognito pool' 'Create a dedicated Cognito User Pool for M2M authentication:' 52 57 'bash' 'manual' <<'WS_TEST_BLOCK_620_2' -GATEWAY_POOL_ID=$(aws cognito-idp create-user-pool \ - --pool-name "mcp-gateway-pool" \ - --region ${AWS_REGION} \ - --no-cli-pager \ - --query 'UserPool.Id' --output text) -echo "export GATEWAY_POOL_ID=${GATEWAY_POOL_ID}" >> ~/environment/.envrc -WS_TEST_BLOCK_620_2 - -ws_run_block 3 'Creating the resource server' 'A resource server defines the API and its scopes. The gateway/invoke scope authorizes clients to call MCP tools:' 67 73 'bash' 'manual' <<'WS_TEST_BLOCK_620_3' -aws cognito-idp create-resource-server \ - --user-pool-id "${GATEWAY_POOL_ID}" \ - --identifier "gateway" \ - --name "Gateway API" \ - --scopes '[{"ScopeName":"invoke","ScopeDescription":"Invoke gateway tools"}]' \ - --region ${AWS_REGION} \ - --no-cli-pager -WS_TEST_BLOCK_620_3 - -ws_run_block 4 'Creating the Cognito domain' 'The clientcredentials flow requires a Cognito domain for the token endpoint:' 84 88 'bash' 'manual' <<'WS_TEST_BLOCK_620_4' -aws cognito-idp create-user-pool-domain \ - --domain "mcp-gateway-${ACCOUNT_ID}" \ - --user-pool-id "${GATEWAY_POOL_ID}" \ - --region ${AWS_REGION} \ - --no-cli-pager -WS_TEST_BLOCK_620_4 - -ws_run_block 5 'Creating the app client' 'Create an app client that uses the clientcredentials grant type:' 98 112 'bash' 'manual' <<'WS_TEST_BLOCK_620_5' -GATEWAY_CLIENT_ID=$(aws cognito-idp create-user-pool-client \ - --user-pool-id "${GATEWAY_POOL_ID}" \ - --client-name "mcp-gateway-client" \ - --generate-secret \ - --allowed-o-auth-flows "client_credentials" \ - --allowed-o-auth-scopes "gateway/invoke" \ - --allowed-o-auth-flows-user-pool-client \ - --region ${AWS_REGION} \ - --no-cli-pager \ - --query 'UserPoolClient.ClientId' --output text) - -GATEWAY_DISCOVERY_URL="https://cognito-idp.${AWS_REGION}.amazonaws.com/${GATEWAY_POOL_ID}/.well-known/openid-configuration" - -echo "export GATEWAY_CLIENT_ID=${GATEWAY_CLIENT_ID}" >> ~/environment/.envrc -echo "export GATEWAY_DISCOVERY_URL=${GATEWAY_DISCOVERY_URL}" >> ~/environment/.envrc -WS_TEST_BLOCK_620_5 - -ws_run_block 6 'Deploying the MCP server' 'Run the setup script to build and deploy the MCP server to AgentCore Runtime:' 149 149 'bash' 'script' <<'WS_TEST_BLOCK_620_6' -~/java-on-aws/apps/java-spring-ai-agents/scripts/05-mcp-runtime.sh -WS_TEST_BLOCK_620_6 - -ws_run_block 7 'Creating the ECR repository' 'Create an Amazon Elastic Container Registry (Amazon ECR) repository to store the container image:' 162 165 'bash' 'manual' <<'WS_TEST_BLOCK_620_7' -aws ecr create-repository \ - --repository-name "backoffice" \ - --region ${AWS_REGION} \ - --no-cli-pager -WS_TEST_BLOCK_620_7 - -ws_run_block 8 'Creating the IAM role' '1. Create the trust policy and role:' 175 194 'bash' 'manual' <<'WS_TEST_BLOCK_620_8' -cat > /tmp/trust-policy.json << EOF -{ - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": {"Service": "bedrock-agentcore.amazonaws.com"}, - "Action": "sts:AssumeRole", - "Condition": { - "StringEquals": {"aws:SourceAccount": "${ACCOUNT_ID}"}, - "ArnLike": {"aws:SourceArn": "arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:*"} - } - }] -} -EOF - -aws iam create-role \ - --role-name "backoffice-role" \ - --permissions-boundary "arn:aws:iam::${ACCOUNT_ID}:policy/workshop-boundary" \ - --assume-role-policy-document file:///tmp/trust-policy.json \ - --no-cli-pager -WS_TEST_BLOCK_620_8 - -ws_run_block 9 'Creating the IAM role' '2. Attach the permissions policy:' 203 228 'bash' 'manual' <<'WS_TEST_BLOCK_620_9' -cat > /tmp/backoffice-policy.json << EOF -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": ["ecr:*", "logs:*", "cloudwatch:*"], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": ["dynamodb:*"], - "Resource": [ - "arn:aws:dynamodb:${AWS_REGION}:${ACCOUNT_ID}:table/backoffice-*", - "arn:aws:dynamodb:${AWS_REGION}:${ACCOUNT_ID}:table/backoffice-*/index/*" - ] - } - ] -} -EOF - -aws iam put-role-policy \ - --role-name "backoffice-role" \ - --policy-name "AgentCorePolicy" \ - --policy-document file:///tmp/backoffice-policy.json \ - --no-cli-pager -WS_TEST_BLOCK_620_9 - -ws_run_block 10 'Building and pushing the container image' 'Build the container image using Spring Boot Buildpacks and push it to ECR:' 239 250 'bash' 'manual' <<'WS_TEST_BLOCK_620_10' -ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/backoffice" - -aws ecr get-login-password --region ${AWS_REGION} --no-cli-pager | \ - docker login --username AWS --password-stdin "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" - -cd ~/environment/backoffice -mvn -ntp spring-boot:build-image \ - -DskipTests \ - -Dspring-boot.build-image.imageName="${ECR_URI}:latest" \ - -Dspring-boot.build-image.imagePlatform=linux/arm64 - -docker push "${ECR_URI}:latest" -WS_TEST_BLOCK_620_10 - -ws_run_block 11 'Creating the AgentCore Runtime' 'Creating the AgentCore Runtime' 263 293 'bash' 'manual' <<'WS_TEST_BLOCK_620_11' -cd ~/environment -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} -SUBNET_ID=$(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[0].SubnetId' --output text --no-cli-pager) && echo ${SUBNET_ID} -SG_ID=$(aws ec2 describe-security-groups \ - --filters "Name=vpc-id,Values=${VPC_ID}" "Name=group-name,Values=default" \ - --query 'SecurityGroups[0].GroupId' --output text --no-cli-pager) && echo ${SG_ID} - -echo "export VPC_ID=${VPC_ID}" >> ~/environment/.envrc -echo "export SUBNET_ID=${SUBNET_ID}" >> ~/environment/.envrc -echo "export SG_ID=${SG_ID}" >> ~/environment/.envrc - -ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/backoffice" -ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/backoffice-role" - -MCP_RUNTIME_ID=$(aws bedrock-agentcore-control create-agent-runtime \ - --agent-runtime-name "backoffice" \ - --role-arn "${ROLE_ARN}" \ - --agent-runtime-artifact "{\"containerConfiguration\":{\"containerUri\":\"${ECR_URI}:latest\"}}" \ - --protocol-configuration '{"serverProtocol":"MCP"}' \ - --network-configuration "{\"networkMode\":\"VPC\",\"networkModeConfig\":{\"subnets\":[\"${SUBNET_ID}\"],\"securityGroups\":[\"${SG_ID}\"]}}" \ - --authorizer-configuration "{\"customJWTAuthorizer\":{\"discoveryUrl\":\"${GATEWAY_DISCOVERY_URL}\",\"allowedClients\":[\"${GATEWAY_CLIENT_ID}\"]}}" \ - --region ${AWS_REGION} \ - --no-cli-pager \ - --query 'agentRuntimeId' --output text) -echo "export MCP_RUNTIME_ID=${MCP_RUNTIME_ID}" >> ~/environment/.envrc -WS_TEST_BLOCK_620_11 - -ws_run_block 12 'Creating the AgentCore Runtime' '- 27: JWT authorizer validates tokens from the M2M Cognito pool — only the gateway client ID is allowed' 306 311 'bash' 'manual' <<'WS_TEST_BLOCK_620_12' -echo -n "Waiting for runtime" -while [ "$(aws bedrock-agentcore-control get-agent-runtime \ - --agent-runtime-id "${MCP_RUNTIME_ID}" --region ${AWS_REGION} \ - --no-cli-pager --query 'status' --output text)" != "READY" ]; do - echo -n "."; sleep 5 -done && echo " READY" -WS_TEST_BLOCK_620_12 - -ws_run_block 13 'Creating the AgentCore Runtime' 'Save the AgentCore Runtime endpoint and token URI for later use:' 317 325 'bash' 'manual' <<'WS_TEST_BLOCK_620_13' -RUNTIME_ARN="arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:runtime/${MCP_RUNTIME_ID}" -MCP_ENDPOINT="https://bedrock-agentcore.${AWS_REGION}.amazonaws.com/runtimes/$(echo -n "${RUNTIME_ARN}" | jq -sRr @uri)/invocations?qualifier=DEFAULT" -COGNITO_DOMAIN=$(aws cognito-idp describe-user-pool \ - --user-pool-id "${GATEWAY_POOL_ID}" --region ${AWS_REGION} \ - --no-cli-pager --query 'UserPool.Domain' --output text) -M2M_TOKEN_URI="https://${COGNITO_DOMAIN}.auth.${AWS_REGION}.amazoncognito.com/oauth2/token" - -echo "export MCP_ENDPOINT=${MCP_ENDPOINT}" >> ~/environment/.envrc -echo "export M2M_TOKEN_URI=${M2M_TOKEN_URI}" >> ~/environment/.envrc -WS_TEST_BLOCK_620_13 - -ws_run_block 14 'Testing the MCP Server on AgentCore Runtime' 'Verify the deployed MCP server by listing its tools:' 336 353 'bash' '' <<'WS_TEST_BLOCK_620_14' -cd ~/environment - -GATEWAY_CLIENT_SECRET=$(aws cognito-idp describe-user-pool-client \ - --user-pool-id "${GATEWAY_POOL_ID}" --client-id "${GATEWAY_CLIENT_ID}" \ - --region ${AWS_REGION} --no-cli-pager \ - --query 'UserPoolClient.ClientSecret' --output text) - -TOKEN=$(curl -s -X POST "${M2M_TOKEN_URI}" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=client_credentials&client_id=${GATEWAY_CLIENT_ID}&client_secret=${GATEWAY_CLIENT_SECRET}&scope=gateway/invoke" \ - | jq -r '.access_token') - -curl -s -X POST "${MCP_ENDPOINT}" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \ - | jq '.result.tools[] | {name}' -WS_TEST_BLOCK_620_14 - -ws_skip_block 15 'Testing the MCP Server on AgentCore Runtime' 'Expected output:' 359 370 '' '' 'informational block without language' - -ws_run_block 16 'Creating the Gateway' 'Run the setup script to create the Gateway with targets:' 412 412 'bash' 'script' <<'WS_TEST_BLOCK_620_16' -~/java-on-aws/apps/java-spring-ai-agents/scripts/06-mcp-gateway.sh -WS_TEST_BLOCK_620_16 - -ws_run_block 17 'Creating the Gateway IAM role' '1. Create the trust policy and role:' 427 445 'bash' 'manual' <<'WS_TEST_BLOCK_620_17' -cat > /tmp/trust-policy.json << EOF -{ - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": {"Service": "bedrock-agentcore.amazonaws.com"}, - "Action": "sts:AssumeRole", - "Condition": { - "StringEquals": {"aws:SourceAccount": "${ACCOUNT_ID}"} - } - }] -} -EOF - -aws iam create-role \ - --role-name "mcp-gateway-role" \ - --permissions-boundary "arn:aws:iam::${ACCOUNT_ID}:policy/workshop-boundary" \ - --assume-role-policy-document file:///tmp/trust-policy.json \ - --no-cli-pager -WS_TEST_BLOCK_620_17 - -ws_run_block 18 'Creating the Gateway IAM role' '2. Attach the permissions policy:' 453 487 'bash' 'manual' <<'WS_TEST_BLOCK_620_18' -cat > /tmp/gateway-policy.json << EOF -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": ["bedrock-agentcore:InvokeAgentRuntime"], - "Resource": "arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:runtime/*" - }, - { - "Effect": "Allow", - "Action": [ - "bedrock-agentcore:GetWorkloadAccessToken", - "bedrock-agentcore:GetResourceApiKey", - "bedrock-agentcore:GetResourceOauth2Token" - ], - "Resource": [ - "arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:workload-identity-directory/*", - "arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:token-vault/*" - ] - }, - { - "Effect": "Allow", - "Action": ["secretsmanager:GetSecretValue"], - "Resource": "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:bedrock-agentcore-identity!*" - } - ] -} -EOF - -aws iam put-role-policy \ - --role-name "mcp-gateway-role" \ - --policy-name "GatewayPolicy" \ - --policy-document file:///tmp/gateway-policy.json \ - --no-cli-pager -WS_TEST_BLOCK_620_18 - -ws_run_block 19 'Creating the Gateway' 'Creating the Gateway' 497 520 'bash' 'manual' <<'WS_TEST_BLOCK_620_19' -GATEWAY_ID=$(aws bedrock-agentcore-control create-gateway \ - --name "mcp-gateway" \ - --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/mcp-gateway-role" \ - --protocol-type "MCP" \ - --protocol-configuration '{"mcp":{"searchType":"SEMANTIC"}}' \ - --authorizer-type "AWS_IAM" \ - --region ${AWS_REGION} \ - --no-cli-pager \ - --query 'gatewayId' --output text) - -echo "export GATEWAY_ID=${GATEWAY_ID}" >> ~/environment/.envrc - -echo -n "Waiting for gateway" -while [ "$(aws bedrock-agentcore-control get-gateway \ - --gateway-identifier "${GATEWAY_ID}" --region ${AWS_REGION} \ - --no-cli-pager --query 'status' --output text)" != "READY" ]; do - echo -n "."; sleep 5 -done && echo " READY" - -GATEWAY_URL=$(aws bedrock-agentcore-control get-gateway \ - --gateway-identifier "${GATEWAY_ID}" \ - --region ${AWS_REGION} \ - --no-cli-pager --query 'gatewayUrl' --output text) -echo "export GATEWAY_URL=${GATEWAY_URL}" >> ~/environment/.envrc -WS_TEST_BLOCK_620_19 - -ws_run_block 20 'Adding the backoffice target' '1. Create an OAuth2 credential provider:' 534 551 'bash' 'manual' <<'WS_TEST_BLOCK_620_20' -cd ~/environment -GATEWAY_CLIENT_SECRET=$(aws cognito-idp describe-user-pool-client \ - --user-pool-id "${GATEWAY_POOL_ID}" --client-id "${GATEWAY_CLIENT_ID}" \ - --region ${AWS_REGION} --no-cli-pager \ - --query 'UserPoolClient.ClientSecret' --output text) - -OAUTH_CONFIG=$(jq -n \ - --arg clientId "${GATEWAY_CLIENT_ID}" \ - --arg clientSecret "${GATEWAY_CLIENT_SECRET}" \ - --arg discoveryUrl "${GATEWAY_DISCOVERY_URL}" \ - '{customOauth2ProviderConfig: {clientId: $clientId, clientSecret: $clientSecret, oauthDiscovery: {discoveryUrl: $discoveryUrl}}}') - -aws bedrock-agentcore-control create-oauth2-credential-provider \ - --name "mcp-backoffice-oauth" \ - --credential-provider-vendor "CustomOauth2" \ - --oauth2-provider-config-input "${OAUTH_CONFIG}" \ - --region ${AWS_REGION} \ - --no-cli-pager -WS_TEST_BLOCK_620_20 - -ws_run_block 21 'Adding the backoffice target' '2. Add the backoffice target:' 560 577 'bash' 'manual' <<'WS_TEST_BLOCK_620_21' -RUNTIME_ARN="arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:runtime/${MCP_RUNTIME_ID}" -MCP_ENDPOINT="https://bedrock-agentcore.${AWS_REGION}.amazonaws.com/runtimes/$(echo -n "${RUNTIME_ARN}" | jq -sRr @uri)/invocations?qualifier=DEFAULT" -TARGET_CONFIG=$(jq -n --arg endpoint "${MCP_ENDPOINT}" '{mcp: {mcpServer: {endpoint: $endpoint}}}') - -OAUTH_PROVIDER_ARN=$(aws bedrock-agentcore-control list-oauth2-credential-providers \ - --region ${AWS_REGION} --no-cli-pager \ - --query "credentialProviders[?name=='mcp-backoffice-oauth'].credentialProviderArn | [0]" --output text) - -CREDENTIAL_CONFIG=$(jq -n --arg providerArn "${OAUTH_PROVIDER_ARN}" \ - '[{credentialProviderType: "OAUTH", credentialProvider: {oauthCredentialProvider: {providerArn: $providerArn, grantType: "CLIENT_CREDENTIALS", scopes: ["gateway/invoke"]}}}]') - -aws bedrock-agentcore-control create-gateway-target \ - --gateway-identifier "${GATEWAY_ID}" \ - --name "backoffice" \ - --target-configuration "${TARGET_CONFIG}" \ - --credential-provider-configurations "${CREDENTIAL_CONFIG}" \ - --region ${AWS_REGION} \ - --no-cli-pager -WS_TEST_BLOCK_620_21 - -ws_run_block 22 'Adding the holidays target' '> Copy and run the block as-is — the jq filter is a one-time conversion and does not need to be understood.' 593 605 'bash' 'manual' <<'WS_TEST_BLOCK_620_22' -OPENAPI_SPEC=$(curl -s -L "https://nagerholidays.com/openapi/community-v4.json" | jq -c ' - .openapi = "3.0.0" | - . + {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]) - ) - ) | - walk(if type == "object" and .type == ["null", "string"] then .type = "string" | .nullable = true - elif type == "object" and .type == ["null", "array"] then .type = "array" | .nullable = true - elif type == "object" and .type == ["null", "integer"] then .type = "integer" | .nullable = true - else . end) -') -WS_TEST_BLOCK_620_22 - -ws_run_block 23 'Adding the holidays target' 'Create an API key credential provider and add the target:' 613 635 'bash' 'manual' <<'WS_TEST_BLOCK_620_23' -aws bedrock-agentcore-control create-api-key-credential-provider \ - --name "mcp-holidays-apikey-provider" \ - --api-key "public-api-no-key-required" \ - --region ${AWS_REGION} \ - --no-cli-pager - -APIKEY_PROVIDER_ARN=$(aws bedrock-agentcore-control list-api-key-credential-providers \ - --region ${AWS_REGION} --no-cli-pager \ - --query "credentialProviders[?name=='mcp-holidays-apikey-provider'].credentialProviderArn | [0]" --output text) - -TARGET_CONFIG=$(jq -n --arg spec "${OPENAPI_SPEC}" \ - '{mcp: {openApiSchema: {inlinePayload: $spec}}}') - -CREDENTIAL_CONFIG=$(jq -n --arg providerArn "${APIKEY_PROVIDER_ARN}" \ - '[{credentialProviderType: "API_KEY", credentialProvider: {apiKeyCredentialProvider: {providerArn: $providerArn, credentialLocation: "HEADER", credentialParameterName: "X-Api-Key"}}}]') - -aws bedrock-agentcore-control create-gateway-target \ - --gateway-identifier "${GATEWAY_ID}" \ - --name "holidays" \ - --target-configuration "${TARGET_CONFIG}" \ - --credential-provider-configurations "${CREDENTIAL_CONFIG}" \ - --region ${AWS_REGION} \ - --no-cli-pager -WS_TEST_BLOCK_620_23 - -ws_run_block 24 'Adding the holidays target' '- 7-8: Look up the credential provider ARN' 644 655 'bash' 'manual' <<'WS_TEST_BLOCK_620_24' -for TARGET_NAME in backoffice holidays; do - TARGET_ID=$(aws bedrock-agentcore-control list-gateway-targets \ - --gateway-identifier "${GATEWAY_ID}" --region ${AWS_REGION} --no-cli-pager \ - --query "items[?name=='${TARGET_NAME}'].targetId | [0]" --output text) - echo -n "Waiting for ${TARGET_NAME}" - while [ "$(aws bedrock-agentcore-control get-gateway-target \ - --gateway-identifier "${GATEWAY_ID}" --target-id "${TARGET_ID}" \ - --region ${AWS_REGION} --no-cli-pager \ - --query 'status' --output text)" != "READY" ]; do - echo -n "."; sleep 5 - done && echo " READY" -done -WS_TEST_BLOCK_620_24 - -ws_run_block 25 'Testing the MCP Server on the AgentCore Gateway' '1. List all tools across both targets:' 668 679 'bash' '' <<'WS_TEST_BLOCK_620_25' -cd ~/environment - -eval "$(aws configure export-credentials --format env --no-cli-pager)" - -curl -s -X POST "${GATEWAY_URL}" \ - --aws-sigv4 aws:amz:${AWS_REGION}:bedrock-agentcore \ - --user ${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY} \ - -H x-amz-security-token:${AWS_SESSION_TOKEN} \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \ - | jq '.result.tools[] | {name}' -WS_TEST_BLOCK_620_25 - -ws_run_block 26 'Testing the MCP Server on the AgentCore Gateway' '2. Register a trip through the Gateway:' 687 696 'bash' '' <<'WS_TEST_BLOCK_620_26' -eval "$(aws configure export-credentials --format env --no-cli-pager)" - -curl -s -X POST "${GATEWAY_URL}" \ - --aws-sigv4 aws:amz:${AWS_REGION}:bedrock-agentcore \ - --user ${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY} \ - -H x-amz-security-token:${AWS_SESSION_TOKEN} \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"backoffice___registerTrip","arguments":{"userId":"testuser","departureDate":"2026-03-15","returnDate":"2026-03-20","origin":"Berlin","destination":"Amsterdam","purpose":"Java conference"}}}' \ - | jq '.result.content[0].text' -r -WS_TEST_BLOCK_620_26 - -ws_end_page - -ws_begin_page 'MCP Client' 640 'mcp/mcp-client/index.en.md' - -ws_run_block 1 'Adding dependencies' '1. Open pom.xml:' 31 31 'bash' '' <<'WS_TEST_BLOCK_640_1' -code ~/environment/aiagent/pom.xml -WS_TEST_BLOCK_640_1 - -ws_run_block 2 'Adding dependencies' '2. Add the MCP client starter and AWS SDK signing dependencies to the section:' 37 50 'xml' '' <<'WS_TEST_BLOCK_640_2' - - - org.springframework.ai - spring-ai-starter-mcp-client - - - - software.amazon.awssdk - auth - - - software.amazon.awssdk - regions - -WS_TEST_BLOCK_640_2 - -ws_run_block 3 'Creating the SigV4 configuration' 'Create src/main/java/com/example/agent/SigV4McpConfig.java:' 60 123 'java' '' <<'WS_TEST_BLOCK_640_3' -cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/SigV4McpConfig.java -package com.example.agent; - -import java.util.Set; - -import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; -import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.ai.mcp.customizer.McpClientCustomizer; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; -import software.amazon.awssdk.http.ContentStreamProvider; -import software.amazon.awssdk.http.SdkHttpMethod; -import software.amazon.awssdk.http.SdkHttpRequest; -import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner; -import software.amazon.awssdk.http.auth.spi.signer.SignedRequest; -import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; - -@Configuration -public class SigV4McpConfig { - - private static final Logger log = LoggerFactory.getLogger(SigV4McpConfig.class); - private static final Set RESTRICTED_HEADERS = Set.of("content-length", "host", "expect"); - - @Bean - McpClientCustomizer sigV4RequestCustomizer() { - var signer = AwsV4HttpSigner.create(); - var credentialsProvider = DefaultCredentialsProvider.builder().build(); - var region = new DefaultAwsRegionProviderChain().getRegion(); - log.info("SigV4 MCP request customizer: region={}, service=bedrock-agentcore", region); - - McpSyncHttpClientRequestCustomizer requestCustomizer = (builder, method, endpoint, body, context) -> { - var httpRequest = SdkHttpRequest.builder() - .uri(endpoint) - .method(SdkHttpMethod.valueOf(method)) - .putHeader("Content-Type", "application/json") - .build(); - - ContentStreamProvider payload = (body != null && !body.isEmpty()) - ? ContentStreamProvider.fromUtf8String(body) - : null; - - SignedRequest signedRequest = signer.sign(r -> r - .identity(credentialsProvider.resolveIdentity().join()) - .request(httpRequest) - .payload(payload) - .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, "bedrock-agentcore") - .putProperty(AwsV4HttpSigner.REGION_NAME, region.id())); - - signedRequest.request().headers().forEach((name, values) -> { - if (!RESTRICTED_HEADERS.contains(name.toLowerCase())) { - values.forEach(value -> builder.setHeader(name, value)); - } - }); - }; - - return (name, transportBuilder) -> { - transportBuilder.httpRequestCustomizer(requestCustomizer); - }; - } -} -EOF -WS_TEST_BLOCK_640_3 - -ws_run_block 4 'Updating the code' '1. Open ChatService.java:' 141 141 'bash' '' <<'WS_TEST_BLOCK_640_4' -code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java -WS_TEST_BLOCK_640_4 - -ws_run_block 5 'Updating the code' '2. Add the MCP tools parameter to the constructor, after Code Interpreter:' 147 148 'java' '' <<'WS_TEST_BLOCK_640_5' - @Qualifier("mcpToolCallbacks") ToolCallbackProvider mcpTools, - ChatClient.Builder chatClientBuilder) { -WS_TEST_BLOCK_640_5 - -ws_run_block 6 'Updating the code' '3. Add MCP tools to the tool callback providers list after the Code Interpreter:' 156 158 'java' '' <<'WS_TEST_BLOCK_640_6' - // MCP Tools - toolCallbackProviders.add(mcpTools); - logger.info("MCP tools enabled"); -WS_TEST_BLOCK_640_6 - -ws_run_block 7 'Testing the application' 'Write the MCP client configuration to application.properties:' 168 175 'bash' '' <<'WS_TEST_BLOCK_640_7' -grep -q "spring.ai.mcp.client" ~/environment/aiagent/src/main/resources/application.properties 2>/dev/null || \ -cat >> ~/environment/aiagent/src/main/resources/application.properties << EOF - -# MCP Client -spring.ai.mcp.client.toolcallback.enabled=true -spring.ai.mcp.client.initialized=false -spring.ai.mcp.client.streamable-http.connections.gateway.url=${GATEWAY_URL} -EOF -WS_TEST_BLOCK_640_7 - -ws_run_block 8 'Testing the application' '- connections.gateway.url points to the AgentCore Gateway MCP endpoint' 183 184 'bash' '' <<'WS_TEST_BLOCK_640_8' -cd ~/environment/aiagent -./mvnw spring-boot:run -WS_TEST_BLOCK_640_8 - -ws_skip_block 9 'Testing the application' 'Testing the application' 188 188 '' '' 'informational block without language' - -ws_run_block 10 'Committing changes' 'Committing changes' 216 218 'bash' '' <<'WS_TEST_BLOCK_640_10' -cd ~/environment/aiagent -git add . -git commit -m "Add MCP client" -WS_TEST_BLOCK_640_10 - -ws_end_page - -ws_begin_page 'Deploy the AI agent' 700 'deploy/index.en.md' - -ws_run_block 1 'Creating user authentication' 'Run the setup script to create the Cognito User Pool and test users:' 31 31 'bash' 'script' <<'WS_TEST_BLOCK_700_1' -~/java-on-aws/apps/java-spring-ai-agents/scripts/07-aiagent-cognito.sh -WS_TEST_BLOCK_700_1 - -ws_run_block 2 'Creating the Cognito User Pool' '1. Create an Amazon Cognito User Pool:' 44 62 'bash' 'manual' <<'WS_TEST_BLOCK_700_2' -AIAGENT_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) - -echo "export AIAGENT_USER_POOL_ID=${AIAGENT_USER_POOL_ID}" >> ~/environment/.envrc -echo "export AIAGENT_DISCOVERY_URL=https://cognito-idp.${AWS_REGION}.amazonaws.com/${AIAGENT_USER_POOL_ID}/.well-known/openid-configuration" >> ~/environment/.envrc -WS_TEST_BLOCK_700_2 - -ws_run_block 3 'Creating the Cognito User Pool' '2. Create an app client for the AI agent:' 72 81 'bash' 'manual' <<'WS_TEST_BLOCK_700_3' -AIAGENT_CLIENT_ID=$(aws cognito-idp create-user-pool-client \ - --user-pool-id "${AIAGENT_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) - -echo "export AIAGENT_CLIENT_ID=${AIAGENT_CLIENT_ID}" >> ~/environment/.envrc -WS_TEST_BLOCK_700_3 - -ws_run_block 4 'Creating the Cognito User Pool' '3. Create test users:' 90 107 'bash' 'manual' <<'WS_TEST_BLOCK_700_4' -for USER in admin alice bob; do - aws cognito-idp admin-create-user \ - --user-pool-id "${AIAGENT_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 "${AIAGENT_USER_POOL_ID}" \ - --username "${USER}" \ - --password "${IDE_PASSWORD}" \ - --permanent \ - --region ${AWS_REGION} \ - --no-cli-pager -done -echo "Test users created: admin, alice, bob" -WS_TEST_BLOCK_700_4 - -ws_run_block 5 'Adding dependencies' '1. Open pom.xml:' 121 121 'bash' '' <<'WS_TEST_BLOCK_700_5' -code ~/environment/aiagent/pom.xml -WS_TEST_BLOCK_700_5 - -ws_run_block 6 'Adding dependencies' '2. Add the Spring Security OAuth2 Resource Server to the section:' 127 131 'xml' '' <<'WS_TEST_BLOCK_700_6' - - - org.springframework.boot - spring-boot-starter-oauth2-resource-server - -WS_TEST_BLOCK_700_6 - -ws_run_block 7 'Updating the code' '1. Create SecurityConfig.java:' 139 177 'java' '' <<'WS_TEST_BLOCK_700_7' -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 -WS_TEST_BLOCK_700_7 - -ws_run_block 8 'Updating the code' '2. Save the Cognito issuer URI to application.properties:' 191 196 'bash' '' <<'WS_TEST_BLOCK_700_8' -grep -q "spring.security.oauth2.resourceserver.jwt.issuer-uri" ~/environment/aiagent/src/main/resources/application.properties 2>/dev/null || \ -cat >> ~/environment/aiagent/src/main/resources/application.properties << EOF - -# Security -spring.security.oauth2.resourceserver.jwt.issuer-uri=https://cognito-idp.${AWS_REGION}.amazonaws.com/${AIAGENT_USER_POOL_ID} -EOF -WS_TEST_BLOCK_700_8 - -ws_run_block 9 'Updating the code' '3. Create ConversationIdResolver.java to extract user identity from JWT tokens:' 202 248 'java' '' <<'WS_TEST_BLOCK_700_9' -cat <<'EOF' > ~/environment/aiagent/src/main/java/com/example/agent/ConversationIdResolver.java -package com.example.agent; - -import org.springaicommunity.agentcore.context.AgentCoreContext; -import org.springaicommunity.agentcore.context.AgentCoreHeaders; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.json.JsonMapper; - -import java.util.Base64; -import java.util.UUID; - -/** - * Utility for extracting conversation ID from AgentCore context. - * Format: userId:sessionId (authenticated) or sessionId (anonymous) - */ -public final class ConversationIdResolver { - - private static final Logger logger = LoggerFactory.getLogger(ConversationIdResolver.class); - private static final JsonMapper jsonMapper = JsonMapper.builder().build(); - - private ConversationIdResolver() {} - - public static String resolve(AgentCoreContext context) { - String sessionId = context.getHeader(AgentCoreHeaders.SESSION_ID); - if (sessionId == null || sessionId.isBlank()) { - sessionId = UUID.randomUUID().toString(); - } - - String authHeader = context.getHeader(AgentCoreHeaders.AUTHORIZATION); - if (authHeader != null && authHeader.startsWith("Bearer ")) { - try { - String jwt = authHeader.substring(7); - String payload = new String(Base64.getUrlDecoder().decode(jwt.split("\\.")[1])); - JsonNode claims = jsonMapper.readTree(payload); - String userId = claims.get("sub").asString(); - return userId + ":" + sessionId; - } catch (Exception e) { - logger.debug("JWT parsing failed, using sessionId only", e); - } - } - - return sessionId; - } -} -EOF -WS_TEST_BLOCK_700_9 - -ws_run_block 10 'Updating the code' '4. Open ChatService.java:' 258 258 'bash' '' <<'WS_TEST_BLOCK_700_10' -code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java -WS_TEST_BLOCK_700_10 - -ws_run_block 11 'Updating the code' '5. Update getConversationId() to use the resolver:' 264 266 'java' '' <<'WS_TEST_BLOCK_700_11' - private String getConversationId(AgentCoreContext context) { - return ConversationIdResolver.resolve(context); - } -WS_TEST_BLOCK_700_11 - -ws_run_block 12 'Testing authentication' '1. Start the application:' 274 275 'bash' '' <<'WS_TEST_BLOCK_700_12' -cd ~/environment/aiagent -./mvnw spring-boot:run -WS_TEST_BLOCK_700_12 - -ws_run_block 13 'Testing authentication' '2. Get a JWT token for alice and send an authenticated request:' 281 294 'bash' '' <<'WS_TEST_BLOCK_700_13' -cd ~/environment - -TOKEN=$(aws cognito-idp initiate-auth \ - --client-id "${AIAGENT_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) - -curl -N -s -X POST http://localhost:8080/invocations \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${TOKEN}" \ - -d '{"prompt":"Hi, I am Alice"}' -WS_TEST_BLOCK_700_13 - -ws_run_block 14 'Testing authentication' 'The AI agent responds with a streamed reply. Without the token, the request is rejected:' 300 302 'bash' '' <<'WS_TEST_BLOCK_700_14' -curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8080/invocations \ - -H "Content-Type: application/json" \ - -d '{"prompt":"Hi"}' -WS_TEST_BLOCK_700_14 - -ws_run_block 15 'Excluding static files from the container' '1. Open pom.xml:' 335 335 'bash' '' <<'WS_TEST_BLOCK_700_15' -code ~/environment/aiagent/pom.xml -WS_TEST_BLOCK_700_15 - -ws_run_block 16 'Excluding static files from the container' '2. Add a Maven profile before the closing tag that excludes the static/ directory from the build:' 341 355 'xml' '' <<'WS_TEST_BLOCK_700_16' - - - headless - - - - src/main/resources - - static/** - - - - - - -WS_TEST_BLOCK_700_16 - -ws_run_block 17 'Deploying to AgentCore Runtime' 'Run the setup script to build and deploy the AI agent to AgentCore Runtime:' 368 368 'bash' 'script' <<'WS_TEST_BLOCK_700_17' -~/java-on-aws/apps/java-spring-ai-agents/scripts/08-aiagent-runtime.sh -WS_TEST_BLOCK_700_17 - -ws_run_block 18 'Creating the ECR repository' 'Creating the ECR repository' 379 382 'bash' 'manual' <<'WS_TEST_BLOCK_700_18' -aws ecr create-repository \ - --repository-name "aiagent" \ - --region ${AWS_REGION} \ - --no-cli-pager -WS_TEST_BLOCK_700_18 - -ws_run_block 19 'Creating the IAM role' '1. Create the trust policy and role:' 392 411 'bash' 'manual' <<'WS_TEST_BLOCK_700_19' -cat > /tmp/trust-policy.json << EOF -{ - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": {"Service": "bedrock-agentcore.amazonaws.com"}, - "Action": "sts:AssumeRole", - "Condition": { - "StringEquals": {"aws:SourceAccount": "${ACCOUNT_ID}"}, - "ArnLike": {"aws:SourceArn": "arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:*"} - } - }] -} -EOF - -aws iam create-role \ - --role-name "aiagent-runtime-role" \ - --permissions-boundary "arn:aws:iam::${ACCOUNT_ID}:policy/workshop-boundary" \ - --assume-role-policy-document file:///tmp/trust-policy.json \ - --no-cli-pager -WS_TEST_BLOCK_700_19 - -ws_run_block 20 'Creating the IAM role' '2. Attach the permissions policy:' 420 442 'bash' 'manual' <<'WS_TEST_BLOCK_700_20' -cat > /tmp/aiagent-policy.json << EOF -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": ["bedrock:*", "bedrock-agentcore:*", "aws-marketplace:*"], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": ["ecr:*", "logs:*", "xray:*", "cloudwatch:*"], - "Resource": "*" - } - ] -} -EOF - -aws iam put-role-policy \ - --role-name "aiagent-runtime-role" \ - --policy-name "AgentCoreExecutionPolicy" \ - --policy-document file:///tmp/aiagent-policy.json \ - --no-cli-pager -WS_TEST_BLOCK_700_20 - -ws_run_block 21 'Building and pushing the container image' 'Building and pushing the container image' 453 465 'bash' 'manual' <<'WS_TEST_BLOCK_700_21' -ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/aiagent" - -aws ecr get-login-password --region ${AWS_REGION} --no-cli-pager | \ - docker login --username AWS --password-stdin "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" - -cd ~/environment/aiagent -mvn -ntp spring-boot:build-image \ - -Pheadless \ - -DskipTests \ - -Dspring-boot.build-image.imageName="${ECR_URI}:latest" \ - -Dspring-boot.build-image.imagePlatform=linux/arm64 - -docker push "${ECR_URI}:latest" -WS_TEST_BLOCK_700_21 - -ws_run_block 22 'Creating the AgentCore Runtime' 'Creating the AgentCore Runtime' 478 493 'bash' 'manual' <<'WS_TEST_BLOCK_700_22' -cd ~/environment - -ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/aiagent" - -AIAGENT_RUNTIME_ID=$(aws bedrock-agentcore-control create-agent-runtime \ - --agent-runtime-name "aiagent" \ - --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/aiagent-runtime-role" \ - --agent-runtime-artifact "{\"containerConfiguration\":{\"containerUri\":\"${ECR_URI}:latest\"}}" \ - --network-configuration "{\"networkMode\":\"VPC\",\"networkModeConfig\":{\"subnets\":[\"${SUBNET_ID}\"],\"securityGroups\":[\"${SG_ID}\"]}}" \ - --authorizer-configuration "{\"customJWTAuthorizer\":{\"discoveryUrl\":\"${AIAGENT_DISCOVERY_URL}\",\"allowedClients\":[\"${AIAGENT_CLIENT_ID}\"]}}" \ - --request-header-configuration '{"requestHeaderAllowlist":["Authorization"]}' \ - --environment-variables PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ - --region ${AWS_REGION} \ - --no-cli-pager \ - --query 'agentRuntimeId' --output text) -echo "export AIAGENT_RUNTIME_ID=${AIAGENT_RUNTIME_ID}" >> ~/environment/.envrc -WS_TEST_BLOCK_700_22 - -ws_run_block 23 'Creating the AgentCore Runtime' '- 12: Skip Playwright'"'"'s local browser download — AgentCore Browser runs remotely' 503 508 'bash' 'manual' <<'WS_TEST_BLOCK_700_23' -echo -n "Waiting for runtime" -while [ "$(aws bedrock-agentcore-control get-agent-runtime \ - --agent-runtime-id "${AIAGENT_RUNTIME_ID}" --region ${AWS_REGION} \ - --no-cli-pager --query 'status' --output text)" != "READY" ]; do - echo -n "."; sleep 5 -done && echo " READY" -WS_TEST_BLOCK_700_23 - -ws_run_block 24 'Creating the AgentCore Runtime' 'Save the AgentCore Runtime endpoint:' 514 517 'bash' 'manual' <<'WS_TEST_BLOCK_700_24' -RUNTIME_ARN="arn:aws:bedrock-agentcore:${AWS_REGION}:${ACCOUNT_ID}:runtime/${AIAGENT_RUNTIME_ID}" -AIAGENT_ENDPOINT="https://bedrock-agentcore.${AWS_REGION}.amazonaws.com/runtimes/$(echo -n "${RUNTIME_ARN}" | jq -sRr @uri)/invocations?qualifier=DEFAULT" - -echo "export AIAGENT_ENDPOINT=${AIAGENT_ENDPOINT}" >> ~/environment/.envrc -WS_TEST_BLOCK_700_24 - -ws_run_block 25 'Testing the AI Agent on the AgentCore Runtime' 'Get a JWT token for alice and send a request to the deployed AI agent:' 528 541 'bash' '' <<'WS_TEST_BLOCK_700_25' -cd ~/environment - -TOKEN=$(aws cognito-idp initiate-auth \ - --client-id "${AIAGENT_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) - -curl -N -s -X POST "${AIAGENT_ENDPOINT}" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${TOKEN}" \ - -d '{"prompt":"Hi, I am alice"}' -WS_TEST_BLOCK_700_25 - -ws_run_block 26 'Accessing the logs' 'View the AgentCore Runtime status:' 549 553 'bash' '' <<'WS_TEST_BLOCK_700_26' -aws bedrock-agentcore-control get-agent-runtime \ - --agent-runtime-id "${AIAGENT_RUNTIME_ID}" \ - --region ${AWS_REGION} \ - --no-cli-pager \ - --query '{status:status,lastUpdated:lastUpdatedAt}' -WS_TEST_BLOCK_700_26 - -ws_run_block 27 'Accessing the logs' 'CloudWatch log group for the AgentCore Runtime:' 559 562 'bash' '' <<'WS_TEST_BLOCK_700_27' -aws logs tail "/aws/bedrock-agentcore/runtimes/${AIAGENT_RUNTIME_ID}-DEFAULT" \ - --region ${AWS_REGION} \ - --since 1h \ - --no-cli-pager -WS_TEST_BLOCK_700_27 - -ws_run_block 28 'Deploying the UI' 'Run the setup script to create the S3 bucket, CloudFront distribution, and upload the UI files:' 584 584 'bash' 'script' <<'WS_TEST_BLOCK_700_28' -~/java-on-aws/apps/java-spring-ai-agents/scripts/09-aiagent-ui.sh -WS_TEST_BLOCK_700_28 - -ws_run_block 29 'Creating the S3 bucket' 'Creating the S3 bucket' 595 603 'bash' 'manual' <<'WS_TEST_BLOCK_700_29' -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 -else - aws s3api create-bucket --bucket "${UI_BUCKET}" \ - --create-bucket-configuration LocationConstraint="${AWS_REGION}" --no-cli-pager -fi -echo "export UI_BUCKET=${UI_BUCKET}" >> ~/environment/.envrc -WS_TEST_BLOCK_700_29 - -ws_run_block 30 'Creating the CloudFront distribution' 'Create an OAI so CloudFront can read from the private S3 bucket, set the bucket policy, and create the distribution:' 613 683 'bash' 'manual' <<'WS_TEST_BLOCK_700_30' -OAI_ID=$(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 --query 'CloudFrontOriginAccessIdentity.Id' --output text) - -OAI_CANONICAL=$(aws cloudfront get-cloud-front-origin-access-identity --id "${OAI_ID}" \ - --no-cli-pager --query 'CloudFrontOriginAccessIdentity.S3CanonicalUserId' --output text) - -cat > /tmp/bucket-policy.json << EOF -{ - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": {"CanonicalUser": "${OAI_CANONICAL}"}, - "Action": "s3:GetObject", - "Resource": "arn:aws:s3:::${UI_BUCKET}/*" - }] -} -EOF - -aws s3api put-bucket-policy --bucket "${UI_BUCKET}" \ - --policy file:///tmp/bucket-policy.json --no-cli-pager - -cat > /tmp/cf-distribution.json << EOF -{ - "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" -} -EOF - -UI_DOMAIN=$(aws cloudfront create-distribution \ - --distribution-config file:///tmp/cf-distribution.json \ - --no-cli-pager --query 'Distribution.DomainName' --output text) - -echo "export UI_DOMAIN=${UI_DOMAIN}" >> ~/environment/.envrc -WS_TEST_BLOCK_700_30 - -ws_run_block 31 'Uploading the files' 'Generate the UI configuration and upload all static files:' 694 718 'bash' 'manual' <<'WS_TEST_BLOCK_700_31' -cd ~/environment -cat > ~/environment/aiagent/src/main/resources/static/config.json << EOF -{ - "userPoolId": "${AIAGENT_USER_POOL_ID}", - "clientId": "${AIAGENT_CLIENT_ID}", - "apiEndpoint": "${AIAGENT_ENDPOINT}", - "enableAttachments": true -} -EOF - -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 - fi -done -WS_TEST_BLOCK_700_31 - -ws_run_block 32 'Uploading the files' '- 7: enableAttachments will be used in next modules' 727 731 'bash' 'manual' <<'WS_TEST_BLOCK_700_32' -echo -n "Waiting for CloudFront" -while [ "$(curl -s -o /dev/null -w "%{http_code}" "https://${UI_DOMAIN}" 2>/dev/null)" != "200" ]; do - echo -n "."; sleep 15 -done && echo " READY" -echo "UI URL: https://${UI_DOMAIN}" -WS_TEST_BLOCK_700_32 - -ws_run_block 33 'Testing the AI agent' 'Open the UI at https://${UIDOMAIN} and log in with the test credentials from the authentication section.' 742 744 'bash' '' <<'WS_TEST_BLOCK_700_33' -echo "UI URL: https://${UI_DOMAIN}" -echo "username: Alice" -echo "password: ${IDE_PASSWORD}" -WS_TEST_BLOCK_700_33 - -ws_run_block 34 'Committing changes' 'Committing changes' 760 762 'bash' '' <<'WS_TEST_BLOCK_700_34' -cd ~/environment/aiagent -git add . -git commit -m "Deploy the AI agent" -WS_TEST_BLOCK_700_34 - -ws_end_page - -ws_begin_page 'Document processing' 740 'document-processing/index.en.md' - -ws_run_block 1 'Document processing' 'Copy sample receipts and invoices to your environment for testing:' 9 10 'bash' '' <<'WS_TEST_BLOCK_740_1' -mkdir -p ~/environment/aiagent/samples/ -cp ~/java-on-aws/apps/java-spring-ai-agents/aiagent/samples/*.png ~/environment/aiagent/samples/ -WS_TEST_BLOCK_740_1 - -ws_end_page - -ws_begin_page 'Gateway plug-and-play' 741 'document-processing/gateway-plug-and-play/index.en.md' - -ws_run_block 1 'Adding expense tools to the MCP server' '1. Copy the expense package from the reference repository:' 13 14 'bash' '' <<'WS_TEST_BLOCK_741_1' -cp -r ~/java-on-aws/apps/java-spring-ai-agents/backoffice/expense \ - ~/environment/backoffice/src/main/java/com/example/backoffice/expense -WS_TEST_BLOCK_741_1 - -ws_run_block 2 'Adding expense tools to the MCP server' '2. Create ExpenseTools.java — the same pattern as TripTools.java from the MCP Server module:' 20 85 'java' '' <<'WS_TEST_BLOCK_741_2' -cat <<'EOF' > ~/environment/backoffice/src/main/java/com/example/backoffice/expense/ExpenseTools.java -package com.example.backoffice.expense; - -import org.springframework.ai.tool.annotation.Tool; -import org.springframework.ai.tool.annotation.ToolParam; -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.math.BigDecimal; -import java.time.LocalDate; -import java.util.List; - -@Component -public class ExpenseTools { - - private final ExpenseService service; - - public ExpenseTools(ExpenseService service) { - this.service = service; - } - - @Bean - public ToolCallbackProvider expenseToolsProvider(ExpenseTools expenseTools) { - return MethodToolCallbackProvider.builder() - .toolObjects(expenseTools) - .build(); - } - - @Tool(description = "Create a new expense report. Optionally link to a trip.") - public Expense createExpense( - @ToolParam(description = "User ID") String userId, - @ToolParam(description = "Amount") BigDecimal amount, - @ToolParam(description = "Currency code (USD, EUR, etc.)") String currency, - @ToolParam(description = "Expense date (YYYY-MM-DD)") LocalDate date, - @ToolParam(description = "Description of expense") String description, - @ToolParam(description = "Type: FLIGHT, HOTEL, MEALS, TRANSPORT, OTHER") Expense.ExpenseType type, - @ToolParam(description = "Trip reference to link (optional, TRP-XXXXXXXX)") String tripReference) { - return service.createExpense(userId, amount, currency, date, description, type, tripReference); - } - - @Tool(description = "Get all expenses for a user") - public List getExpenses(@ToolParam(description = "User ID") String userId) { - return service.getExpenses(userId); - } - - @Tool(description = "Get expense details by reference number") - public Expense getExpense( - @ToolParam(description = "Expense reference (EXP-XXXXXXXX)") String expenseReference) { - return service.getExpense(expenseReference); - } - - @Tool(description = "Get all expenses linked to a specific trip") - public List getExpensesForTrip( - @ToolParam(description = "Trip reference (TRP-XXXXXXXX)") String tripReference) { - return service.getExpensesForTrip(tripReference); - } - - @Tool(description = "Submit a draft expense for approval") - public Expense submitExpense( - @ToolParam(description = "Expense reference (EXP-XXXXXXXX)") String expenseReference) { - return service.submitExpense(expenseReference); - } -} -EOF -WS_TEST_BLOCK_741_2 - -ws_run_block 3 'Adding expense tools to the MCP server' '3. Create the DynamoDB table and indexes for expenses:' 94 110 'bash' '' <<'WS_TEST_BLOCK_741_3' -aws dynamodb create-table \ - --table-name "backoffice-expense" \ - --attribute-definitions \ - AttributeName=pk,AttributeType=S \ - AttributeName=sk,AttributeType=S \ - AttributeName=expenseReference,AttributeType=S \ - AttributeName=tripReference,AttributeType=S \ - --key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \ - --global-secondary-indexes \ - "IndexName=expenseReference-index,KeySchema=[{AttributeName=expenseReference,KeyType=HASH}],Projection={ProjectionType=ALL}" \ - "IndexName=tripReference-index,KeySchema=[{AttributeName=tripReference,KeyType=HASH}],Projection={ProjectionType=ALL}" \ - --billing-mode PAY_PER_REQUEST \ - --region ${AWS_REGION} \ - --no-cli-pager - -aws dynamodb wait table-exists --table-name "backoffice-expense" --region ${AWS_REGION} -echo "Table created: backoffice-expense" -WS_TEST_BLOCK_741_3 - -ws_run_block 4 'Redeploying the MCP server' 'Run the setup script to rebuild and redeploy the MCP server to AgentCore Runtime:' 121 121 'bash' 'script' <<'WS_TEST_BLOCK_741_4' -~/java-on-aws/apps/java-spring-ai-agents/scripts/10-mcp-runtime-redeploy.sh -WS_TEST_BLOCK_741_4 - -ws_run_block 5 'Redeploying the MCP server' 'Rebuild the container image and update the AgentCore Runtime:' 132 155 'bash' 'manual' <<'WS_TEST_BLOCK_741_5' -cd ~/environment - -ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/backoffice" - -aws ecr get-login-password --region ${AWS_REGION} --no-cli-pager | \ - docker login --username AWS --password-stdin "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" - -cd ~/environment/backoffice -mvn -ntp spring-boot:build-image \ - -DskipTests \ - -Dspring-boot.build-image.imageName="${ECR_URI}:latest" \ - -Dspring-boot.build-image.imagePlatform=linux/arm64 - -docker push "${ECR_URI}:latest" - -aws bedrock-agentcore-control update-agent-runtime \ - --agent-runtime-id "${MCP_RUNTIME_ID}" \ - --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/backoffice-role" \ - --agent-runtime-artifact "{\"containerConfiguration\":{\"containerUri\":\"${ECR_URI}:latest\"}}" \ - --protocol-configuration '{"serverProtocol":"MCP"}' \ - --network-configuration "{\"networkMode\":\"VPC\",\"networkModeConfig\":{\"subnets\":[\"${SUBNET_ID}\"],\"securityGroups\":[\"${SG_ID}\"]}}" \ - --authorizer-configuration "{\"customJWTAuthorizer\":{\"discoveryUrl\":\"${GATEWAY_DISCOVERY_URL}\",\"allowedClients\":[\"${GATEWAY_CLIENT_ID}\"]}}" \ - --region ${AWS_REGION} \ - --no-cli-pager -WS_TEST_BLOCK_741_5 - -ws_run_block 6 'Redeploying the MCP server' '- 16-24: Update the AgentCore Runtime — AgentCore pulls the new image and restarts the container' 164 169 'bash' 'manual' <<'WS_TEST_BLOCK_741_6' -echo -n "Waiting for runtime" -while [ "$(aws bedrock-agentcore-control get-agent-runtime \ - --agent-runtime-id "${MCP_RUNTIME_ID}" --region ${AWS_REGION} \ - --no-cli-pager --query 'status' --output text)" != "READY" ]; do - echo -n "."; sleep 5 -done && echo " READY" -WS_TEST_BLOCK_741_6 - -ws_run_block 7 'Redeploying the MCP server' 'Synchronize the Gateway target so it discovers the new expense tools. The Gateway pre-computes vector embeddings for semantic search, so it needs an explicit sync when the MCP server'"'"'s tool catalog changes:' 175 185 'bash' 'manual' <<'WS_TEST_BLOCK_741_7' -cd ~/environment - -BACKOFFICE_TARGET_ID=$(aws bedrock-agentcore-control list-gateway-targets \ - --gateway-identifier "${GATEWAY_ID}" --region ${AWS_REGION} --no-cli-pager \ - --query "items[?name=='backoffice'].targetId | [0]" --output text) - -aws bedrock-agentcore-control synchronize-gateway-targets \ - --gateway-identifier "${GATEWAY_ID}" \ - --target-id-list "${BACKOFFICE_TARGET_ID}" \ - --region ${AWS_REGION} \ - --no-cli-pager -WS_TEST_BLOCK_741_7 - -ws_run_block 8 'Committing changes' 'Committing changes' 194 196 'bash' '' <<'WS_TEST_BLOCK_741_8' -cd ~/environment/backoffice -git add . -git commit -m "Add expense tools" -WS_TEST_BLOCK_741_8 - -ws_run_block 9 'Adding a currency converter Lambda to the Gateway' 'Run the setup script to deploy the currency converter Lambda and add it as a Gateway target:' 212 212 'bash' 'script' <<'WS_TEST_BLOCK_741_9' -~/java-on-aws/apps/java-spring-ai-agents/scripts/11-mcp-currency.sh -WS_TEST_BLOCK_741_9 - -ws_run_block 10 'Adding a currency converter Lambda to the Gateway' '1. Copy the currency converter application from the reference repository:' 223 223 'bash' 'manual' <<'WS_TEST_BLOCK_741_10' -cp -r ~/java-on-aws/apps/java-spring-ai-agents/currency ~/environment/currency -WS_TEST_BLOCK_741_10 - -ws_run_block 11 'Adding a currency converter Lambda to the Gateway' '2. Build the Lambda package:' 229 230 'bash' 'manual' <<'WS_TEST_BLOCK_741_11' -cd ~/environment/currency -mvn clean package -DskipTests -ntp -WS_TEST_BLOCK_741_11 - -ws_run_block 12 'Adding a currency converter Lambda to the Gateway' '3. Create the IAM role and deploy the Lambda function:' 236 272 'bash' 'manual' <<'WS_TEST_BLOCK_741_12' -aws iam create-role \ - --role-name "mcp-currency-role" \ - --permissions-boundary "arn:aws:iam::${ACCOUNT_ID}:policy/workshop-boundary" \ - --assume-role-policy-document '{ - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": {"Service": "lambda.amazonaws.com"}, - "Action": "sts:AssumeRole" - }] - }' \ - --no-cli-pager - -aws iam attach-role-policy \ - --role-name "mcp-currency-role" \ - --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole \ - --no-cli-pager - -sleep 10 - -JAR_FILE=$(ls ~/environment/currency/target/*.jar | head -1) -aws lambda create-function \ - --function-name "mcp-currency" \ - --runtime java25 \ - --role "arn:aws:iam::${ACCOUNT_ID}:role/mcp-currency-role" \ - --handler "com.example.currency.CurrencyHandler::handleRequest" \ - --zip-file "fileb://${JAR_FILE}" \ - --timeout 30 \ - --memory-size 512 \ - --region ${AWS_REGION} \ - --no-cli-pager - -aws lambda wait function-active-v2 \ - --function-name "mcp-currency" \ - --region ${AWS_REGION} \ - --no-cli-pager -echo "Lambda ready: mcp-currency" -WS_TEST_BLOCK_741_12 - -ws_run_block 13 'Adding a currency converter Lambda to the Gateway' '4. Add Lambda invoke permission to the Gateway role:' 282 295 'bash' 'manual' <<'WS_TEST_BLOCK_741_13' -aws iam put-role-policy \ - --role-name "mcp-gateway-role" \ - --policy-name "CurrencyLambdaInvoke" \ - --policy-document "{ - \"Version\": \"2012-10-17\", - \"Statement\": [{ - \"Effect\": \"Allow\", - \"Action\": \"lambda:InvokeFunction\", - \"Resource\": \"arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:mcp-currency\" - }] - }" \ - --no-cli-pager - -sleep 10 -WS_TEST_BLOCK_741_13 - -ws_run_block 14 'Adding a currency converter Lambda to the Gateway' '5. Create the Lambda target on the Gateway:' 301 334 'bash' 'manual' <<'WS_TEST_BLOCK_741_14' -cd ~/environment -LAMBDA_TOOLS='[ - { - "name": "convertCurrency", - "description": "Convert amount between currencies using real-time exchange rates", - "inputSchema": { - "type": "object", - "properties": { - "fromCurrency": {"type": "string", "description": "Source currency code (USD, EUR, GBP, etc.)"}, - "toCurrency": {"type": "string", "description": "Target currency code"}, - "amount": {"type": "number", "description": "Amount to convert"} - }, - "required": ["fromCurrency", "toCurrency", "amount"] - } - }, - { - "name": "getSupportedCurrencies", - "description": "Get list of all supported currency codes for conversion", - "inputSchema": {"type": "object", "properties": {}} - } -]' - -TARGET_CONFIG=$(jq -n \ - --arg arn "arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:mcp-currency" \ - --argjson tools "${LAMBDA_TOOLS}" \ - '{mcp: {lambda: {lambdaArn: $arn, toolSchema: {inlinePayload: $tools}}}}') - -aws bedrock-agentcore-control create-gateway-target \ - --gateway-identifier "${GATEWAY_ID}" \ - --name "currency" \ - --target-configuration "${TARGET_CONFIG}" \ - --credential-provider-configurations '[{"credentialProviderType":"GATEWAY_IAM_ROLE"}]' \ - --region ${AWS_REGION} \ - --no-cli-pager -WS_TEST_BLOCK_741_14 - -ws_run_block 15 'Adding a currency converter Lambda to the Gateway' '- 32: GATEWAYIAMROLE — Gateway uses its own IAM role to invoke the function, no separate credential provider needed' 343 352 'bash' 'manual' <<'WS_TEST_BLOCK_741_15' -TARGET_ID=$(aws bedrock-agentcore-control list-gateway-targets \ - --gateway-identifier "${GATEWAY_ID}" --region ${AWS_REGION} --no-cli-pager \ - --query "items[?name=='currency'].targetId | [0]" --output text) -echo -n "Waiting for currency target" -while [ "$(aws bedrock-agentcore-control get-gateway-target \ - --gateway-identifier "${GATEWAY_ID}" --target-id "${TARGET_ID}" \ - --region ${AWS_REGION} --no-cli-pager \ - --query 'status' --output text)" != "READY" ]; do - echo -n "."; sleep 5 -done && echo " READY" -WS_TEST_BLOCK_741_15 - -ws_end_page - -ws_begin_page 'Multi-modal chat' 742 'document-processing/multi-modal-chat/index.en.md' - -ws_run_block 1 'Updating the ChatRequest record' '1. Open ChatService.java:' 15 15 'bash' '' <<'WS_TEST_BLOCK_742_1' -code ~/environment/aiagent/src/main/java/com/example/agent/ChatService.java -WS_TEST_BLOCK_742_1 - -ws_run_block 2 'Updating the ChatRequest record' '2. Replace the ChatRequest record with a version that includes file fields:' 21 25 'java' '' <<'WS_TEST_BLOCK_742_2' -record ChatRequest(String prompt, String fileBase64, String fileName) { - public boolean hasFile() { - return fileBase64 != null && !fileBase64.isEmpty() && fileName != null && !fileName.isEmpty(); - } -} -WS_TEST_BLOCK_742_2 - -ws_run_block 3 'Updating the ChatService' '1. Add the multimodal imports after the existing imports:' 35 42 'java' '' <<'WS_TEST_BLOCK_742_3' -import java.util.Base64; -import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.model.tool.ToolCallingChatOptions; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.http.MediaType; -import org.springframework.http.MediaTypeFactory; -import org.springframework.util.MimeType; -import org.springframework.util.MimeTypeUtils; -WS_TEST_BLOCK_742_3 - -ws_run_block 4 'Updating the ChatService' '2. Add two fields after the chatClient field:' 48 49 'java' '' <<'WS_TEST_BLOCK_742_4' - private final ChatClient documentClient; - private final String documentModel; -WS_TEST_BLOCK_742_4 - -ws_run_block 5 'Updating the ChatService' '3. Add two constructor parameters after the existing ones:' 55 57 'java' '' <<'WS_TEST_BLOCK_742_5' - ChatModel chatModel, - @Value("${app.ai.document.model:global.anthropic.claude-opus-4-6-v1}") String documentModel, - ChatClient.Builder chatClientBuilder) { -WS_TEST_BLOCK_742_5 - -ws_run_block 6 'Updating the ChatService' '4. Initialize the fields inside the constructor body, before the chatClient build:' 63 64 'java' '' <<'WS_TEST_BLOCK_742_6' - this.documentModel = documentModel; - this.documentClient = ChatClient.builder(chatModel).build(); -WS_TEST_BLOCK_742_6 - -ws_run_block 7 'Updating the ChatService' '5. Update the @AgentCoreInvocation method to route file uploads to document processing:' 72 86 'java' '' <<'WS_TEST_BLOCK_742_7' - @AgentCoreInvocation - public Flux chat(ChatRequest request, AgentCoreContext context) { - if (request.hasFile()) { - return processDocument(request.prompt(), request.fileBase64(), request.fileName()) - .collectList() - .map(chunks -> String.join("", chunks)) - .flatMapMany(documentAnalysis -> { - String userPrompt = (request.prompt() != null && !request.prompt().trim().isEmpty()) - ? request.prompt() : "Process this document"; - String combinedPrompt = userPrompt + "\n\nDocument analysis:\n" + documentAnalysis; - return chat(combinedPrompt, getConversationId(context)); - }); - } - return chat(request.prompt(), getConversationId(context)); - } -WS_TEST_BLOCK_742_7 - -ws_run_block 8 'Updating the ChatService' '6. Add the processDocument and determineMimeType methods at the end of the class:' 94 122 'java' '' <<'WS_TEST_BLOCK_742_8' - private Flux processDocument(String prompt, String fileBase64, String fileName) { - logger.info("Processing document: {}", fileName); - - MimeType mimeType = determineMimeType(fileName); - byte[] fileData = Base64.getDecoder().decode(fileBase64); - ByteArrayResource resource = new ByteArrayResource(fileData); - String userPrompt = (prompt != null && !prompt.trim().isEmpty()) ? prompt : "Analyze this document"; - - return documentClient.prompt() - .options(ToolCallingChatOptions.builder().model(documentModel)) - .user(userSpec -> { - userSpec.text(userPrompt); - userSpec.media(mimeType, resource); - }) - .stream() - .content() - .onErrorResume(error -> { - logger.error("Error processing document", error); - return Flux.just("Error analyzing document: " + error.getMessage()); - }); - } - - private MimeType determineMimeType(String fileName) { - if (fileName != null && !fileName.trim().isEmpty()) { - MediaType mediaType = MediaTypeFactory.getMediaType(fileName).orElse(MediaType.APPLICATION_OCTET_STREAM); - return new MimeType(mediaType.getType(), mediaType.getSubtype()); - } - return MimeTypeUtils.APPLICATION_OCTET_STREAM; - } -WS_TEST_BLOCK_742_8 - -ws_run_block 9 'Redeploying the AI agent' 'Run the setup script to rebuild and redeploy the AI agent to AgentCore Runtime:' 138 138 'bash' 'script' <<'WS_TEST_BLOCK_742_9' -~/java-on-aws/apps/java-spring-ai-agents/scripts/12-aiagent-redeploy.sh -WS_TEST_BLOCK_742_9 - -ws_run_block 10 'Redeploying the AI agent' 'Rebuild the container image and update the AgentCore Runtime:' 149 174 'bash' 'manual' <<'WS_TEST_BLOCK_742_10' -cd ~/environment - -ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/aiagent" - -aws ecr get-login-password --region ${AWS_REGION} --no-cli-pager | \ - docker login --username AWS --password-stdin "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" - -cd ~/environment/aiagent -mvn -ntp spring-boot:build-image \ - -Pheadless \ - -DskipTests \ - -Dspring-boot.build-image.imageName="${ECR_URI}:latest" \ - -Dspring-boot.build-image.imagePlatform=linux/arm64 - -docker push "${ECR_URI}:latest" - -aws bedrock-agentcore-control update-agent-runtime \ - --agent-runtime-id "${AIAGENT_RUNTIME_ID}" \ - --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/aiagent-runtime-role" \ - --agent-runtime-artifact "{\"containerConfiguration\":{\"containerUri\":\"${ECR_URI}:latest\"}}" \ - --network-configuration "{\"networkMode\":\"VPC\",\"networkModeConfig\":{\"subnets\":[\"${SUBNET_ID}\"],\"securityGroups\":[\"${SG_ID}\"]}}" \ - --authorizer-configuration "{\"customJWTAuthorizer\":{\"discoveryUrl\":\"${AIAGENT_DISCOVERY_URL}\",\"allowedClients\":[\"${AIAGENT_CLIENT_ID}\"]}}" \ - --request-header-configuration '{"requestHeaderAllowlist":["Authorization"]}' \ - --environment-variables PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ - --region ${AWS_REGION} \ - --no-cli-pager -WS_TEST_BLOCK_742_10 - -ws_run_block 11 'Redeploying the AI agent' 'Wait for the AgentCore Runtime to be ready:' 180 185 'bash' 'manual' <<'WS_TEST_BLOCK_742_11' -echo -n "Waiting for runtime" -while [ "$(aws bedrock-agentcore-control get-agent-runtime \ - --agent-runtime-id "${AIAGENT_RUNTIME_ID}" --region ${AWS_REGION} \ - --no-cli-pager --query 'status' --output text)" != "READY" ]; do - echo -n "."; sleep 5 -done && echo " READY" -WS_TEST_BLOCK_742_11 - -ws_run_block 12 'Testing the AI agent' 'Open the UI at https://${UIDOMAIN}, log off and log into the new section.' 196 198 'bash' '' <<'WS_TEST_BLOCK_742_12' -echo "UI URL: https://${UI_DOMAIN}" -echo "username: Alice" -echo "password: ${IDE_PASSWORD}" -WS_TEST_BLOCK_742_12 - -ws_run_block 13 'Committing changes' 'Committing changes' 214 216 'bash' '' <<'WS_TEST_BLOCK_742_13' -cd ~/environment/aiagent -git add . -git commit -m "Add multimodal support" -WS_TEST_BLOCK_742_13 - -ws_end_page - -ws_begin_page 'Observability' 800 'observability/index.en.md' - -ws_run_block 1 'Enabling Bedrock model invocation logging' 'Enable logging to both CloudWatch Logs and Amazon S3:' 21 52 'bash' '' <<'WS_TEST_BLOCK_800_1' -BUCKET_NAME=$(aws ssm get-parameter --name workshop-bucket-name \ - --query 'Parameter.Value' --output text --no-cli-pager) -ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/workshop-bedrock-logging-role" - -aws logs create-log-group \ - --log-group-name /aws/bedrock/model-invocations --no-cli-pager || true - -cat > /tmp/bedrock-logging-config.json << EOF -{ - "loggingConfig": { - "cloudWatchConfig": { - "logGroupName": "/aws/bedrock/model-invocations", - "roleArn": "${ROLE_ARN}", - "largeDataDeliveryS3Config": { - "bucketName": "${BUCKET_NAME}", - "keyPrefix": "bedrock-logs" - } - }, - "s3Config": { - "bucketName": "${BUCKET_NAME}", - "keyPrefix": "bedrock-logs" - }, - "textDataDeliveryEnabled": true, - "imageDataDeliveryEnabled": true, - "embeddingDataDeliveryEnabled": true - } -} -EOF - -aws bedrock put-model-invocation-logging-configuration \ - --cli-input-json file:///tmp/bedrock-logging-config.json \ - --no-cli-pager -WS_TEST_BLOCK_800_1 - -ws_run_block 2 'Enabling Bedrock model invocation logging' '- 14-16: Amazon S3 configuration for long-term log retention' 61 61 'bash' '' <<'WS_TEST_BLOCK_800_2' -aws bedrock get-model-invocation-logging-configuration --no-cli-pager -WS_TEST_BLOCK_800_2 - -ws_end_page - -ws_begin_page 'Clean up' 1000 'cleanup/index.en.md' - -ws_run_block 1 'Cleaning up workshop resources' 'Run the cleanup script to delete all resources created during the workshop:' 18 19 'bash' 'own' <<'WS_TEST_BLOCK_1000_1' - -~/java-on-aws/apps/java-spring-ai-agents/scripts/99-cleanup.sh -WS_TEST_BLOCK_1000_1 - -ws_run_block 2 'Deleting the workshop infrastructure' '2. Delete AWS CloudFormation template:' 35 38 'bash' 'own' <<'WS_TEST_BLOCK_1000_2' -aws cloudformation delete-stack --stack-name workshop-stack -aws cloudformation wait stack-delete-complete --stack-name workshop-stack -CFN_S3=$(aws s3api list-buckets --query "Buckets[?starts_with(Name, 'cfn-')].Name" --output text) -aws s3 rb s3://${CFN_S3} --force -WS_TEST_BLOCK_1000_2 - -ws_end_page - -ws_finish_run diff --git a/infra/workshops.json b/infra/workshops.json index d18ddd97..da77a568 100644 --- a/infra/workshops.json +++ b/infra/workshops.json @@ -25,10 +25,7 @@ }, { "template": "java-ai-agents", - "repository": "java-ai-agents", - "test": { - "enabled": true - } + "repository": "java-ai-agents" }, { "template": "java-ai-agents-advanced", From b1cdf0340d3d7bdb19d24d277b1de9c06f93f0ae Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Tue, 25 Aug 2026 16:50:03 +0200 Subject: [PATCH 33/38] fix(infra): Add Checkov skip annotations for CodeBuild IAM policies --- .../java/sample/com/constructs/CodeBuild.java | 6 ++++++ infra/cfn/java-ai-agents-advanced-stack.yaml | 11 ++++++++--- infra/cfn/java-ai-agents-stack.yaml | 11 ++++++++--- infra/cfn/java-on-amazon-eks-stack.yaml | 11 ++++++++--- infra/cfn/java-on-aws-stack.yaml | 17 +++++++++++------ infra/cfn/java-spring-ai-agents-stack.yaml | 18 ++++++++++++++---- 6 files changed, 55 insertions(+), 19 deletions(-) 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 b85830c4..d7052cc2 100644 --- a/infra/cdk/src/main/java/sample/com/constructs/CodeBuild.java +++ b/infra/cdk/src/main/java/sample/com/constructs/CodeBuild.java @@ -182,6 +182,12 @@ public CodeBuild(final Construct scope, final String id, final CodeBuildProps pr "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) diff --git a/infra/cfn/java-ai-agents-advanced-stack.yaml b/infra/cfn/java-ai-agents-advanced-stack.yaml index a359cb63..a1059a87 100644 --- a/infra/cfn/java-ai-agents-advanced-stack.yaml +++ b/infra/cfn/java-ai-agents-advanced-stack.yaml @@ -482,7 +482,7 @@ Resources: - CodeBuildReportLambdaFunctionA3C396F7 - CodeBuildStartLambdaFunction8349284F Properties: - ContentHash: "1787666977653" + ContentHash: "1787668864122" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -705,6 +705,11 @@ 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: @@ -3189,7 +3194,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825160937" + - "-20260825164104" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -3273,7 +3278,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825160937" + - "-20260825164104" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-ai-agents-stack.yaml b/infra/cfn/java-ai-agents-stack.yaml index 2ec0136e..56f4755f 100644 --- a/infra/cfn/java-ai-agents-stack.yaml +++ b/infra/cfn/java-ai-agents-stack.yaml @@ -482,7 +482,7 @@ Resources: - CodeBuildReportLambdaFunctionA3C396F7 - CodeBuildStartLambdaFunction8349284F Properties: - ContentHash: "1787666974232" + ContentHash: "1787668860631" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -705,6 +705,11 @@ 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: @@ -3189,7 +3194,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825160934" + - "-20260825164100" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -3273,7 +3278,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825160934" + - "-20260825164100" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-on-amazon-eks-stack.yaml b/infra/cfn/java-on-amazon-eks-stack.yaml index 416a8269..0ec70622 100644 --- a/infra/cfn/java-on-amazon-eks-stack.yaml +++ b/infra/cfn/java-on-amazon-eks-stack.yaml @@ -534,7 +534,7 @@ Resources: - CodeBuildReportLambdaFunctionA3C396F7 - CodeBuildStartLambdaFunction8349284F Properties: - ContentHash: "1787666966801" + ContentHash: "1787668853203" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -757,6 +757,11 @@ 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: @@ -4927,7 +4932,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825160926" + - "-20260825164053" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -5011,7 +5016,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825160926" + - "-20260825164053" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-on-aws-stack.yaml b/infra/cfn/java-on-aws-stack.yaml index d9636af4..4598cbbe 100644 --- a/infra/cfn/java-on-aws-stack.yaml +++ b/infra/cfn/java-on-aws-stack.yaml @@ -534,7 +534,7 @@ Resources: - CodeBuildReportLambdaFunctionA3C396F7 - CodeBuildStartLambdaFunction8349284F Properties: - ContentHash: "1787666963070" + ContentHash: "1787668849024" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -699,12 +699,12 @@ Resources: Environment: ComputeType: BUILD_GENERAL1_MEDIUM EnvironmentVariables: - - Name: TEMPLATE_TYPE - Type: PLAINTEXT - Value: java-on-aws - Name: GIT_BRANCH Type: PLAINTEXT Value: feat/holmes-remediation + - Name: TEMPLATE_TYPE + Type: PLAINTEXT + Value: java-on-aws Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 ImagePullCredentialsType: CODEBUILD PrivilegedMode: false @@ -757,6 +757,11 @@ 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: @@ -4927,7 +4932,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825160923" + - "-20260825164049" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -5011,7 +5016,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825160923" + - "-20260825164049" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 diff --git a/infra/cfn/java-spring-ai-agents-stack.yaml b/infra/cfn/java-spring-ai-agents-stack.yaml index 585d0c2f..0812c57a 100644 --- a/infra/cfn/java-spring-ai-agents-stack.yaml +++ b/infra/cfn/java-spring-ai-agents-stack.yaml @@ -970,7 +970,7 @@ Resources: - CodeBuildReportLambdaFunctionA3C396F7 - CodeBuildStartLambdaFunction8349284F Properties: - ContentHash: "1787666970419" + ContentHash: "1787668856875" ProjectName: Ref: CodeBuildProjectA0FF5539 ServiceToken: @@ -1193,6 +1193,11 @@ 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: @@ -3804,7 +3809,7 @@ Resources: - VpcC3027511 - VpcVPCGW42EC8516 Properties: - ContentHash: "1787666970573" + ContentHash: "1787668857026" ProjectName: Ref: PlaceholderImageBuildProjectC08F4D66 ServiceToken: @@ -4043,6 +4048,11 @@ 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: @@ -5409,7 +5419,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825160930" + - "-20260825164056" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true @@ -5493,7 +5503,7 @@ Resources: - Ref: AWS::AccountId - "-" - Ref: AWS::Region - - "-20260825160930" + - "-20260825164056" LoggingConfiguration: DestinationBucketName: Ref: WorkshopBucketAccessLogs476BAB88 From 24824a0e590556f64208820f34540ab15c7ed94a Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Tue, 25 Aug 2026 17:28:47 +0200 Subject: [PATCH 34/38] fix(java-spring-ai-agents): Simplify deployment logging and enhance ECR repository validation --- .../java-spring-ai-agents/03-knowledge.sh | 2 +- .../java-spring-ai-agents/_suite-lib.sh | 21 ++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/infra/scripts/deploy/java-spring-ai-agents/03-knowledge.sh b/infra/scripts/deploy/java-spring-ai-agents/03-knowledge.sh index c87fbf11..419a1124 100755 --- a/infra/scripts/deploy/java-spring-ai-agents/03-knowledge.sh +++ b/infra/scripts/deploy/java-spring-ai-agents/03-knowledge.sh @@ -17,4 +17,4 @@ PGVECTOR_VERSION=$(jq -r '.records[0][0].stringValue // empty' <<<"${RESULT}") 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}; the suite uses Aurora/PgVector RAG and does not create a managed Bedrock Knowledge Base." +log "Validated PgVector ${PGVECTOR_VERSION} for Aurora/PgVector RAG." diff --git a/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh b/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh index 3eb3ff75..b325a322 100755 --- a/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh +++ b/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh @@ -216,9 +216,24 @@ ensure_eks_context() { } ensure_ecr_repository() { - local repository="$1" - aws_cli ecr describe-repositories --repository-names "${repository}" >/dev/null || \ - die "Predeployed ECR repository not found: ${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() { From 604849d2ba3de497e7de39424e606d3019c648f1 Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Tue, 25 Aug 2026 17:44:00 +0200 Subject: [PATCH 35/38] fix(java-spring-ai-agents): Refactor ingress and DNS readiness checks --- .../java-spring-ai-agents/04-mcp-server.sh | 13 ++-- .../java-spring-ai-agents/10-deploy-eks.sh | 14 ++--- .../java-spring-ai-agents/_suite-lib.sh | 62 ++++++++++++++++--- 3 files changed, 63 insertions(+), 26 deletions(-) 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 index 84d51242..94681669 100755 --- a/infra/scripts/deploy/java-spring-ai-agents/04-mcp-server.sh +++ b/infra/scripts/deploy/java-spring-ai-agents/04-mcp-server.sh @@ -140,15 +140,10 @@ kubectl apply -f "${MCPSERVER_DIR}/k8s/service.yaml" kubectl apply -f "${MCPSERVER_DIR}/k8s/ingress.yaml" kubectl rollout status deployment/mcpserver -n mcpserver --timeout=300s -MCP_URL="" -for i in {1..40}; do - MCP_URL=$(get_mcp_url || true) - [[ -n "${MCP_URL}" ]] && break - log "Waiting for MCP ingress hostname (${i}/40)" - ((i == 40)) || sleep 15 -done -[[ -n "${MCP_URL}" ]] || die "MCP ingress did not receive a hostname" -wait_for_http_status "MCP server" "${MCP_URL}/actuator/health" '^(200)$' 30 10 +wait_for_ingress_hostname "MCP ingress hostname" mcpserver mcpserver 40 15 +MCP_URL="http://${INGRESS_HOST}" +wait_for_dns "MCP ingress" "${INGRESS_HOST}" 30 10 +wait_for_http_status "MCP server HTTP readiness" "${MCP_URL}/actuator/health" '^(200)$' 30 10 state_set MCP_URL "${MCP_URL}" SAMPLE_NAME="suite-unicorn-classic-small" 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 index a8acddb4..f2294d19 100755 --- a/infra/scripts/deploy/java-spring-ai-agents/10-deploy-eks.sh +++ b/infra/scripts/deploy/java-spring-ai-agents/10-deploy-eks.sh @@ -135,16 +135,10 @@ kubectl apply -f "${AIAGENT_DIR}/k8s/service.yaml" kubectl apply -f "${AIAGENT_DIR}/k8s/ingress.yaml" kubectl rollout status deployment/aiagent -n aiagent --timeout=300s -host="" -for i in {1..40}; do - host=$(kubectl get ingress aiagent -n aiagent -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' 2>/dev/null || true) - [[ -n "${host}" ]] && break - log "Waiting for AI-agent ingress hostname (${i}/40)" - ((i == 40)) || sleep 15 -done -[[ -n "${host}" ]] || die "AI-agent ingress did not receive a hostname" -AIAGENT_ENDPOINT="http://${host}" -wait_for_http_status "EKS AI-agent health" "${AIAGENT_ENDPOINT}/actuator/health" '^(200)$' 30 10 +wait_for_ingress_hostname "AI-agent ingress hostname" aiagent aiagent 40 15 +AIAGENT_ENDPOINT="http://${INGRESS_HOST}" +wait_for_dns "AI-agent ingress" "${INGRESS_HOST}" 30 10 +wait_for_http_status "EKS AI-agent HTTP readiness" "${AIAGENT_ENDPOINT}/actuator/health" '^(200)$' 30 10 state_set ACTIVE_TARGET eks state_set AIAGENT_ENDPOINT "${AIAGENT_ENDPOINT}" log "AI agent reconciled on EKS: ${AIAGENT_ENDPOINT}" diff --git a/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh b/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh index b325a322..95a428b5 100755 --- a/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh +++ b/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh @@ -175,22 +175,70 @@ wait_for_command() { } http_status() { - curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 10 --max-time 30 "$1" || true + 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 status + local i http_code="" + printf '[%s] Waiting for %s' "${SUITE_NAME}" "${description}" for ((i=1; i<=attempts; i++)); do - status=$(http_status "${url}") - if [[ "${status}" =~ ${expected_regex} ]]; then - log "${description}: HTTP ${status}" + http_code=$(http_status "${url}") + if [[ "${http_code}" =~ ${expected_regex} ]]; then + printf ' HTTP %s\n' "${http_code}" return 0 fi - log "${description}: HTTP ${status:-000} (${i}/${attempts})" + printf '.' ((i == attempts)) || sleep "${interval}" done - die "Timed out waiting for ${description}" + printf '\n' + die "Timed out waiting for ${description}; last HTTP status was ${http_code:-000}" } require_workshop_role() { From fb6256fb6db27312c68c9e98d59882a67d62388b Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Tue, 25 Aug 2026 18:43:55 +0200 Subject: [PATCH 36/38] fix(java-spring-ai-agents): Standardize Docker image tags and runtime names --- .../java-spring-ai-agents/04-mcp-server.sh | 4 +- .../java-spring-ai-agents/10-deploy-eks.sh | 4 +- .../java-spring-ai-agents/11-deploy-ecs.sh | 4 +- .../13-deploy-agentcore.sh | 6 +-- .../java-spring-ai-agents/90-diagnose.sh | 2 +- .../README-alternative.md | 33 --------------- .../deploy/java-spring-ai-agents/README.md | 40 +++++++++++++++++++ .../java-spring-ai-agents/_suite-lib.sh | 2 +- 8 files changed, 51 insertions(+), 44 deletions(-) delete mode 100644 infra/scripts/deploy/java-spring-ai-agents/README-alternative.md create mode 100644 infra/scripts/deploy/java-spring-ai-agents/README.md 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 index 94681669..71748075 100755 --- a/infra/scripts/deploy/java-spring-ai-agents/04-mcp-server.sh +++ b/infra/scripts/deploy/java-spring-ai-agents/04-mcp-server.sh @@ -11,8 +11,8 @@ 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 alternative -IMAGE_DIGEST=$(aws_cli ecr describe-images --repository-name mcpserver --image-ids imageTag=alternative \ +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}" 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 index f2294d19..213e26ca 100755 --- a/infra/scripts/deploy/java-spring-ai-agents/10-deploy-eks.sh +++ b/infra/scripts/deploy/java-spring-ai-agents/10-deploy-eks.sh @@ -12,8 +12,8 @@ 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 alternative -IMAGE_DIGEST=$(aws_cli ecr describe-images --repository-name aiagent --image-ids imageTag=alternative \ +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}" diff --git a/infra/scripts/deploy/java-spring-ai-agents/11-deploy-ecs.sh b/infra/scripts/deploy/java-spring-ai-agents/11-deploy-ecs.sh index 0d1c404b..5966c948 100755 --- a/infra/scripts/deploy/java-spring-ai-agents/11-deploy-ecs.sh +++ b/infra/scripts/deploy/java-spring-ai-agents/11-deploy-ecs.sh @@ -10,8 +10,8 @@ load_state require_state MCP_URL COGNITO_ISSUER_URI [[ -f "${AIAGENT_DIR}/pom.xml" ]] || die "AI-agent source not found. Run 01-setup.sh first." -build_and_push_jib "${AIAGENT_DIR}" aiagent alternative -IMAGE_DIGEST=$(aws_cli ecr describe-images --repository-name aiagent --image-ids imageTag=alternative \ +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}" 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 index a1747666..1f69651a 100755 --- a/infra/scripts/deploy/java-spring-ai-agents/13-deploy-agentcore.sh +++ b/infra/scripts/deploy/java-spring-ai-agents/13-deploy-agentcore.sh @@ -89,13 +89,13 @@ ENTRYPOINT ["java", "-jar", "/app.jar"] EOF REGISTRY="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" -ECR_URI="${REGISTRY}/aiagent:alternative-agentcore" +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=alternative-agentcore \ +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}" @@ -130,7 +130,7 @@ DESIRED_ENV=$(jq -nc --arg db_url "${DB_URL}" --arg db_user "${DB_USER}" --arg d '{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-alternative" +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 diff --git a/infra/scripts/deploy/java-spring-ai-agents/90-diagnose.sh b/infra/scripts/deploy/java-spring-ai-agents/90-diagnose.sh index 9594c40b..19c4907b 100755 --- a/infra/scripts/deploy/java-spring-ai-agents/90-diagnose.sh +++ b/infra/scripts/deploy/java-spring-ai-agents/90-diagnose.sh @@ -35,7 +35,7 @@ aws_cli cognito-idp list-user-pools --max-results 60 --query "UserPools[?Name==' 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-alternative'].{Name:agentRuntimeName,Id:agentRuntimeId,Status:status}" --output table + --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 diff --git a/infra/scripts/deploy/java-spring-ai-agents/README-alternative.md b/infra/scripts/deploy/java-spring-ai-agents/README-alternative.md deleted file mode 100644 index 149bfe44..00000000 --- a/infra/scripts/deploy/java-spring-ai-agents/README-alternative.md +++ /dev/null @@ -1,33 +0,0 @@ -# Alternative idempotent deployment suite -# Alternative idempotent deployment suite - -This suite deploys the Unicorn Rentals Spring AI workshop and replaces the removed legacy `1-mcp-server.sh` through `8-agentcore.sh` scripts. The separately linked legacy `cleanup.sh` remains unchanged and is never called by this suite. - -## Order and targets - -Run the complete flow with exactly one 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 be run independently; each prints its prerequisites. Use `01-setup.sh --force` only when existing `~/environment/aiagent` or `~/environment/mcpserver` files should be refreshed. Existing directories are otherwise left untouched. `05-security.sh --rotate-passwords` is the only mode that changes passwords for existing users. - -The EKS, ECS, Lambda, and AgentCore scripts are alternatives. Re-running a stage discovers deterministic resource names and applies 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. The generated application uses Spring Boot 4.1.0, Spring AI 2.0.1, `spring-ai-vector-store-advisor`, and the modern Bedrock Converse properties with Claude Sonnet 4.6. The AgentCore target adds the AgentCore 2.1.0 BOM and runtime starter in an isolated build directory; its Runtime log group is `/aws/bedrock-agentcore/runtimes/-DEFAULT`. UI configuration always includes the selected AWS Region. - -## Test scope - -`30-test.sh --target TARGET` obtains user and administrator Cognito tokens and hard-fails on health/readiness, unauthenticated access, authenticated invocation, persona, conversation memory, PgVector-backed RAG using an exact dynamic retrieval marker, a representative date/time tool call, and MCP Unicorn inventory checks. Knowledge loading is restricted to the `admin` Cognito user and bounded to 4,096 characters; normal assertions use dynamic markers and broad capability evidence rather than exact model prose. - -## Cleanup safety - -`99-cleanup.sh` is plan-only by default. `99-cleanup.sh --apply` removes only suite-created resources and data or restores settings that the suite recorded before modifying. 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. The Lambda ZIP object is restored when it predated the suite and removed otherwise; the deterministic sample Unicorn is removed only when this suite created it. - -Use `90-diagnose.sh` for read-only status collection before changing or cleaning up resources. - - -## Live AWS verification still required - -The scripts are syntax-checked without invoking AWS or Kubernetes mutations. Before workshop use, run the stages in a disposable workshop account and verify the predeployed resource names and IAM permissions, EKS Pod Identity/Secrets Store CSI integration, internal MCP ALB reachability from every target, ECS Express Gateway update behavior, Java 25 Lambda Web Adapter layer availability, AgentCore-supported private Availability Zones, Runtime custom-JWT authorization, and CloudFront propagation. Also confirm Bedrock access to Claude Sonnet 4.6 and Titan Text Embeddings V2 in the selected Region. `30-test.sh` is the required live acceptance gate; a deployment is not considered successful until all of its health, authentication, persona, memory, RAG, tool, and MCP assertions pass. - -Run `90-diagnose.sh` for read-only discovery before a live deployment. Review `99-cleanup.sh` without arguments first; only `99-cleanup.sh --apply` performs the ownership-scoped cleanup plan. \ No newline at end of file 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 index 95a428b5..397cdd9a 100755 --- a/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh +++ b/infra/scripts/deploy/java-spring-ai-agents/_suite-lib.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -Eeuo pipefail -SUITE_NAME="java-spring-ai-agents-alternative" +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}" From f78c3b420a2ad84c4a9e03e936786a71e26e86d8 Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Tue, 25 Aug 2026 18:56:43 +0200 Subject: [PATCH 37/38] fix(java-spring-ai-agents): Refactor variable declaration in test invocation --- infra/scripts/deploy/java-spring-ai-agents/30-test.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/infra/scripts/deploy/java-spring-ai-agents/30-test.sh b/infra/scripts/deploy/java-spring-ai-agents/30-test.sh index cd9d0772..a44c80bc 100755 --- a/infra/scripts/deploy/java-spring-ai-agents/30-test.sh +++ b/infra/scripts/deploy/java-spring-ai-agents/30-test.sh @@ -49,7 +49,8 @@ log "Health and authentication checks passed" tmp_dir=$(mktemp -d "${WORK_DIR}/tests.XXXXXX") trap 'rm -rf "${tmp_dir}"' EXIT invoke() { - local name="$1" prompt="$2" output="${tmp_dir}/${name}.txt" + 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}" \ --data "$(jq -nc --arg prompt "${prompt}" '{prompt:$prompt}')" > "${output}" From d8d41a733d4f66be59ec4b61c191882b8ea4130a Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Tue, 25 Aug 2026 19:07:21 +0200 Subject: [PATCH 38/38] fix(java-spring-ai-agents): Enhance test invocation with session IDs and improved logging --- .../deploy/java-spring-ai-agents/30-test.sh | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/infra/scripts/deploy/java-spring-ai-agents/30-test.sh b/infra/scripts/deploy/java-spring-ai-agents/30-test.sh index a44c80bc..55124dca 100755 --- a/infra/scripts/deploy/java-spring-ai-agents/30-test.sh +++ b/infra/scripts/deploy/java-spring-ai-agents/30-test.sh @@ -41,10 +41,21 @@ else 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 @@ -53,6 +64,7 @@ invoke() { 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" @@ -62,31 +74,33 @@ invoke() { printf '%s' "${output}" } assert_matches() { - local file="$1" regex="$2" description="$3" - grep -Eiq "${regex}" "${file}" || die "${description} response lacked expected capability evidence" + 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 } -file=$(invoke persona "Briefly identify the company you assist and what service it provides.") +log "Testing persona: Who are you?" +file=$(invoke persona "Who are you?") assert_matches "${file}" 'unicorn|rental' "Persona" +log "Persona check passed" -# The database-backed chat advisor intentionally retains chat history. Use one stable -# marker per suite/account/Region and avoid adding another store turn when it is already -# retrievable; the recall checks themselves still add unavoidable chat-memory rows. -MEMORY_MARKER="memory-${SUITE_OWNER}-${ACCOUNT_ID}-${AWS_REGION}" -file=$(invoke memory_existing "What verification marker did I ask you to remember? Reply with the exact marker if known.") -if ! grep -Fqi -- "${MEMORY_MARKER}" "${file}"; then - invoke memory_store "Remember this verification marker for our conversation: ${MEMORY_MARKER}." >/dev/null - file=$(invoke memory_recall "What verification marker did I ask you to remember?") -fi -assert_matches "${file}" "${MEMORY_MARKER}" "Conversation memory" -log "Memory check uses a stable marker; chat prompts/responses remain retained by the workshop memory store." +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 @@ -98,13 +112,18 @@ 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."