Skip to content

Instantly share code, notes, and snippets.

@lorello
Last active March 30, 2021 13:07
Show Gist options
  • Select an option

  • Save lorello/dc4e6a26c53ede16b2fe7157167b4b90 to your computer and use it in GitHub Desktop.

Select an option

Save lorello/dc4e6a26c53ede16b2fe7157167b4b90 to your computer and use it in GitHub Desktop.
Swarm deploy utility with slack notification
#!/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