├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── clone.sh ├── cluster.template ├── common.sh ├── filterEnv.js ├── main.sh └── runbuild.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | *.template 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lambci.log 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.4 2 | 3 | WORKDIR /src 4 | 5 | RUN apk add --no-cache bash curl git docker nodejs 6 | 7 | ADD . . 8 | 9 | CMD ./main.sh 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LambCI ECS cluster and Docker image 2 | 3 | More documentation should be coming soon, but to get up and running quickly, 4 | launch the `cluster.template` file in CloudFormation and give your stack a name like `lambci-ecs` 5 | 6 | (You should have already created a LambCI stack as documented at https://github.com/lambci/lambci) 7 | 8 | This will create an auto-scaling group and an ECS cluster and task definition, 9 | which you can find in the AWS console from `Services > EC2 Container Service` 10 | 11 | ## LambCI configuration 12 | 13 | You'll need to give the Lambda function in your LambCI stack access to run the task, so will need add to IAM 14 | permissions something like this: 15 | 16 | ```json 17 | { 18 | "Effect": "Allow", 19 | "Action": "ecs:RunTask", 20 | "Resource": "arn:aws:ecs:*:*:task-definition/lambci-ecs-BuildTask-1PVABCDEFKFT" 21 | } 22 | ``` 23 | 24 | Where you replace the resource with the name of the ECS task definition created in your `lambci-ecs` stack. 25 | 26 | Then in the project you want to build using ECS, you'll need to ensure the following LambCI config settings are given: 27 | 28 | ```js 29 | { 30 | docker: { 31 | cluster: 'lambci-ecs-Cluster-1TZABCDEF987', 32 | task: 'lambci-ecs-BuildTask-1PVABCDEFKFT', 33 | } 34 | } 35 | ``` 36 | 37 | (replacing with the actual names of your cluster and task) 38 | 39 | These are normal LambCI config settings which you can set in your `.lambci.js[on]` file or in the config DB. 40 | -------------------------------------------------------------------------------- /clone.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | CLONE_URL="https://${GITHUB_TOKEN}@github.com/${LAMBCI_CLONE_REPO}.git" 4 | 5 | rm -rf "$CLONE_DIR" 6 | 7 | git clone --depth 5 "$CLONE_URL" -b "$LAMBCI_CHECKOUT_BRANCH" "$CLONE_DIR" 8 | 9 | # Echo a "safe" version of the command 10 | echo "+ git clone --depth 5 ${CLONE_URL/$GITHUB_TOKEN/XXXX} -b $LAMBCI_CHECKOUT_BRANCH $CLONE_DIR" 11 | 12 | set -x 13 | 14 | cd "$CLONE_DIR" 15 | 16 | git checkout -qf $LAMBCI_COMMIT 17 | 18 | -------------------------------------------------------------------------------- /cluster.template: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Description": "LambCI build servers running on ECS", 4 | "Parameters": { 5 | "InstanceType": { 6 | "Description": "EC2 instance type (t2.micro, t2.medium, t2.large, etc)", 7 | "Type": "String", 8 | "Default": "t2.micro", 9 | "ConstraintDescription": "must be a valid EC2 instance type." 10 | } 11 | }, 12 | "Mappings": { 13 | "EcsAmisByRegion": { 14 | "us-east-1": {"ami": "ami-275ffe31"}, 15 | "us-east-2": {"ami": "ami-62745007"}, 16 | "us-west-1": {"ami": "ami-689bc208"}, 17 | "us-west-2": {"ami": "ami-62d35c02"}, 18 | "eu-west-1": {"ami": "ami-95f8d2f3"}, 19 | "eu-west-2": {"ami": "ami-bf9481db"}, 20 | "eu-central-1": {"ami": "ami-085e8a67"}, 21 | "ap-northeast-1": {"ami": "ami-f63f6f91"}, 22 | "ap-southeast-1": {"ami": "ami-b4ae1dd7"}, 23 | "ap-southeast-2": {"ami": "ami-fbe9eb98"}, 24 | "ca-central-1": {"ami": "ami-ee58e58a"} 25 | } 26 | }, 27 | "Resources": { 28 | "Cluster": { 29 | "Type": "AWS::ECS::Cluster" 30 | }, 31 | "BuildTask": { 32 | "Type": "AWS::ECS::TaskDefinition", 33 | "Properties": { 34 | "ContainerDefinitions": [{ 35 | "Name": "build", 36 | "Image": "lambci/ecs", 37 | "Memory": 450, 38 | "LogConfiguration": { 39 | "LogDriver": "awslogs", 40 | "Options": { 41 | "awslogs-group": {"Ref": "EcsLogs"}, 42 | "awslogs-region": {"Ref": "AWS::Region"} 43 | } 44 | }, 45 | "Environment": [ 46 | {"Name": "LOG_GROUP", "Value": {"Ref": "EcsLogs"}}, 47 | {"Name": "AWS_REGION", "Value": {"Ref": "AWS::Region"}} 48 | ], 49 | "MountPoints": [{"SourceVolume": "docker-socket", "ContainerPath": "/var/run/docker.sock"}] 50 | }], 51 | "Volumes": [{"Name": "docker-socket", "Host": {"SourcePath": "/var/run/docker.sock"}}] 52 | } 53 | }, 54 | "EcsLogs": { 55 | "Type": "AWS::Logs::LogGroup" 56 | }, 57 | "AutoScalingGroup": { 58 | "Type": "AWS::AutoScaling::AutoScalingGroup", 59 | "Properties": { 60 | "AvailabilityZones": {"Fn::GetAZs": ""}, 61 | "LaunchConfigurationName": {"Ref": "LaunchConfig"}, 62 | "DesiredCapacity": "1", 63 | "MinSize": "0", 64 | "MaxSize": "4" 65 | }, 66 | "CreationPolicy": { 67 | "ResourceSignal": { 68 | "Count": "1" 69 | } 70 | } 71 | }, 72 | "LaunchConfig": { 73 | "Type": "AWS::AutoScaling::LaunchConfiguration", 74 | "Properties": { 75 | "ImageId": {"Fn::FindInMap": ["EcsAmisByRegion", {"Ref": "AWS::Region"}, "ami"]}, 76 | "IamInstanceProfile": {"Ref": "InstanceProfile"}, 77 | "InstanceType": {"Ref": "InstanceType"}, 78 | "UserData": { 79 | "Fn::Base64": { 80 | "Fn::Join": ["", [ 81 | "#!/bin/bash\n", 82 | "echo ECS_CLUSTER=", {"Ref": "Cluster"}, " >> /etc/ecs/ecs.config\n", 83 | "yum install -y aws-cfn-bootstrap\n", 84 | "/opt/aws/bin/cfn-signal -e $? --resource AutoScalingGroup --stack ", {"Ref": "AWS::StackName"}, " --region ", {"Ref": "AWS::Region"} 85 | ]] 86 | } 87 | } 88 | } 89 | }, 90 | "InstanceProfile": { 91 | "Type": "AWS::IAM::InstanceProfile", 92 | "Properties": { 93 | "Path": "/", 94 | "Roles": [{"Ref": "InstanceRole"}] 95 | } 96 | }, 97 | "InstanceRole": { 98 | "Type": "AWS::IAM::Role", 99 | "Properties": { 100 | "AssumeRolePolicyDocument": { 101 | "Statement": { 102 | "Effect": "Allow", 103 | "Principal": {"Service": "ec2.amazonaws.com"}, 104 | "Action": "sts:AssumeRole" 105 | } 106 | }, 107 | "Policies": [{ 108 | "PolicyName": "RunEcs", 109 | "PolicyDocument": { 110 | "Statement": { 111 | "Effect": "Allow", 112 | "Action": [ 113 | "ecs:DeregisterContainerInstance", 114 | "ecs:DiscoverPollEndpoint", 115 | "ecs:Poll", 116 | "ecs:RegisterContainerInstance", 117 | "ecs:StartTelemetrySession", 118 | "ecs:Submit*" 119 | ], 120 | "Resource": "*" 121 | } 122 | } 123 | },{ 124 | "PolicyName": "WriteLogs", 125 | "PolicyDocument": { 126 | "Statement": { 127 | "Effect": "Allow", 128 | "Action": [ 129 | "logs:CreateLogStream", 130 | "logs:PutLogEvents" 131 | ], 132 | "Resource": "*" 133 | } 134 | } 135 | }] 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /common.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | [ -n "$START_TIME" ] || export START_TIME=$(date +%s) 4 | 5 | export SCRIPT_DIR=$(cd $(dirname $0) && pwd) 6 | 7 | export CLONE_DIR="/tmp/lambci/${LAMBCI_REPO}/${LAMBCI_COMMIT}" 8 | 9 | export LAMBCI_BUILD_NUM="${LAMBCI_BUILD_NUM:-1}" 10 | export LAMBCI_BRANCH="${LAMBCI_BRANCH:-master}" 11 | export LAMBCI_CLONE_REPO="${LAMBCI_CLONE_REPO:-$LAMBCI_REPO}" 12 | export LAMBCI_CHECKOUT_BRANCH="${LAMBCI_CHECKOUT_BRANCH:-$LAMBCI_BRANCH}" 13 | export LAMBCI_DOCKER_CMD="${LAMBCI_DOCKER_CMD:-$SCRIPT_DIR/runbuild.sh}" 14 | export LAMBCI_DOCKER_FILE="${LAMBCI_DOCKER_FILE:-Dockerfile.test}" 15 | export LAMBCI_DOCKER_TAG="${LAMBCI_DOCKER_TAG:-lambci-ecs-${LAMBCI_REPO,,}:${LAMBCI_COMMIT}}" 16 | 17 | export CONTAINER_ID=$(cat /proc/self/cgroup | grep "cpu:/" | sed 's/\([0-9]\):cpu:\/docker\///g') 18 | 19 | export LOG_FILE="${SCRIPT_DIR}/lambci.log" 20 | export LOG_GROUP="${LOG_GROUP:-/lambci/ecs}" 21 | export LOG_STREAM="${LOG_STREAM:-$CONTAINER_ID}" 22 | 23 | cleanup() { 24 | EXIT_STATUS=$1 25 | 26 | if [ $EXIT_STATUS -eq 0 ]; then 27 | echo "Build #${LAMBCI_BUILD_NUM} successful" | tee "$LOG_FILE" 28 | github_status success "Build #${LAMBCI_BUILD_NUM} successful" 29 | slack_status good "Build #${LAMBCI_BUILD_NUM} successful" '' "Success: ${LAMBCI_REPO} #${LAMBCI_BUILD_NUM}" 30 | else 31 | echo "Build #${LAMBCI_BUILD_NUM} failed" | tee "$LOG_FILE" 32 | github_status failure "Build #${LAMBCI_BUILD_NUM} failed" 33 | slack_status danger "Build #${LAMBCI_BUILD_NUM} failed" "$(json_escape "\`\`\`$(tail -60 "$LOG_FILE")\`\`\`")" \ 34 | "Failed: ${LAMBCI_REPO} #${LAMBCI_BUILD_NUM}" 35 | fi 36 | } 37 | 38 | github_status() { 39 | github_request "repos/${LAMBCI_REPO}/statuses/${LAMBCI_COMMIT}" \ 40 | '{ 41 | "state": "'"$1"'", 42 | "description": "'"$2"'", 43 | "target_url": "'"$(aws_log_url)"'", 44 | "context": "continuous-integration/lambci" 45 | }' 46 | } 47 | 48 | slack_status() { 49 | if [ -n "$3" ]; then 50 | # Must be JSON escaped already, including surrounding double quotes 51 | local STATUS_TEXT='"text": '"$3"', "mrkdwn_in": ["text"],' 52 | fi 53 | if [ -n "$LAMBCI_PULL_REQUEST" ]; then 54 | local TITLE="Pull Request" 55 | local VALUE="" 56 | else 57 | local TITLE="Branch" 58 | local VALUE="" 59 | fi 60 | slack_msg --data-urlencode attachments=\ 61 | '[{ 62 | "color": "'"$1"'", 63 | "title": "'"$2"'", 64 | "title_link": "'"$(aws_log_url)"'", 65 | "fallback": "'"${4:-$2}"'", 66 | '"$STATUS_TEXT"' 67 | "fields": [{ 68 | "title": "Repository", 69 | "value": "", 70 | "short": true 71 | }, { 72 | "title": "'"$TITLE"'", 73 | "value": "'"$VALUE"'", 74 | "short": true 75 | }] 76 | }]' 77 | } 78 | 79 | github_request() { 80 | [ -n "$GITHUB_TOKEN" ] || return 0 81 | curl -s -u ${GITHUB_TOKEN}:x-oauth-basic -H 'User-Agent: lambci' \ 82 | -H 'Accept: application/vnd.github.v3+json' -H 'Content-Type: application/vnd.github.v3+json' \ 83 | -d "$2" "https://api.github.com/$1" > /dev/null 84 | } 85 | 86 | slack_msg() { 87 | [ -n "$SLACK_TOKEN" ] || return 0 88 | ARGS=(-d token="$SLACK_TOKEN" -d channel="${SLACK_CHANNEL:-#general}" -d username="${SLACK_USERNAME:-LambCI}") 89 | ARGS+=(-d icon_url="${SLACK_ICON_URL:-https://lambci.s3.amazonaws.com/assets/logo-48x48.png}") 90 | [ -n "$SLACK_AS_USER" ] && ARGS+=(-d as_user=true) 91 | curl -s "${ARGS[@]}" "$@" https://slack.com/api/chat.postMessage > /dev/null 92 | } 93 | 94 | aws_log_url() { 95 | node -p ' 96 | var region = "'"${AWS_REGION:-us-east-1}"'" 97 | var params = { 98 | group: "'"$LOG_GROUP"'", 99 | stream: "'"$LOG_STREAM"'", 100 | start: new Date('$START_TIME' * 1000).toISOString().slice(0, 19) + "Z", 101 | } 102 | "https://console.aws.amazon.com/cloudwatch/home?region=" + region + "#logEvent:" + 103 | Object.keys(params).map(function(key) { return key + "=" + encodeURIComponent(params[key]) }).join(";") 104 | ' 105 | } 106 | 107 | json_escape() { 108 | node -p 'JSON.stringify(process.argv[1])' -- "$1" 109 | } 110 | 111 | -------------------------------------------------------------------------------- /filterEnv.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | 3 | var dockerfile = process.argv[2] || 'Dockerfile.test' 4 | var envCopy = Object.assign({}, process.env) 5 | 6 | var dockerfileLines 7 | try { 8 | dockerfileLines = fs.readFileSync(dockerfile, 'utf8').split(/\r?\n/g) 9 | } catch (e) { 10 | dockerfileLines = [] 11 | } 12 | 13 | // Remove all variables from process.env that exist in the given Dockerfile 14 | var inEnv = false 15 | dockerfileLines.forEach(function(line) { 16 | if (!inEnv && !/^ENV\s/.test(line)) { 17 | inEnv = false 18 | return 19 | } 20 | var pieces = line.trim().split(/\s+/g) 21 | if (!inEnv) { 22 | pieces = pieces.slice(1) 23 | } 24 | if (pieces[pieces.length - 1] == '\\') { 25 | inEnv = true 26 | pieces.pop() 27 | } else { 28 | inEnv = false 29 | } 30 | if (pieces.length == 2 && !~pieces[0].indexOf('=')) { 31 | pieces = [pieces.join('=')] 32 | } 33 | pieces.forEach(piece => delete envCopy[piece.split('=')[0]]) 34 | }) 35 | 36 | // Also remove variables that don't make sense to pass 37 | delete envCopy.HOME 38 | delete envCopy.HOSTNAME 39 | delete envCopy.PWD 40 | delete envCopy.TERM 41 | delete envCopy.SHELL 42 | delete envCopy.SHLVL 43 | delete envCopy._ 44 | 45 | console.log(Object.keys(envCopy).join('\n')) 46 | -------------------------------------------------------------------------------- /main.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | . $(dirname $0)/common.sh 4 | 5 | { "$SCRIPT_DIR/clone.sh" && cd "$CLONE_DIR" && $LAMBCI_DOCKER_CMD; } 2>&1 | tee "$LOG_FILE" 6 | 7 | cleanup ${PIPESTATUS[0]} 8 | -------------------------------------------------------------------------------- /runbuild.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Try to clean up old images, but don't care if we fail 4 | OLD_IMGS=$(docker images | grep -E '^lambci-ecs-.+(week|month|year)s? ago' | awk '{print $3}') 5 | [ -z "$OLD_IMGS" ] || docker rmi $OLD_IMGS 2>/dev/null || true 6 | 7 | set -x 8 | 9 | # Build the repo's Dockerfile.test 10 | docker build --pull -f "$LAMBCI_DOCKER_FILE" -t $LAMBCI_DOCKER_TAG $LAMBCI_DOCKER_BUILD_ARGS . 11 | 12 | set +x 13 | 14 | # Pass a filtered list of env vars through to docker run 15 | # TODO: Does it even make sense to have this feature? 16 | ENV_ARGS=$(node ${SCRIPT_DIR}/filterEnv "$LAMBCI_DOCKER_FILE" | awk '{print "-e "$1}') 17 | 18 | set -x 19 | 20 | docker run --rm $ENV_ARGS $LAMBCI_DOCKER_RUN_ARGS $LAMBCI_DOCKER_TAG 21 | 22 | # We don't want to remove the docker image right away because we want to keep the cached layers 23 | --------------------------------------------------------------------------------