diff --git a/fargate-support/ecs-deploy b/fargate-support/ecs-deploy new file mode 100644 index 0000000..56f443f --- /dev/null +++ b/fargate-support/ecs-deploy @@ -0,0 +1,770 @@ +#!/usr/bin/env bash + +# Setup default values for variables +VERSION="3.9.1" +CLUSTER=false +SERVICE=false +TASK_DEFINITION=false +TASK_DEFINITION_FILE=false +MAX_DEFINITIONS=0 +AWS_ASSUME_ROLE=false +IMAGE=false +MIN=false +MAX=false +TIMEOUT=90 +VERBOSE=false +TAGVAR=false +TAGONLY="" +ENABLE_ROLLBACK=false +USE_MOST_RECENT_TASK_DEFINITION=false +AWS_CLI=$(which aws) +AWS_ECS="$AWS_CLI --output json ecs" +FORCE_NEW_DEPLOYMENT=false +SKIP_DEPLOYMENTS_CHECK=false +RUN_TASK=false +RUN_TASK_LAUNCH_TYPE=false +RUN_TASK_NETWORK_CONFIGURATION=false +RUN_TASK_WAIT_FOR_SUCCESS=false + +function usage() { + cat < /dev/null 2>&1 || { + echo "Some of the required software is not installed:" + echo " please install $1" >&2; + exit 4; + } +} + +function assumeRole() { + + temp_role=$(aws sts assume-role \ + --role-arn "${AWS_ASSUME_ROLE}" \ + --role-session-name "$(date +"%s")") + + export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq .Credentials.AccessKeyId | xargs) + export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq .Credentials.SecretAccessKey | xargs) + export AWS_SESSION_TOKEN=$(echo $temp_role | jq .Credentials.SessionToken | xargs) +} + + +function assumeRoleClean() { + unset AWS_ACCESS_KEY_ID + unset AWS_SECRET_ACCESS_KEY + unset AWS_SESSION_TOKEN +} + + +# Check that all required variables/combinations are set +function assertRequiredArgumentsSet() { + + # AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION and AWS_PROFILE can be set as environment variables + if [ -z ${AWS_ACCESS_KEY_ID+x} ]; then unset AWS_ACCESS_KEY_ID; fi + if [ -z ${AWS_SECRET_ACCESS_KEY+x} ]; then unset AWS_SECRET_ACCESS_KEY; fi + if [ -z ${AWS_DEFAULT_REGION+x} ]; + then unset AWS_DEFAULT_REGION + else + AWS_ECS="$AWS_ECS --region $AWS_DEFAULT_REGION" + fi + if [ -z ${AWS_PROFILE+x} ]; + then unset AWS_PROFILE + else + AWS_ECS="$AWS_ECS --profile $AWS_PROFILE" + fi + + if [ $SERVICE == false ] && [ $TASK_DEFINITION == false ]; then + echo "One of SERVICE or TASK DEFINITION is required. You can pass the value using -n / --service-name for a service, or -d / --task-definition for a task" + exit 5 + fi + if [ $SERVICE != false ] && [ $TASK_DEFINITION != false ]; then + echo "Only one of SERVICE or TASK DEFINITION may be specified, but you supplied both" + exit 6 + fi + if [ $SERVICE != false ] && [ $CLUSTER == false ]; then + echo "CLUSTER is required. You can pass the value using -c or --cluster" + exit 7 + fi + if [ $IMAGE == false ] && [ $FORCE_NEW_DEPLOYMENT == false ]; then + echo "IMAGE is required. You can pass the value using -i or --image" + exit 8 + fi + if ! [[ $MAX_DEFINITIONS =~ ^-?[0-9]+$ ]]; then + echo "MAX_DEFINITIONS must be numeric, or not defined." + exit 9 + fi + + if [ $RUN_TASK == false ] && [ $RUN_TASK_LAUNCH_TYPE != false ]; then + echo 'LAUNCH TYPE requires setting RUN TASK argument. You can set it using --run-task flag.' + exit 10 + fi + + if [ $RUN_TASK == false ] && [ $RUN_TASK_NETWORK_CONFIGURATION != false ]; then + echo 'NETWORK CONFIGURATION requires setting RUN TASK argument. You can set it using --run-task flag.' + exit 11 + fi + + if [ $RUN_TASK == false ] && [ $RUN_TASK_WAIT_FOR_SUCCESS != false ]; then + echo 'WAIT FOR SUCCESS requires setting RUN TASK argument. You can set it using --run-task flag.' + exit 11 + fi + +} + +function parseImageName() { + + # Define regex for image name + # This regex will create groups for: + # - domain + # - port + # - repo + # - image + # - tag + # If a group is missing it will be an empty string + if [[ "x$TAGONLY" == "x" ]]; then + imageRegex="^([a-zA-Z0-9\.\-]+):?([0-9]+)?/([a-zA-Z0-9\._\-]+)(/[\/a-zA-Z0-9\._\-]+)?:?([a-zA-Z0-9\._\-]+)?$" + else + imageRegex="^:?([a-zA-Z0-9\._-]+)?$" + fi + + if [[ $IMAGE =~ $imageRegex ]]; then + # Define variables from matching groups + if [[ "x$TAGONLY" == "x" ]]; then + domain=${BASH_REMATCH[1]} + port=${BASH_REMATCH[2]} + repo=${BASH_REMATCH[3]} + img=${BASH_REMATCH[4]/#\//} + tag=${BASH_REMATCH[5]} + + # Validate what we received to make sure we have the pieces needed + if [[ "x$domain" == "x" ]]; then + echo "Image name does not contain a domain or repo as expected. See usage for supported formats." + exit 10; + fi + if [[ "x$repo" == "x" ]]; then + echo "Image name is missing the actual image name. See usage for supported formats." + exit 11; + fi + + # When a match for image is not found, the image name was picked up by the repo group, so reset variables + if [[ "x$img" == "x" ]]; then + img=$repo + repo="" + fi + else + tag=${BASH_REMATCH[1]} + domain="" + port="" + repo="" + img="" + fi + else + # check if using root level repo with format like mariadb or mariadb:latest + rootRepoRegex="^([a-zA-Z0-9\-]+):?([a-zA-Z0-9\.\-]+)?$" + if [[ $IMAGE =~ $rootRepoRegex ]]; then + img=${BASH_REMATCH[1]} + if [[ "x$img" == "x" ]]; then + echo "Invalid image name. See usage for supported formats." + exit 12 + fi + tag=${BASH_REMATCH[2]} + + # for root level repo, initialize unused variables for checks when rebuilding image below + domain="" + port="" + repo="" + + else + echo "Unable to parse image name: $IMAGE, check the format and try again" + exit 13 + fi + fi + + # If tag is missing make sure we can get it from env var, or use latest as default + if [[ "x$tag" == "x" ]]; then + if [[ $TAGVAR == false ]]; then + tag="latest" + else + tag=${!TAGVAR} + if [[ "x$tag" == "x" ]]; then + tag="latest" + fi + fi + fi + + # Reassemble image name + useImage="" + if [[ "x$TAGONLY" == "x" ]]; then + + if [[ ! -z "$domain" ]]; then + useImage="$domain" + fi + if [[ ! -z "$port" ]]; then + useImage="$useImage:$port" + fi + if [[ ! -z "$repo" ]]; then + useImage="$useImage/$repo" + fi + if [[ ! -z "$img" ]]; then + if [[ -z "$useImage" ]]; then + useImage="$img" + else + useImage="$useImage/$img" + fi + fi + imageWithoutTag="$useImage" + if [[ ! -z "$tag" ]]; then + useImage="$useImage:$tag" + fi + + else + useImage="$TAGONLY" + fi + + # If in test mode output $useImage + if [ "$BASH_SOURCE" != "$0" ]; then + echo $useImage + fi +} + +function getCurrentTaskDefinition() { + if [ $SERVICE != false ]; then + # Get current task definition arn from service + TASK_DEFINITION_ARN=`$AWS_ECS describe-services --services $SERVICE --cluster $CLUSTER | jq -r .services[0].taskDefinition` + TASK_DEFINITION=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION_ARN` + + # For rollbacks + LAST_USED_TASK_DEFINITION_ARN=$TASK_DEFINITION_ARN + + if [ $USE_MOST_RECENT_TASK_DEFINITION != false ]; then + # Use the most recently created TD of the family; rather than the most recently used. + TASK_DEFINITION_FAMILY=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION_ARN | jq -r .taskDefinition.family` + TASK_DEFINITION=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION_FAMILY` + TASK_DEFINITION_ARN=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION_FAMILY | jq -r .taskDefinition.taskDefinitionArn` + fi + elif [ $TASK_DEFINITION != false ]; then + # Get current task definition arn from family[:revision] (or arn) + TASK_DEFINITION_ARN=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION | jq -r .taskDefinition.taskDefinitionArn` + fi + TASK_DEFINITION=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION_ARN` +} + +function createNewTaskDefJson() { + + if [ $TASK_DEFINITION_FILE == false ]; then + taskDefinition="$TASK_DEFINITION" + else + taskDefinition="$(cat $TASK_DEFINITION_FILE)" + fi + + # Get a JSON representation of the current task definition + # + Update definition to use new image name + # + Filter the def + if [[ "x$TAGONLY" == "x" ]]; then + DEF=$( echo "$TASK_DEFINITION" \ + | sed -e 's~"image":.*'"${imageWithoutTag}"'.*,~"image": "'"${useImage}"'",~g' \ + | jq '.taskDefinition' ) + else + DEF=$( echo "$taskDefinition" \ + | sed -e "s|\(\"image\": *\".*:\)\(.*\)\"|\1${useImage}\"|g" \ + | jq '.taskDefinition' ) + fi + + # Default JQ filter for new task definition + NEW_DEF_JQ_FILTER="family: .family, volumes: .volumes, containerDefinitions: .containerDefinitions, placementConstraints: .placementConstraints" + + # Some options in task definition should only be included in new definition if present in + # current definition. If found in current definition, append to JQ filter. + CONDITIONAL_OPTIONS=(networkMode taskRoleArn placementConstraints executionRoleArn) + for i in "${CONDITIONAL_OPTIONS[@]}"; do + re=".*${i}.*" + if [[ "$DEF" =~ $re ]]; then + NEW_DEF_JQ_FILTER="${NEW_DEF_JQ_FILTER}, ${i}: .${i}" + fi + done + + # Updated jq filters for AWS Fargate + REQUIRES_COMPATIBILITIES=$(echo "${DEF}" | jq -r '. | select(.requiresCompatibilities != null) | .requiresCompatibilities[]') + if `echo ${REQUIRES_COMPATIBILITIES[@]} | grep -q "FARGATE"`; then + FARGATE_JQ_FILTER='requiresCompatibilities: .requiresCompatibilities, cpu: .cpu, memory: .memory' + + if [[ ! "$NEW_DEF_JQ_FILTER" =~ ".*executionRoleArn.*" ]]; then + FARGATE_JQ_FILTER="${FARGATE_JQ_FILTER}, executionRoleArn: .executionRoleArn" + fi + NEW_DEF_JQ_FILTER="${NEW_DEF_JQ_FILTER}, ${FARGATE_JQ_FILTER}" + fi + + # Build new DEF with jq filter + NEW_DEF=$(echo "$DEF" | jq "{${NEW_DEF_JQ_FILTER}}") + + # If in test mode output $NEW_DEF + if [ "$BASH_SOURCE" != "$0" ]; then + echo "$NEW_DEF" + fi +} + +function registerNewTaskDefinition() { + # Register the new task definition, and store its ARN + NEW_TASKDEF=`$AWS_ECS register-task-definition --cli-input-json "$NEW_DEF" | jq -r .taskDefinition.taskDefinitionArn` +} + +function rollback() { + echo "Rolling back to ${LAST_USED_TASK_DEFINITION_ARN}" + $AWS_ECS update-service --cluster $CLUSTER --service $SERVICE --task-definition $LAST_USED_TASK_DEFINITION_ARN > /dev/null +} + +function updateServiceForceNewDeployment() { + echo 'Force a new deployment of the service' + $AWS_ECS update-service --cluster $CLUSTER --service $SERVICE --force-new-deployment > /dev/null +} + +function updateService() { + if [[ $(echo ${NEW_DEF} | jq ".containerDefinitions[0].healthCheck != null") == true ]]; then + checkFieldName="healthStatus" + checkFieldValue="HEALTHY" + else + checkFieldName="lastStatus" + checkFieldValue="RUNNING" + fi + + UPDATE_SERVICE_SUCCESS="false" + DEPLOYMENT_CONFIG="" + if [ $MAX != false ]; then + DEPLOYMENT_CONFIG=",maximumPercent=$MAX" + fi + if [ $MIN != false ]; then + DEPLOYMENT_CONFIG="$DEPLOYMENT_CONFIG,minimumHealthyPercent=$MIN" + fi + if [ ! -z "$DEPLOYMENT_CONFIG" ]; then + DEPLOYMENT_CONFIG="--deployment-configuration ${DEPLOYMENT_CONFIG:1}" + fi + + DESIRED_COUNT="" + if [ ! -z ${DESIRED+undefined-guard} ]; then + DESIRED_COUNT="--desired-count $DESIRED" + fi + + # Update the service + UPDATE=`$AWS_ECS update-service --cluster $CLUSTER --service $SERVICE $DESIRED_COUNT --task-definition $NEW_TASKDEF $DEPLOYMENT_CONFIG` + + # Only excepts RUNNING state from services whose desired-count > 0 + SERVICE_DESIREDCOUNT=`$AWS_ECS describe-services --cluster $CLUSTER --service $SERVICE | jq '.services[]|.desiredCount'` + if [ $SERVICE_DESIREDCOUNT -gt 0 ]; then + # See if the service is able to come up again + every=10 + i=0 + while [ $i -lt $TIMEOUT ] + do + # Scan the list of running tasks for that service, and see if one of them is the + # new version of the task definition + + RUNNING_TASKS=$($AWS_ECS list-tasks --cluster "$CLUSTER" --service-name "$SERVICE" --desired-status RUNNING \ + | jq -r '.taskArns[]') + + if [[ ! -z $RUNNING_TASKS ]] ; then + RUNNING=$($AWS_ECS describe-tasks --cluster "$CLUSTER" --tasks $RUNNING_TASKS \ + | jq ".tasks[]| if .taskDefinitionArn == \"$NEW_TASKDEF\" then . else empty end|.${checkFieldName}" \ + | grep -e "${checkFieldValue}") || : + + if [ "$RUNNING" ]; then + echo "Service updated successfully, new task definition running."; + + if [[ $MAX_DEFINITIONS -gt 0 ]]; then + FAMILY_PREFIX=${TASK_DEFINITION_ARN##*:task-definition/} + FAMILY_PREFIX=${FAMILY_PREFIX%*:[0-9]*} + TASK_REVISIONS=`$AWS_ECS list-task-definitions --family-prefix $FAMILY_PREFIX --status ACTIVE --sort ASC` + NUM_ACTIVE_REVISIONS=$(echo "$TASK_REVISIONS" | jq ".taskDefinitionArns|length") + if [[ $NUM_ACTIVE_REVISIONS -gt $MAX_DEFINITIONS ]]; then + LAST_OUTDATED_INDEX=$(($NUM_ACTIVE_REVISIONS - $MAX_DEFINITIONS - 1)) + for i in $(seq 0 $LAST_OUTDATED_INDEX); do + OUTDATED_REVISION_ARN=$(echo "$TASK_REVISIONS" | jq -r ".taskDefinitionArns[$i]") + + echo "Deregistering outdated task revision: $OUTDATED_REVISION_ARN" + + $AWS_ECS deregister-task-definition --task-definition "$OUTDATED_REVISION_ARN" > /dev/null + done + fi + + fi + UPDATE_SERVICE_SUCCESS="true" + break + fi + fi + + sleep $every + i=$(( $i + $every )) + done + + if [[ "${UPDATE_SERVICE_SUCCESS}" != "true" ]]; then + # Timeout + echo "ERROR: New task definition not running within $TIMEOUT seconds" + if [[ "${ENABLE_ROLLBACK}" != "false" ]]; then + rollback + fi + exit 1 + fi + else + echo "Skipping check for running task definition, as desired-count <= 0" + fi +} + +function waitForGreenDeployment { + DEPLOYMENT_SUCCESS="false" + every=2 + i=0 + echo "Waiting for service deployment to complete..." + while [ $i -lt $TIMEOUT ] + do + NUM_DEPLOYMENTS=$($AWS_ECS describe-services --services $SERVICE --cluster $CLUSTER | jq "[.services[].deployments[]] | length") + + # Wait to see if more than 1 deployment stays running + # If the wait time has passed, we need to roll back + if [ $NUM_DEPLOYMENTS -eq 1 ]; then + echo "Service deployment successful." + DEPLOYMENT_SUCCESS="true" + # Exit the loop. + i=$TIMEOUT + else + sleep $every + i=$(( $i + $every )) + fi + done + + if [[ "${DEPLOYMENT_SUCCESS}" != "true" ]]; then + if [[ "${ENABLE_ROLLBACK}" != "false" ]]; then + rollback + fi + exit 1 + fi +} + +function runTask { + echo "Run task: $NEW_TASKDEF"; + AWS_ECS_RUN_TASK="$AWS_ECS run-task --cluster $CLUSTER --task-definition $NEW_TASKDEF" + if [ $RUN_TASK_LAUNCH_TYPE != false ]; then + AWS_ECS_RUN_TASK="$AWS_ECS_RUN_TASK --launch-type $RUN_TASK_LAUNCH_TYPE" + fi + + if [ $RUN_TASK_NETWORK_CONFIGURATION != false ]; then + AWS_ECS_RUN_TASK="$AWS_ECS_RUN_TASK --network-configuration \"$RUN_TASK_NETWORK_CONFIGURATION\"" + fi + + TASK_ARN=$(eval $AWS_ECS_RUN_TASK | jq -r '.tasks[0].taskArn') + echo "Executed task: $TASK_ARN" + + if [ $RUN_TASK_WAIT_FOR_SUCCESS == true ]; then + RUN_TASK_SUCCESS=false + every=10 + i=0 + while [ $i -lt $TIMEOUT ] + do + + TASK_JSON=$($AWS_ECS describe-tasks --cluster "$CLUSTER" --tasks "$TASK_ARN") + + TASK_STATUS=$(echo $TASK_JSON | jq -r '.tasks[0].lastStatus') + TASK_EXIT_CODE=$(echo $TASK_JSON | jq -r '.tasks[0].containers[0].exitCode') + + if [ $TASK_STATUS == "STOPPED" ]; then + echo "Task finished with status: $TASK_STATUS" + if [ $TASK_EXIT_CODE != 0 ]; then + echo "Task execution failed with exit code: $TASK_EXIT_CODE" + exit 1 + fi + RUN_TASK_SUCCESS=true + break; + fi + + echo "Checking task status every $every seconds. Status: $TASK_STATUS" + + sleep $every + i=$(( $i + $every )) + done + + if [ $RUN_TASK_SUCCESS == false ]; then + echo "ERROR: New task run took longer than $TIMEOUT seconds" + exit 1 + fi + fi + + + + echo "Task $TASK_ARN executed successfully!" + exit 0 + +} + +###################################################### +# When not being tested, run application as expected # +###################################################### +if [ "$BASH_SOURCE" == "$0" ]; then + set -o errexit + set -o pipefail + set -u + set -e + # If no args are provided, display usage information + if [ $# == 0 ]; then usage; fi + + # Check for AWS, AWS Command Line Interface + require aws + # Check for jq, Command-line JSON processor + require jq + + # Loop through arguments, two at a time for key and value + while [[ $# -gt 0 ]] + do + key="$1" + + case $key in + -k|--aws-access-key) + AWS_ACCESS_KEY_ID="$2" + shift # past argument + ;; + -s|--aws-secret-key) + AWS_SECRET_ACCESS_KEY="$2" + shift # past argument + ;; + -r|--region) + AWS_DEFAULT_REGION="$2" + shift # past argument + ;; + -p|--profile) + AWS_PROFILE="$2" + shift # past argument + ;; + --aws-instance-profile) + echo "--aws-instance-profile is not yet in use" + AWS_IAM_ROLE=true + ;; + -a|--aws-assume-role) + AWS_ASSUME_ROLE="$2" + shift + ;; + -c|--cluster) + CLUSTER="$2" + shift # past argument + ;; + -n|--service-name) + SERVICE="$2" + shift # past argument + ;; + -d|--task-definition) + TASK_DEFINITION="$2" + shift + ;; + -i|--image) + IMAGE="$2" + shift + ;; + -t|--timeout) + TIMEOUT="$2" + shift + ;; + -m|--min) + MIN="$2" + shift + ;; + -M|--max) + MAX="$2" + shift + ;; + -D|--desired-count) + DESIRED="$2" + shift + ;; + -e|--tag-env-var) + TAGVAR="$2" + shift + ;; + -to|--tag-only) + TAGONLY="$2" + shift + ;; + --max-definitions) + MAX_DEFINITIONS="$2" + shift + ;; + --task-definition-file) + TASK_DEFINITION_FILE="$2" + shift + ;; + --enable-rollback) + ENABLE_ROLLBACK=true + ;; + --use-latest-task-def) + USE_MOST_RECENT_TASK_DEFINITION=true + ;; + --force-new-deployment) + FORCE_NEW_DEPLOYMENT=true + ;; + --skip-deployments-check) + SKIP_DEPLOYMENTS_CHECK=true + ;; + --run-task) + RUN_TASK=true + ;; + --launch-type) + RUN_TASK_LAUNCH_TYPE="$2" + shift + ;; + --wait-for-success) + RUN_TASK_WAIT_FOR_SUCCESS=true + ;; + --network-configuration) + RUN_TASK_NETWORK_CONFIGURATION="$2" + shift + ;; + -v|--verbose) + VERBOSE=true + ;; + --version) + echo ${VERSION} + exit 0 + ;; + *) + #If another key was given that is not empty display usage. + if [[ ! -z "$key" ]]; then + usage + exit 2 + fi + ;; + esac + shift # past argument or value + done + + if [ $VERBOSE == true ]; then + set -x + fi + + # Check that required arguments are provided + assertRequiredArgumentsSet + + if [[ "$AWS_ASSUME_ROLE" != false ]]; then + assumeRole + fi + + # Not required creation of new a task definition + if [ $FORCE_NEW_DEPLOYMENT == true ]; then + updateServiceForceNewDeployment + if [[ $SKIP_DEPLOYMENTS_CHECK != true ]]; then + waitForGreenDeployment + fi + exit 0 + fi + + # Determine image name + parseImageName + echo "Using image name: $useImage" + + # Get current task definition + getCurrentTaskDefinition + echo "Current task definition: $TASK_DEFINITION_ARN"; + + # create new task definition json + createNewTaskDefJson + + # register new task definition + registerNewTaskDefinition + echo "New task definition: $NEW_TASKDEF"; + + # update service if needed + if [ $SERVICE == false ]; then + if [ $RUN_TASK == true ]; then + runTask + fi + echo "Task definition updated successfully" + else + updateService + + if [[ $SKIP_DEPLOYMENTS_CHECK != true ]]; then + waitForGreenDeployment + fi + fi + + if [[ "$AWS_ASSUME_ROLE" != false ]]; then + assumeRoleClean + fi + + exit 0 + +fi +############################# +# End application run logic # +#############################