diff --git a/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/converters/EcsCreateServerGroupAtomicOperationConverter.java b/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/converters/EcsCreateServerGroupAtomicOperationConverter.java index 302a9a15473..60073451007 100644 --- a/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/converters/EcsCreateServerGroupAtomicOperationConverter.java +++ b/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/converters/EcsCreateServerGroupAtomicOperationConverter.java @@ -1,8 +1,24 @@ +/* + * Copyright 2018 Lookout, Inc. + * + * 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.netflix.spinnaker.clouddriver.ecs.deploy.converters; -import com.netflix.spinnaker.clouddriver.deploy.DeployAtomicOperation; import com.netflix.spinnaker.clouddriver.ecs.EcsOperation; -import com.netflix.spinnaker.clouddriver.ecs.deploy.description.BasicEcsDeployDescription; +import com.netflix.spinnaker.clouddriver.ecs.deploy.description.CreateServerGroupDescription; +import com.netflix.spinnaker.clouddriver.ecs.deploy.ops.CreateServerGroupAtomicOperation; import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperation; import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperations; import com.netflix.spinnaker.clouddriver.security.AbstractAtomicOperationsCredentialsSupport; @@ -11,17 +27,17 @@ import java.util.Map; @EcsOperation(AtomicOperations.CREATE_SERVER_GROUP) -@Component("basicEcsDeployDescription") +@Component("ecsCreateServerGroup") public class EcsCreateServerGroupAtomicOperationConverter extends AbstractAtomicOperationsCredentialsSupport { @Override public AtomicOperation convertOperation(Map input) { - return new DeployAtomicOperation(convertDescription(input)); + return new CreateServerGroupAtomicOperation(convertDescription(input)); } @Override - public BasicEcsDeployDescription convertDescription(Map input) { - BasicEcsDeployDescription converted = getObjectMapper().convertValue(input, BasicEcsDeployDescription.class); + public CreateServerGroupDescription convertDescription(Map input) { + CreateServerGroupDescription converted = getObjectMapper().convertValue(input, CreateServerGroupDescription.class); converted.setCredentials(getCredentialsObject(input.get("credentials").toString())); return converted; diff --git a/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/description/CreateServerGroupDescription.java b/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/description/CreateServerGroupDescription.java new file mode 100644 index 00000000000..0c82ef35f56 --- /dev/null +++ b/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/description/CreateServerGroupDescription.java @@ -0,0 +1,50 @@ +/* + * Copyright 2018 Lookout, Inc. + * + * 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.netflix.spinnaker.clouddriver.ecs.deploy.description; + +import com.amazonaws.services.cloudwatch.model.MetricAlarm; +import com.amazonaws.services.ecs.model.PlacementStrategy; +import com.netflix.spinnaker.clouddriver.model.ServerGroup; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; +import java.util.Map; + +@Data +@EqualsAndHashCode(callSuper = false) +public class CreateServerGroupDescription extends AbstractECSDescription { + String ecsClusterName; + String iamRole; + Integer containerPort; + String targetGroup; + List securityGroups; + + String portProtocol; + + Integer computeUnits; + Integer reservedMemory; + + String dockerImageAddress; + + ServerGroup.Capacity capacity; + + Map> availabilityZones; + + List autoscalingPolicies; + List placementStrategySequence; +} diff --git a/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/ops/CreateServerGroupAtomicOperation.java b/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/ops/CreateServerGroupAtomicOperation.java new file mode 100644 index 00000000000..2a7409c6d33 --- /dev/null +++ b/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/ops/CreateServerGroupAtomicOperation.java @@ -0,0 +1,350 @@ +/* + * Copyright 2018 Lookout, Inc. + * + * 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.netflix.spinnaker.clouddriver.ecs.deploy.ops; + +import com.amazonaws.auth.AWSCredentialsProvider; +import com.amazonaws.services.applicationautoscaling.AWSApplicationAutoScaling; +import com.amazonaws.services.applicationautoscaling.model.RegisterScalableTargetRequest; +import com.amazonaws.services.applicationautoscaling.model.ScalableDimension; +import com.amazonaws.services.applicationautoscaling.model.ServiceNamespace; +import com.amazonaws.services.cloudwatch.model.MetricAlarm; +import com.amazonaws.services.ecs.AmazonECS; +import com.amazonaws.services.ecs.model.ContainerDefinition; +import com.amazonaws.services.ecs.model.CreateServiceRequest; +import com.amazonaws.services.ecs.model.DeploymentConfiguration; +import com.amazonaws.services.ecs.model.KeyValuePair; +import com.amazonaws.services.ecs.model.ListServicesRequest; +import com.amazonaws.services.ecs.model.ListServicesResult; +import com.amazonaws.services.ecs.model.LoadBalancer; +import com.amazonaws.services.ecs.model.PortMapping; +import com.amazonaws.services.ecs.model.RegisterTaskDefinitionRequest; +import com.amazonaws.services.ecs.model.RegisterTaskDefinitionResult; +import com.amazonaws.services.ecs.model.Service; +import com.amazonaws.services.ecs.model.TaskDefinition; +import com.amazonaws.services.elasticloadbalancingv2.AmazonElasticLoadBalancing; +import com.amazonaws.services.elasticloadbalancingv2.model.DescribeTargetGroupsRequest; +import com.amazonaws.services.elasticloadbalancingv2.model.DescribeTargetGroupsResult; +import com.amazonaws.services.identitymanagement.AmazonIdentityManagement; +import com.amazonaws.services.identitymanagement.model.GetRoleRequest; +import com.amazonaws.services.identitymanagement.model.GetRoleResult; +import com.amazonaws.services.identitymanagement.model.Role; +import com.netflix.spinnaker.clouddriver.aws.security.AmazonCredentials; +import com.netflix.spinnaker.clouddriver.aws.security.AssumeRoleAmazonCredentials; +import com.netflix.spinnaker.clouddriver.aws.security.NetflixAssumeRoleAmazonCredentials; +import com.netflix.spinnaker.clouddriver.deploy.DeploymentResult; +import com.netflix.spinnaker.clouddriver.ecs.deploy.description.CreateServerGroupDescription; +import com.netflix.spinnaker.clouddriver.ecs.provider.agent.IamPolicyReader; +import com.netflix.spinnaker.clouddriver.ecs.provider.agent.IamTrustRelationship; +import com.netflix.spinnaker.clouddriver.ecs.security.NetflixAssumeRoleEcsCredentials; +import com.netflix.spinnaker.clouddriver.ecs.services.EcsCloudMetricService; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public class CreateServerGroupAtomicOperation extends AbstractEcsAtomicOperation { + + private static final String NECESSARY_TRUSTED_SERVICE = "ecs-tasks.amazonaws.com"; + + @Autowired + EcsCloudMetricService ecsCloudMetricService; + @Autowired + IamPolicyReader iamPolicyReader; + + public CreateServerGroupAtomicOperation(CreateServerGroupDescription description) { + super(description, "CREATE_ECS_SERVER_GROUP"); + } + + @Override + public DeploymentResult operate(List priorOutputs) { + updateTaskStatus("Initializing Create Amazon ECS Server Group Operation..."); + + AmazonCredentials credentials = getCredentials(); + + AmazonECS ecs = getAmazonEcsClient(); + + String serverGroupVersion = inferNextServerGroupVersion(ecs); + + updateTaskStatus("Creating Amazon ECS Task Definition..."); + TaskDefinition taskDefinition = registerTaskDefinition(ecs, serverGroupVersion); + updateTaskStatus("Done creating Amazon ECS Task Definition..."); + + String ecsServiceRole = inferAssumedRoleArn(credentials); + Service service = createService(ecs, taskDefinition, ecsServiceRole, serverGroupVersion); + + String resourceId = registerAutoScalingGroup(credentials, service); + + if (!description.getAutoscalingPolicies().isEmpty()) { + List alarmNames = description.getAutoscalingPolicies().stream() + .map(MetricAlarm::getAlarmName) + .collect(Collectors.toList()); + ecsCloudMetricService.associateAsgWithMetrics(description.getCredentialAccount(), getRegion(), alarmNames, service.getServiceName(), resourceId); + } + + return makeDeploymentResult(service); + } + + private TaskDefinition registerTaskDefinition(AmazonECS ecs, String version) { + + Collection containerEnvironment = new LinkedList<>(); + containerEnvironment.add(new KeyValuePair().withName("SERVER_GROUP").withValue(version)); + containerEnvironment.add(new KeyValuePair().withName("CLOUD_STACK").withValue(description.getStack())); + containerEnvironment.add(new KeyValuePair().withName("CLOUD_DETAIL").withValue(description.getFreeFormDetails())); + + PortMapping portMapping = new PortMapping() + .withHostPort(0) + .withContainerPort(description.getContainerPort()) + .withProtocol(description.getPortProtocol() != null ? description.getPortProtocol() : "tcp"); + + Collection portMappings = new LinkedList<>(); + portMappings.add(portMapping); + + ContainerDefinition containerDefinition = new ContainerDefinition() + .withName(version) + .withEnvironment(containerEnvironment) + .withPortMappings(portMappings) + .withCpu(description.getComputeUnits()) + .withMemoryReservation(description.getReservedMemory()) + .withImage(description.getDockerImageAddress()); + + Collection containerDefinitions = new LinkedList<>(); + containerDefinitions.add(containerDefinition); + + RegisterTaskDefinitionRequest request = new RegisterTaskDefinitionRequest() + .withContainerDefinitions(containerDefinitions) + .withFamily(getFamilyName()); + + if (!description.getIamRole().equals("None (No IAM role)")) { + checkRoleTrustRelations(description.getIamRole()); + request.setTaskRoleArn(description.getIamRole()); + } + + RegisterTaskDefinitionResult registerTaskDefinitionResult = ecs.registerTaskDefinition(request); + + return registerTaskDefinitionResult.getTaskDefinition(); + } + + private Service createService(AmazonECS ecs, TaskDefinition taskDefinition, String ecsServiceRole, String version) { + String serviceName = getNextServiceName(version); + Collection loadBalancers = new LinkedList<>(); + loadBalancers.add(retrieveLoadBalancer(version)); + + Integer desiredCount = description.getCapacity().getDesired(); + String taskDefinitionArn = taskDefinition.getTaskDefinitionArn(); + + DeploymentConfiguration deploymentConfiguration = new DeploymentConfiguration() + .withMinimumHealthyPercent(100) + .withMaximumPercent(200); + + CreateServiceRequest request = new CreateServiceRequest() + .withServiceName(serviceName) + .withDesiredCount(desiredCount) + .withCluster(description.getEcsClusterName()) + .withRole(ecsServiceRole) + .withLoadBalancers(loadBalancers) + .withTaskDefinition(taskDefinitionArn) + .withPlacementStrategy(description.getPlacementStrategySequence()) + .withDeploymentConfiguration(deploymentConfiguration); + + updateTaskStatus(String.format("Creating %s of %s with %s for %s.", + desiredCount, serviceName, taskDefinitionArn, description.getCredentialAccount())); + + Service service = ecs.createService(request).getService(); + + updateTaskStatus(String.format("Done creating %s of %s with %s for %s.", + desiredCount, serviceName, taskDefinitionArn, description.getCredentialAccount())); + + return service; + } + + private String registerAutoScalingGroup(AmazonCredentials credentials, + Service service) { + + AWSApplicationAutoScaling autoScalingClient = getAmazonApplicationAutoScalingClient(); + String assumedRoleArn = inferAssumedRoleArn(credentials); + + RegisterScalableTargetRequest request = new RegisterScalableTargetRequest() + .withServiceNamespace(ServiceNamespace.Ecs) + .withScalableDimension(ScalableDimension.EcsServiceDesiredCount) + .withResourceId(String.format("service/%s/%s", description.getEcsClusterName(), service.getServiceName())) + .withRoleARN(assumedRoleArn) + .withMinCapacity(description.getCapacity().getMin()) + .withMaxCapacity(description.getCapacity().getMax()); + + updateTaskStatus("Creating Amazon Application Auto Scaling Scalable Target Definition..."); + autoScalingClient.registerScalableTarget(request); + updateTaskStatus("Done creating Amazon Application Auto Scaling Scalable Target Definition."); + + return request.getResourceId(); + } + + private String inferAssumedRoleArn(AmazonCredentials credentials) { + String role; + if (credentials instanceof AssumeRoleAmazonCredentials) { + role = ((AssumeRoleAmazonCredentials) credentials).getAssumeRole(); + } else if (credentials instanceof NetflixAssumeRoleAmazonCredentials) { + role = ((NetflixAssumeRoleAmazonCredentials) credentials).getAssumeRole(); + } else if (credentials instanceof NetflixAssumeRoleEcsCredentials) { + role = ((NetflixAssumeRoleEcsCredentials) credentials).getAssumeRole(); + } else { + throw new UnsupportedOperationException("The given kind of credentials is not supported, " + + "please report this issue to the Spinnaker project on Github."); + } + + return String.format("arn:aws:iam::%s:%s", credentials.getAccountId(), role); + } + + private void checkRoleTrustRelations(String roleName) { + updateTaskStatus("Checking role trust relations for: " + roleName); + AmazonIdentityManagement iamClient = getAmazonIdentityManagementClient(); + + GetRoleResult response = iamClient.getRole(new GetRoleRequest() + .withRoleName(roleName)); + Role role = response.getRole(); + + Set trustedEntities = iamPolicyReader.getTrustedEntities(role.getAssumeRolePolicyDocument()); + + Set trustedServices = trustedEntities.stream() + .filter(trustRelation -> trustRelation.getType().equals("Service")) + .map(IamTrustRelationship::getValue) + .collect(Collectors.toSet()); + + if (!trustedServices.contains(NECESSARY_TRUSTED_SERVICE)) { + throw new IllegalArgumentException("The " + roleName + " role does not have a trust relationship to ecs-tasks.amazonaws.com."); + } + } + + private DeploymentResult makeDeploymentResult(Service service) { + Map namesByRegion = new HashMap<>(); + namesByRegion.put(getRegion(), service.getServiceName()); + + DeploymentResult result = new DeploymentResult(); + result.setServerGroupNames(Arrays.asList(getServerGroupName(service))); + result.setServerGroupNameByRegion(namesByRegion); + return result; + } + + private LoadBalancer retrieveLoadBalancer(String version) { + LoadBalancer loadBalancer = new LoadBalancer(); + loadBalancer.setContainerName(version); + loadBalancer.setContainerPort(description.getContainerPort()); + + if (description.getTargetGroup() != null) { + AmazonElasticLoadBalancing loadBalancingV2 = getAmazonElasticLoadBalancingClient(); + + DescribeTargetGroupsRequest request = new DescribeTargetGroupsRequest().withNames(description.getTargetGroup()); + DescribeTargetGroupsResult describeTargetGroupsResult = loadBalancingV2.describeTargetGroups(request); + + if (describeTargetGroupsResult.getTargetGroups().size() == 1) { + loadBalancer.setTargetGroupArn(describeTargetGroupsResult.getTargetGroups().get(0).getTargetGroupArn()); + } else if (describeTargetGroupsResult.getTargetGroups().size() > 1) { + throw new IllegalArgumentException("There are multiple target groups with the name " + description.getTargetGroup() + "."); + } else { + throw new IllegalArgumentException("There is no target group with the name " + description.getTargetGroup() + "."); + } + + } + return loadBalancer; + } + + private AWSApplicationAutoScaling getAmazonApplicationAutoScalingClient() { + AWSCredentialsProvider credentialsProvider = getCredentials().getCredentialsProvider(); + String credentialAccount = description.getCredentialAccount(); + + return amazonClientProvider.getAmazonApplicationAutoScaling(credentialAccount, credentialsProvider, getRegion()); + } + + private AmazonElasticLoadBalancing getAmazonElasticLoadBalancingClient() { + AWSCredentialsProvider credentialsProvider = getCredentials().getCredentialsProvider(); + String credentialAccount = description.getCredentialAccount(); + + return amazonClientProvider.getAmazonElasticLoadBalancingV2(credentialAccount, credentialsProvider, getRegion()); + } + + private AmazonIdentityManagement getAmazonIdentityManagementClient() { + AWSCredentialsProvider credentialsProvider = getCredentials().getCredentialsProvider(); + String credentialAccount = description.getCredentialAccount(); + + return amazonClientProvider.getAmazonIdentityManagement(credentialAccount, credentialsProvider, getRegion()); + } + + private String getServerGroupName(Service service) { + // See in Orca MonitorKatoTask#getServerGroupNames for a reason for this + return getRegion() + ":" + service.getServiceName(); + } + + private String getNextServiceName(String versionString) { + return getFamilyName() + "-" + versionString; + } + + @Override + protected String getRegion() { + //CreateServerGroupDescription does not contain a region. Instead it has AvailabilityZones + return description.getAvailabilityZones().keySet().iterator().next(); + } + + private String inferNextServerGroupVersion(AmazonECS ecs) { + int latestVersion = 0; + String familyName = getFamilyName(); + + String nextToken = null; + do { + ListServicesRequest request = new ListServicesRequest().withCluster(description.getEcsClusterName()); + if (nextToken != null) { + request.setNextToken(nextToken); + } + + ListServicesResult result = ecs.listServices(request); + for (String serviceArn : result.getServiceArns()) { + if (serviceArn.contains(familyName)) { + int currentVersion; + try { + String versionString = StringUtils.substringAfterLast(serviceArn, "-").replaceAll("v", ""); + currentVersion = Integer.parseInt(versionString); + } catch (NumberFormatException e) { + currentVersion = 0; + } + latestVersion = Math.max(currentVersion, latestVersion); + } + } + + nextToken = result.getNextToken(); + } while (nextToken != null && nextToken.length() != 0); + + return String.format("v%04d", (latestVersion + 1)); + } + + private String getFamilyName() { + String familyName = description.getApplication(); + + if (description.getStack() != null) { + familyName += "-" + description.getStack(); + } + if (description.getFreeFormDetails() != null) { + familyName += "-" + description.getFreeFormDetails(); + } + + return familyName; + } +} diff --git a/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/validators/EcsCreateServerGroupAtomicOperationValidator.java b/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/validators/EcsCreateServerGroupAtomicOperationValidator.java deleted file mode 100644 index 01cc9789876..00000000000 --- a/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/validators/EcsCreateServerGroupAtomicOperationValidator.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.netflix.spinnaker.clouddriver.ecs.deploy.validators; - -import com.netflix.spinnaker.clouddriver.deploy.DescriptionValidator; -import com.netflix.spinnaker.clouddriver.ecs.EcsOperation; -import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperations; -import org.springframework.stereotype.Component; -import org.springframework.validation.Errors; - -import java.util.List; - -@EcsOperation(AtomicOperations.CREATE_SERVER_GROUP) -@Component("ecsCreateServerGroupAtomicOperationValidator") -public class EcsCreateServerGroupAtomicOperationValidator extends DescriptionValidator { - - @Override - public void validate(List priorDescriptions, Object description, Errors errors) { - - // TODO - Implement this stub - - } -} diff --git a/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/validators/EcsCreateServerGroupDescriptionValidator.java b/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/validators/EcsCreateServerGroupDescriptionValidator.java new file mode 100644 index 00000000000..e6a81ed89ed --- /dev/null +++ b/clouddriver-ecs/src/main/java/com/netflix/spinnaker/clouddriver/ecs/deploy/validators/EcsCreateServerGroupDescriptionValidator.java @@ -0,0 +1,135 @@ +/* + * Copyright 2018 Lookout, Inc. + * + * 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.netflix.spinnaker.clouddriver.ecs.deploy.validators; + +import com.amazonaws.services.ecs.model.PlacementStrategy; +import com.amazonaws.services.ecs.model.PlacementStrategyType; +import com.google.common.collect.Sets; +import com.netflix.spinnaker.clouddriver.ecs.EcsOperation; +import com.netflix.spinnaker.clouddriver.ecs.deploy.description.CreateServerGroupDescription; +import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperations; +import org.springframework.stereotype.Component; +import org.springframework.validation.Errors; + +import java.util.List; +import java.util.Set; + +@EcsOperation(AtomicOperations.CREATE_SERVER_GROUP) +@Component("ecsCreateServerGroupDescriptionValidator") +public class EcsCreateServerGroupDescriptionValidator extends CommonValidator { + + private static final Set BINPACK_VALUES = Sets.newHashSet("cpu", "memory"); + private static final Set SPREAD_VALUES = Sets.newHashSet( + "instanceId", + "attribute:ecs.availability-zone", + "attribute:ecs.instance-type", + "attribute:ecs.os-type", + "attribute:ecs.ami-id" + ); + + public EcsCreateServerGroupDescriptionValidator() { + super("createServerGroupDescription"); + } + + @Override + public void validate(List priorDescriptions, Object description, Errors errors) { + CreateServerGroupDescription createServerGroupDescription = (CreateServerGroupDescription) description; + + validateCredentials(createServerGroupDescription, errors, "credentials"); + validateCapacity(errors, createServerGroupDescription.getCapacity()); + + if (createServerGroupDescription.getAvailabilityZones() != null) { + if (createServerGroupDescription.getAvailabilityZones().size() != 1) { + rejectValue(errors, "availabilityZones", "must.have.only.one"); + } + } else { + rejectValue(errors, "availabilityZones", "not.nullable"); + } + + if (createServerGroupDescription.getPlacementStrategySequence() != null) { + for (PlacementStrategy placementStrategy : createServerGroupDescription.getPlacementStrategySequence()) { + PlacementStrategyType type; + try { + type = PlacementStrategyType.fromValue(placementStrategy.getType()); + } catch (IllegalArgumentException e) { + rejectValue(errors, "placementStrategySequence.type", "invalid"); + continue; + } + + switch (type) { + case Random: + break; + case Spread: + if (!SPREAD_VALUES.contains(placementStrategy.getField())) { + rejectValue(errors, "placementStrategySequence.spread", "invalid"); + } + break; + case Binpack: + if (!BINPACK_VALUES.contains(placementStrategy.getField())) { + rejectValue(errors, "placementStrategySequence.binpack", "invalid"); + } + break; + } + + } + } else { + rejectValue(errors, "placementStrategySequence", "not.nullable"); + } + + if (createServerGroupDescription.getAutoscalingPolicies() == null) { + rejectValue(errors, "autoscalingPolicies", "not.nullable"); + } + + if (createServerGroupDescription.getApplication() == null) { + rejectValue(errors, "application", "not.nullable"); + } + + if (createServerGroupDescription.getEcsClusterName() == null) { + rejectValue(errors, "ecsClusterName", "not.nullable"); + } + + if (createServerGroupDescription.getDockerImageAddress() == null) { + rejectValue(errors, "dockerImageAddress", "not.nullable"); + } + + if (createServerGroupDescription.getContainerPort() != null) { + if (createServerGroupDescription.getContainerPort() < 0 || createServerGroupDescription.getContainerPort() > 65535) { + rejectValue(errors, "containerPort", "invalid"); + } + } else { + rejectValue(errors, "containerPort", "not.nullable"); + } + + if (createServerGroupDescription.getComputeUnits() != null) { + if (createServerGroupDescription.getComputeUnits() < 0) { + rejectValue(errors, "computeUnits", "invalid"); + } + } else { + rejectValue(errors, "computeUnits", "not.nullable"); + } + + if (createServerGroupDescription.getReservedMemory() != null) { + if (createServerGroupDescription.getReservedMemory() < 0) { + rejectValue(errors, "reservedMemory", "invalid"); + } + } else { + rejectValue(errors, "reservedMemory", "not.nullable"); + } + + } + +} diff --git a/clouddriver-ecs/src/test/groovy/com/netflix/spinnaker/clouddriver/ecs/deploy/converters/EcsCreateServerGroupAtomicOperationConverterSpec.groovy b/clouddriver-ecs/src/test/groovy/com/netflix/spinnaker/clouddriver/ecs/deploy/converters/EcsCreateServerGroupAtomicOperationConverterSpec.groovy new file mode 100644 index 00000000000..042525e8a55 --- /dev/null +++ b/clouddriver-ecs/src/test/groovy/com/netflix/spinnaker/clouddriver/ecs/deploy/converters/EcsCreateServerGroupAtomicOperationConverterSpec.groovy @@ -0,0 +1,70 @@ +/* + * Copyright 2018 Lookout, Inc. + * + * 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.netflix.spinnaker.clouddriver.ecs.deploy.converters + +import com.amazonaws.services.ecs.model.PlacementStrategy +import com.amazonaws.services.ecs.model.PlacementStrategyType +import com.fasterxml.jackson.databind.ObjectMapper +import com.netflix.spinnaker.clouddriver.ecs.TestCredential +import com.netflix.spinnaker.clouddriver.ecs.deploy.description.CreateServerGroupDescription +import com.netflix.spinnaker.clouddriver.ecs.deploy.ops.CreateServerGroupAtomicOperation +import com.netflix.spinnaker.clouddriver.model.ServerGroup +import com.netflix.spinnaker.clouddriver.security.AccountCredentialsProvider +import spock.lang.Specification + +class EcsCreateServerGroupAtomicOperationConverterSpec extends Specification { + def accountCredentialsProvider = Mock(AccountCredentialsProvider) + + def 'should convert'() { + given: + def converter = new EcsCreateServerGroupAtomicOperationConverter(objectMapper: new ObjectMapper()) + converter.accountCredentialsProvider = accountCredentialsProvider + + def input = [ + ecsClusterName : 'mycluster', + iamRole : 'role-arn', + containerPort : 1337, + targetGroup : 'target-group-arn', + securityGroups : ['sg-deadbeef'], + serverGroupVersion : 'v007', + portProtocol : 'tc', + computeUnits : 256, + reservedMemory : 512, + dockerImageAddress : 'docker-url', + capacity : new ServerGroup.Capacity(0, 2, 1,), + availabilityZones : ['us-west-1': ['us-west-1a']], + autoscalingPolicies : [], + placementStrategySequence: [new PlacementStrategy().withType(PlacementStrategyType.Random)], + region : 'us-west-1', + credentials : 'test' + ] + + accountCredentialsProvider.getCredentials(_) >> TestCredential.named('test') + + when: + def description = converter.convertDescription(input) + + then: + description instanceof CreateServerGroupDescription + + when: + def operation = converter.convertOperation(input) + + then: + operation instanceof CreateServerGroupAtomicOperation + } +} diff --git a/clouddriver-ecs/src/test/groovy/com/netflix/spinnaker/clouddriver/ecs/deploy/ops/CreateServerGroupAtomicOperationSpec.groovy b/clouddriver-ecs/src/test/groovy/com/netflix/spinnaker/clouddriver/ecs/deploy/ops/CreateServerGroupAtomicOperationSpec.groovy new file mode 100644 index 00000000000..71fe38b21c2 --- /dev/null +++ b/clouddriver-ecs/src/test/groovy/com/netflix/spinnaker/clouddriver/ecs/deploy/ops/CreateServerGroupAtomicOperationSpec.groovy @@ -0,0 +1,115 @@ +/* + * Copyright 2018 Lookout, Inc. + * + * 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.netflix.spinnaker.clouddriver.ecs.deploy.ops + +import com.amazonaws.services.applicationautoscaling.AWSApplicationAutoScaling +import com.amazonaws.services.ecs.model.* +import com.amazonaws.services.elasticloadbalancingv2.AmazonElasticLoadBalancing +import com.amazonaws.services.elasticloadbalancingv2.model.DescribeTargetGroupsResult +import com.amazonaws.services.elasticloadbalancingv2.model.TargetGroup +import com.amazonaws.services.identitymanagement.AmazonIdentityManagement +import com.amazonaws.services.identitymanagement.model.GetRoleResult +import com.amazonaws.services.identitymanagement.model.Role +import com.netflix.spinnaker.clouddriver.aws.security.AmazonCredentials +import com.netflix.spinnaker.clouddriver.aws.security.AssumeRoleAmazonCredentials +import com.netflix.spinnaker.clouddriver.ecs.deploy.description.CreateServerGroupDescription +import com.netflix.spinnaker.clouddriver.ecs.provider.agent.IamPolicyReader +import com.netflix.spinnaker.clouddriver.ecs.provider.agent.IamTrustRelationship +import com.netflix.spinnaker.clouddriver.ecs.services.EcsCloudMetricService +import com.netflix.spinnaker.clouddriver.model.ServerGroup +import com.netflix.spinnaker.fiat.model.resources.Permissions + +class CreateServerGroupAtomicOperationSpec extends CommonAtomicOperation { + + def 'should create a service'() { + given: + def iamClient = Mock(AmazonIdentityManagement) + def iamPolicyReader = Mock(IamPolicyReader) + def loadBalancingV2 = Mock(AmazonElasticLoadBalancing) + def autoScalingClient = Mock(AWSApplicationAutoScaling) + + def applicationName = 'myapp' + def stack = 'kcats' + def detail = 'liated' + def serviceName = "${applicationName}-${stack}-${detail}" + + def description = new CreateServerGroupDescription( + application: applicationName, + stack: stack, + freeFormDetails: detail, + ecsClusterName: 'test-cluster', + iamRole: 'test-role', + containerPort: 1337, + targetGroup: 'target-group-arn', + securityGroups: ['sg-deadbeef'], + portProtocol: 'tcp', + computeUnits: 9001, + reservedMemory: 9001, + dockerImageAddress: 'docker-image-url', + capacity: new ServerGroup.Capacity(1, 1, 1), + availabilityZones: ['us-west-1': ['us-west-1a', 'us-west-1b', 'us-west-1c']], + autoscalingPolicies: [], + placementStrategySequence: [] + ) + + def operation = new CreateServerGroupAtomicOperation(description) + + def trustRelationships = [new IamTrustRelationship(type: 'Service', value: 'ecs-tasks.amazonaws.com'), + new IamTrustRelationship(type: 'Service', value: 'ecs.amazonaws.com')] + + def role = new Role(assumeRolePolicyDocument: "json-encoded-string-here") + + def creds = new AssumeRoleAmazonCredentials("test", "test", "test", "test", "test", + [new AmazonCredentials.AWSRegion('us-west-1', ['us-west-1a', 'us-west-1b'])], + [], [], Permissions.factory([:]), [], false, 'test-role', "test") + + def taskDefinition = new TaskDefinition().withTaskDefinitionArn("task-def-arn") + + def targetGroup = new TargetGroup().withLoadBalancerArns("loadbalancer-arn") + + def service = new Service(serviceName: "${serviceName}") + + operation.amazonClientProvider = amazonClientProvider + operation.ecsCloudMetricService = Mock(EcsCloudMetricService) + operation.iamPolicyReader = iamPolicyReader + operation.accountCredentialsProvider = accountCredentialsProvider + operation.containerInformationService = containerInformationService + + amazonClientProvider.getAmazonEcs(_, _, _) >> ecs + amazonClientProvider.getAmazonIdentityManagement(_, _, _) >> iamClient + amazonClientProvider.getAmazonElasticLoadBalancingV2(_, _, _) >> loadBalancingV2 + amazonClientProvider.getAmazonApplicationAutoScaling(_, _, _) >> autoScalingClient + containerInformationService.getClusterName(_, _, _) >> 'cluster-name' + accountCredentialsProvider.getCredentials(_) >> creds + + when: + def result = operation.operate([]) + + then: + 1 * ecs.listServices(_) >> new ListServicesResult().withServiceArns("${serviceName}-v007") + 1 * ecs.registerTaskDefinition(_) >> new RegisterTaskDefinitionResult().withTaskDefinition(taskDefinition) + 1 * iamClient.getRole(_) >> new GetRoleResult().withRole(role) + 1 * iamPolicyReader.getTrustedEntities(_) >> trustRelationships + 1 * loadBalancingV2.describeTargetGroups(_) >> new DescribeTargetGroupsResult().withTargetGroups(targetGroup) + 1 * ecs.createService(_) >> new CreateServiceResult().withService(service) + result.getServerGroupNames().size() == 1 + result.getServerGroupNameByRegion().size() == 1 + result.getServerGroupNames().contains("us-west-1:" + serviceName) + result.getServerGroupNameByRegion().containsKey('us-west-1') + result.getServerGroupNameByRegion().get('us-west-1').contains(serviceName) + } +} diff --git a/clouddriver-ecs/src/test/groovy/com/netflix/spinnaker/clouddriver/ecs/deploy/validators/EcsCreateServergroupDescriptionValidatorSpec.groovy b/clouddriver-ecs/src/test/groovy/com/netflix/spinnaker/clouddriver/ecs/deploy/validators/EcsCreateServergroupDescriptionValidatorSpec.groovy new file mode 100644 index 00000000000..a3bb90363a5 --- /dev/null +++ b/clouddriver-ecs/src/test/groovy/com/netflix/spinnaker/clouddriver/ecs/deploy/validators/EcsCreateServergroupDescriptionValidatorSpec.groovy @@ -0,0 +1,172 @@ +/* + * Copyright 2018 Lookout, Inc. + * + * 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.netflix.spinnaker.clouddriver.ecs.deploy.validators + +import com.amazonaws.services.ecs.model.PlacementStrategy +import com.amazonaws.services.ecs.model.PlacementStrategyType +import com.netflix.spinnaker.clouddriver.deploy.DescriptionValidator +import com.netflix.spinnaker.clouddriver.ecs.TestCredential +import com.netflix.spinnaker.clouddriver.ecs.deploy.description.AbstractECSDescription +import com.netflix.spinnaker.clouddriver.ecs.deploy.description.CreateServerGroupDescription +import com.netflix.spinnaker.clouddriver.model.ServerGroup +import org.springframework.validation.Errors + +class EcsCreateServergroupDescriptionValidatorSpec extends AbstractValidatorSpec { + + void 'should fail when the capacity is null'() { + given: + def description = (CreateServerGroupDescription) getDescription() + description.capacity = null + def errors = Mock(Errors) + + when: + validator.validate([], description, errors) + + then: + 1 * errors.rejectValue('capacity', "${getDescriptionName()}.capacity.not.nullable") + } + + void 'should fail when desired is greater than max'() { + given: + def description = (CreateServerGroupDescription) getDescription() + description.capacity.setDesired(9001) + def errors = Mock(Errors) + + when: + validator.validate([], description, errors) + + then: + 1 * errors.rejectValue('capacity.desired', "${getDescriptionName()}.capacity.desired.exceeds.max") + } + + void 'should fail when desired is less than min'() { + given: + def description = (CreateServerGroupDescription) getDescription() + description.capacity.setDesired(0) + def errors = Mock(Errors) + + when: + validator.validate([], description, errors) + + then: + 1 * errors.rejectValue('capacity.desired', "${getDescriptionName()}.capacity.desired.less.than.min") + } + + void 'should fail when more than one availability zones is present'() { + given: + def description = (CreateServerGroupDescription) getDescription() + description.availabilityZones = ['us-west-1': ['us-west-1a'], 'us-west-2': ['us-west-2a']] + def errors = Mock(Errors) + + when: + validator.validate([], description, errors) + + then: + 1 * errors.rejectValue('availabilityZones', "${getDescriptionName()}.availabilityZones.must.have.only.one") + } + + + @Override + AbstractECSDescription getNulledDescription() { + def description = (CreateServerGroupDescription) getDescription() + description.placementStrategySequence = null + description.availabilityZones = null + description.autoscalingPolicies = null + description.application = null + description.ecsClusterName = null + description.dockerImageAddress = null + description.credentials = null + description.containerPort = null + description.computeUnits = null + description.reservedMemory = null + description.capacity.setDesired(null) + description.capacity.setMin(null) + description.capacity.setMax(null) + return description + } + + @Override + Set notNullableProperties() { + ['placementStrategySequence', 'availabilityZones', 'autoscalingPolicies', 'application', + 'ecsClusterName', 'dockerImageAddress', 'credentials', 'containerPort', 'computeUnits', + 'reservedMemory', 'capacity.desired', 'capacity.min', 'capacity.max'] + } + + @Override + AbstractECSDescription getInvalidDescription() { + def description = (CreateServerGroupDescription) getDescription() + description.reservedMemory = -1 + description.computeUnits = -1 + description.containerPort = -1 + description.getCapacity().setDesired(-1) + description.getCapacity().setMax(-2) + description.getCapacity().setMin(-1) + description.placementStrategySequence = [ + new PlacementStrategy().withType("invalid-type"), + new PlacementStrategy().withType(PlacementStrategyType.Binpack).withField("invalid"), + new PlacementStrategy().withType(PlacementStrategyType.Spread).withField("invalid") + ] + return description + } + + @Override + Set invalidProperties() { + ['reservedMemory', 'computeUnits', 'containerPort', 'placementStrategySequence.binpack', + 'placementStrategySequence.type', 'capacity.desired', 'placementStrategySequence.spread', + 'capacity.min', 'capacity.max', 'capacity.min.max.range'] + } + + @Override + DescriptionValidator getDescriptionValidator() { + new EcsCreateServerGroupDescriptionValidator() + } + + @Override + String getDescriptionName() { + 'createServerGroupDescription' + } + + @Override + AbstractECSDescription getDescription() { + def description = new CreateServerGroupDescription() + description.credentials = TestCredential.named('test') + description.region = 'us-west-1' + + description.application = 'my-app' + description.ecsClusterName = 'mycluster' + description.iamRole = 'iam-role-arn' + description.containerPort = 1337 + description.targetGroup = 'target-group-arn' + description.securityGroups = ['sg-deadbeef'] + description.portProtocol = 'tcp' + description.computeUnits = 256 + description.reservedMemory = 512 + description.dockerImageAddress = 'docker-image-url' + description.capacity = new ServerGroup.Capacity(1, 2, 1) + description.availabilityZones = ['us-west-1': ['us-west-1a']] + description.autoscalingPolicies = [] + description.placementStrategySequence = [new PlacementStrategy().withType(PlacementStrategyType.Random)] + + description + } + + @Override + def setTestRegion() { + //Region testing is not done in the same way as normal description. + testRegion = false + } +}