Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ecs credentials provider #1091

Merged
merged 21 commits into from
Sep 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,330 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import Foundation
import XCTest
import AWSCloudWatchLogs
import AWSECS
import AWSEC2
import AWSIAM
import AWSSTS
import ClientRuntime

class ECSCredentialsProviderTests: XCTestCase {

private let taskRoleName = "ecs_integ_test_task_role"
private let executionRoleName = "ecs_integ_test_execution_role"
private let clusterName = "ecs-integ-test-cluster"
private let taskFamilyName = "ecs-integ-test-family"
private let serviceName = "ecs-integ-test-service"
private let logGroupName = "/ecs/integ-test-group"

private var taskRoleArn: String? = nil
private var executionRoleArn: String? = nil
private var networkingConfig: ECSClientTypes.NetworkConfiguration = ECSClientTypes.NetworkConfiguration()
private var taskDefArn: String = ""

override func setUp() async throws {
try await setupIAMRolesAndPolicies()
try await setupCloudwatchLogs()
try await setupNetworkingConfig(
securityGroupNames: ["default"],
availabilityZones: ["us-east-1a", "us-east-1b"]
)
}

func test_ecsCredentialsProvider() async throws {
let ecsClient = try await ECSClient()

// create cluster
let testCluster = try await ecsClient.createCluster(input: CreateClusterInput(clusterName: clusterName))
guard let testClusterName = testCluster.cluster?.clusterName else {
XCTFail("Cluster could not be created!")
return
}

// setup container
guard let accountId = try await getAccountId() else {
XCTFail("Couldn't retrieve account id from STS!")
return
}
let containerDefinition = getTestContainerDefinition(accountId: accountId)

// register the task definition
let taskDefinition = getTestTaskDefinitionInput(container: containerDefinition)
guard let createdTaskArn = try await registerTaskDefinition(ecsClient, taskDefinition: taskDefinition) else {
XCTFail("Couldn't register task definition!")
return
}
taskDefArn = createdTaskArn

// run the task directly without creating a service
let runTaskResp = try await ecsClient.runTask(input: RunTaskInput(
cluster: testClusterName,
count: 1,
launchType: .fargate,
networkConfiguration: networkingConfig,
taskDefinition: taskDefArn
))
guard let tasks = runTaskResp.tasks, !tasks.isEmpty else {
XCTFail("Failed to run task")
return
}

// there should only be one since we specified count: 1
let taskArns = tasks.compactMap { $0.taskArn }

// wait for task to complete, check every 30 seconds
try await waitForTaskToComplete(ecsClient, clusterName: testClusterName, tasks: taskArns, intervalSeconds: 30)

// check logs for "Success!"
let logsContainKeyword = try await checkLogsForKeyword(keyword: "Success!")
XCTAssertTrue(logsContainKeyword, "Logs did not contain the expected keyword. Test failed!")
}

override func tearDown() async throws {
// clean up resources
let ecsClient = try await ECSClient()

// degregister task definition
_ = try await ecsClient.deregisterTaskDefinition(input: DeregisterTaskDefinitionInput(
taskDefinition: taskDefArn
))

// delete cluster
_ = try await ecsClient.deleteCluster(input: DeleteClusterInput(
cluster: clusterName
))
}

private func getAccountId() async throws -> String? {
let stsClient = try await STSClient()
let stsResp = try await stsClient.getCallerIdentity(input: GetCallerIdentityInput())
return stsResp.account
}

private func setupIAMRolesAndPolicies() async throws {
// Create IAM Role and Trust Policy
let iamClient = try await IAMClient()

// create a trust policy and allows ecs to assume role
let trustPolicyJSON = """
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ecs-tasks.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
"""

taskRoleArn = try await createRole(iamClient, policy: trustPolicyJSON, roleName: taskRoleName)

try await attachPolicy(
iamClient,
policyArn: "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
roleName: taskRoleName
)

executionRoleArn = try await createRole(iamClient, policy: trustPolicyJSON, roleName: executionRoleName)

try await attachPolicy(
iamClient,
policyArn: "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",
roleName: executionRoleName
)
}

private func setupCloudwatchLogs() async throws {
// create cloudwatch log group if it doesn't exist
let logsClient = try await CloudWatchLogsClient()
let describeLogGroupsOutput = try await logsClient.describeLogGroups(input: DescribeLogGroupsInput(
logGroupNamePrefix: logGroupName
))
if describeLogGroupsOutput.logGroups?.isEmpty ?? true {
_ = try await logsClient.createLogGroup(input: CreateLogGroupInput(
logGroupName: logGroupName
))
print("Created log group \(logGroupName)")
} else {
print("Log group \(logGroupName) already exists")
}
}

private func setupNetworkingConfig(securityGroupNames: [String], availabilityZones: [String]) async throws {
// create an ec2 client for getting security group and subnet
let ec2Client = try await EC2Client()

// get default security group
let describeSecurityGroupsResp = try await ec2Client.describeSecurityGroups(input: DescribeSecurityGroupsInput(
groupNames: securityGroupNames
))
guard let defaultSecurityGroupId = describeSecurityGroupsResp.securityGroups?.first?.groupId else {
XCTFail("Could not retrieve the default security group!")
return
}

// get subnets
let describeSubnetsResponse = try await ec2Client.describeSubnets(input: DescribeSubnetsInput(
filters: [EC2ClientTypes.Filter(name: "availability-zone", values: availabilityZones)]
))
guard let foundSubnets = describeSubnetsResponse.subnets else {
XCTFail("Could not retrieve subnets!")
return
}
let subnetIds = foundSubnets.compactMap { $0.subnetId }

networkingConfig = ECSClientTypes.NetworkConfiguration(
awsvpcConfiguration: ECSClientTypes.AwsVpcConfiguration(
assignPublicIp: .enabled,
securityGroups: [defaultSecurityGroupId],
subnets: subnetIds
)
)
}

private func getTestContainerDefinition(accountId: String) -> ECSClientTypes.ContainerDefinition {
// Create container def that points to ECR repo
return ECSClientTypes.ContainerDefinition(
cpu: 256,
image: "\(accountId).dkr.ecr.us-east-1.amazonaws.com/ecs-integ-test:latest",
logConfiguration: ECSClientTypes.LogConfiguration(
logDriver: .awslogs,
options: [
"awslogs-group": logGroupName,
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
]
),
memory: 512,
name: "ecs-integ-test-container"
)
}

private func getTestTaskDefinitionInput(container: ECSClientTypes.ContainerDefinition) -> RegisterTaskDefinitionInput {
return RegisterTaskDefinitionInput(
containerDefinitions: [container],
cpu: "256",
executionRoleArn: executionRoleArn,
family: taskFamilyName,
memory: "512",
networkMode: .awsvpc,
requiresCompatibilities: [.fargate],
runtimePlatform: ECSClientTypes.RuntimePlatform(
cpuArchitecture: ECSClientTypes.CPUArchitecture.arm64
),
taskRoleArn: taskRoleArn
)
}

private func registerTaskDefinition(_ ecsClient: ECSClient, taskDefinition: RegisterTaskDefinitionInput) async throws -> String? {
let registerTaskDefResp = try await ecsClient.registerTaskDefinition(input: taskDefinition)
return registerTaskDefResp.taskDefinition?.taskDefinitionArn
}

private func getCreateServiceInput(clusterName: String, taskDefinitionArn: String) -> CreateServiceInput {
return CreateServiceInput(
cluster: clusterName,
desiredCount: 1,
launchType: .fargate,
networkConfiguration: networkingConfig,
serviceName: serviceName,
taskDefinition: taskDefinitionArn
)
}

private func createRole(_ iamClient: IAMClient, policy: String, roleName: String) async throws -> String? {
if let existingRoleArn = try await getRole(iamClient, roleName: roleName) {
return existingRoleArn
}
return try await createNewRole(iamClient, roleName: roleName, policy: policy)
}

private func getRole(_ iamClient: IAMClient, roleName: String) async throws -> String? {
let fetchRoleResp = try await iamClient.getRole(input: GetRoleInput(roleName: roleName))
return fetchRoleResp.role?.arn
}

private func createNewRole(_ iamClient: IAMClient, roleName: String, policy: String) async throws -> String? {
let createRoleResp = try await iamClient.createRole(input: CreateRoleInput(
assumeRolePolicyDocument: policy,
roleName: roleName
))
return createRoleResp.role?.arn
}

private func attachPolicy(_ iamClient: IAMClient, policyArn: String, roleName: String) async throws {
do {
_ = try await iamClient.attachRolePolicy(input: AttachRolePolicyInput(
policyArn: policyArn,
roleName: roleName
))
} catch {
print("Error occurred while attaching policy")
}
}

private func waitForTaskToComplete(_ ecsClient: ECSClient, clusterName: String, tasks: [String], intervalSeconds: UInt64) async throws {
var isTaskCompleted = false
while !isTaskCompleted {
let describeTasksResp = try await ecsClient.describeTasks(input: DescribeTasksInput(cluster: clusterName, tasks: tasks))
if let task = describeTasksResp.tasks?.first, task.lastStatus == "STOPPED" {
isTaskCompleted = true
}
try await Task.sleep(nanoseconds: intervalSeconds * 1_000_000_000) // Sleep for X seconds before retrying
}
}

private func checkLogsForKeyword(keyword: String) async throws -> Bool {
let logsClient = try await CloudWatchLogsClient()
let logStreamsResp = try await logsClient.describeLogStreams(input: DescribeLogStreamsInput(
descending: true,
logGroupName: logGroupName,
orderBy: .lasteventtime
))

if let logStreamName = logStreamsResp.logStreams?.first?.logStreamName {
let logEventsResp = try await logsClient.getLogEvents(input: GetLogEventsInput(
logGroupName: logGroupName,
logStreamName: logStreamName
))

print("Log Group name: \(logGroupName)")
print("Log Stream name: \(logStreamName)")

for event in logEventsResp.events ?? [] {
if let message = event.message, message.contains(keyword) {
return true
}
}
}
return false
}

private func waitForServiceTasksToDrain(_ ecsClient: ECSClient, clusterName: String, serviceName: String, intervalSeconds: UInt64 = 10) async throws {
var allTasksStopped = false
while !allTasksStopped {
let serviceDescription = try await ecsClient.describeServices(input: DescribeServicesInput(
cluster: clusterName,
services: [serviceName]
))

if let service = serviceDescription.services?.first, service.runningCount == 0 {
allTasksStopped = true
}

if !allTasksStopped {
try await Task.sleep(nanoseconds: intervalSeconds * 1_000_000_000) // Sleep for X seconds before retrying
}
}
}
}
8 changes: 8 additions & 0 deletions IntegrationTests/Services/AWSECSIntegrationTests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# AWSECSIntegrationTests Description

