Last active
December 19, 2022 13:13
-
-
Save 0xTim/321de40a381408b4ce13c0c1a2cf481a to your computer and use it in GitHub Desktop.
A Swift script to deploy an app (in this case Vapor) to AWS Fargate from scratch. It first checks to see if there's a repository in ECR for the app, if not it creates one, builds the container and pushes it. It then checks for a registered task definition. In one doesn't exist in ECS, it updates the provided task definition with the latest ECR iβ¦
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/swift | |
import Foundation | |
// MARK: - Script variables | |
let awsProfileName: String? = "myProfile" | |
let serviceName = "someService" | |
// MARK: - Functions | |
@discardableResult | |
func shell(_ args: String..., returnStdOut: Bool = false, stdIn: Pipe? = nil) -> (Int32, Pipe) { | |
return shell(args, returnStdOut: returnStdOut, stdIn: stdIn) | |
} | |
@discardableResult | |
func shell(_ args: [String], returnStdOut: Bool = false, stdIn: Pipe? = nil) -> (Int32, Pipe) { | |
let task = Process() | |
task.launchPath = "/usr/bin/env" | |
task.arguments = args | |
let pipe = Pipe() | |
if returnStdOut { | |
task.standardOutput = pipe | |
} | |
if let stdIn = stdIn { | |
task.standardInput = stdIn | |
} | |
task.launch() | |
task.waitUntilExit() | |
return (task.terminationStatus, pipe) | |
} | |
extension Pipe { | |
func string() -> String? { | |
let data = self.fileHandleForReading.readDataToEndOfFile() | |
let result: String? | |
if let string = String(data: data, encoding: String.Encoding.utf8) { | |
result = string | |
} else { | |
result = nil | |
} | |
return result | |
} | |
} | |
// MARK: - Codable Types | |
struct ECRRepositories: Codable { | |
let repositories: [ECRRepository] | |
} | |
struct ECRRepository: Codable { | |
let repositoryName: String | |
let repositoryUri: String | |
} | |
struct CreateECRRepositoryResponse: Codable { | |
let repository: ECRRepository | |
} | |
struct TaskDefinitionList: Codable { | |
let taskDefinitionArns: [String] | |
} | |
struct ECRDescribeImages: Codable { | |
let imageDetails: [ECRImageDetails] | |
} | |
struct ECRImageDetails: Codable { | |
let imagePushedAt: String | |
let imageTags: [String] | |
} | |
struct RegisterTaskDefinitionResponse: Codable { | |
let taskDefinition: TaskDefinitionDetail | |
} | |
struct TaskDefinitionDetail: Codable { | |
let taskDefinitionArn: String | |
} | |
struct GetStacksResponse: Codable { | |
let Stacks: [StackDetails] | |
} | |
struct StackDetails: Codable { | |
let StackName: String | |
} | |
// MARK: - Script | |
guard CommandLine.argc > 1 else { | |
print("β ERROR: You must provide the deployment environment as an argument, e.g. test") | |
exit(1) | |
} | |
if CommandLine.arguments.contains("help") { | |
print("===================================================") | |
print("--------------AWS Deployment Script----------------") | |
print("===================================================") | |
print("") | |
print("The script takes two arguments:") | |
print(" 1. Name of the environment to deploy to") | |
print(" 2. Path to a password-free deployment key used for building the container") | |
print(" for the first time. Note that this is not required if the ECR repository") | |
print(" already exists") | |
exit(0) | |
} | |
let environment = CommandLine.arguments[1] | |
print("π Deploying to \(environment)") | |
if let profileName = awsProfileName { | |
print("βΉοΈ Will use profile \(profileName) for AWS actions") | |
} else { | |
print("βΉοΈ Will use no profile for AWS actions") | |
} | |
print("β Checking to see if the repository exists in ECR...") | |
var getECRRepositoriesArgs = ["aws", "ecr", "describe-repositories"] | |
if let profile = awsProfileName { | |
getECRRepositoriesArgs.append(contentsOf: ["--profile", profile]) | |
} | |
let (ecrResult, ecrDataReturned) = shell(getECRRepositoriesArgs, returnStdOut: true) | |
guard ecrResult == 0, let ecrData = ecrDataReturned.string() else { | |
print("β ERROR: Failed to query ECR for repositories") | |
print("Response: \(ecrDataReturned.string() ?? "No response")") | |
exit(1) | |
} | |
let createdECRRepository: Bool | |
var ecrImagePushed: String? = nil | |
var repositoryPushedTo: String? = nil | |
let decoder = JSONDecoder() | |
let existingRepositories = try decoder.decode(ECRRepositories.self, from: ecrData.data(using: .utf8)!) | |
if existingRepositories.repositories.contains(where: {$0.repositoryName == serviceName}) { | |
print("β ECR Repository already exists, won't create again. Will assume that we don't need to push a new image") | |
createdECRRepository = false | |
} else { | |
createdECRRepository = true | |
print("βΉοΈ ECR Repository doesn't exist, creating...") | |
guard CommandLine.argc > 2 else { | |
print("β ERROR: You must provide the deployment key for Docker to use") | |
exit(1) | |
} | |
// Create Repository | |
var ecrCreateArgs = ["aws", "ecr", "create-repository", "--repository-name", serviceName] | |
if let profile = awsProfileName { | |
ecrCreateArgs.append(contentsOf: ["--profile", profile]) | |
} | |
let (ecrCreateResult, ecrCreateResponse) = shell(ecrCreateArgs, returnStdOut: true) | |
guard ecrCreateResult == 0, let ecrCreateString = ecrCreateResponse.string() else { | |
print("β ERROR: Failed to create repository in ECR") | |
print("Response: \(ecrCreateResponse.string() ?? "No response")") | |
exit(1) | |
} | |
let createRepositoryResponse = try decoder.decode(CreateECRRepositoryResponse.self, from: ecrCreateString.data(using: .utf8)!) | |
let repositoryURI = createRepositoryResponse.repository.repositoryUri | |
print("β ECR Repository \(serviceName) created with URI \(repositoryURI)") | |
// Log in to new repository | |
print("π Logging in to ECR...") | |
var ecrLoginArgs = ["aws", "ecr", "get-login-password"] | |
if let profile = awsProfileName { | |
ecrLoginArgs.append(contentsOf: ["--profile", profile]) | |
} | |
let (ecrLoginPasswordResult, ecrLoginPasswordResponse) = shell(ecrLoginArgs, returnStdOut: true) | |
guard ecrLoginPasswordResult == 0 else { | |
print("β ERROR: Failed to log in to get ECR password for Docker") | |
print("Response: \(ecrLoginPasswordResponse.string() ?? "No response")") | |
exit(1) | |
} | |
let dockerLoginArgs = ["docker", "login", "--username", "AWS", "--password-stdin", repositoryURI] | |
let (dockerLoginResult, _) = shell(dockerLoginArgs, stdIn: ecrLoginPasswordResponse) | |
guard dockerLoginResult == 0 else { | |
print("β ERROR: Failed to log in to ECR") | |
exit(1) | |
} | |
print("π Authenticated with ECR") | |
// Build and push container | |
print("π³ Building container...") | |
let (getPrivateKeyResult, getPrivateKeyPipe) = shell("cat", CommandLine.arguments[2], returnStdOut: true) | |
guard getPrivateKeyResult == 0, let privateKey = getPrivateKeyPipe.string() else { | |
print("β ERROR: Failed to get SSH key for Docker") | |
exit(1) | |
} | |
let (gitRevisionResult, gitRevisionPipe) = shell("git", "rev-parse", "HEAD", returnStdOut: true) | |
guard gitRevisionResult == 0, let gitRevision = gitRevisionPipe.string()?.replacingOccurrences(of: "\n", with: "") else { | |
print("β ERROR: Failed to get Git revision") | |
exit(1) | |
} | |
let (dockerBuildResult, _) = shell("docker", "build", "--build-arg", "SSH_PRIVATE_KEY=\(privateKey)", "-t", "\(repositoryURI):\(gitRevision)", "-f", "deploy/Dockerfile", ".") | |
guard dockerBuildResult == 0 else { | |
print("β ERROR: Failed to build container") | |
exit(1) | |
} | |
print("π¦ Pusing container...") | |
let (dockerPushResult, _) = shell("docker", "push", "\(repositoryURI):\(gitRevision)") | |
guard dockerPushResult == 0 else { | |
print("β ERROR: Failed to push container") | |
exit(1) | |
} | |
ecrImagePushed = "\(repositoryURI):\(gitRevision)" | |
repositoryPushedTo = repositoryURI | |
print("β Container pushed to ECR") | |
} | |
print("β Checking for registered task definitions") | |
let taskDefFamily: String | |
if environment == "prod" { | |
taskDefFamily = serviceName | |
} else { | |
taskDefFamily = "\(serviceName)-\(environment)" | |
} | |
var checkTaskDefArgs = ["aws", "ecs", "list-task-definitions", "--family-prefix", taskDefFamily] | |
if let profile = awsProfileName { | |
checkTaskDefArgs.append(contentsOf: ["--profile", profile]) | |
} | |
let (checkTaskDefResult, checkTaskDefPipe) = shell(checkTaskDefArgs, returnStdOut: true) | |
guard checkTaskDefResult == 0, let checkTaskDefResponseString = checkTaskDefPipe.string() else { | |
print("β ERROR: Failed to get task definitions") | |
print("Response: \(checkTaskDefPipe.string() ?? "No response")") | |
exit(1) | |
} | |
let checkTaskDefResponse = try decoder.decode(TaskDefinitionList.self, from: checkTaskDefResponseString.data(using: .utf8)!) | |
let taskDefinitionToUse: String | |
if checkTaskDefResponse.taskDefinitionArns.count == 0 { | |
print("βΉοΈ No registered task definiton found - will create one") | |
let imageForTaskDef: String | |
let repositoryURIForTaskDef: String | |
if let imageAlreadyPushed = ecrImagePushed { | |
imageForTaskDef = imageAlreadyPushed | |
repositoryURIForTaskDef = repositoryPushedTo! | |
} else { | |
print("π¦ Getting latest image ID from ECR") | |
var getLatestImageArgs = ["aws", "ecr", "describe-images", "--repository-name", serviceName] | |
if let profile = awsProfileName { | |
getLatestImageArgs.append(contentsOf: ["--profile", profile]) | |
} | |
let (getLatestECRImageResult, getLatestECRImagePipe) = shell(getLatestImageArgs, returnStdOut: true) | |
guard getLatestECRImageResult == 0, let latestECRImageString = getLatestECRImagePipe.string() else { | |
print("β ERROR: Failed to get latest image from ECR") | |
print("Response: \(getLatestECRImagePipe.string() ?? "No response")") | |
exit(1) | |
} | |
let ecrImageResults = try decoder.decode(ECRDescribeImages.self, from: latestECRImageString.data(using: .utf8)!) | |
let latest = ecrImageResults.imageDetails.sorted(by: { $0.imagePushedAt > $1.imagePushedAt }) | |
let latestHash = latest.first!.imageTags.first! | |
print("Image ID is \(latestHash)") | |
// We will only not push the image if the repository exists so assume we already have it | |
guard let repositoryURI = existingRepositories.repositories.first(where: {$0.repositoryName == serviceName}) else { | |
print("β ERROR: Something went wrong retrieving the Repository URI from memory") | |
exit(1) | |
} | |
imageForTaskDef = "\(repositoryURI.repositoryUri):\(latestHash)" | |
repositoryURIForTaskDef = repositoryURI.repositoryUri | |
} | |
let taskDefFilename: String | |
if environment == "prod" { | |
taskDefFilename = "deploy/task-def.json" | |
} else { | |
taskDefFilename = "deploy/task-def-\(environment).json" | |
} | |
let fileManager = FileManager() | |
let tempFile = "\(taskDefFilename).tmp" | |
let taskDefContents: String | |
do { | |
try fileManager.copyItem(atPath: taskDefFilename, toPath: tempFile) | |
taskDefContents = try String(contentsOfFile: tempFile) | |
} catch { | |
print("β ERROR: Failed to read task def file \(taskDefFilename)") | |
print(error) | |
exit(1) | |
} | |
let newTaskDefContents = taskDefContents.replacingOccurrences(of: "\(repositoryURIForTaskDef):latest", with: imageForTaskDef) | |
do { | |
try newTaskDefContents.write(toFile: tempFile, atomically: true, encoding: .utf8) | |
} catch { | |
print("β ERROR: Failed to write new task def file \(taskDefFilename)") | |
print(error) | |
exit(1) | |
} | |
// Register new file | |
var registerTaskDefArgs = ["aws", "ecs", "register-task-definition", "--cli-input-json", "file://\(tempFile)"] | |
if let profile = awsProfileName { | |
registerTaskDefArgs.append(contentsOf: ["--profile", profile]) | |
} | |
let (registerTaskDefResult, registerTaskDefPipe) = shell(registerTaskDefArgs, returnStdOut: true) | |
guard registerTaskDefResult == 0, let registerTaskDefString = registerTaskDefPipe.string() else { | |
print("β ERROR: Failed to register task definition") | |
print("Response: \(registerTaskDefPipe.string() ?? "No response")") | |
exit(1) | |
} | |
let registerTaskDefinitionResponse = try decoder.decode(RegisterTaskDefinitionResponse.self, from: registerTaskDefString.data(using: .utf8)!) | |
taskDefinitionToUse = registerTaskDefinitionResponse.taskDefinition.taskDefinitionArn | |
// Clean up | |
try fileManager.removeItem(atPath: tempFile) | |
print("β New task definition registered") | |
} else { | |
print("βΉοΈ Found task definitions - assume they're kept up to date with CI. Will use latest one") | |
taskDefinitionToUse = checkTaskDefResponse.taskDefinitionArns.last! | |
} | |
print("π Deploying CF stack...") | |
let stackName: String | |
if environment == "prod" { | |
stackName = "\(serviceName)-stack" | |
} else { | |
stackName = "\(serviceName)-\(environment)-stack" | |
} | |
var deployStackArgs = ["aws", "cloudformation", "deploy", "--stack-name", stackName, "--template-file", "deploy/deploy.yaml", "--parameter-overrides", "EnvironmentName=\(environment)", "TaskDefinition=\(taskDefinitionToUse)", "--capabilities", "CAPABILITY_NAMED_IAM"] | |
if let profile = awsProfileName { | |
deployStackArgs.append(contentsOf: ["--profile", profile]) | |
} | |
let (deployStackResult, _) = shell(deployStackArgs, returnStdOut: true) | |
guard deployStackResult == 0 else { | |
print("β ERROR: Failed to deploy stack \(stackName)") | |
exit(1) | |
} | |
print("β Stack \(stackName) deployed") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment