#!/usr/bin/env bash # # Deploy/update/undeploy/info/status Docker stacks in a Portainer instance. # And also list stacks, services, tasks and containers set -e ############################ # Main entrypoint # # Globals: # # HTTPIE_VERIFY_SSL # # PORTAINER_URL # # PORTAINER_USER # # PORTAINER_AUTH_TOKEN # # PORTAINER_PASSWORD # # PORTAINER_STACK_NAME # # PORTAINER_SERVICE_NAME # # STACKS # # STACK # # TIMEOUT # # ACTION # # Arguments: # # None # # Returns: # # None # ############################ main() { VERSION="0.2.0-alpha.20" OPTIONS_TABLE=( # option_key;flag_text;option_text;description "url;-l;--url=URL;URL of the Portainer instance" "user;-u;--user=USERNAME;Username of the Portainer instance" "password;-p;--password=PASSWORD;Password of the Portainer instance" "auth-token;-A;--auth-token=[AUTH_TOKEN];Use a Portainer auth token instead of '--user' and '--password' options, you can get it with the 'psu login' command. Defaults to null" "name;-n;--name=STACK_NAME;Stack name" "compose-file;-c;--compose-file=[FILE_PATH];Path to docker-compose file (required if action=deploy)" "stack-file;;--stack-file=[FILE_PATH];Path to docker-stack file. Alias of '--compose-file' option" "env-file;-g;--env-file;Path to file with environment variables to be used by the stack (only used when action=deploy|update)" "endpoint;-e;--endpoint=[ENDPOINT_ID];Which Docker endpoint to use. Defaults to 1" "prune;-r;--prune;Whether to prune unused containers or not (only used when action=deploy). Defaults to false" "timeout;-T;--timeout=[SECONDS];Timeout, number of seconds before thrown an error (only used when action=status|tasks|tasks:healthy). Defaults to 100" "detect-job;-j;--detect-job=[true|false];Auto detect services who are jobs in the current stack. Defaults to true" "service;-S;--service=[SERVICE_NAME];Filtering by a service name of the current stack (only used when action=status|tasks|tasks:healthy|containers)" "insecure;-i;--insecure;Skip the host's SSL certificate verification, use at your own risk. Defaults to false" "verbose;-v;--verbose;Increase the verbosity of messages. Defaults to false" "debug;-d;--debug;Print as much information as possible to help diagnosing a malfunction. Defaults to false" "masked-variables;-m;--masked-variables;In debug/verbose mode, value of sensitive variables will be hidden, avoid leaking passwords/tokens in logs. Possible values: true|extended|false. Defaults to false" "quiet;-q;--quiet;Display the minimum of information or nothing, UNIX/Linux friendly. Defaults to false" "strict;-t;--strict;Never updates an existent stack nor removes an inexistent one, and instead exits with an error. Defaults to false" "lint;-L;--lint=[true|false];Validate the Docker compose/stack file before deploying the stack (only used when action=deploy). Defaults to true" "help;-h;--help;Display help message. To display help of a given action, run: 'psu --help'" "version;-V;--version;Display the version of this program" "secure;-s;--secure[=yes|no];DEPRECATED: Use the '--insecure' option instead. Enable or disable the host's SSL certificate verification. Defaults to 'yes'" "action;-a;--action=[ACTION_NAME];DEPRECATED: Use argument instead. The name of the action to execute" ) ACTIONS_TABLE=( # action_name;description[;required_option_key1|required_option_key2...][;optional_option_key1|optional_option_key2...] "deploy;Deploy the stack;url|user|password|name|compose-file;auth-token|endpoint|lint|env-file|prune|insecure|verbose|debug|masked-variables|strict" "undeploy;Undeploy/remove the stack;url|user|password|name;auth-token|endpoint|insecure|verbose|debug|masked-variables|strict" "login;Log in to a Portainer instance;url|user|password;insecure|verbose|debug|masked-variables" "list;Lists of the stacks already deployed;url|user|password;auth-token|endpoint|quiet|insecure|verbose|debug|masked-variables|help" "info;Stack information;url|user|password|name;auth-token|endpoint|quiet|insecure|verbose|debug|masked-variables" "status;Check if the stack is running/deployed correctly;url|user|password|name;auth-token|endpoint|service|detect-job|timeout|insecure|verbose|debug|masked-variables" "system:info;Display Docker system-wide information;url|user|password;auth-token|endpoint|insecure|verbose|debug|masked-variables" "services;Lists services already deployed for the current stack;url|user|password|name;auth-token|endpoint|quiet|insecure|verbose|debug|masked-variables" "tasks;Lists tasks for the current stack;url|user|password|name;auth-token|endpoint|service|detect-job|timeout|quiet|insecure|verbose|debug|masked-variables" "tasks:healthy;Lists tasks who are running correctly for the current stack;url|user|password|name;auth-token|endpoint|service|detect-job|timeout|quiet|insecure|verbose|debug|masked-variables" "containers;Lists containers running for the current stack;url|user|password|name;auth-token|endpoint|service|quiet|insecure|verbose|debug|masked-variables" "lint;Validate the Docker compose/stack file;compose-file;verbose|debug" "actions;Lists available actions of this program;;verbose|debug" "help;Display help message" "version;Display this program version" ) # Special actions who display text only # No HTTP requests will be made ACTIONS_TEXT_ONLY="actions lint help version" # Aliases of default actions # NOTICE: This is an experimental feature declare -A ACTIONS_ALIASES ACTIONS_ALIASES=( ["update"]="deploy" ["remove"]="undeploy" ["stacks:deploy"]="deploy" ["stacks:undeploy"]="undeploy" ["auth"]="login" ["stacks:list"]="list" ["stacks:info"]="info" ["stacks:status"]="status" ["system"]="system:info" ["docker:info"]="system:info" ["services:list"]="services" ["tasks:list"]="tasks" ["containers:list"]="containers" ["actions:list"]="actions" ) # TODO: move this stuff in a function # Set the ACTIONS variable # and the ACTIONS_ASSOC variable declare -A ACTIONS_ASSOC local action_table local action_name local action_description local required_options local optional_options for action in "${ACTIONS_TABLE[@]}"; do IFS=';' read -ra action_table <<< "$action" action_name="${action_table[0]}" action_description="${action_table[1]}" required_options="${action_table[2]}" optional_options="${action_table[3]}" ACTIONS_ASSOC["$action_name"]="$action_description;$required_options;$optional_options" if [ -n "$action_description" ]; then if [ -n "$ACTIONS" ]; then ACTIONS+=" $action_name" else ACTIONS="$action_name" fi fi done # TODO: move this stuff in a function # Set the OPTIONS variable # and the OPTIONS_ASSOC variable declare -A OPTIONS_ASSOC local option_table local option_key local flag_text local option_text local option_description for option in "${OPTIONS_TABLE[@]}"; do IFS=';' read -ra option_table <<< "$option" option_key="${option_table[0]}" flag_text="${option_table[1]}" option_text="${option_table[2]}" option_description="${option_table[3]}" OPTIONS_ASSOC["$option_key"]="$flag_text;$option_text;$option_description" if [ -n "$OPTIONS" ]; then OPTIONS+=" $option_key" else OPTIONS="$option_key" fi done export PORTAINER_AUTH_TOKEN set_globals "$@" if [[ ! ${ACTIONS_TEXT_ONLY[*]} =~ $ACTION ]]; then if [ -z "$PORTAINER_AUTH_TOKEN" ]; then login fi # Get list of all stacks echo_verbose "Getting stack $PORTAINER_STACK_NAME..." STACKS=$(http \ --check-status \ --ignore-stdin \ --verify=$HTTPIE_VERIFY_SSL \ "$PORTAINER_URL/api/stacks" \ "Authorization: Bearer $PORTAINER_AUTH_TOKEN") check_for_errors $? "$STACKS" echo_debug_safe_json "Get stacks response -> $(echo $STACKS | jq -C .)" "$STACKS" # Get desired stack from stacks list by it's name STACK=$(echo "$STACKS" \ | jq --arg PORTAINER_STACK_NAME "$PORTAINER_STACK_NAME" -jc '.[] | select(.Name == $PORTAINER_STACK_NAME)') echo_debug_safe_json "Stack ${PORTAINER_STACK_NAME} -> $(echo $STACK | jq -C .)" "$STACK" fi if [ $ACTION == "deploy" ]; then deploy exit 0 fi if [ $ACTION == "undeploy" ]; then undeploy exit 0 fi if [ $ACTION == "login" ]; then if [ $QUIET_MODE == "false" ] && [ $MASKED_VARIABLES == "false" ]; then echo "$PORTAINER_AUTH_TOKEN" fi exit 0 fi # Returns Docker system info if [ $ACTION == "system:info" ]; then local docker_info echo_verbose "Getting Docker info..." docker_info="$(docker_info)" echo_debug "Docker info -> $(echo $docker_info | jq -C .)" if [ -n "$docker_info" ]; then echo "$docker_info" exit 0 fi exit 1 fi if [ $ACTION == "services" ]; then echo_verbose "List service(s) of stack '$PORTAINER_STACK_NAME'..." local services services=$(services) echo_debug "Services action response -> $(echo $services | jq -C .)" if [ -n "$services" ] && [ ! "$services" == "[]" ]; then if [ $QUIET_MODE == "false" ]; then # Returns response in JSON format echo "$services" else # Only display service(s) name in quiet mode echo "$services" | jq -r '.[] | [.Spec.Name] | add' fi exit 0 fi exit 1 fi if [ $ACTION == "status" ]; then echo_verbose "Status of tasks for the stack '$PORTAINER_STACK_NAME'..." local status # WIP: Each services should have at least one task # with desired state == 'running' and state == 'running' # or desired state == 'shutdown' and state == 'complete' # Tasks should not have one of these states: # 'failed', 'orphaned', 'remove' timeout $TIMEOUT bash -c "until (export PORTAINER_SERVICE_NAME=$PORTAINER_SERVICE_NAME && export DEBUG_MODE=false && export VERBOSE_MODE=false && psu -a tasks:healthy -q) >/dev/null 2>&1; do echo -n \$(if [ \"\$VERBOSE_MODE\" == \"true\" ]; then echo -n .; fi) && sleep 1; done;" status=$? if $(exit $status); then echo_verbose "Status: healthy for the stack '$PORTAINER_STACK_NAME'" exit 0 else echo_verbose "Status: unhealthy for the stack '$PORTAINER_STACK_NAME'" echo_error "Error: No tasks or not all tasks are running correctly for the stack \"$PORTAINER_STACK_NAME\"" exit 1 fi fi if [ $ACTION == "tasks" ] || [ $ACTION == "tasks:healthy" ]; then local scope scope="$(if [ $ACTION == "tasks:healthy" ]; then echo healthy; else echo all; fi)" local tasks if [ -n "$PORTAINER_SERVICE_NAME" ]; then echo_verbose "List $scope tasks from service '$PORTAINER_SERVICE_NAME' of stack '$PORTAINER_STACK_NAME'..." tasks=$(if [ $scope == "healthy" ]; then tasks_healthy; else tasks; fi) echo_debug "Tasks action response -> $(echo $tasks | jq -C .)" else echo_verbose "List $scope tasks of stack '$PORTAINER_STACK_NAME'..." local services services="$(timeout $TIMEOUT bash -c "until (export DEBUG_MODE=false && export VERBOSE_MODE=false && echo \"\$(psu -a services -q)\"); do sleep 1; done;")" # TODO: Improve performance, find a way to do parallel requests # See: https://stackoverflow.com/questions/8634109/parallel-download-using-curl-command-line-utility#comment51239188_24276040 # Try and tweaks these command: # psu services -u admin -p password -l http://portainer.local -n $PORTAINER_STACK_NAME -q | sed "s/^${PORTAINER_STACK_NAME}_\(.*\)$/\1/" | xargs -I {} -P 10 -- psu tasks -u admin -p password -l http://portainer.local -n $PORTAINER_STACK_NAME -S {} -q for service in $services; do export PORTAINER_SERVICE_NAME=${service#"${PORTAINER_STACK_NAME}_"} local new_tasks new_tasks=$(if [ $scope == "healthy" ]; then tasks_healthy; else tasks; fi) if [ -z "$new_tasks" ] || [ "$new_tasks" == "[]" ]; then echo_verbose "Error: $scope tasks aren't running correctly for the stack \"$PORTAINER_STACK_NAME\"" exit 1; fi tasks="$(echo "${tasks}${new_tasks}" | jq -sjc 'add | unique_by(.ID)')" done echo_debug "Tasks action response -> $(echo $tasks | jq -C .)" fi if [ -n "$tasks" ] && [ ! "$tasks" == "[]" ]; then if [ $QUIET_MODE == "false" ]; then # Returns response in JSON format echo "$tasks" else # Only display task(s) id in quiet mode echo "$tasks" | jq -r '.[] | [.ID] | add' fi exit 0 fi echo_verbose "Error: No tasks or not all tasks are running correctly for the stack \"$PORTAINER_STACK_NAME\"" exit 1 fi if [ $ACTION == "containers" ]; then echo_verbose "List container(s) of stack '$PORTAINER_STACK_NAME'..." local containers containers=$(containers) echo_debug "Containers action response -> $(echo $containers | jq -C .)" if [ -n "$containers" ] && [ ! "$containers" == "[]" ]; then if [ $QUIET_MODE == "false" ]; then # Returns response in JSON format echo "$containers" else # Only display container(s) id in quiet mode echo "$containers" | jq -r '.[] | [.Id] | add' fi exit 0 fi exit 1 fi # Returns stack info # If it already exists if [ $ACTION == "info" ]; then if [ -n "$STACK" ]; then if [ $QUIET_MODE == "false" ]; then echo "$STACK" else # Only display the stack name in quiet mode echo "$STACK" | jq -r ".Name" fi exit 0 fi exit 1 fi # Get list of all stacks if [ $ACTION == "list" ]; then if [ $QUIET_MODE == "false" ]; then # Returns response in JSON format echo "$STACKS" else # Only display stack names in quiet mode echo "$STACKS" | jq -r '.[] | [.Name] | add' fi exit 0 fi # Lint the Docker compose/stack file if [ $ACTION == "lint" ]; then lint exit 0 fi # Get list of all actions who can be used for this program if [ $ACTION == "actions" ]; then echo "Portainer Stack Utils, version $VERSION" echo "" display_actions_message exit 0 fi if [[ ! ${ACTIONS[*]} =~ $ACTION ]]; then echo_error "Error: Unknown action \"$ACTION\"." exit 1 fi } ################################ # Set globals # # Globals: # # ACTION # # PORTAINER_USER # # PORTAINER_PASSWORD # # PORTAINER_AUTH_TOKEN # # PORTAINER_URL # # PORTAINER_STACK_NAME # # PORTAINER_SERVICE_NAME # # DOCKER_COMPOSE_FILE # # DOCKER_COMPOSE_LINT # # ENVIRONMENT_VARIABLES_FILE # # PORTAINER_ENDPOINT # # PORTAINER_PRUNE # # TIMEOUT # # AUTO_DETECT_JOB # # HTTPIE_VERIFY_SSL # # VERBOSE_MODE # # DEBUG_MODE # # QUIET_MODE # # STRICT_MODE # # MASKED_VARIABLES # # Arguments: # # None # # Returns: # # None # ################################ set_globals() { # Set arguments through envvars VERSION=${VERSION} ACTIONS=${ACTIONS} ACTION=${ACTION} PORTAINER_AUTH_TOKEN=${PORTAINER_AUTH_TOKEN} PORTAINER_USER=${PORTAINER_USER} PORTAINER_PASSWORD=${PORTAINER_PASSWORD} PORTAINER_URL=${PORTAINER_URL} PORTAINER_STACK_NAME=${PORTAINER_STACK_NAME} PORTAINER_SERVICE_NAME=${PORTAINER_SERVICE_NAME} DOCKER_COMPOSE_FILE=${DOCKER_COMPOSE_FILE} DOCKER_COMPOSE_LINT=${DOCKER_COMPOSE_LINT:-"true"} ENVIRONMENT_VARIABLES_FILE=${ENVIRONMENT_VARIABLES_FILE} PORTAINER_ENDPOINT=${PORTAINER_ENDPOINT:-"1"} PORTAINER_PRUNE=${PORTAINER_PRUNE:-"false"} TIMEOUT=${TIMEOUT:-100} AUTO_DETECT_JOB=${AUTO_DETECT_JOB:-"true"} HTTPIE_VERIFY_SSL=${HTTPIE_VERIFY_SSL:-"yes"} VERBOSE_MODE=${VERBOSE_MODE:-"false"} DEBUG_MODE=${DEBUG_MODE:-"false"} QUIET_MODE=${QUIET_MODE:-"false"} STRICT_MODE=${STRICT_MODE:-"false"} MASKED_VARIABLES=${MASKED_VARIABLES:-"false"} # Set arguments through argument and options (overwrite envvars) inputs "$@" # Print config (only if debug mode is active) echo_debug "ACTION -> $ACTION" echo_debug "PORTAINER_USER -> $PORTAINER_USER" echo_debug "PORTAINER_PASSWORD -> $PORTAINER_PASSWORD" echo_debug "PORTAINER_AUTH_TOKEN -> $PORTAINER_AUTH_TOKEN" echo_debug "PORTAINER_URL -> $PORTAINER_URL" echo_debug "PORTAINER_STACK_NAME -> $PORTAINER_STACK_NAME" echo_debug "PORTAINER_SERVICE_NAME -> $PORTAINER_SERVICE_NAME" echo_debug "DOCKER_COMPOSE_FILE -> $DOCKER_COMPOSE_FILE" echo_debug "DOCKER_COMPOSE_LINT -> $DOCKER_COMPOSE_LINT" echo_debug "ENVIRONMENT_VARIABLES_FILE -> $ENVIRONMENT_VARIABLES_FILE" echo_debug "PORTAINER_ENDPOINT -> $PORTAINER_ENDPOINT" echo_debug "PORTAINER_PRUNE -> $PORTAINER_PRUNE" echo_debug "TIMEOUT -> $TIMEOUT" echo_debug "AUTO_DETECT_JOB -> $AUTO_DETECT_JOB" echo_debug "HTTPIE_VERIFY_SSL -> $HTTPIE_VERIFY_SSL" echo_debug "VERBOSE_MODE -> $VERBOSE_MODE" echo_debug "DEBUG_MODE -> $DEBUG_MODE" echo_debug "QUIET_MODE -> $QUIET_MODE" echo_debug "STRICT_MODE -> $STRICT_MODE" echo_debug "MASKED_VARIABLES -> $MASKED_VARIABLES" # Check required arguments have been provided check_argument "$ACTION" "action" "ACTION" "a" "action" if [[ ! ${ACTIONS_TEXT_ONLY[*]} =~ $ACTION ]]; then if [ -z "$PORTAINER_AUTH_TOKEN" ]; then check_argument "$PORTAINER_USER" "portainer user" "PORTAINER_USER" "u" "user" check_argument "$PORTAINER_PASSWORD" "portainer password" "PORTAINER_PASSWORD" "p" "password" fi check_argument "$PORTAINER_URL" "portainer url" "PORTAINER_URL" "l" "url" fi if [ "$ACTION" == "deploy" ] || [ "$ACTION" == "lint" ]; then check_argument "$DOCKER_COMPOSE_FILE" "docker compose file" "DOCKER_COMPOSE_FILE" "c" "compose-file" if [ -n "$ENVIRONMENT_VARIABLES_FILE" ] && [[ ! -f "$ENVIRONMENT_VARIABLES_FILE" ]]; then echo_error "Error: File path \"$ENVIRONMENT_VARIABLES_FILE\" not found for \"ENVIRONMENT_VARIABLES_FILE\" environment variable or the \"-g\" flag or the \"--env-file\" option." exit 1 fi fi if [ "$ACTION" != "list" ] && [ "$ACTION" != "login" ] && [ "$ACTION" != "system:info" ] && [[ ! ${ACTIONS_TEXT_ONLY[*]} =~ $ACTION ]]; then check_argument "$PORTAINER_STACK_NAME" "portainer stack name" "PORTAINER_STACK_NAME" "n" "name" fi } inputs() { while [ $# -gt 0 ]; do case "$1" in -A=*|--auth-token=*|-A|--auth-token) PORTAINER_AUTH_TOKEN=$(input_option "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -u=*|--user=*|-u|--user) PORTAINER_USER=$(input_option "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -p=*|--password=*|-p|--password) PORTAINER_PASSWORD=$(input_option "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -l=*|--url=*|-l|--url) PORTAINER_URL=$(input_option "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -n=*|--name=*|-n|--name) PORTAINER_STACK_NAME=$(input_option "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -c=*|--compose-file=*|-c|--compose-file) DOCKER_COMPOSE_FILE=$(input_option "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; --stack-file=*|--stack-file) # Alias of the '--compose-file' option DOCKER_COMPOSE_FILE=$(input_option "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -L|--lint|-L=*|--lint=*) DOCKER_COMPOSE_LINT=$(input_flag "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -g=*|--env-file=*|-g|--env-file) ENVIRONMENT_VARIABLES_FILE=$(input_option "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -e=*|--endpoint=*|-e|--endpoint) PORTAINER_ENDPOINT=$(input_option "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -r|--prune|-r=*|--prune=*) PORTAINER_PRUNE=$(input_flag "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -T=*|--timeout=*|-T|--timeout) TIMEOUT=$(input_option "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -j|--detect-job|-j=*|--detect-job=*) AUTO_DETECT_JOB=$(input_flag "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -S=*|--service=*|-S|--service) PORTAINER_SERVICE_NAME=$(input_option "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -d|--debug|-d=*|--debug=*) DEBUG_MODE=$(input_flag "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -v|--verbose|-v=*|--verbose=*) VERBOSE_MODE=$(input_flag "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -m|--masked-variables|-m=*|--masked-variables=*) MASKED_VARIABLES=$(input_flag "$1" "$2" "true extended false") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -q|--quiet|-q=*|--quiet=*) QUIET_MODE=$(input_flag "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -t|--strict|-t=*|--strict=*) STRICT_MODE=$(input_flag "$1" "$2") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -i|--insecure|-i=*|--insecure=*) local insecure insecure=$(input_flag "$1" "$2") if [ "$insecure" == "true" ]; then HTTPIE_VERIFY_SSL="no" else HTTPIE_VERIFY_SSL="yes" fi if [ "$HTTPIE_VERIFY_SSL" == "no" ] && [ -z "$PYTHONWARNINGS" ]; then # Fix httpie with Debian and Ubuntu export PYTHONWARNINGS="ignore:Unverified HTTPS request" fi if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; -s|--secure|-s=*|--secure=*) echo_verbose "DEPRECATED: Use '--insecure' option, instead of this '$1' option" HTTPIE_VERIFY_SSL=$(input_flag "$1" "$2" "yes no" "no") if [ "$HTTPIE_VERIFY_SSL" == "no" ] && [ -z "$PYTHONWARNINGS" ]; then # Fix httpie with Debian and Ubuntu export PYTHONWARNINGS="ignore:Unverified HTTPS request" fi if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; actions) ACTION="actions" ;; -V|--version|version) if [ -z "$ACTION" ]; then ACTION="version" echo "Portainer Stack Utils, version $VERSION License GPLv3: GNU GPL version 3" else input_error "Error: invalid option '$1' for the '$ACTION' action" exit 1 fi ;; -h|--help|help) if [ -z "$ACTION" ] || [ "$1" == "help" ]; then ACTION="help" display_help_message else display_help_action_message ACTION="help" fi exit 0 ;; -a=*|--action=*|-a|--action) # To keep backwards compatibility with psu 0.1.x echo_verbose "DEPRECATED: Use 'psu [options]' command, instead of using this '$1' option" ACTION=$(input_option "$1" "$2" "$ACTIONS") if [ -n "$2" ] && [[ ! $2 =~ ^-.+$ ]] ; then # When the second argument is the value of the current option shift fi ;; *) # deploy|undeploy|list|info|status|tasks|services... argument if [ -n "${ACTIONS_ALIASES[$1]}" ] && [ -z "$ACTION" ]; then # Use aliased action '$1' who use the action '${ACTIONS_ALIASES[$1]}'" ACTION="${ACTIONS_ALIASES[$1]}"; elif [[ ${ACTIONS[*]} =~ $1 ]]; then if [ -z "$ACTION" ]; then ACTION="$1"; else input_error "Error: argument already set with the '$ACTION' value" exit 1 fi else input_error "Error: Unknown action '$1', run the 'psu help' command for more information" exit 1 fi esac shift done if [ -z "$ACTION" ]; then input_error "Error: argument is required, run the 'psu help' command for more information" exit 1 fi } input_option() { local option local argument local value local allowed_values option="$1" argument="$2" allowed_values="$3" value="${option#*=}" if [ "$value" == "$option" ]; then if [[ -n $argument ]] && [[ ! $argument =~ ^-.+$ ]]; then # When the second argument is the value of the current option value="$argument" else unset value if [ -n "$argument" ]; then input_error "Error: wrong value '$argument' given for the '$option' option" exit 1 fi fi fi if [ -n "$value" ]; then if [ -z "$allowed_values" ] || [[ ${allowed_values[*]} =~ $value ]]; then echo "$value" else input_error "Error: wrong value given for the '${option%=*}' option, should be '${allowed_values// /\' or \'}'" exit 1 fi else input_error "Error: no value given for the '$option' option" exit 1 fi } input_flag() { local option local argument local value local allowed_values local default_value option="$1" argument="$2" allowed_values="${3:-"true false"}" default_value="${4:-"true"}" value="${option#*=}" if [ "$value" == "$option" ]; then if [[ -n $argument ]] && [[ ! $argument =~ ^-.+$ ]]; then # Second argument is a value for the current option value="$argument" else value="$default_value" fi fi if [ -z "$allowed_values" ] || [[ ${allowed_values[*]} =~ $value ]]; then echo "$value" else input_error "Error: wrong value given for the '${option%=*}' option, should be '${allowed_values// /\' or \'}'" exit 1 fi } input_error() { local message if [ -n "$1" ]; then message="$1" else message="Error: Invalid argument." fi echo_error "$message" } ############################ # Print an error to stderr # # Globals: # # None # # Arguments: # # $1 Error message # # Returns: # # None # ############################ echo_error() { local error_message="$@" local red='\033[0;31m' local nc='\033[0m' echo -e "${red}[$(date +'%Y-%m-%dT%H:%M:%S%z')]: ${error_message}${nc}" >&2 } ####################################### # Check a parameter has been provided # # Globals: # # None # # Arguments: # # $1 Argument value # # $2 Argument name # # $3 Argument envvar # # $4 Argument flag # # Returns: # # None # ####################################### check_argument() { local argument_value=$1 local argument_name=$2 local argument_envvar=$3 local argument_flag=$4 local argument_option=$5 if [ -z "$argument_value" ]; then echo_error "Error: Missing argument \"$argument_name\"." echo_error "Try setting \"$argument_envvar\" environment variable $(if [ -n "$argument_flag" ]; then echo or using the \"-$argument_flag\" flag; fi)$(if [ -n "$argument_option" ]; then echo " or using the \"--$argument_option\" option"; fi)." exit 1 fi } ########################################### # Checks for error exit codes from httpie # # Globals: # # None # # Arguments: # # $1 Httpie exit code # # $2 Response returned by Portainer API # # Returns: # # None # ########################################### check_for_errors() { local exit_code=$1 local response=$2 if [ $exit_code -ne 0 ]; then case $exit_code in 2) echo_error 'Request timed out!' ;; 3) echo_error 'Unexpected HTTP 3xx Redirection!' ;; 4) echo_error 'HTTP 4xx Client Error!' echo_error $response ;; 5) echo_error 'HTTP 5xx Server Error!' echo_error $response ;; 6) echo_error 'Exceeded --max-redirects= redirects!' ;; *) echo_error 'Unholy Error!' ;; esac exit 1 fi } echo_debug_safe_json() { echo_safe_json "debug" "$1" "$2" } echo_verbose_safe_json() { echo_safe_json "verbose" "$1" "$2" } echo_safe_json() { local type="$1" local message="$2" local json="$3" local temp_file # If the $json variable has an '.Env' entry # We parse its content to mask sensitive values if [ "$MASKED_VARIABLES" == "extended" ] && [ -n "$(echo "$json" | jq -j 'if type == "array" then .[] else . end | .Env // ""')" ]; then temp_file=".stack_envs-$(echo "$(date +%s%3N)")" ( echo "$json" | jq -r 'if type == "array" then .[] else . end | .Env | map(.name = .name + "=" + .value) | map (.name) | .[]' | sed "s/\"/\\\\\"/g" | sed "s/^\([^=]\{1,\}=\)\(.* .*\)$/\1\"\2\"/" > $temp_file && \ . $temp_file && rm -f $temp_file && \ if [ "$type" == "debug" ]; then \ echo_debug "$message"; \ elif [ "$type" == "verbose" ]; then \ echo_verbose "$message"; \ fi ) else if [ "$type" == "debug" ]; then echo_debug "$message" elif [ "$type" == "verbose" ]; then echo_verbose "$message" fi fi } ########################################### # Print message if verbose mode is active # # Globals: # # VERBOSE_MODE # # Arguments: # # $1 Message # # Returns: # # None # ########################################### echo_verbose() { local message=$1 local yellow='\033[1;33m' local nc='\033[0m' if [ $VERBOSE_MODE == "true" ]; then local verbose_message verbose_message=$(mask_variables "$message") echo -e "${yellow}${verbose_message}${nc}" fi } ######################################### # Print message if debug mode is active # # Globals: # # DEBUG_MODE # # Arguments: # # $1 Message # # Returns: # # None # ######################################### echo_debug() { local message=$1 if [ $DEBUG_MODE == "true" ]; then local debug_message debug_message=$(mask_variables "$message") echo -e "${debug_message}" fi } mask_variables() { local message="$1" if [ "$MASKED_VARIABLES" == "true" ] && [ -n "$PORTAINER_PASSWORD" ]; then # Mask PORTAINER_PASSWORD and PORTAINER_AUTH_TOKEN variable values local auth_token_masked auth_token_masked="$PORTAINER_AUTH_TOKEN" if [ -z "$auth_token_masked" ]; then auth_token_masked="$PORTAINER_PASSWORD" fi message=$(echo "$message" | sed "s/\($PORTAINER_PASSWORD\|$auth_token_masked\)/[MASKED]/g") elif [ "$MASKED_VARIABLES" == "extended" ]; then # Mask all variable values with PASSWORD or TOKEN in their name local masked_vars # Get all declared variable names and filtering by specific terms masked_vars="$(compgen -v | grep -i 'PASSWORD\|TOKEN')" for masked_var in $masked_vars; do if [ -n "$masked_var" ] && [ -n "${!masked_var}" ]; then # Converts multi lines value to one line value masked_value="$(echo ${!masked_var})" message="$(echo "$message" | sed "s/$masked_value/[MASKED]/g")" fi done fi echo "$message" } ################################ # Create/update a stack # # Globals: # # STACK # # DOCKER_COMPOSE_FILE # # DOCKER_COMPOSE_LINT # # PORTAINER_STACK_NAME # # PORTAINER_URL # # ENVIRONMENT_VARIABLES_FILE # # HTTPIE_VERIFY_SSL # # PORTAINER_ENDPOINT # # PORTAINER_AUTH_TOKEN # # Arguments: # # None # # Returns: # # None # ################################ deploy() { # Lint docker-compose file if --lint option is "true" if [ "$DOCKER_COMPOSE_LINT" == "true" ]; then lint fi # Read docker-compose file content local docker_compose_file_content docker_compose_file_content="$( jq -Rscjr '{StackFileContent: . }' $DOCKER_COMPOSE_FILE | tail -c +2 | head -c -1 )" echo_debug "DOCKER_COMPOSE_FILE -> $DOCKER_COMPOSE_FILE" echo_debug "docker_compose_file_content -> $docker_compose_file_content" # If the stack does not exist if [ -z "$STACK" ]; then echo_verbose "Stack $PORTAINER_STACK_NAME does not exist." # Get Docker info echo_verbose "Getting Docker info..." local docker_info docker_info=$(docker_info) check_for_errors $? "$docker_info" echo_debug "Docker info -> $(echo $docker_info | jq -C .)" # Get Docker swarm ID echo_verbose "Getting swarm cluster (if any)..." local swarm_id swarm_id=$(echo $docker_info | jq -r ".Swarm.Cluster.ID // empty") echo_debug "Swarm ID -> $swarm_id" # If there is no swarm ID if [ -z "$swarm_id" ];then echo_verbose "Swarm cluster not found." echo_verbose "Preparing stack JSON..." local stack_envvars stack_envvars="[]" if [ -n "$ENVIRONMENT_VARIABLES_FILE" ]; then stack_envvars=$(env_file_to_json) fi local data_prefix="{\"Name\":\"$PORTAINER_STACK_NAME\"," local data_suffix=",\"Env\":"$stack_envvars"}" echo "$data_prefix$docker_compose_file_content$data_suffix" > json.tmp echo_debug_safe_json "Stack JSON -> $(echo $data_prefix$docker_compose_file_content$data_suffix | jq -C .)" "$data_prefix$docker_compose_file_content$data_suffix" # Create stack for single Docker instance echo_verbose "Creating stack $PORTAINER_STACK_NAME..." local create create=$(http \ --check-status \ --ignore-stdin \ --verify=$HTTPIE_VERIFY_SSL \ --timeout=300 \ "$PORTAINER_URL/api/stacks" \ "Authorization: Bearer $PORTAINER_AUTH_TOKEN" \ type==2 \ method==string \ endpointId==$PORTAINER_ENDPOINT \ @json.tmp) check_for_errors $? "$create" echo_debug "Create action response -> $(echo $create | jq -C .)" else echo_verbose "Swarm cluster found." echo_verbose "Preparing stack JSON..." local stack_envvars stack_envvars="[]" if [ -n "$ENVIRONMENT_VARIABLES_FILE" ]; then stack_envvars=$(env_file_to_json) fi local data_prefix="{\"Name\":\"$PORTAINER_STACK_NAME\",\"SwarmID\":\"$swarm_id\"," local data_suffix=",\"Env\":"$stack_envvars"}" echo "$data_prefix$docker_compose_file_content$data_suffix" > json.tmp echo_debug_safe_json "Stack JSON -> $(echo $data_prefix$docker_compose_file_content$data_suffix | jq -C .)" "$data_prefix$docker_compose_file_content$data_suffix" # Create stack for Docker swarm echo_verbose "Creating stack $PORTAINER_STACK_NAME..." local create create=$(http \ --check-status \ --ignore-stdin \ --verify=$HTTPIE_VERIFY_SSL \ --timeout=300 \ "$PORTAINER_URL/api/stacks" \ "Authorization: Bearer $PORTAINER_AUTH_TOKEN" \ type==1 \ method==string \ endpointId==$PORTAINER_ENDPOINT \ @json.tmp) check_for_errors $? "$create" echo_debug "Create action response -> $(echo $create | jq -C .)" fi rm json.tmp else if [ $STRICT_MODE == "true" ]; then echo_error "Error: Stack $PORTAINER_STACK_NAME already exists." exit 1 fi echo_verbose "Stack $PORTAINER_STACK_NAME exists." echo_verbose "Preparing stack JSON..." local stack_id stack_id="$(echo "$STACK" | jq -j ".Id")" local stack_envvars stack_envvars="$(echo -n "$STACK" | jq ".Env" -jc)" if [ -n "$ENVIRONMENT_VARIABLES_FILE" ]; then local new_stack_envvars new_stack_envvars=$(env_file_to_json) stack_envvars="$(echo "${new_stack_envvars}${stack_envvars}" | jq -sjc 'add | unique_by(.name)')" fi local data_prefix="{\"Id\":\"$stack_id\"," local data_suffix=",\"Env\":"$stack_envvars",\"Prune\":$PORTAINER_PRUNE}" echo "$data_prefix$docker_compose_file_content$data_suffix" > json.tmp echo_debug_safe_json "Stack JSON -> $(echo $data_prefix$docker_compose_file_content$data_suffix | jq -C .)" "$data_prefix$docker_compose_file_content$data_suffix" # Update stack echo_verbose "Updating stack $PORTAINER_STACK_NAME..." local update update=$(http \ --check-status \ --ignore-stdin \ --verify=$HTTPIE_VERIFY_SSL \ --timeout=300 \ PUT "$PORTAINER_URL/api/stacks/$stack_id" \ "Authorization: Bearer $PORTAINER_AUTH_TOKEN" \ endpointId==$PORTAINER_ENDPOINT \ @json.tmp) check_for_errors $? "$update" echo_debug "Update action response -> $(echo $update | jq -C .)" rm json.tmp fi } ########################## # Remove a stack # # Globals: # # STACK # # PORTAINER_STACK_NAME # # PORTAINER_URL # # HTTPIE_VERIFY_SSL # # PORTAINER_AUTH_TOKEN # # Arguments: # # None # # Returns: # # None # ########################## undeploy() { if [ -z "$STACK" ]; then if [ $STRICT_MODE == "true" ]; then echo_error "Error: Stack $PORTAINER_STACK_NAME does not exist." exit 1 else echo_verbose "Stack $PORTAINER_STACK_NAME does not exist. No need to remove it." exit 0 fi fi echo_verbose "Stack $PORTAINER_STACK_NAME exists." local stack_id stack_id="$(echo "$STACK" | jq -j ".Id")" echo_debug "Stack ID -> $stack_id" echo_verbose "Deleting stack $PORTAINER_STACK_NAME..." local delete delete=$(http \ --check-status \ --ignore-stdin \ --verify=$HTTPIE_VERIFY_SSL \ DELETE "$PORTAINER_URL/api/stacks/$stack_id" \ "Authorization: Bearer $PORTAINER_AUTH_TOKEN") check_for_errors $? "$delete" echo_debug "Delete action response -> $(echo $delete | jq -C .)" } # Get Portainer auth token. Will be used on every API request. login() { echo_verbose "Getting auth token..." local auth_token_json auth_token_json=$(http \ --check-status \ --ignore-stdin \ --verify=$HTTPIE_VERIFY_SSL \ $PORTAINER_URL/api/auth \ username=$PORTAINER_USER \ password=$PORTAINER_PASSWORD) check_for_errors $? "$auth_token_json" PORTAINER_AUTH_TOKEN=$(echo $auth_token_json | jq -r .jwt) echo_debug "Get auth token response -> $(echo $auth_token_json | jq -C .)" echo_debug "Auth token -> $PORTAINER_AUTH_TOKEN" if [ -z "$PORTAINER_AUTH_TOKEN" ]; then echo_error "Auth token is empty, check if your username and password are correct." exit 1 fi } ################################################### # Convert environment variables from file to JSON # # Globals: # # ENVIRONMENT_VARIABLES_FILE # # Arguments: # # None # # Returns: # # JSON string # ################################################### env_file_to_json() { echo "$(env -i sh -c "(unset \$(env | sed 's/=.*//'); set -a; . $(readlink -f $ENVIRONMENT_VARIABLES_FILE); set +a; jq -njc 'env | to_entries | map({name: .key, value: .value})')")" } # Get Docker info docker_info() { local docker_info docker_info=$(http \ --check-status \ --ignore-stdin \ --verify=$HTTPIE_VERIFY_SSL \ "$PORTAINER_URL/api/endpoints/$PORTAINER_ENDPOINT/docker/info" \ "Authorization: Bearer $PORTAINER_AUTH_TOKEN") check_for_errors $? "$docker_info" echo "$docker_info" } tasks() { local desired_state=$1 local state=$2 local service_name service_name=${PORTAINER_STACK_NAME}_${PORTAINER_SERVICE_NAME} local filter_service filter_service="\"service\":{\"$service_name\":true}" local filter_desired_state if [ -n "$desired_state" ]; then filter_desired_state="\"desired-state\":{\"$desired_state\":true}" fi local filters filters="{$filter_service$(if [ -n "$filter_desired_state" ]; then echo ",$filter_desired_state"; fi)}" local tasks tasks=$(http \ --check-status \ --ignore-stdin \ --verify=$HTTPIE_VERIFY_SSL \ "$PORTAINER_URL/api/endpoints/$PORTAINER_ENDPOINT/docker/tasks" \ filters=="$filters" \ "Authorization: Bearer $PORTAINER_AUTH_TOKEN") check_for_errors $? "$tasks" if [ -n "$state" ]; then local filter_status filter_status="map(select(any(.Status; .State == \"$state\")))" if [ "$desired_state" == "shutdown" ] && [ "$state" == "complete" ]; then local filter_include_job_auto_detection filter_include_job_auto_detection=$(if [ "$AUTO_DETECT_JOB" == "true" ]; then echo 'map(select(any(.Spec.RestartPolicy; .Condition == "none")))'; else echo 'map(select(.Spec.ContainerSpec.Labels."job-name"))'; fi) # For tasks which run a script then shutdown when it's successfully executed, like 'Job' in Kubernetes local last_task_created_at last_task_created_at=$(tasks | jq -jc "max_by(.CreatedAt) | .CreatedAt") local filter_created_at filter_created_at="map(select(.CreatedAt >= \"$last_task_created_at\"))" echo "$tasks" | jq -jc "$filter_status | $filter_include_job_auto_detection | sort_by(.CreatedAt) | reverse | unique_by(.Slot) | $filter_created_at" else local filter_exclude_job_auto_detection filter_exclude_job_auto_detection=$(if [ "$AUTO_DETECT_JOB" == "true" ]; then echo 'map(select(any(.Spec.RestartPolicy; .Condition != "none")))'; else echo 'map(select(.Spec.ContainerSpec.Labels."job-name" | not))'; fi) echo "$tasks" | jq -jc "$filter_status | $filter_exclude_job_auto_detection | sort_by(.CreatedAt) | reverse | unique_by(.Slot)" fi else echo "$tasks" | jq -jc 'sort_by(.CreatedAt) | reverse | unique_by(.Slot)' fi } tasks_healthy() { local tasks_running tasks_running=$(tasks 'running' 'running') local tasks_job_complete tasks_job_complete=$(tasks 'shutdown' 'complete') echo "$(echo "${tasks_running}${tasks_job_complete}" | jq -sjc 'add | unique_by(.ID)')" } services() { local services local filter_service filter_service="\"label\":{\"com.docker.stack.namespace=$PORTAINER_STACK_NAME\":true}" services=$(http \ --check-status \ --ignore-stdin \ --verify=$HTTPIE_VERIFY_SSL \ "$PORTAINER_URL/api/endpoints/$PORTAINER_ENDPOINT/docker/services" \ filters=="{$filter_service}" \ "Authorization: Bearer $PORTAINER_AUTH_TOKEN") check_for_errors $? "$services" local filter_mode # If a service has a replicas set to zero, skip it! filter_mode="map(select(.Spec.Mode.Global or any(.Spec.Mode.Replicated; .Replicas > 0)))" echo "$services" | jq -jc "$filter_mode" } containers() { local containers local filter_stack local filter_task local service_name local filter_service local filters filter_stack="\"label\":{\"com.docker.stack.namespace=$PORTAINER_STACK_NAME\":true}" filter_task="\"is-task\":{\"true\":true}" if [ -n "$PORTAINER_SERVICE_NAME" ]; then service_name=${PORTAINER_STACK_NAME}_${PORTAINER_SERVICE_NAME} filter_service="\"label\":{\"com.docker.swarm.service.name=$service_name\":true}" fi filters="{$filter_stack$(if [ -n "$filter_service" ]; then echo ",$filter_service"; fi),$filter_task}" containers=$(http \ --check-status \ --ignore-stdin \ --verify=$HTTPIE_VERIFY_SSL \ "$PORTAINER_URL/api/endpoints/$PORTAINER_ENDPOINT/docker/containers/json" \ filters=="$filters" \ "Authorization: Bearer $PORTAINER_AUTH_TOKEN") check_for_errors $? "$containers" echo "$containers" } lint() { local docker_stack_error local docker_stack_validation if [ -x "$(command -v docker-compose)" ]; then echo_verbose "Linting Docker compose/stack file..." docker_stack_error="error_docker_stack_is_invalid" docker_stack_validation=$(docker-compose -f "$DOCKER_COMPOSE_FILE" config -q > docker_stack_validation_report 2>&1 || echo $docker_stack_error) if [[ $docker_stack_validation =~ $docker_stack_error$ ]]; then echo_error "Error: The '$DOCKER_COMPOSE_FILE' Docker compose/stack file is invalid:" echo_error "$(cat docker_stack_validation_report)" rm -f docker_stack_validation_report exit 1 else if [ "$ACTION" == "lint" ]; then echo "[OK]" fi echo_verbose "The '$DOCKER_COMPOSE_FILE' Docker compose/stack file is valid" fi else echo "WARNING: Cannot validate '$DOCKER_COMPOSE_FILE' file, 'docker-compose' is not installed or not executable." echo_verbose "You can run the default Docker image of psu, who bundles 'docker-compose'." echo_verbose "For more informations, see: https://docs.docker.com/compose/install/" fi || true } display_options_message() { echo "Options:" local table local flag_columns=6 local columns=30 local row local flag local name local description for option in $OPTIONS; do display_options "${OPTIONS_ASSOC[$option]}" done } display_help_action_message() { local actions_table local action_description local required_options_table local optional_options_table local required_options local optional_options local option_list IFS=';' read -ra actions_table <<< "${ACTIONS_ASSOC[$ACTION]}" action_description="${actions_table[0]}" IFS='|' read -ra required_options_table <<< "${actions_table[1]}" for required_option in "${required_options_table[@]}"; do option_list=("${OPTIONS_ASSOC[$required_option]}") required_options+="$(display_options "${option_list[@]}")\n" done IFS='|' read -ra optional_options_table <<< "${actions_table[2]}" for optional_option in "${optional_options_table[@]}"; do option_list=("${OPTIONS_ASSOC[$optional_option]}") optional_options+="$(display_options "${option_list[@]}")\n" done echo "Usage: psu $ACTION [options] Required options: $(echo -e "$required_options") Optional options: $(echo -e "$optional_options") Help: $action_description" } display_options() { local table local flag_columns=6 local columns=30 local row local flag local name local description local options if [ -n "$1" ]; then options=("$1") else options=("${OPTIONS_ASSOC[@]}") fi for option in "${options[@]}"; do IFS=';' read -ra table <<< "$option" flag="${table[0]}" name="${table[1]}" description="${table[2]}" if [ -n "$description" ]; then row=$(printf "%-${flag_columns}s %-${columns}s %-${columns}s \n" "$(if [ -n "$flag" ]; then echo "$flag,"; fi)" "$name" "$description") echo " $row" fi done } display_actions_message() { echo "Available actions:" local table local columns=15 local row local name local description for action in "${ACTIONS_TABLE[@]}"; do IFS=';' read -ra table <<< "$action" name="${table[0]}" description="${table[1]}" if [ -n "$description" ]; then row=$(printf "%-${columns}s %-${columns}s \n" "$name" "$description") echo " $row" fi done if [ $VERBOSE_MODE == "true" ]; then display_actions_aliased_message fi } display_actions_aliased_message() { if [ ${#ACTIONS_ALIASES[@]} -ne 0 ]; then echo "" echo "Available aliased actions:" local alias_columns=25 local alias_table_header local alias_table_separator local alias_table_row alias_table_header=$(printf "| %-${alias_columns}s | %-${alias_columns}s |\n" "Aliased action:" "Equivalent action:") alias_table_separator=$(printf "+%$((${#alias_table_header}-2))s+\n" "" | tr ' ' '-') echo " $alias_table_separator" echo " $alias_table_header" echo " $alias_table_separator" for action in "${!ACTIONS_ALIASES[@]}"; do alias_table_row=$(printf "| %-${alias_columns}s | %-${alias_columns}s |\n" "$action" "${ACTIONS_ALIASES[$action]}") echo " $alias_table_row" done | sort echo " $alias_table_separator" fi } display_help_message() { echo "Portainer Stack Utils, version $VERSION Usage: psu [options] Arguments: action The name of the action to execute (possible values: '${ACTIONS// /\', \'}') $(display_options_message) $(display_actions_message) Help: You can deploy/update/undeploy/list... stacks in a Portainer instance easily with this tool!" } main "$@"