#!/usr/bin/env bash
# from https://github.com/greenled/portainer-stack-utils
#
# Deploy/update/undeploy Docker stacks in a Portainer instance.

NOWID=`date "+%Y%m%d-%H%M%S"`
TMP_JSON_FILE="/tmp/psu-json.${NOWID}.tmp"

##########################
# Main entrypoint        #
# Globals:               #
#   AUTH_TOKEN           #
#   HTTPIE_VERIFY_SSL    #
#   PORTAINER_URL        #
#   PORTAINER_USER       #
#   PORTAINER_PASSWORD   #
#   PORTAINER_STACK_NAME #
#   STACK                #
#   ACTION               #
# Arguments:             #
#   None                 #
# Returns:               #
#   None                 #
##########################
main() {
  set_globals "$@"

  # Get Portainer auth token. Will be used on every API request.
  echo_verbose "Getting auth token..."
  AUTH_TOKEN=$(http \
    --check-status \
    --ignore-stdin \
    --verify=$HTTPIE_VERIFY_SSL \
    $PORTAINER_URL/api/auth \
    username=$PORTAINER_USER \
    password=$PORTAINER_PASSWORD)
  check_for_errors $? "$AUTH_TOKEN"
  echo_debug "Get auth token response -> $(echo $AUTH_TOKEN | jq -C .)"
  AUTH_TOKEN=$(echo $AUTH_TOKEN | jq -r .jwt)
  echo_debug "Auth token -> $AUTH_TOKEN"

  # Get list of all stacks
  echo_verbose "Getting stack $PORTAINER_STACK_NAME..."
  local stacks
  stacks=$(http \
    --check-status \
    --ignore-stdin \
    --verify=$HTTPIE_VERIFY_SSL \
    "$PORTAINER_URL/api/stacks" \
    "Authorization: Bearer $AUTH_TOKEN")
  check_for_errors $? "$stacks"
  echo_debug "Get stacks response -> $(echo $stacks | jq -C .)"

  # 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 "Stack ${PORTAINER_STACK_NAME} -> $(echo $STACK | jq -C .)"

  if [ $ACTION == "deploy" ]; then
    deploy
    exit 0
  fi

  if [ $ACTION == "undeploy" ]; then
    undeploy
    exit 0
  fi

  echo_error "Error: Unknown action \"$ACTION\"."
  exit 1
}

################################
# Set globals                  #
# Globals:                     #
#   ACTION                     #
#   PORTAINER_USER             #
#   PORTAINER_PASSWORD         #
#   PORTAINER_URL              #
#   PORTAINER_STACK_NAME       #
#   DOCKER_COMPOSE_FILE        #
#   ENVIRONMENT_VARIABLES_FILE #
#   PORTAINER_ENDPOINT         #
#   PORTAINER_PRUNE            #
#   HTTPIE_VERIFY_SSL          #
#   VERBOSE_MODE               #
#   DEBUG_MODE                 #
#   STRICT_MODE                #
# Arguments:                   #
#   None                       #
# Returns:                     #
#   None                       #
################################
set_globals() {
  # Set arguments through envvars
  ACTION=${ACTION}
  PORTAINER_USER=${PORTAINER_USER}
  PORTAINER_PASSWORD=${PORTAINER_PASSWORD}
  PORTAINER_URL=${PORTAINER_URL}
  PORTAINER_STACK_NAME=${PORTAINER_STACK_NAME}
  DOCKER_COMPOSE_FILE=${DOCKER_COMPOSE_FILE}
  ENVIRONMENT_VARIABLES_FILE=${ENVIRONMENT_VARIABLES_FILE}
  PORTAINER_ENDPOINT=${PORTAINER_ENDPOINT:-"1"}
  PORTAINER_PRUNE=${PORTAINER_PRUNE:-"false"}
  HTTPIE_VERIFY_SSL=${HTTPIE_VERIFY_SSL:-"yes"}
  VERBOSE_MODE=${VERBOSE_MODE:-"false"}
  DEBUG_MODE=${DEBUG_MODE:-"false"}
  STRICT_MODE=${STRICT_MODE:-"false"}

  # Set arguments through flags (overwrite envvars)
  while getopts a:u:p:l:n:c:e:g:rsvdt option; do
    case "${option}" in
      a) ACTION=${OPTARG} ;;
      u) PORTAINER_USER=${OPTARG} ;;
      p) PORTAINER_PASSWORD=${OPTARG} ;;
      l) PORTAINER_URL=${OPTARG} ;;
      n) PORTAINER_STACK_NAME=${OPTARG} ;;
      c) DOCKER_COMPOSE_FILE=${OPTARG} ;;
      e) PORTAINER_ENDPOINT=${OPTARG} ;;
      g) ENVIRONMENT_VARIABLES_FILE=${OPTARG} ;;
      r) PORTAINER_PRUNE="true" ;;
      s) HTTPIE_VERIFY_SSL="no" ;;
      v) VERBOSE_MODE="true" ;;
      d) DEBUG_MODE="true" ;;
      t) STRICT_MODE="true" ;;
      *)
        echo_error "Unexpected option ${option}"
        exit 1
        ;;
    esac
  done

  # 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_URL -> $PORTAINER_URL"
  echo_debug "PORTAINER_STACK_NAME -> $PORTAINER_STACK_NAME"
  echo_debug "DOCKER_COMPOSE_FILE -> $DOCKER_COMPOSE_FILE"
  echo_debug "ENVIRONMENT_VARIABLES_FILE -> $ENVIRONMENT_VARIABLES_FILE"
  echo_debug "PORTAINER_ENDPOINT -> $PORTAINER_ENDPOINT"
  echo_debug "PORTAINER_PRUNE -> $PORTAINER_PRUNE"
  echo_debug "HTTPIE_VERIFY_SSL -> $HTTPIE_VERIFY_SSL"
  echo_debug "VERBOSE_MODE -> $VERBOSE_MODE"
  echo_debug "DEBUG_MODE -> $DEBUG_MODE"
  echo_debug "STRICT_MODE -> $STRICT_MODE"

  # Check required arguments have been provided
  check_argument "$ACTION" "action" "ACTION" "a"
  check_argument "$PORTAINER_USER" "portainer user" "PORTAINER_USER" "u"
  check_argument "$PORTAINER_PASSWORD" "portainer password" "PORTAINER_PASSWORD" "p"
  check_argument "$PORTAINER_URL" "portainer url" "PORTAINER_URL" "l"
  check_argument "$PORTAINER_STACK_NAME" "portainer stack name" "PORTAINER_STACK_NAME" "n"
  if [ $ACTION == "deploy" ]; then
    check_argument "$DOCKER_COMPOSE_FILE" "docker compose file" "DOCKER_COMPOSE_FILE" "c"
    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."
      exit 1
    fi
  fi
}