- ECSCredentialsProviderTests will launch all configuration needed to run a dockerized Swift package as a task inside of a Fargate ARM64 ECS cluster.
- The test will poll the task every X seconds (30) to see if it is completed.
- Upon task completion, the latest log stream will be scanned to look for keyword 'Success!' which the Swift program running inside of the cluster will emit if successful.
- ECS resources are cleaned up but cloudwatch logs and IAM roles remain so that the test can be re-run.
- Test should take ~3-5 minutes to run.
- See README.md inside of ECSIntegTestApp for further details.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Can also be set to a specific swift version like swift:5.7
FROM swift:latest

# Install dependencies to container
RUN apt-get update && apt-get install -y libssl-dev

# Set our working directory
WORKDIR /app

# Copy the entire Swift project into the Docker image
COPY . .

# Build the swift application in release mode
RUN swift build --configuration release

# Command to run the test application
CMD [".build/release/ECSIntegTestApp"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// swift-tools-version: 5.5

import PackageDescription

let package = Package(
name: "ECSIntegTestApp",
platforms: [
.macOS(.v10_15),
.iOS(.v13)
],
dependencies: [
.package(url: "https://github.com/awslabs/aws-sdk-swift.git", branch: "main"),
.package(url: "https://github.com/smithy-lang/smithy-swift.git", branch: "main"),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.0"),
],
targets: [
.executableTarget(
name: "ECSIntegTestApp",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "AWSSTS", package: "aws-sdk-swift"),
.product(name: "AWSClientRuntime", package: "aws-sdk-swift"),
]
)
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## ECS Integration Testing

This package will be used to test aws-sdk-swift and AWS ECS. The below steps will need to be excuted prior to running the integration test. The contents of this package are executed inside of an ECS cluster.

Note: `./deploy-docker-to-ecr` only needs to be executed once per AWS account and if updates are needed to the underlying package versions or test run inside ECS container

How to use `./deploy-docker-to-ecr`:
- Make sure you have permissions configured using aws configure and docker daemon running
- `chmod +x deploy-docker-to-ecr.sh`
- `./deploy-docker-to-ecr.sh 123456789012` or `./deploy-docker-to-ecr.sh 123456789012 us-west-2` or `./deploy-docker-to-ecr.sh 123456789012 us-west-2 my-repo-name`
- if login window pops up, login using either your aws username/password or temporary access credentials and write `exit` upon successful login (bug)

Loading
Loading