mirror of
https://gitlab.com/psuapp/psu.git
synced 2024-08-30 18:12:34 +00:00
ebbc89d8bd
Hurray!
1475 lines
52 KiB
Bash
1475 lines
52 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# Deploy/update/remove/inspect/status Docker stacks in a Portainer instance.
|
|
# List stacks, services, tasks and containers
|
|
# And more!
|
|
|
|
set -e
|
|
[[ "$PSU_TRACE" ]] && set -x
|
|
|
|
############################
|
|
# 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="1.0.0"
|
|
|
|
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|-f;--compose-file=FILE_PATH;Path to docker compose/stack file (required if action=deploy)"
|
|
"compose-file-base64;-C|-F;--compose-file-base64=[BASE64];Content of docker compose/stack file, encoded in base64, useful with Docker in Docker (only used when action=deploy)"
|
|
"env-file;-g;--env-file=[FILE_PATH];Path to a file of environment variables, to be used by the stack (only used when action=deploy)"
|
|
"env-file-base64;-G;--env-file-base64=[BASE64];Content of file with environment variables, encoded in base64, to be used by the stack, useful with Docker in Docker (only used when action=deploy)"
|
|
"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 <action> --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 <action> 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 given stack;url|user|password|name|compose-file;auth-token|endpoint|lint|compose-file-base64|env-file|env-file-base64|prune|insecure|verbose|debug|masked-variables|strict"
|
|
"rm;Remove/undeploy the given stack;url|user|password|name;auth-token|endpoint|insecure|verbose|debug|masked-variables|strict"
|
|
"ls;List stacks already deployed;url|user|password;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"
|
|
"services;List services already deployed in the current stack;url|user|password|name;auth-token|endpoint|quiet|insecure|verbose|debug|masked-variables"
|
|
"tasks;List tasks in the current stack;url|user|password|name;auth-token|endpoint|service|detect-job|timeout|quiet|insecure|verbose|debug|masked-variables"
|
|
"tasks:healthy;List tasks who are running correctly in the current stack;url|user|password|name;auth-token|endpoint|service|detect-job|timeout|quiet|insecure|verbose|debug|masked-variables"
|
|
"containers;List containers running in the current stack;url|user|password|name;auth-token|endpoint|service|quiet|insecure|verbose|debug|masked-variables"
|
|
"login;Log in to a Portainer instance;url|user|password;insecure|verbose|debug|masked-variables"
|
|
"lint;Validate the Docker compose/stack file;compose-file;compose-file-base64|verbose|debug"
|
|
"inspect;Display low-level information of the current stack;url|user|password|name;auth-token|endpoint|quiet|insecure|verbose|debug|masked-variables"
|
|
"system:info;Display Docker system-wide information;url|user|password;auth-token|endpoint|insecure|verbose|debug|masked-variables"
|
|
"actions;List 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 not a long term supported feature
|
|
# Please, avoid to use these action aliases in production
|
|
declare -A ACTIONS_ALIASES
|
|
ACTIONS_ALIASES=(
|
|
["auth"]="login"
|
|
["docker:info"]="system:info"
|
|
["remove"]="rm"
|
|
["undeploy"]="rm"
|
|
["list"]="ls"
|
|
["ps"]="tasks"
|
|
["ps:healthy"]="tasks:healthy"
|
|
["update"]="deploy"
|
|
["validate"]="lint"
|
|
)
|
|
|
|
transform_actions_table
|
|
# Decode $ACTIONS_ASSOC associative array
|
|
eval $ACTIONS_ASSOC_ENCODED
|
|
|
|
transform_options_table
|
|
# Decode $OPTIONS_ASSOC associative array
|
|
eval $OPTIONS_ASSOC_ENCODED
|
|
|
|
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 == "rm" ]; 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 == "inspect" ]; 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 == "ls" ]; 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" != "ls" ] && [ "$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 section ###################
|
|
###################################################
|
|
|
|
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=*|-f=*|--compose-file=*|--file=*|-c|-f|--compose-file|--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
|
|
;;
|
|
-C=*|-F=*|--compose-file-base64=*|--file-base64=*|-C|-F|--compose-file-base64|--file-base64)
|
|
local docker_compose_file_base64
|
|
docker_compose_file_base64=$(input_option "$1" "$2")
|
|
echo "$docker_compose_file_base64" | base64 -d > docker_compose_file_from_base64.yml
|
|
DOCKER_COMPOSE_FILE=docker_compose_file_from_base64.yml
|
|
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
|
|
;;
|
|
-G=*|--env-file-base64=*|-G|--env-file-base64)
|
|
local env_file_base64
|
|
env_file_base64=$(input_option "$1" "$2")
|
|
echo "$env_file_base64" | base64 -d > env_file_from_base64
|
|
ENVIRONMENT_VARIABLES_FILE=env_file_from_base64
|
|
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 <action> [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|rm|ls|inspect|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: <action> 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: <action> 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"
|
|
}
|
|
|
|
###################################################
|
|
############### prints section ###################
|
|
###################################################
|
|
|
|
############################
|
|
# 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=<n> 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"
|
|
}
|
|
|
|
###################################################
|
|
############### actions section ###################
|
|
###################################################
|
|
|
|
################################
|
|
# 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
|
|
}
|
|
|
|
# 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
|
|
}
|
|
|
|
###################################################
|
|
############## helpers section ###################
|
|
###################################################
|
|
|
|
###################################################
|
|
# 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})')")"
|
|
}
|
|
|
|
# Set the ACTIONS variable who's a list of all psu actions
|
|
# Set the ACTIONS_ASSOC variable who's an associative array of all psu actions
|
|
transform_actions_table() {
|
|
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
|
|
|
|
# Bash array variable can't be exported
|
|
# See: https://stackoverflow.com/questions/5564418/exporting-an-array-in-bash-script
|
|
ACTIONS_ASSOC_ENCODED=$(declare -p ACTIONS_ASSOC)
|
|
}
|
|
|
|
# Set the OPTIONS variable who's a list of all psu options
|
|
# Set the OPTIONS_ASSOC variable who's an associative array of all psu options
|
|
transform_options_table() {
|
|
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
|
|
|
|
# Bash array variable can't be exported
|
|
# See: https://stackoverflow.com/questions/5564418/exporting-an-array-in-bash-script
|
|
OPTIONS_ASSOC_ENCODED=$(declare -p OPTIONS_ASSOC)
|
|
}
|
|
|
|
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 <action> [options]
|
|
|
|
Arguments:
|
|
action The name of the action to execute (possible values: '${ACTIONS// /\', \'}')
|
|
|
|
$(display_options_message)
|
|
|
|
$(display_actions_message)
|
|
|
|
Help:
|
|
You can deploy/update/remove/list... stacks in a Portainer instance easily with this tool!"
|
|
}
|
|
|
|
main "$@"
|