mirror of
https://gitlab.com/psuapp/psu.git
synced 2024-08-30 18:12:34 +00:00
For smaller Docker images, faster execution and to be more portable Running concurrently 'psu' commands should work now, by creating unique temporary file names
1528 lines
55 KiB
Bash
Executable File
1528 lines
55 KiB
Bash
Executable File
#!/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.2.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=$(curl_wrapper \
|
|
--request GET \
|
|
--header "Authorization: Bearer ${PORTAINER_AUTH_TOKEN}" \
|
|
"${PORTAINER_URL}/api/stacks")
|
|
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 && psu --action=tasks:healthy --quiet --debug=false --verbose=false) >/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")
|
|
local docker_compose_file_from_base64_path
|
|
docker_compose_file_from_base64_path="$(unique_temp_file_path)"
|
|
echo "$docker_compose_file_base64" | base64 -d > "$docker_compose_file_from_base64_path"
|
|
DOCKER_COMPOSE_FILE="$docker_compose_file_from_base64_path"
|
|
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")
|
|
local env_file_from_base64_path
|
|
env_file_from_base64_path="$(unique_temp_file_path)"
|
|
echo "$env_file_base64" | base64 -d > "$env_file_from_base64_path"
|
|
ENVIRONMENT_VARIABLES_FILE="$env_file_from_base64_path"
|
|
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="${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
|
|
22)
|
|
echo_error 'HTTP 4xx or 5xx Client Error!'
|
|
echo_error "$response"
|
|
;;
|
|
28) echo_error 'Request timed out!' ;;
|
|
47) echo_error 'Exceeded --max-redirs <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="$(unique_temp_file_path)"
|
|
(
|
|
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"
|
|
}
|
|
|
|
unique_temp_file_path() {
|
|
local file_prefix="psu"
|
|
local fallback_temp_path
|
|
local temp_path
|
|
local temp_file_path
|
|
|
|
fallback_temp_path="$(if [ -w "/tmp" ]; then echo "/tmp"; else pwd; fi)"
|
|
temp_path="${TMPDIR:-${TMP:-${TEMP:-$fallback_temp_path}}}"
|
|
if [ -w "$temp_path" ]; then
|
|
local file_uuid
|
|
# Generate universally unique identifier (UUID)
|
|
file_uuid=$(if [ -x "$(command -v uuidgen)" ]; then uuidgen; else cat /proc/sys/kernel/random/uuid; fi)
|
|
if [ -z "$file_uuid" ]; then
|
|
echo_error "You must install the 'uuidgen' program, to generate universally unique identifier"
|
|
exit 1
|
|
fi
|
|
# Unique temp file path
|
|
temp_file_path="${temp_path}/${file_prefix}_${file_uuid}.tmp"
|
|
else
|
|
echo_error "'${temp_path}' path is NOT WRITABLE!"
|
|
exit 1
|
|
fi
|
|
|
|
echo "$temp_file_path"
|
|
}
|
|
|
|
curl_wrapper() {
|
|
local result
|
|
# Use --fail-with-body cURL option if available
|
|
# And use environment variable to cache result
|
|
CURL_HAS_FAIL_WITH_BODY="${CURL_HAS_FAIL_WITH_BODY:-$(curl --help all | grep -w '\-\-fail\-with\-body' || true)}"
|
|
export CURL_HAS_FAIL_WITH_BODY
|
|
|
|
# Otherwise fallback to a temp result file storing
|
|
# Borrowed from: https://stackoverflow.com/a/55434980
|
|
local result_response_file
|
|
if [ -z "$CURL_HAS_FAIL_WITH_BODY" ]; then
|
|
result_response_file="$(unique_temp_file_path)"
|
|
fi
|
|
|
|
local curl_fail_params
|
|
curl_fail_params=$(if [ -n "$CURL_HAS_FAIL_WITH_BODY" ]; then echo --fail-with-body; else echo --output "$result_response_file" --write-out %\{http_code\}; fi)
|
|
|
|
result="$(curl \
|
|
$curl_fail_params \
|
|
--header "Content-Type: application/json" \
|
|
--silent \
|
|
$(if [ "$HTTPIE_VERIFY_SSL" == "no" ]; then echo --insecure; else $(if [ -f "$HTTPIE_VERIFY_SSL" ]; then echo --cacert "$HTTPIE_VERIFY_SSL"; else echo ''; fi); fi) \
|
|
"$@")"
|
|
|
|
result_exit_code="$?"
|
|
if [ -n "$result_response_file" ]; then
|
|
local response
|
|
response="$(cat "$result_response_file")"
|
|
rm -f "$result_response_file"
|
|
local response_result
|
|
response_result="$result"
|
|
result="$response"
|
|
echo "$result"
|
|
if [ "$response_result" -ge 400 ] && [ "$response_result" -le 599 ]; then
|
|
exit 22
|
|
fi
|
|
exit "$result_exit_code"
|
|
else
|
|
echo "$result"
|
|
exit "$result_exit_code"
|
|
fi
|
|
}
|
|
|
|
###################################################
|
|
############### 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"
|
|
|
|
local json_temp_path
|
|
json_temp_path="$(unique_temp_file_path)"
|
|
|
|
# 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_temp_path"
|
|
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=$(curl_wrapper \
|
|
--max-time 300 \
|
|
--request POST \
|
|
--header "Authorization: Bearer ${PORTAINER_AUTH_TOKEN}" \
|
|
--data "@${json_temp_path}" \
|
|
"${PORTAINER_URL}/api/stacks?type=2&method=string&endpointId=${PORTAINER_ENDPOINT}")
|
|
|
|
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_temp_path"
|
|
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=$(curl_wrapper \
|
|
--max-time 300 \
|
|
--request POST \
|
|
--header "Authorization: Bearer ${PORTAINER_AUTH_TOKEN}" \
|
|
--data "@${json_temp_path}" \
|
|
"${PORTAINER_URL}/api/stacks?type=1&method=string&endpointId=${PORTAINER_ENDPOINT}")
|
|
check_for_errors $? "$create"
|
|
echo_debug "Create action response -> $(echo $create | jq -C .)"
|
|
fi
|
|
|
|
rm -f "$json_temp_path"
|
|
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_temp_path"
|
|
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=$(curl_wrapper \
|
|
--max-time 300 \
|
|
--request PUT \
|
|
--header "Authorization: Bearer ${PORTAINER_AUTH_TOKEN}" \
|
|
--data "@${json_temp_path}" \
|
|
"${PORTAINER_URL}/api/stacks/${stack_id}?endpointId=${PORTAINER_ENDPOINT}")
|
|
check_for_errors $? "$update"
|
|
echo_debug "Update action response -> $(echo $update | jq -C .)"
|
|
|
|
rm -f "$json_temp_path"
|
|
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=$(curl_wrapper \
|
|
--request DELETE \
|
|
--header "Authorization: Bearer ${PORTAINER_AUTH_TOKEN}" \
|
|
"${PORTAINER_URL}/api/stacks/${stack_id}?endpointId=${PORTAINER_ENDPOINT}")
|
|
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=$(curl_wrapper \
|
|
--request POST \
|
|
--data "{\"username\":\"${PORTAINER_USER}\",\"password\":\"${PORTAINER_PASSWORD}\"}" \
|
|
"${PORTAINER_URL}/api/auth")
|
|
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=$(curl_wrapper \
|
|
--request GET \
|
|
--header "Authorization: Bearer ${PORTAINER_AUTH_TOKEN}" \
|
|
"${PORTAINER_URL}/api/endpoints/${PORTAINER_ENDPOINT}/docker/info")
|
|
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=$(curl_wrapper \
|
|
--request GET \
|
|
--get \
|
|
--header "Authorization: Bearer ${PORTAINER_AUTH_TOKEN}" \
|
|
--data-urlencode "filters=${filters}" \
|
|
"${PORTAINER_URL}/api/endpoints/${PORTAINER_ENDPOINT}/docker/tasks")
|
|
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
|
|
local filters
|
|
filter_service="\"label\":{\"com.docker.stack.namespace=$PORTAINER_STACK_NAME\":true}"
|
|
filters="{${filter_service}}"
|
|
services=$(curl_wrapper \
|
|
--request GET \
|
|
--get \
|
|
--header "Authorization: Bearer ${PORTAINER_AUTH_TOKEN}" \
|
|
--data-urlencode "filters=${filters}" \
|
|
"${PORTAINER_URL}/api/endpoints/${PORTAINER_ENDPOINT}/docker/services")
|
|
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=$(curl_wrapper \
|
|
--request GET \
|
|
--get \
|
|
--header "Authorization: Bearer ${PORTAINER_AUTH_TOKEN}" \
|
|
--data-urlencode "filters=${filters}" \
|
|
"${PORTAINER_URL}/api/endpoints/${PORTAINER_ENDPOINT}/docker/containers/json")
|
|
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"
|
|
local docker_stack_validation_report
|
|
docker_stack_validation_report="$(unique_temp_file_path)"
|
|
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 "$@"
|