#!/usr/bin/env bash # Setup default values for variables VERSION="3.10.17" 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_PLATFORM_VERSION=false RUN_TASK_NETWORK_CONFIGURATION=false RUN_TASK_WAIT_FOR_SUCCESS=false TASK_DEFINITION_TAGS=false COPY_TASK_DEFINITION_TAGS=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 if [ $RUN_TASK == false ] && [ $RUN_TASK_PLATFORM_VERSION != false ]; then echo 'PLATFORM VERSION requires setting RUN TASK argument. You can set it using --run-task flag.' exit 12 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 # Get task definition using current task definition arn # If we're copying task definition tags to the new revision, also get current task definition tags if [[ "$COPY_TASK_DEFINITION_TAGS" == true ]]; then TASK_DEFINITION=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION_ARN --include TAGS` TASK_DEFINITION_TAGS=$( echo "$TASK_DEFINITION" | jq ".tags" ) else TASK_DEFINITION=`$AWS_ECS describe-task-definition --task-def $TASK_DEFINITION_ARN` fi } 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 "$taskDefinition" \ | 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 runtimePlatform ephemeralStorage) 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 if [[ "$COPY_TASK_DEFINITION_TAGS" == true && "$TASK_DEFINITION_TAGS" != false && "$TASK_DEFINITION_TAGS" != "[]" ]]; then NEW_TASKDEF=`$AWS_ECS register-task-definition --cli-input-json "$NEW_DEF" --tags "$TASK_DEFINITION_TAGS" | jq -r .taskDefinition.taskDefinitionArn` else NEW_TASKDEF=`$AWS_ECS register-task-definition --cli-input-json "$NEW_DEF" | jq -r .taskDefinition.taskDefinitionArn` fi } 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 until 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_PLATFORM_VERSION != false ]; then AWS_ECS_RUN_TASK="$AWS_ECS_RUN_TASK --platform-version $RUN_TASK_PLATFORM_VERSION" 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 ;; --platform-version) RUN_TASK_PLATFORM_VERSION="$2" shift ;; --wait-for-success) RUN_TASK_WAIT_FOR_SUCCESS=true ;; --network-configuration) RUN_TASK_NETWORK_CONFIGURATION="$2" shift ;; --copy-task-definition-tags) COPY_TASK_DEFINITION_TAGS=true ;; -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 # #############################