############################
# 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
  if [ -z "$argument_value" ]; then
    echo_error "Error: Missing argument \"$argument_name\"."
    echo_error "Try setting \"$argument_envvar\" environment variable or using the \"-$argument_flag\" flag."
    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
}

###########################################
# 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
    echo -e "${yellow}${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
    echo -e "${message}"
  fi
}

################################
# Create/update a stack        #
# Globals:                     #
#   STACK                      #
#   DOCKER_COMPOSE_FILE        #
#   PORTAINER_STACK_NAME       #
#   PORTAINER_URL              #
#   ENVIRONMENT_VARIABLES_FILE #
#   HTTPIE_VERIFY_SSL          #
#   PORTAINER_ENDPOINT         #
#   AUTH_TOKEN                 #
# Arguments:                   #
#   None                       #
# Returns:                     #
#   None                       #
################################
deploy() {
  # Read docker-compose file content
  local docker_compose_file_content
  docker_compose_file_content=$(cat "$DOCKER_COMPOSE_FILE")

  # Remove carriage returns
  docker_compose_file_content="${docker_compose_file_content//$'\r'/''}"

  # Escape double quotes
  docker_compose_file_content="${docker_compose_file_content//$'"'/'\"'}"

  # Escape newlines
  docker_compose_file_content="${docker_compose_file_content//$'\n'/'\n'}"

  # 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=$(http \
      --check-status \
      --ignore-stdin \
      --verify=$HTTPIE_VERIFY_SSL \
      "$PORTAINER_URL/api/endpoints/$PORTAINER_ENDPOINT/docker/info" \
      "Authorization: Bearer $AUTH_TOKEN")
    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\",\"StackFileContent\":\""
      local data_suffix="\",\"Env\":"$stack_envvars"}"
      echo "$data_prefix$docker_compose_file_content$data_suffix" > $TMP_JSON_FILE
      echo_debug "Stack JSON -> $(echo $data_prefix$docker_compose_file_content$data_suffix | jq -C .)"

      # 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 $AUTH_TOKEN" \
        type==2 \
        method==string \
        endpointId==$PORTAINER_ENDPOINT \
        @$TMP_JSON_FILE)
      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\",\"StackFileContent\":\""
      local data_suffix="\",\"Env\":"$stack_envvars"}"
      echo "$data_prefix$docker_compose_file_content$data_suffix" > $TMP_JSON_FILE
      echo_debug "Stack JSON -> $(echo $data_prefix$docker_compose_file_content$data_suffix | jq -C .)"

      # 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 $AUTH_TOKEN" \
        type==1 \
        method==string \
        endpointId==$PORTAINER_ENDPOINT \
        @$TMP_JSON_FILE)
      check_for_errors $? "$create"
      echo_debug "Create action response -> $(echo $create | jq -C .)"
    fi

    rm $TMP_JSON_FILE
  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\",\"StackFileContent\":\""
    local data_suffix="\",\"Env\":"$stack_envvars",\"Prune\":$PORTAINER_PRUNE}"
    echo "$data_prefix$docker_compose_file_content$data_suffix" > $TMP_JSON_FILE
    echo_debug "Stack JSON -> $(echo $data_prefix$docker_compose_file_content$data_suffix | jq -C .)"

    # 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 $AUTH_TOKEN" \
      endpointId==$PORTAINER_ENDPOINT \
      @$TMP_JSON_FILE)
    check_for_errors $? "$update"
    echo_debug "Update action response -> $(echo $update | jq -C .)"

    rm $TMP_JSON_FILE
  fi
}

##########################
# Remove a stack         #
# Globals:               #
#   STACK                #
#   PORTAINER_STACK_NAME #
#   PORTAINER_URL        #
#   HTTPIE_VERIFY_SSL    #
#   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 undeploy 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 $AUTH_TOKEN")
  check_for_errors $? "$delete"
  echo_debug "Delete action response -> $(echo $delete | jq -C .)"
}

###################################################
# 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})')")"
}

main "$@"
