From a1609e3a298ce449a608b623aacaf6138db3c1f1 Mon Sep 17 00:00:00 2001 From: Thomas Poepping Date: Fri, 7 Oct 2022 14:34:51 -0700 Subject: [PATCH] Fix bug attaching multiple CapacityProviders in multiple EC2 services. (#359) * Fix bug attaching multiple CapacityProviders in multiple EC2 services. When launching multiple EC2 services the current tenant-onboarding-app CloudFormation stack tries to create a ClusterCapacityAssociations CloudFormation resource for each service. However because attaching CapacityProviders to ECS Clusters is non-atomic only the first ClusterCapacityAssociations will actually succeed. This commit adds a CustomResource which on CapacityProvider create and delete will attach or detach that CapacityProvider to the existing ECSCluster. Because this attachment is a non-atomic operation (i.e. it involves first getting the existing capacityProviders for the ECS cluster before then overwriting with a new set of capacityProviders) this commit also includes using the existing Onboarding DynamoDB table as a locking mechanism to prevent against illogical overwrites between multiple stacks operating on the same ECS Cluster. Fixes #356. * Fix null pointer in cloudformation-utils Co-authored-by: PoeppingT Co-authored-by: brtrvn <38959183+brtrvn@users.noreply.github.com> --- .../saasboost/CloudFormationResponse.java | 3 +- .../attach-ecs-capacity-provider/pom.xml | 134 +++++++++++++++ .../AttachCapacityProviderRequestHandler.java | 154 ++++++++++++++++++ .../saasboost/AttachEcsCapacityProvider.java | 76 +++++++++ .../saasboost/CapacityProviderLock.java | 128 +++++++++++++++ .../saasfactory/saasboost/HandleResult.java | 53 ++++++ .../saasfactory/saasboost/RequestContext.java | 83 ++++++++++ .../src/main/resources/lambda-assembly.xml | 42 +++++ .../src/main/resources/log4j2.xml | 33 ++++ ...EcsCapacityProviderRequestHandlerTest.java | 145 +++++++++++++++++ .../saasboost/CapacityProviderLockTest.java | 134 +++++++++++++++ .../src/test/resources/template.json | 12 ++ .../attach-ecs-capacity-provider/update.sh | 56 +++++++ resources/custom-resources/pom.xml | 5 +- resources/saas-boost-core.yaml | 72 ++++++++ resources/saas-boost-svc-onboarding.yaml | 1 + resources/tenant-onboarding-app.yaml | 21 +-- .../saasfactory/saasboost/Onboarding.java | 9 + .../saasboost/OnboardingService.java | 4 + .../saasboost/OnboardingServiceDAL.java | 4 + .../saasboost/OnboardingServiceDALTest.java | 2 + 21 files changed, 1158 insertions(+), 13 deletions(-) create mode 100644 resources/custom-resources/attach-ecs-capacity-provider/pom.xml create mode 100644 resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/AttachCapacityProviderRequestHandler.java create mode 100644 resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/AttachEcsCapacityProvider.java create mode 100644 resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/CapacityProviderLock.java create mode 100644 resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/HandleResult.java create mode 100644 resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/RequestContext.java create mode 100644 resources/custom-resources/attach-ecs-capacity-provider/src/main/resources/lambda-assembly.xml create mode 100644 resources/custom-resources/attach-ecs-capacity-provider/src/main/resources/log4j2.xml create mode 100644 resources/custom-resources/attach-ecs-capacity-provider/src/test/java/com/amazon/aws/partners/saasfactory/saasboost/AttachEcsCapacityProviderRequestHandlerTest.java create mode 100644 resources/custom-resources/attach-ecs-capacity-provider/src/test/java/com/amazon/aws/partners/saasfactory/saasboost/CapacityProviderLockTest.java create mode 100644 resources/custom-resources/attach-ecs-capacity-provider/src/test/resources/template.json create mode 100755 resources/custom-resources/attach-ecs-capacity-provider/update.sh diff --git a/layers/cloudformation-utils/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/CloudFormationResponse.java b/layers/cloudformation-utils/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/CloudFormationResponse.java index d2f5a328..5c542622 100644 --- a/layers/cloudformation-utils/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/CloudFormationResponse.java +++ b/layers/cloudformation-utils/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/CloudFormationResponse.java @@ -32,6 +32,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.function.Function; public class CloudFormationResponse { @@ -123,7 +124,7 @@ protected static String buildResponseBody(Map event, Context con responseBody.put("Data", responseData != null ? responseData : Collections.EMPTY_MAP); } else { // CloudFormation will blow up if the failure response string is longer than 256 chars - String error = (String) responseData.getOrDefault("Reason", ""); + String error = Objects.toString(responseData.getOrDefault("Reason", ""), ""); if (error.length() > 256) { error = error.substring(0, 256); } diff --git a/resources/custom-resources/attach-ecs-capacity-provider/pom.xml b/resources/custom-resources/attach-ecs-capacity-provider/pom.xml new file mode 100644 index 00000000..d7be7275 --- /dev/null +++ b/resources/custom-resources/attach-ecs-capacity-provider/pom.xml @@ -0,0 +1,134 @@ + + + + 4.0.0 + + com.amazon.aws.partners.saasfactory.saasboost + saasboost-custom-resources + 1.0.0 + + AttachEcsCapacityProvider + 1.0.0 + jar + + + Apache-2.0 + http://www.apache.org/licenses/LICENSE-2.0 + + + + 0 + + + + ${project.artifactId} + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.apache.maven.plugins + maven-assembly-plugin + + + pl.project13.maven + git-commit-id-plugin + 4.0.0 + + + get-the-git-infos + + revision + + initialize + + + + true + ${project.build.outputDirectory}/git.properties + + ^git.commit.id.describe + ^git.commit.id.describe-short + ^git.commit.time + ^git.closest.tag.name + + full + ../../.git + false + + + + + + + + com.amazon.aws.partners.saasfactory.saasboost + Utils + 1.0.0 + + provided + + + com.amazon.aws.partners.saasfactory.saasboost + CloudFormationUtils + 1.0.0 + + provided + + + org.mockito + mockito-core + + + software.amazon.awssdk + ecs + ${aws.java.sdk.version} + + + software.amazon.awssdk + netty-nio-client + + + software.amazon.awssdk + apache-client + + + + + software.amazon.awssdk + dynamodb + ${aws.java.sdk.version} + + + software.amazon.awssdk + netty-nio-client + + + software.amazon.awssdk + apache-client + + + + + + diff --git a/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/AttachCapacityProviderRequestHandler.java b/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/AttachCapacityProviderRequestHandler.java new file mode 100644 index 00000000..c4ab216d --- /dev/null +++ b/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/AttachCapacityProviderRequestHandler.java @@ -0,0 +1,154 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.amazon.aws.partners.saasfactory.saasboost; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.ecs.EcsClient; +import software.amazon.awssdk.services.ecs.model.Cluster; +import software.amazon.awssdk.services.ecs.model.ClusterNotFoundException; +import software.amazon.awssdk.services.ecs.model.DescribeClustersRequest; +import software.amazon.awssdk.services.ecs.model.PutClusterCapacityProvidersRequest; +import software.amazon.awssdk.services.ecs.model.UpdateInProgressException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.function.Function; +import java.util.stream.Collectors; + +public final class AttachCapacityProviderRequestHandler implements Callable { + + private static final Logger LOGGER = LoggerFactory.getLogger(AttachCapacityProviderRequestHandler.class); + + private final RequestContext requestContext; + private final CapacityProviderLock lock; + private final EcsClient ecs; + + public AttachCapacityProviderRequestHandler( + RequestContext requestContext, + CapacityProviderLock lock, + EcsClient ecs) { + this.ecs = ecs; + this.requestContext = requestContext; + this.lock = lock; + } + + @Override + public HandleResult call() { + HandleResult result = new HandleResult(); + LOGGER.info(requestContext.requestType.toUpperCase()); + if ("Create".equalsIgnoreCase(requestContext.requestType) + || "Update".equalsIgnoreCase(requestContext.requestType)) { + LOGGER.info("Attaching capacity provider {} to ecs cluster {} for tenant {}", + requestContext.capacityProvider, requestContext.ecsCluster, requestContext.tenantId); + result = atomicallyUpdateCapacityProviders((capacityProviders) -> { + if (!capacityProviders.contains(requestContext.capacityProvider)) { + List modifiedCapacityProviders = new ArrayList(capacityProviders); + modifiedCapacityProviders.add(requestContext.capacityProvider); + return modifiedCapacityProviders; + } + return capacityProviders; + }); + } else if ("Delete".equalsIgnoreCase(requestContext.requestType)) { + // unclear whether we need this.. commenting it out for testing. + LOGGER.info("Detaching capacity provider {} from ecs cluster {} for tenant {}", + requestContext.capacityProvider, requestContext.ecsCluster, requestContext.tenantId); + result = atomicallyUpdateCapacityProviders((capacityProviders) -> { + return capacityProviders.stream() + .filter((capacityProvider) -> !capacityProvider.equals(requestContext.capacityProvider)) + .collect(Collectors.toList()); + }); + result.setSucceeded(); + } else { + LOGGER.error("FAILED unknown requestType {}", requestContext.requestType); + result.putFailureReason("Unknown RequestType " + requestContext.requestType); + result.setFailed(); + } + + return result; + } + + private HandleResult atomicallyUpdateCapacityProviders( + Function, List> capacityProvidersMutationFunction) { + HandleResult result = new HandleResult(); + // lock ddb + lock.lock(requestContext); + try { + // read capacity providers into list + List existingCapacityProviders = getExistingCapacityProviders(); + + List mutatedCapacityProviders = capacityProvidersMutationFunction.apply(existingCapacityProviders); + LOGGER.debug("existingCapacityProviders {} mutated to {}", + existingCapacityProviders, mutatedCapacityProviders); + + boolean successful = false; + // if the mutate did nothing, no point in slowing us down to make an ECS call + if (existingCapacityProviders.equals(mutatedCapacityProviders)) { + successful = true; + result.setSucceeded(); + } + while (!successful) { + try { + // set capacity providers. response doesn't really give us anything but a + // description of the new cluster. exceptions are thrown on failure + ecs.putClusterCapacityProviders(PutClusterCapacityProvidersRequest.builder() + .cluster(requestContext.ecsCluster) + .capacityProviders(mutatedCapacityProviders) + .build()); + successful = true; + result.setSucceeded(); + } catch (UpdateInProgressException uipe) { + // There's a Amazon ECS container agent update in progress on this container instance. + // ECS errors indicate this can be retried. Wait 10 seconds and try again. + LOGGER.error("Received error calling putClusterCapacityProviders", uipe); + LOGGER.error(Utils.getFullStackTrace(uipe)); + LOGGER.error("Waiting 10 seconds before retrying.."); + Thread.sleep(10 * 1000); // 10 seconds + } + } + } catch (ClusterNotFoundException cnfe) { + LOGGER.error("Could not find ecs cluster: {}", requestContext.ecsCluster); + LOGGER.error(Utils.getFullStackTrace(cnfe)); + result.putFailureReason(cnfe.getMessage()); + result.setFailed(); + } catch (InterruptedException ie) { + LOGGER.error("Error while waiting between putClusterCapacityProvider calls", ie.getMessage()); + LOGGER.error(Utils.getFullStackTrace(ie)); + result.putFailureReason(ie.getMessage()); + result.setFailed(); + } finally { + // unlock ddb + lock.unlock(requestContext); + } + return result; + } + + private List getExistingCapacityProviders() { + List returnedClusters = ecs.describeClusters( + DescribeClustersRequest.builder().clusters(requestContext.ecsCluster).build()).clusters(); + if (returnedClusters.size() != 1) { + // we only passed one cluster ARN but we received 0 or 2 + LOGGER.error("Expected 1 cluster with name {} but found {}", + requestContext.ecsCluster, returnedClusters.size()); + } + List existingCapacityProviders = returnedClusters.get(0).capacityProviders(); + return existingCapacityProviders == null ? List.of() : existingCapacityProviders; + } + +} diff --git a/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/AttachEcsCapacityProvider.java b/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/AttachEcsCapacityProvider.java new file mode 100644 index 00000000..0bf21135 --- /dev/null +++ b/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/AttachEcsCapacityProvider.java @@ -0,0 +1,76 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.amazon.aws.partners.saasfactory.saasboost; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.ecs.EcsClient; + +import java.util.*; +import java.util.concurrent.*; + +public class AttachEcsCapacityProvider implements RequestHandler, Object> { + + private static final Logger LOGGER = LoggerFactory.getLogger(AttachEcsCapacityProvider.class); + + private final EcsClient ecs; + private final CapacityProviderLock lock; + + public AttachEcsCapacityProvider() { + LOGGER.info("Version Info: {}", Utils.version(this.getClass())); + ecs = Utils.sdkClient(EcsClient.builder(), EcsClient.SERVICE_NAME); + lock = new CapacityProviderLock(Utils.sdkClient(DynamoDbClient.builder(), DynamoDbClient.SERVICE_NAME)); + } + + @Override + public Object handleRequest(Map event, Context context) { + Utils.logRequestEvent(event); + + Map resourceProperties = (Map) event.get("ResourceProperties"); + RequestContext requestContext = RequestContext.builder() + .requestType((String) event.get("RequestType")) + .capacityProvider((String) resourceProperties.get("CapacityProvider")) + .ecsCluster((String) resourceProperties.get("ECSCluster")) + .onboardingDdbTable((String) resourceProperties.get("OnboardingDdbTable")) + .tenantId((String) resourceProperties.get("TenantId")) + .build(); + HandleResult handleRequestResult = new HandleResult(); + ExecutorService service = Executors.newSingleThreadExecutor(); + try { + Callable c = new AttachCapacityProviderRequestHandler(requestContext, lock, ecs); + Future f = service.submit(c); + handleRequestResult = (HandleResult) f.get(context.getRemainingTimeInMillis() - 1000, + TimeUnit.MILLISECONDS); + } catch (final TimeoutException | InterruptedException | ExecutionException e) { + // Timed out + LOGGER.error("FAILED unexpected error or request timed out " + e.getMessage()); + String stackTrace = Utils.getFullStackTrace(e); + LOGGER.error(stackTrace); + handleRequestResult.setFailed(); + handleRequestResult.putResponseData("Reason", stackTrace); + } finally { + service.shutdown(); + } + CloudFormationResponse.send(event, context, + handleRequestResult.succeeded() ? "SUCCESS" : "FAILED", + handleRequestResult.getResponseData()); + return null; + } +} \ No newline at end of file diff --git a/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/CapacityProviderLock.java b/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/CapacityProviderLock.java new file mode 100644 index 00000000..f68bb3b7 --- /dev/null +++ b/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/CapacityProviderLock.java @@ -0,0 +1,128 @@ +package com.amazon.aws.partners.saasfactory.saasboost; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; +import software.amazon.awssdk.services.dynamodb.model.DynamoDbException; +import software.amazon.awssdk.services.dynamodb.model.ScanRequest; +import software.amazon.awssdk.services.dynamodb.model.ScanResponse; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +import java.util.Map; + +public class CapacityProviderLock { + private static final Logger LOGGER = LoggerFactory.getLogger(CapacityProviderLock.class); + + private final DynamoDbClient ddb; + private AttributeValue onboardingId = null; + + public CapacityProviderLock(DynamoDbClient ddb) { + this.ddb = ddb; + } + + /** + * Locks the distributed lock for reading/writing CapacityProviders. + * + * This function blocks indefinitely until the operation is successful, relying on outside + * timeouts to prevent us from actually blocking forever. + */ + public void lock(RequestContext requestContext) { + boolean locked = false; + while (!locked) { + locked = tryLockUnlock(requestContext, true); + if (!locked) { + // self-throttle so we don't blow up DDB trying to attain the lock + try { + Thread.sleep(5 * 1000); // 5 seconds + } catch (InterruptedException ie) { + // do nothing, keep trying + } + } + } + } + + /** + * Unlocks the distributed lock for reading/writing CapacityProviders. + * + * This function blocks indefinitely until the operation is successful, relying on outside + * timeouts to prevent us from actually blocking forever. We don't allow unlocking an unlocked + * lock, since it is an invalid operation: we should only be unlocking after our own lock. + */ + public void unlock(RequestContext requestContext) { + boolean success = false; + while (!success) { + success = tryLockUnlock(requestContext, false); + if (!success) { + // self-throttle so we don't blow up DDB trying to relinquish the lock + try { + Thread.sleep(5 * 1000); // 5 seconds + } catch (InterruptedException ie) { + // do nothing, keep trying + } + } + } + } + + // VisibleForTesting + protected AttributeValue currentOnboardingId(RequestContext requestContext) { + if (onboardingId == null) { + try { + ScanRequest scanRequest = ScanRequest.builder() + .tableName(requestContext.onboardingDdbTable) + .filterExpression("tenant_id = :tenantid") + .expressionAttributeValues((Map) Map.of( + ":tenantid", AttributeValue.builder().s(requestContext.tenantId).build())) + .build(); + LOGGER.debug("sending scan with ScanRequest {}", scanRequest); + ScanResponse scanResponse = ddb.scan(ScanRequest.builder() + .tableName(requestContext.onboardingDdbTable) + .filterExpression("tenant_id = :tenantid") + .expressionAttributeValues((Map) Map.of( + ":tenantid", AttributeValue.builder().s(requestContext.tenantId).build())) + .build()); + this.onboardingId = scanResponse.items().get(0).get("id"); + } catch (DynamoDbException ddbe) { + LOGGER.error("Error trying to scan for current onboarding id: {}", ddbe.getMessage()); + LOGGER.error(Utils.getFullStackTrace(ddbe)); + throw new RuntimeException(ddbe); + } + } + return this.onboardingId; + } + + // VisibleForTesting + protected boolean tryLockUnlock(RequestContext requestContext, boolean tryLock) { + try { + UpdateItemRequest updateItemRequest = UpdateItemRequest.builder() + .tableName(requestContext.onboardingDdbTable) + .key(Map.of("id", currentOnboardingId(requestContext))) + .conditionExpression("ecs_cluster_locked = :lock_expected") + .updateExpression("SET ecs_cluster_locked = :new_lock") + .expressionAttributeValues(Map.of( + ":lock_expected", AttributeValue.builder().bool(!tryLock).build(), + ":new_lock", AttributeValue.builder().bool(tryLock).build())) + .build(); + LOGGER.debug("trying to {} with updateItemRequest {}", tryLock ? "lock" : "unlock", updateItemRequest); + ddb.updateItem(UpdateItemRequest.builder() + .tableName(requestContext.onboardingDdbTable) + .key(Map.of("id", currentOnboardingId(requestContext))) + .conditionExpression("ecs_cluster_locked = :lock_expected") + .updateExpression("SET ecs_cluster_locked = :new_lock") + .expressionAttributeValues(Map.of( + ":lock_expected", AttributeValue.builder().bool(!tryLock).build(), + ":new_lock", AttributeValue.builder().bool(tryLock).build())) + .build()); + } catch (ConditionalCheckFailedException ccfe) { + LOGGER.error("Could not {} ecs_cluster_locked, conditional check failed: {}", + tryLock ? "lock" : "unlock", ccfe.getMessage()); + return false; + } catch (DynamoDbException ddbe) { + LOGGER.error("Error trying to update lock for current onboarding id: {}", ddbe.getMessage()); + LOGGER.error(Utils.getFullStackTrace(ddbe)); + throw new RuntimeException(ddbe); + } + return true; + } +} diff --git a/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/HandleResult.java b/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/HandleResult.java new file mode 100644 index 00000000..f2dd9de3 --- /dev/null +++ b/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/HandleResult.java @@ -0,0 +1,53 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.amazon.aws.partners.saasfactory.saasboost; + +import java.util.HashMap; +import java.util.Map; + +public class HandleResult { + private boolean success = false; + private Map responseData = new HashMap(); + + public void setSucceeded() { + this.success = true; + } + + public void setFailed() { + this.success = false; + } + + public void setResponseData(Map responseData) { + this.responseData = responseData; + } + + public void putResponseData(String key, Object value) { + this.responseData.put(key, value); + } + + public void putFailureReason(String reason) { + putResponseData("Reason", reason); + } + + public boolean succeeded() { + return this.success; + } + + public Map getResponseData() { + return this.responseData; + } +} \ No newline at end of file diff --git a/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/RequestContext.java b/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/RequestContext.java new file mode 100644 index 00000000..5c855109 --- /dev/null +++ b/resources/custom-resources/attach-ecs-capacity-provider/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/RequestContext.java @@ -0,0 +1,83 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.amazon.aws.partners.saasfactory.saasboost; + +public final class RequestContext { + public final String requestType; + public final String ecsCluster; + public final String onboardingDdbTable; + public final String capacityProvider; + public final String tenantId; + + private RequestContext(Builder b) { + this.requestType = b.requestType; + this.ecsCluster = b.ecsCluster; + this.onboardingDdbTable = b.onboardingDdbTable; + this.capacityProvider = b.capacityProvider; + this.tenantId = b.tenantId; + } + + public static Builder builder() { + return new Builder(); + } + + public static Builder builder(RequestContext requestContext) { + return new Builder() + .requestType(requestContext.requestType) + .ecsCluster(requestContext.ecsCluster) + .onboardingDdbTable(requestContext.onboardingDdbTable) + .capacityProvider(requestContext.capacityProvider) + .tenantId(requestContext.tenantId); + } + + public static class Builder { + private String requestType; + private String ecsCluster; + private String onboardingDdbTable; + private String capacityProvider; + private String tenantId; + + public Builder requestType(String requestType) { + this.requestType = requestType; + return this; + } + + public Builder ecsCluster(String ecsCluster) { + this.ecsCluster = ecsCluster; + return this; + } + + public Builder onboardingDdbTable(String onboardingDdbTable) { + this.onboardingDdbTable = onboardingDdbTable; + return this; + } + + public Builder capacityProvider(String capacityProvider) { + this.capacityProvider = capacityProvider; + return this; + } + + public Builder tenantId(String tenantId) { + this.tenantId = tenantId; + return this; + } + + public RequestContext build() { + return new RequestContext(this); + } + } +} diff --git a/resources/custom-resources/attach-ecs-capacity-provider/src/main/resources/lambda-assembly.xml b/resources/custom-resources/attach-ecs-capacity-provider/src/main/resources/lambda-assembly.xml new file mode 100644 index 00000000..26364854 --- /dev/null +++ b/resources/custom-resources/attach-ecs-capacity-provider/src/main/resources/lambda-assembly.xml @@ -0,0 +1,42 @@ + + + + lambda + + zip + + false + + + + ${project.build.outputDirectory} + + com/amazon/aws/partners/saasfactory/** + log4j2.xml + git.properties + + + + + + false + true + lib + + + \ No newline at end of file diff --git a/resources/custom-resources/attach-ecs-capacity-provider/src/main/resources/log4j2.xml b/resources/custom-resources/attach-ecs-capacity-provider/src/main/resources/log4j2.xml new file mode 100644 index 00000000..04128110 --- /dev/null +++ b/resources/custom-resources/attach-ecs-capacity-provider/src/main/resources/log4j2.xml @@ -0,0 +1,33 @@ + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %X{AWSRequestId} %-5p %C{1} - %m%n + + + + + + + + + + + + diff --git a/resources/custom-resources/attach-ecs-capacity-provider/src/test/java/com/amazon/aws/partners/saasfactory/saasboost/AttachEcsCapacityProviderRequestHandlerTest.java b/resources/custom-resources/attach-ecs-capacity-provider/src/test/java/com/amazon/aws/partners/saasfactory/saasboost/AttachEcsCapacityProviderRequestHandlerTest.java new file mode 100644 index 00000000..8e32ca6a --- /dev/null +++ b/resources/custom-resources/attach-ecs-capacity-provider/src/test/java/com/amazon/aws/partners/saasfactory/saasboost/AttachEcsCapacityProviderRequestHandlerTest.java @@ -0,0 +1,145 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.amazon.aws.partners.saasfactory.saasboost; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.services.ecs.EcsClient; +import software.amazon.awssdk.services.ecs.model.Cluster; +import software.amazon.awssdk.services.ecs.model.DescribeClustersRequest; +import software.amazon.awssdk.services.ecs.model.DescribeClustersResponse; +import software.amazon.awssdk.services.ecs.model.PutClusterCapacityProvidersRequest; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class AttachEcsCapacityProviderRequestHandlerTest { + + private static final String CAPACITY_PROVIDER_1 = "capacityProvider1"; + private static final String CAPACITY_PROVIDER_2 = "capacityProvider2"; + private static final String NEW_CAPACITY_PROVIDER = "capacityProvider3"; + private static final List EXISTING_PROVIDERS = List.of(CAPACITY_PROVIDER_1, CAPACITY_PROVIDER_2); + private static final RequestContext BASE_REQUEST_CONTEXT = RequestContext.builder() + .requestType("Create") + .capacityProvider(NEW_CAPACITY_PROVIDER) + .ecsCluster("ecsCluster") + .onboardingDdbTable("onboardingDdbTable") + .tenantId("tenant-123-456") + .build(); + private static final ArgumentCaptor putRequestCaptor = + ArgumentCaptor.forClass(PutClusterCapacityProvidersRequest.class); + + CapacityProviderLock mockLock = mock(CapacityProviderLock.class); // already no-op? + EcsClient mockEcs = mock(EcsClient.class); + + @Before + public void setup() { + // when you describe clusters to look for capacity providers in ECS you find EXISTING_PROVIDERS + doReturn(DescribeClustersResponse.builder() + .clusters(Cluster.builder().capacityProviders(EXISTING_PROVIDERS).build()) + .build()).when(mockEcs).describeClusters(any(DescribeClustersRequest.class)); + // right now response is ignored + doReturn(null).when(mockEcs).putClusterCapacityProviders(putRequestCaptor.capture()); + } + + @Test + public void testCall_basicCreate() { + AttachCapacityProviderRequestHandler testHandler = new AttachCapacityProviderRequestHandler( + BASE_REQUEST_CONTEXT, mockLock, mockEcs); + List expectedProviders = new ArrayList<>(EXISTING_PROVIDERS); + expectedProviders.add(NEW_CAPACITY_PROVIDER); + testCall(testHandler, true, expectedProviders); + } + + @Test + public void testCall_basicUpdate() { + AttachCapacityProviderRequestHandler testHandler = new AttachCapacityProviderRequestHandler( + RequestContext.builder(BASE_REQUEST_CONTEXT).requestType("Update").build(), mockLock, mockEcs); + List expectedProviders = new ArrayList<>(EXISTING_PROVIDERS); + expectedProviders.add(NEW_CAPACITY_PROVIDER); + testCall(testHandler, true, expectedProviders); + } + + @Test + public void testCall_basicDelete() { + AttachCapacityProviderRequestHandler testHandler = new AttachCapacityProviderRequestHandler( + RequestContext.builder(BASE_REQUEST_CONTEXT) + .requestType("Delete") + .capacityProvider(CAPACITY_PROVIDER_1) + .build(), mockLock, mockEcs); + List expectedProviders = new ArrayList<>(EXISTING_PROVIDERS); + expectedProviders.remove(CAPACITY_PROVIDER_1); + testCall(testHandler, true, expectedProviders); + } + + @Test + public void testCall_addExistingCapacityProvider() { + AttachCapacityProviderRequestHandler testHandler = new AttachCapacityProviderRequestHandler( + RequestContext.builder(BASE_REQUEST_CONTEXT) + .requestType("Create") + .capacityProvider(CAPACITY_PROVIDER_1) + .build(), mockLock, mockEcs); + // pass null for expected capacity providers to indicate we shouldn't make a call to ECS + testCall(testHandler, true, null); + } + + @Test + public void testCall_removeNonExistingCapacityProvider() { + AttachCapacityProviderRequestHandler testHandler = new AttachCapacityProviderRequestHandler( + RequestContext.builder(BASE_REQUEST_CONTEXT) + .requestType("Delete") + .capacityProvider(NEW_CAPACITY_PROVIDER) + .build(), mockLock, mockEcs); + List expectedProviders = new ArrayList<>(EXISTING_PROVIDERS); + expectedProviders.remove(CAPACITY_PROVIDER_1); + testCall(testHandler, true, null); + } + + @Test + public void testCall_unknownRequestType() { + AttachCapacityProviderRequestHandler testHandler = new AttachCapacityProviderRequestHandler( + RequestContext.builder(BASE_REQUEST_CONTEXT) + .requestType("UNKNOWN") + .capacityProvider(CAPACITY_PROVIDER_1) + .build(), mockLock, mockEcs); + testCall(testHandler, false, null); + } + + private void testCall(AttachCapacityProviderRequestHandler handler, + boolean expectSuccess, List expectedPassedCapacityProviders) { + // start start + HandleResult result = handler.call(); + assertEquals(expectSuccess, result.succeeded()); + if (expectSuccess) { + verify(mockLock, times(1)).lock(any(RequestContext.class)); + verify(mockLock, times(1)).unlock(any(RequestContext.class)); + if (expectedPassedCapacityProviders != null) { + assertEquals(expectedPassedCapacityProviders, putRequestCaptor.getValue().capacityProviders()); + } else { + verify(mockEcs, times(0)).putClusterCapacityProviders(any(PutClusterCapacityProvidersRequest.class)); + } + } + } +} \ No newline at end of file diff --git a/resources/custom-resources/attach-ecs-capacity-provider/src/test/java/com/amazon/aws/partners/saasfactory/saasboost/CapacityProviderLockTest.java b/resources/custom-resources/attach-ecs-capacity-provider/src/test/java/com/amazon/aws/partners/saasfactory/saasboost/CapacityProviderLockTest.java new file mode 100644 index 00000000..9dadc5a2 --- /dev/null +++ b/resources/custom-resources/attach-ecs-capacity-provider/src/test/java/com/amazon/aws/partners/saasfactory/saasboost/CapacityProviderLockTest.java @@ -0,0 +1,134 @@ +package com.amazon.aws.partners.saasfactory.saasboost; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; +import software.amazon.awssdk.services.dynamodb.model.InternalServerErrorException; +import software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException; +import software.amazon.awssdk.services.dynamodb.model.ScanRequest; +import software.amazon.awssdk.services.dynamodb.model.ScanResponse; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemResponse; + +public final class CapacityProviderLockTest { + private static final String ONBOARDING_DDB_TABLE = "onboarding"; + private static final String TENANT_ID = "123-456"; + private static final String ONBOARDING_ID = "onb-123-456"; + private static final RequestContext TEST_CONTEXT = RequestContext.builder() + .requestType("Create") + .ecsCluster("ecsCluster") + .onboardingDdbTable(ONBOARDING_DDB_TABLE) + .capacityProvider("capacityProvider") + .tenantId(TENANT_ID) + .build(); + + private DynamoDbClient mockDdb; + private CapacityProviderLock testLock; + + @Before + public void setup() { + mockDdb = mock(DynamoDbClient.class); + testLock = new CapacityProviderLock(mockDdb); + } + + /** + * trylock something already locked + * tryunlock something not locked + * trylock happy case + * tryunlock happy case + * verify each test does a scan + * verify each test does an update + * verify a conditional update fail means false + */ + + @Test + public void getOnboardingId_basic() { + final ArgumentCaptor scanCaptor = ArgumentCaptor.forClass(ScanRequest.class); + final AttributeValue onboardingId = AttributeValue.builder().s("onb-123-456").build(); + final AttributeValue tenantIdAttributeValue = AttributeValue.builder().s(TENANT_ID).build(); + doReturn(ScanResponse.builder().items(List.of(Map.of("id", onboardingId))).build()) + .when(mockDdb).scan(scanCaptor.capture()); + AttributeValue foundOnboardingId = testLock.currentOnboardingId(TEST_CONTEXT); + assertEquals(onboardingId, foundOnboardingId); + assertTrue("scan for onboarding ID should include the tenant id passed in request context", + scanCaptor.getValue().expressionAttributeValues().values().contains(tenantIdAttributeValue)); + + doReturn(ScanResponse.builder().build()).when(mockDdb).scan(any(ScanRequest.class)); + // assert that we cache onboardingId, since it should not change for the lifetime of the lambda + assertEquals(onboardingId, testLock.currentOnboardingId(TEST_CONTEXT)); + verify(mockDdb, times(1)).scan(any(ScanRequest.class)); + } + + @Test(expected = RuntimeException.class) + public void getOnboardingId_scanFailure() { + doThrow(ResourceNotFoundException.builder().build()).when(mockDdb).scan(any(ScanRequest.class)); + testLock.currentOnboardingId(TEST_CONTEXT); + } + + @Test + public void tryLockUnlock_basic() { + final AttributeValue onboardingId = AttributeValue.builder().s(ONBOARDING_ID).build(); + doReturn(ScanResponse.builder().items(List.of(Map.of("id", onboardingId))).build()) + .when(mockDdb).scan(any(ScanRequest.class)); + + final ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(UpdateItemRequest.class); + doReturn(UpdateItemResponse.builder().build()).when(mockDdb).updateItem(updateCaptor.capture()); + + boolean success = testLock.tryLockUnlock(TEST_CONTEXT, true); + UpdateItemRequest actualRequest = updateCaptor.getValue(); + assertEquals(onboardingId, actualRequest.key().get("id")); + assertEquals("ecs_cluster_locked = :lock_expected", actualRequest.conditionExpression()); + assertEquals("SET ecs_cluster_locked = :new_lock", actualRequest.updateExpression()); + assertEquals(AttributeValue.builder().bool(false).build(), actualRequest.expressionAttributeValues().get(":lock_expected")); + assertEquals(AttributeValue.builder().bool(true).build(), actualRequest.expressionAttributeValues().get(":new_lock")); + assertTrue(success); + + success = testLock.tryLockUnlock(TEST_CONTEXT, false); + actualRequest = updateCaptor.getValue(); + assertEquals(onboardingId, actualRequest.key().get("id")); + assertEquals("ecs_cluster_locked = :lock_expected", actualRequest.conditionExpression()); + assertEquals("SET ecs_cluster_locked = :new_lock", actualRequest.updateExpression()); + assertEquals(AttributeValue.builder().bool(true).build(), actualRequest.expressionAttributeValues().get(":lock_expected")); + assertEquals(AttributeValue.builder().bool(false).build(), actualRequest.expressionAttributeValues().get(":new_lock")); + assertTrue(success); + } + + @Test + public void tryLockUnlock_conditionNotMet() { + final AttributeValue onboardingId = AttributeValue.builder().s(ONBOARDING_ID).build(); + doReturn(ScanResponse.builder().items(List.of(Map.of("id", onboardingId))).build()) + .when(mockDdb).scan(any(ScanRequest.class)); + + doThrow(ConditionalCheckFailedException.builder().build()).when(mockDdb).updateItem(any(UpdateItemRequest.class)); + + boolean success = testLock.tryLockUnlock(TEST_CONTEXT, true); + assertFalse(success); + } + + @Test(expected = RuntimeException.class) + public void tryLockUnlock_unexpectedException() { + final AttributeValue onboardingId = AttributeValue.builder().s(ONBOARDING_ID).build(); + doReturn(ScanResponse.builder().items(List.of(Map.of("id", onboardingId))).build()) + .when(mockDdb).scan(any(ScanRequest.class)); + + doThrow(InternalServerErrorException.builder().build()).when(mockDdb).updateItem(any(UpdateItemRequest.class)); + testLock.tryLockUnlock(TEST_CONTEXT, true); + } +} diff --git a/resources/custom-resources/attach-ecs-capacity-provider/src/test/resources/template.json b/resources/custom-resources/attach-ecs-capacity-provider/src/test/resources/template.json new file mode 100644 index 00000000..7d020875 --- /dev/null +++ b/resources/custom-resources/attach-ecs-capacity-provider/src/test/resources/template.json @@ -0,0 +1,12 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Resource Name Macro Test", + "Parameters": { + "ApplicationServices": { + "Type": "String", + "Default": "" + } + }, + "Resources": { + } +} \ No newline at end of file diff --git a/resources/custom-resources/attach-ecs-capacity-provider/update.sh b/resources/custom-resources/attach-ecs-capacity-provider/update.sh new file mode 100755 index 00000000..5dc47e80 --- /dev/null +++ b/resources/custom-resources/attach-ecs-capacity-provider/update.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +if [ -z $1 ]; then + echo "Usage: $0 [Lambda Folder]" + exit 2 +fi + +MY_AWS_REGION=$(aws configure list | grep region | awk '{print $2}') +echo "AWS Region = $MY_AWS_REGION" + +ENVIRONMENT=$1 +LAMBDA_STAGE_FOLDER=$2 +if [ -z $LAMBDA_STAGE_FOLDER ]; then + LAMBDA_STAGE_FOLDER="lambdas" +fi +LAMBDA_CODE=AttachEcsCapacityProvider-lambda.zip + +#set this for V2 AWS CLI to disable paging +export AWS_PAGER="" + +SAAS_BOOST_BUCKET=$(aws --region $MY_AWS_REGION ssm get-parameter --name "/saas-boost/${ENVIRONMENT}/SAAS_BOOST_BUCKET" --query 'Parameter.Value' --output text) +echo "SaaS Boost Bucket = $SAAS_BOOST_BUCKET" +if [ -z $SAAS_BOOST_BUCKET ]; then + echo "Can't find SAAS_BOOST_BUCKET in Parameter Store" + exit 1 +fi + +# Do a fresh build of the project +mvn +if [ $? -ne 0 ]; then + echo "Error building project" + exit 1 +fi + +# And copy it up to S3 +aws s3 cp target/$LAMBDA_CODE s3://$SAAS_BOOST_BUCKET/$LAMBDA_STAGE_FOLDER/ + +eval FUNCTIONS=\$\("aws --region $MY_AWS_REGION lambda list-functions --query 'Functions[?starts_with(FunctionName, \`sb-${ENVIRONMENT}-attach-capacity-provider\`)] | [].FunctionName' --output text"\) +FUNCTIONS=($FUNCTIONS) +for FX in "${FUNCTIONS[@]}"; do + printf "Updating function code for %s\n" $FX + aws lambda --region "$MY_AWS_REGION" update-function-code --function-name "$FX" --s3-bucket "$SAAS_BOOST_BUCKET" --s3-key $LAMBDA_STAGE_FOLDER/$LAMBDA_CODE +done diff --git a/resources/custom-resources/pom.xml b/resources/custom-resources/pom.xml index dd957e4b..3c206281 100644 --- a/resources/custom-resources/pom.xml +++ b/resources/custom-resources/pom.xml @@ -14,14 +14,15 @@ pom https://github.com/awslabs/aws-saas-boost + app-services-ecr-macro + attach-ecs-capacity-provider cidr-dynamodb clear-s3-bucket set-instance-protection fsx-dns-name rds-bootstrap rds-options - redshift-table - app-services-ecr-macro + redshift-table diff --git a/resources/saas-boost-core.yaml b/resources/saas-boost-core.yaml index 27743df9..cc4fc1ab 100644 --- a/resources/saas-boost-core.yaml +++ b/resources/saas-boost-core.yaml @@ -844,6 +844,78 @@ Resources: Value: !Ref Environment - Key: "BoostService" Value: "SetInstanceProtection" + AttachCapacityProviderExecRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub sb-${Environment}-attach-capacity-provider-${AWS::Region} + Path: '/' + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - sts:AssumeRole + Policies: + - PolicyName: !Sub sb-${Environment}-attach-capacity-provider-${AWS::Region} + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - logs:PutLogEvents + Resource: + - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:*:log-stream:* + - Effect: Allow + Action: + - logs:DescribeLogStreams + - logs:CreateLogStream + Resource: + - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:* + - Effect: Allow + Action: + - dynamodb:Scan + - dynamodb:UpdateItem + Resource: !Sub arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/sb-${Environment}-onboarding + - Effect: Allow + Action: + - ecs:DescribeClusters + - ecs:PutClusterCapacityProviders + Resource: !Sub arn:${AWS::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:cluster/* + AttachCapacityProviderLogs: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub /aws/lambda/sb-${Environment}-attach-capacity-provider + RetentionInDays: 30 + AttachCapacityProviderFunction: + Type: AWS::Lambda::Function + DependsOn: + - AttachCapacityProviderLogs + Properties: + FunctionName: !Sub sb-${Environment}-attach-capacity-provider + Role: !GetAtt AttachCapacityProviderExecRole.Arn + Runtime: java11 + Timeout: 870 + MemorySize: 640 + Handler: com.amazon.aws.partners.saasfactory.saasboost.AttachEcsCapacityProvider + Code: + S3Bucket: !Ref SaaSBoostBucket + S3Key: !Sub ${LambdaSourceFolder}/AttachEcsCapacityProvider-lambda.zip + Layers: + - !Ref SaaSBoostUtilsLayer + - !Ref CloudFormationUtilsLayer + Environment: + Variables: + JAVA_TOOL_OPTIONS: '-XX:+TieredCompilation -XX:TieredStopAtLevel=1' + Tags: + - Key: "Application" + Value: "SaaSBoost" + - Key: "Environment" + Value: !Ref Environment + - Key: "BoostService" + Value: "AttachEcsCapacityProvider" # Macro transform will add as many AWS::ECR::Repository resources as necessary # based on the length of the list of ApplicationServices passed as a parameter Outputs: diff --git a/resources/saas-boost-svc-onboarding.yaml b/resources/saas-boost-svc-onboarding.yaml index 9175a92b..a8bf037a 100644 --- a/resources/saas-boost-svc-onboarding.yaml +++ b/resources/saas-boost-svc-onboarding.yaml @@ -620,6 +620,7 @@ Resources: - lambda:InvokeFunction Resource: - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:sb-${Environment}-set-instance-protection + - !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:sb-${Environment}-attach-capacity-provider - Effect: Allow Action: - servicediscovery:CreatePrivateDnsNamespace diff --git a/resources/tenant-onboarding-app.yaml b/resources/tenant-onboarding-app.yaml index 5d4026ca..652e27e5 100644 --- a/resources/tenant-onboarding-app.yaml +++ b/resources/tenant-onboarding-app.yaml @@ -223,6 +223,9 @@ Parameters: Type: String AllowedValues: ['true', 'false'] Default: 'false' + OnboardingDdbTable: + Description: Internal DDB table used to store Onboarding Metadata + Type: String # These params are here to read the image values from the public SSM. Leave the defaults. WIN2022FULL: Type: AWS::SSM::Parameter::Value @@ -294,21 +297,19 @@ Resources: Value: !Ref TenantId - Key: Tier Value: !Ref Tier - ClusterCapacityProviderAssociations: - Type: AWS::ECS::ClusterCapacityProviderAssociations + AttachEcsCapacityProvider: + Type: Custom::CustomResource Condition: Ec2LaunchType Properties: - Cluster: !Ref ECSCluster - CapacityProviders: - - !Ref CapacityProvider - DefaultCapacityProviderStrategy: - - CapacityProvider: !Ref CapacityProvider - Base: 0 - Weight: 1 + ServiceToken: !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:sb-${Environment}-attach-capacity-provider + CapacityProvider: !Ref CapacityProvider + ECSCluster: !Ref ECSCluster + OnboardingDdbTable: !Ref OnboardingDdbTable + TenantId: !Ref TenantId CapacityProviderWaitHandle: Type: AWS::CloudFormation::WaitConditionHandle Condition: Ec2LaunchType - DependsOn: ClusterCapacityProviderAssociations + DependsOn: AttachEcsCapacityProvider ClusterCapacityAssociationWaitCondition: Type: AWS::CloudFormation::WaitCondition Properties: diff --git a/services/onboarding-service/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/Onboarding.java b/services/onboarding-service/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/Onboarding.java index b22a550d..987b30f7 100644 --- a/services/onboarding-service/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/Onboarding.java +++ b/services/onboarding-service/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/Onboarding.java @@ -32,6 +32,7 @@ public class Onboarding { private OnboardingRequest request; private List stacks = new ArrayList<>(); private String zipFile; + private boolean ecsClusterLocked; public Onboarding() { } @@ -106,6 +107,14 @@ public void addStack(OnboardingStack stack) { } } + public boolean isEcsClusterLocked() { + return ecsClusterLocked; + } + + public void setEcsClusterLocked(boolean locked) { + this.ecsClusterLocked = locked; + } + public boolean hasBaseStacks() { return !getStacks() .stream() diff --git a/services/onboarding-service/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/OnboardingService.java b/services/onboarding-service/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/OnboardingService.java index f51a127f..7f119ab3 100644 --- a/services/onboarding-service/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/OnboardingService.java +++ b/services/onboarding-service/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/OnboardingService.java @@ -66,6 +66,7 @@ public class OnboardingService { private static final String EVENT_SOURCE = "saas-boost"; private static final String SAAS_BOOST_ENV = System.getenv("SAAS_BOOST_ENV"); private static final String SAAS_BOOST_EVENT_BUS = System.getenv("SAAS_BOOST_EVENT_BUS"); + private static final String ONBOARDING_TABLE = System.getenv("ONBOARDING_TABLE"); private static final String API_GATEWAY_HOST = System.getenv("API_GATEWAY_HOST"); private static final String API_GATEWAY_STAGE = System.getenv("API_GATEWAY_STAGE"); private static final String API_TRUST_ROLE = System.getenv("API_TRUST_ROLE"); @@ -953,6 +954,9 @@ protected void handleOnboardingBaseProvisioned(Map event, Contex templateParameters.add(Parameter.builder().parameterKey("ContainerRepository").parameterValue(containerRepo).build()); templateParameters.add(Parameter.builder().parameterKey("ContainerRepositoryTag").parameterValue(imageTag).build()); templateParameters.add(Parameter.builder().parameterKey("ECSCluster").parameterValue(ecsCluster).build()); + templateParameters.add(Parameter.builder() + .parameterKey("OnboardingDdbTable") + .parameterValue(ONBOARDING_TABLE).build()); templateParameters.add(Parameter.builder().parameterKey("PubliclyAddressable").parameterValue(isPublic.toString()).build()); templateParameters.add(Parameter.builder().parameterKey("PublicPathRoute").parameterValue(pathPart).build()); templateParameters.add(Parameter.builder().parameterKey("PublicPathRulePriority").parameterValue(publicPathRulePriority.toString()).build()); diff --git a/services/onboarding-service/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/OnboardingServiceDAL.java b/services/onboarding-service/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/OnboardingServiceDAL.java index 741cb5f1..5dbb8011 100644 --- a/services/onboarding-service/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/OnboardingServiceDAL.java +++ b/services/onboarding-service/src/main/java/com/amazon/aws/partners/saasfactory/saasboost/OnboardingServiceDAL.java @@ -382,6 +382,7 @@ public static Map toAttributeValueMap(Onboarding onboard ).build() ); } + item.put("ecs_cluster_locked", AttributeValue.builder().bool(onboarding.isEcsClusterLocked()).build()); return item; } @@ -472,6 +473,9 @@ public static Onboarding fromAttributeValueMap(Map item) .collect(Collectors.toList()) ); } + if (item.containsKey("ecs_cluster_locked")) { + onboarding.setEcsClusterLocked(item.get("ecs_cluster_locked").bool()); + } } return onboarding; } diff --git a/services/onboarding-service/src/test/java/com/amazon/aws/partners/saasfactory/saasboost/OnboardingServiceDALTest.java b/services/onboarding-service/src/test/java/com/amazon/aws/partners/saasfactory/saasboost/OnboardingServiceDALTest.java index ca58c7c2..5b235898 100644 --- a/services/onboarding-service/src/test/java/com/amazon/aws/partners/saasfactory/saasboost/OnboardingServiceDALTest.java +++ b/services/onboarding-service/src/test/java/com/amazon/aws/partners/saasfactory/saasboost/OnboardingServiceDALTest.java @@ -55,6 +55,7 @@ public void testToAttributeValueMap() { onboarding.setRequest(new OnboardingRequest("Unit Test", "default")); onboarding.setStacks(stacks); onboarding.setZipFile("foobar"); + onboarding.setEcsClusterLocked(false); Map expected = new HashMap<>(); expected.put("id", AttributeValue.builder().s(onboardingId.toString()).build()); @@ -74,6 +75,7 @@ public void testToAttributeValueMap() { )).build()) .collect(Collectors.toList()) ).build()); + expected.put("ecs_cluster_locked", AttributeValue.builder().bool(false).build()); Map actual = OnboardingServiceDAL.toAttributeValueMap(onboarding);