Last active
March 30, 2021 13:07
-
-
Save lorello/dc4e6a26c53ede16b2fe7157167b4b90 to your computer and use it in GitHub Desktop.
Swarm deploy utility with slack notification
This file contains hidden or 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
| #!/bin/bash | |
| # vim: autoindent tabstop=2 shiftwidth=2 expandtab softtabstop=2 fileencoding=utf-8 | |
| set -Eeuo pipefail | |
| trap cleanup SIGINT SIGTERM ERR EXIT | |
| script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P) | |
| DEPLOY_CONTEXT= | |
| usage() { | |
| cat <<EOF | |
| Usage: $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-f] -p param_value stack | |
| Deploy to docker swarm. | |
| Available options: | |
| -h, --help Print this help and exit | |
| -v, --verbose Print script debug info | |
| EOF | |
| exit | |
| } | |
| # Usage: $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-f] -p param_value stack | |
| #-f, --flag Some flag description | |
| #-p, --prune Some param description | |
| cleanup() { | |
| trap - SIGINT SIGTERM ERR EXIT | |
| # script cleanup here | |
| if [[ -n $DEPLOY_CONTEXT ]]; then | |
| docker::context::use $old_context | |
| fi | |
| } | |
| setup_colors() { | |
| if [[ -t 2 ]] && [[ -z "${NO_COLOR-}" ]] && [[ "${TERM-}" != "dumb" ]]; then | |
| NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' YELLOW='\033[1;33m' | |
| else | |
| NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE='' PURPLE='' CYAN='' YELLOW='' | |
| fi | |
| } | |
| msg() { | |
| echo >&2 -e "${1-}" | |
| } | |
| die() { | |
| local msg=$1 | |
| local code=${2-1} # default exit status 1 | |
| msg "$msg" | |
| exit "$code" | |
| } | |
| parse_params() { | |
| # default values of variables set from params | |
| flag=0 | |
| param='' | |
| while :; do | |
| case "${1-}" in | |
| -h | --help) usage ;; | |
| -v | --verbose) set -x ;; | |
| --no-color) NO_COLOR=1 ;; | |
| -f | --flag) flag=1 ;; # example flag | |
| -p | --param) # example named parameter | |
| param="${2-}" | |
| shift | |
| ;; | |
| -?*) die "Unknown option: $1" ;; | |
| *) break ;; | |
| esac | |
| shift | |
| done | |
| args=("$@") | |
| # check required params and arguments | |
| #[[ -z "${param-}" ]] && die "Missing required parameter: param" | |
| [[ ${#args[@]} -eq 0 ]] && die "Missing script arguments" | |
| APPNAME=${1:?"Error, missing param #1: deploy <stack>"} | |
| # Resolve common error: if appname is passed as full yml filename, remove the extension | |
| APPNAME=${APPNAME//.yml/} | |
| return 0 | |
| } | |
| ask_confirm() | |
| { | |
| local question | |
| local answer | |
| question=${1:="Confirm?"} | |
| read -p "$question [Y/n]" -n 1 -r answer | |
| echo | |
| if [[ $answer =~ ^[yY]$ ]] || [[ -z $answer ]]; then | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| docker::context::get_current() | |
| { | |
| local context | |
| context=$(docker context inspect | jq --raw-output .[].Name) | |
| echo $context | |
| } | |
| docker::context::use() | |
| { | |
| local context=$1 | |
| docker context use $context 2>&1 >/dev/null | |
| if [[ $? -eq 0 ]]; then | |
| echo | |
| else | |
| die "Unable to switch to docker context '$context', cannot continue" | |
| fi | |
| } | |
| docker::service::get_full_name() | |
| { | |
| local name=$1 | |
| local image | |
| local namespace | |
| image=$(docker service inspect -f '{{ index .Spec.Labels "com.docker.stack.image"}}' $name) | |
| namespace=$(docker service inspect -f '{{ index .Spec.Labels "com.docker.stack.namespace"}}' $name) | |
| echo "${namespace}/${image}" | |
| } | |
| slack::message() | |
| { | |
| local title=$1 | |
| local msg=$2 | |
| slacktee=$(which slacktee) | |
| if [[ -n $slacktee ]]; then | |
| echo "$msg" | $slacktee --plain-text --channel deploy --attachment good \ | |
| --title "$title" --no-output 2> /dev/null | |
| fi | |
| } | |
| # example of header | |
| # traefik.http.routers.sensor-amiu-https.rule=Host(`segnalazioni.amiu.genova.it`) | |
| docker::get_traefik_routers() | |
| { | |
| local name=${1:?'get_traefik_routers(): missing param #1'} | |
| local routers=$(docker service inspect $name | jq -r '.[0].Spec.Labels | keys[]' | egrep 'traefik\.http\.routers\.[^.]+\.rule') | |
| for label in $routers; do | |
| echo $(docker service inspect -f "{{ index .Spec.Labels \"$label\"}}" $name) | |
| done | |
| } | |
| docker::service::get_label() | |
| { | |
| local name=${1:?'has_label(): missing param #1'} | |
| local label=${2:?'has_label(): missing param #2'} | |
| value=$(docker service inspect -f "{{ index .Spec.Labels \"$label\"}}" $name) | |
| echo $value | |
| } | |
| docker::service::has_label() | |
| { | |
| local name=${1:?'has_label(): missing param #1'} | |
| local label=${2:?'has_label(): missing param #2'} | |
| local desired_value=$3 | |
| local current_value=$(docker service inspect -f "{{ index .Spec.Labels \"$label\"}}" $name) | |
| if [[ -n $current_value ]]; then | |
| if [[ -n $desired_value ]]; then | |
| if [[ $current_value == $desired_value ]]; then | |
| echo true | |
| else | |
| echo false | |
| fi | |
| else | |
| echo true | |
| fi | |
| else | |
| echo false | |
| fi | |
| } | |
| parse_params "$@" | |
| setup_colors | |
| # check script requisites | |
| for i in jq curl slacktee; do | |
| if [[ -z $(which $i) ]]; then | |
| die "Command '${i}' not available, cannot continue" | |
| fi | |
| done | |
| # stop if user has more privileges than needed | |
| [[ $UID -eq 0 ]] && die "Running this script with root privileges is not required, ensuring that your user belongs to docker group is enough" | |
| #msg "${RED}Read parameters:${NOFORMAT}" | |
| #msg "- flag: ${flag}" | |
| #msg "- param: ${param}" | |
| #msg "- arguments: ${args[*]-}" | |
| ### SCRIPT STARTS HERE | |
| # Load configuration file, if available | |
| [[ -f ./.docker-deploy ]] && | |
| . ./.docker-deploy | |
| # check if stack file is present | |
| [[ ! -f ./${APPNAME}.yml ]] && \ | |
| die "Missing ${APPNAME}.yml (or you are in the wrong path), this script must be run from the stack files directory" | |
| # If required switch to specific context to deploy | |
| if [[ -n $DEPLOY_CONTEXT ]]; then | |
| if ! docker context inspect $DEPLOY_CONTEXT >/dev/null 2>&1; then | |
| die "Cannot find docker context '${DEPLOY_CONTEXT}', cannot continue" | |
| fi | |
| old_context=$(docker::context::get_current) | |
| docker::context::use $DEPLOY_CONTEXT | |
| fi | |
| # start deploy | |
| msg "==> Deploy ${BLUE}${APPNAME}${NOFORMAT}" | |
| docker stack deploy -c ${APPNAME}.yml --prune --with-registry-auth ${APPNAME} | |
| if [[ $? -eq 0 ]]; then | |
| msg "${GREEN}Success!${NOFORMAT}" | |
| else | |
| msg "${RED}Error :-(${NOFORMAT}" | |
| fi | |
| echo | |
| echo "Waiting for deploy results..." | |
| sleep 7 | |
| docker stack services ${APPNAME} --format '{{ .Name }} #{{.Replicas}} (image: {{ .Image }})' | |
| echo | |
| echo | |
| services=$(docker stack services ${APPNAME} -q) | |
| title=":rocket: ${USER}@${HOSTNAME} just deployed <https://portainer.boat.opencontent.io/#!/1/docker/stacks/${APPNAME}?type=1&external=true|${APPNAME}>" | |
| body=" | |
| " | |
| for service_id in $services; do | |
| name=$(docker stack services ${APPNAME} --filter "id=$service_id" --format '{{ .Name }}') | |
| short_name=$(echo $name | cut -d'_' -f2) | |
| image=$(docker stack services ${APPNAME} --filter "id=$service_id" --format '{{ .Image }}' ) | |
| mode=$(docker stack services ${APPNAME} --filter "id=$service_id" --format '{{ .Mode }}') | |
| short_image=${image#"registry.gitlab.com/"} | |
| replicas= | |
| if [[ $mode = 'replicated' ]]; then | |
| replicas=$(docker service inspect ${name} --format '{{ .Spec.Mode.Replicated.Replicas }}') | |
| fi | |
| is_public=$(docker::service::has_label $service_id 'traefik.enable' 'true') | |
| is_cron=$(docker::service::has_label $service_id 'swarm.cronjob.enable' 'true') | |
| ############################################# | |
| if [[ $mode == 'global' ]]; then | |
| main_icon=':earth_africa:' | |
| else | |
| main_icon=":docker:" | |
| [[ $is_public == 'true' ]] && main_icon=':globe_with_meridians:' | |
| [[ $is_cron == 'true' ]] && main_icon=':mantelpiece_clock:' | |
| fi | |
| body="${body} | |
| ${main_icon} <https://portainer.boat.opencontent.io/#!/1/docker/services/${service_id}|*${short_name}*> $replicas" | |
| body="${body} | |
| :cd: $image" | |
| traefik_routers=$(docker::get_traefik_routers $name) | |
| if [[ $is_public == 'true' && -n $traefik_routers ]]; then | |
| if [[ $(echo $traefik_routers | wc -w) -gt 1 ]]; then | |
| body="${body} | |
| :traefik: routers rules:" | |
| for rule in $traefik_routers; do | |
| body="${body} | |
| :white_small_square: $rule" | |
| done | |
| else | |
| body="${body} | |
| :traefik: router rule: $traefik_routers" | |
| fi | |
| fi | |
| body="${body} | |
| Tasks:" | |
| msg "* Service ${CYAN}${short_name}${NOFORMAT}" | |
| msg " image: ${short_image}" | |
| msg "" | |
| # ==> Running tasks | |
| tasks=$(docker service ps $name --filter "desired-state=running" -q) | |
| for task_id in $tasks; do | |
| state=$(docker service ps $name --filter "id=$task_id" --format '{{ .CurrentState }}') | |
| node=$(docker service ps $name --filter "id=$task_id" --format '{{ .Node }}') | |
| error=$(docker service ps $name --no-trunc --filter "id=$task_id" --format '{{ .Error }}') | |
| if [[ -z $error ]]; then | |
| body="${body} | |
| :white_small_square: :white_check_mark: _${state}_ @ ${node}" | |
| msg " [ ${GREEN}OK${NOFORMAT} ]\t${state} @ ${node}" | |
| else | |
| body="${body} | |
| :white_small_square: :zzz: _${state}_ with error _${error}_" | |
| msg " [ ${YELLOW}Zzz${NOFORMAT} ]\t${state}: ${error}" | |
| fi | |
| done | |
| # ==> Shutdown tasks | |
| tasks_count=$(echo $tasks | wc -w) | |
| if [[ $mode == 'replicated' && $tasks_count -lt $replicas ]]; then | |
| tasks=$(docker service ps $name --filter "desired-state=shutdown" --filter "desired-state=accepted" -q) | |
| for task_id in $tasks; do | |
| state=$(docker service ps $name --filter "id=$task_id" --format '{{ .CurrentState }}') | |
| node=$(docker service ps $name --filter "id=$task_id" --format '{{ .Node }}') | |
| error=$(docker service ps $name --no-trunc --filter "id=$task_id" --format '{{ .Error }}') | |
| if [[ -z $error ]]; then | |
| body="${body} | |
| :white_small_square: :white_check_mark: _${state}_ @ ${node}" | |
| msg " [ ${GREEN}OK${NOFORMAT} ]\t${state} @ ${node}" | |
| else | |
| body="${body} | |
| :white_small_square: :fail: $node: ${state} _${error}_" | |
| msg " [ ${RED}KO${NOFORMAT} ]\t${state} @ $node: ${error}" | |
| fi | |
| done | |
| fi | |
| if [[ $mode == 'global' ]]; then | |
| tasks=$(docker service ps $name --filter "desired-state=shutdown" --filter "desired-state=accepted" -q) | |
| for task_id in $tasks; do | |
| state=$(docker service ps $name --filter "id=$task_id" --format '{{ .CurrentState }}') | |
| node=$(docker service ps $name --filter "id=$task_id" --format '{{ .Node }}') | |
| error=$(docker service ps $name --no-trunc --filter "id=$task_id" --format '{{ .Error }}') | |
| if [[ -z $error ]]; then | |
| body="${body} | |
| :white_small_square: :white_check_mark: _${state}_ @ ${node}" | |
| msg " [ ${GREEN}OK${NOFORMAT} ]\t${state} @ ${node}" | |
| else | |
| body="${body} | |
| :white_small_square: :fail: $node: _${state}_ with error _${error}_" | |
| msg " [ ${RED}KO${NOFORMAT} ]\t${state} @ $node: ${error}" | |
| fi | |
| done | |
| fi | |
| msg "" | |
| body="${body} | |
| " | |
| done | |
| slack::message "$title" "$body" | |
| msg "Check also slack channel ${YELLOW}#deploy${NOFORMAT} for more details" | |
| msg "" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment