Skip to content

Instantly share code, notes, and snippets.

@mrjk
Last active April 26, 2024 16:18
Show Gist options
  • Save mrjk/9609b2a8be1215f63987fd1ca5102610 to your computer and use it in GitHub Desktop.
Save mrjk/9609b2a8be1215f63987fd1ca5102610 to your computer and use it in GitHub Desktop.
Generic docker-credential-helper
#!/bin/bash
# TEMPLATE_VERSION=2024-04-25
set -eu
# App Global variable
# =================
APP_NAME="${0##*/}"
APP_AUTHOR="mrjk"
APP_EMAIL="mrjk.78@gmail.com"
APP_LICENSE="GPLv3"
APP_URL="https://github.com/$APP_AUTHOR/$APP_NAME"
APP_REPO="https://github.com/$APP_AUTHOR/$APP_NAME.git"
APP_GIT="git@github.com:$APP_AUTHOR/$APP_NAME.git"
APP_STATUS=beta
APP_DATE="2024-04-25"
APP_VERSION=0.0.1
APP_DEPENDENCIES="docker"
APP_LOG_SCALE="TRACE:DEBUG:RUN:INFO:DRY:HINT:NOTICE:CMD:USER:WARN:ERR:ERROR:CRIT:TODO:DIE"
APP_LOG_LEVEL=INFO
APP_FORCE=${APP_FORCE:-false}
# CLI libraries
# =================
# Log output
_log ()
{
local lvl="${1:-DEBUG}"
shift 1 || true
# Check log level filter
if [[ ! ":${APP_LOG_SCALE#*"$APP_LOG_LEVEL":}:$APP_LOG_LEVEL:" =~ :"$lvl": ]]; then
if [[ ! ":${APP_LOG_SCALE}" =~ :"$lvl": ]]; then
>&2 printf "%s\n" " BUG: Unknown log level: $lvl"
else
return 0
fi
fi
local msg=${*}
if [[ "$msg" == '-' ]]; then
msg="$(cat - )"
fi
while read -r -u 3 line ; do
>&2 printf "%5s: %s\\n" "$lvl" "${line:- }"
done 3<<<"$msg"
}
# Die app
_die ()
{
local rc=${1:-1}
shift 1 || true
local msg="${*:-}"
if [[ -z "$msg" ]]; then
[ "$rc" -ne 0 ] || exit 0
_log DIE "Program terminated with error: $rc"
else
_log DIE "$msg"
fi
# Remove EXIT trap and exit nicely
trap '' EXIT
exit "$rc"
}
# Validate bin
_check_bin ()
{
local cmd cmds="${*:-}"
for cmd in $cmds; do
command -v "$1" >&/dev/null || return 1
done
}
# Usage: trap '_sh_trap_error $? ${LINENO} trap_exit 42' EXIT
_sh_trap_error () {
local rc=$1
[[ "$rc" -ne 0 ]] || return 0
local line="$2"
local msg="${3-}"
local code="${4:-1}"
set +x
_log ERR "Uncatched bug:"
if [[ -n "$msg" ]] ; then
_log ERR "Error on or near line ${line}: ${msg}; got status ${rc}"
else
_log ERR "Error on or near line ${line}; got status ${rc}"
fi
exit "${code}"
}
# CLIsh framework
# =================
# Dispatch command
clish_dispatch ()
{
local prefix=$1
local cmd=${2-}
shift 2 || true
[ -n "$cmd" ] || _die 3 "Missing command name, please check usage"
if [[ $(type -t "${prefix}${cmd}") == function ]]; then
"${prefix}${cmd}" "$@"
else
_log ERROR "Unknown command for ${prefix%%_?}: $cmd"
return 3
fi
}
# Parse command options
# Called function must return an args array with remaining args
clish_parse_opts ()
{
local func=$1
shift
clish_dispatch "$func" _options "$@"
}
# Read CLI options for a given function/command
# Options must be in a case statement and surounded by
# 'parse-opt-start' and 'parse-opt-stop' strings. Returns
# a list of value separated by ,. Fields are:
clish_help_options ()
{
local func=$1
local data=
# Check where to look options function
if declare -f "${func}_options" >/dev/null; then
func="${func}_options"
data=$(declare -f "$func")
data=$(printf "%s\n%s\n" 'parse-opt-start' "$data" )
else
data=$(declare -f "$func")
fi
# declare -f ${func} \
echo "$data" | awk '/parse-opt-start/,/parse-opt-stop/ {print}' \
| grep --no-group-separator -A 1 -E '^ *--?[a-zA-Z0-9].*)$' \
| sed -E '/\)$/s@[ \)]@@g;s/.*: "//;s/";//' \
| xargs -n2 -d'\n' \
| sed 's/ /,/;/^$/d'
}
# List all available commands starting with prefix
clish_help_subcommands ()
{
local prefix=${1:-cli__}
declare -f \
| grep -E -A 2 '^'"$prefix"'[a-z0-9]*(__[a-z0-9]*)*? \(\)' \
| sed '/{/d;/--/d;s/'"$prefix"'//;s/ ()/,/;s/";$//;s/^ *: "//;' \
| xargs -n2 -d'\n' \
| sed 's/, */,/;s/__/ /g;/,,$/d'
}
# Show help message of a function
clish_help_msg ()
{
local func=$1
clish_dispatch "$func" _usage 2>/dev/null || true
}
# Show cli usage for a given command
clish_help ()
{
: ",Show this help"
local func=${1:-cli}
local commands='' options='' message=''
# Help message
message=$(clish_help_msg "$func")
# Fetch command options
options=$(
while IFS=, read -r flags meta desc _; do
if [ -n "${flags:-}" ]; then
printf " %-16s %-20s %s\n" "$flags" "$meta" "$desc"
fi
done <<< "$(clish_help_options "$func")"
)
# Fetch sub command informations
commands=$(
while IFS=, read -r flags meta desc _; do
if [ -n "${flags:-}" ]; then
printf " %-16s %-20s %s\n" "$flags" "$meta" "$desc"
fi
done <<< "$(clish_help_subcommands "${func}"__)"
)
# Display help message
printf "%s\n" "${message:+$message}
${commands:+
commands:
$commands}
${options:+
options:
$options
}"
# Append extra infos
if ! [[ "$func" == *"_"* ]]; then
cat <<EOF
info:
author: $APP_AUTHOR ${APP_EMAIL:+<$APP_EMAIL>}
version: ${APP_VERSION:-0.0.1}-${APP_STATUS:-beta}${APP_DATE:+ ($APP_DATE)}
license: ${APP_LICENSE:-MIT}
EOF
fi
}
# Internal helpers
# =================
# Generate docker config file output
gen_docker_config ()
{
local target=${1:-$APP_REG}
printf '{"ServerURL":"%s","Username":"%q","Secret":"%q"}\n' \
"$target" "$APP_USER" "$APP_PASS"
}
parse_prefix_configs ()
{
# TODO: support gitlab: DOCKER_AUTH_CONFIG
# Get default config
default_conf=$(parse_prefix_config "DOCKER")
echo "$default_conf"
# Scan other configs
# shellcheck disable=SC2086
set -- ${default_conf//;/ }
DEFAULT_USER=$2
DEFAULT_PASS=$3
for item in $(env | grep DOCKER | sort); do
varname=${item%%=*}
prefix=${varname%%_*}
suffix=${varname#*_}
count=${prefix//[A-Z]}
# Skip not numered env vars
[[ -n "$count" ]] || continue
# Skip non registry declaration
if [[ ! "$suffix" == *"REG"* ]] && [[ ! "$suffix" == *"DOM"* ]]; then
continue
fi
# Generate creds
parse_prefix_config "$prefix"
done
}
# Fetch credential config from various places
parse_prefix_config ()
{
local prefix=$1
local APP_USER='' APP_PASS='' APP_REG=''
APP_USER=$( scan_first_env \
CLI_USER \
"${prefix}"_USER \
"${prefix}"_USR \
"${prefix}"_LOGIN \
CI_REGISTRY_USER \
DEFAULT_USER
)
APP_PASS=$( scan_first_env \
CLI_PASS \
"${prefix}"_PASS \
"${prefix}"_PSW \
"${prefix}"_PASSWORD \
CI_REGISTRY_PASSWORD \
DEFAULT_PASS
)
APP_REG=$( scan_first_env \
CLI_REGISTRY \
"${prefix}"_REGISTRY \
"${prefix}"_REG \
"${prefix}"_DOMAIN \
CI_REGISTRY \
DEFAULT_REGISTRY
)
[[ -n "$APP_USER" ]] || {
_die 10 "Impossible to find docker login"
}
[[ -n "$APP_PASS" ]] || {
_die 10 "Impossible to find docker password"
}
[[ -n "$APP_REG" ]] || {
_die 10 "Impossible to find docker registry"
}
# Cleanup vars
APP_REG="${APP_REG#https://}"
APP_REG="${APP_REG%%/*}"
printf "%s;%s;%q\n" "$APP_REG" "$APP_USER" "$APP_PASS"
}
# Scan environment var and return the first non empty one
scan_first_env ()
{
# shellcheck disable=SC2068
for name in $@; do
local value=${!name:-}
if [[ -n "$value" ]]; then
_log "DEBUG" "Found environment var: ${name}"
echo "$value"
return
else
_log "TRACE" "Empty environment var '${name}', trying next"
fi
done
}
# CLI Commands
# =================
cli__login ()
{
: "[HOST],Direct docker login"
local target=${1:-}
for item in $(parse_prefix_configs); do
# shellcheck disable=SC2086
set -- ${item//;/ }
local registry=$1
local user=$2
local pass=$3
[[ "${target:-$registry}" == "$registry" ]] || continue
_log INFO "Logging in: $registry as $user"
echo "$pass" | docker login -u "$user" --password-stdin "$registry" \
|& grep -v '^$\|credential\|unencrypted'
done
}
cli__logout ()
{
: "[HOST],Direct docker logout"
local target=${1:-}
for item in $(parse_prefix_configs); do
# shellcheck disable=SC2086
set -- ${item//;/ }
local registry=$1
local user=$2
local pass=$3
[[ "${target:-$registry}" == "$registry" ]] || continue
_log INFO "Logging off: $registry as $user"
docker logout "$registry"
done
}
cli__show ()
{
: "[HOST],Show configuration"
local target=${1:-}
for item in $(parse_prefix_configs); do
# shellcheck disable=SC2086
set -- ${item//;/ }
local registry=$1
local user=$2
local pass=$3
[[ "$registry" == "${target:-$registry}" ]] || continue
printf '%s,%q,%q\n' \
"$registry" "$user" "$pass"
done
}
cli__get ()
{
: ",Docker credential helper API, see documentation, registry is stdin"
# Read requested host from stdin
read -r target
local matches=0
for item in $(parse_prefix_configs); do
# shellcheck disable=SC2086
set -- ${item//;/ }
local registry=$1
local user=$2
local pass=$3
[[ "$registry" == "${target:-$registry}" ]] || continue
printf '{"ServerURL":"%s","Username":"%q","Secret":"%q"}\n' \
"$registry" "$user" "$pass"
matches=$(( matches + 1 ))
done
[[ "$matches" -eq 1 ]] || {
_die 1 "No credentials available for: ${target:-Empty registry}"
}
}
cli__list ()
{
: ",Docker credential helper API, see documentation"
local out=""
for item in $(parse_prefix_configs); do
# shellcheck disable=SC2086
set -- ${item//;/ }
out="${out:+$out,\n}\"$1\":\"$2\""
done
echo -e "{\n$out\n}"
}
cli__store ()
{
: ","
: ",Docker credential helper API, see documentation"
_log WARN "store is ignored in $APP_NAME, we are still using env variables for docker"
}
cli__install ()
{
: ",Override local user config to enable this helper"
local dest=~/.docker/config.json
local suffix=${APP_NAME##*-}
local prefix=${APP_NAME%-"$suffix"}
[[ "$prefix" == "docker-credential" ]] || \
_die 22 "This program must be prefixed by docker-credential, got: $prefix"
command -v "$APP_NAME" >&/dev/null || \
_log WARN "This application does not seems to be installed in your PATH, don't forget to update it"
if [[ -e "$dest" ]]; then
[[ "$APP_FORCE" == "true" ]] || _die 13 "Use force mode to override existing $dest"
_log WARN "Overriding existing $dest"
fi
echo '{ "credsStore": "'"${APP_NAME##*-}"'" }' #> "$dest"
_log INFO "Overrided docker config in: $dest"
}
# Core App
# =================
# Default Values
APP_ENV_PREFIX=${APP_ENV_PREFIX:-DOCKER}
# App help message
cli_usage ()
{
cat <<EOF
${APP_NAME} is a docker login helper
This is a very basic Docker credential helper that uses environment variables
to authenticate to Docker. It's not as secure as the other credential helpers
that Docker provides, but it can be very helpful in some circumstances (such
as when using it with Jenkins).
usage: ${APP_NAME} login [HOST]
${APP_NAME} logout
${APP_NAME} dump
${APP_NAME} help
environment vars:
To use it, you need to have the following environment variables set:
DOCKER_REGISTRY - Your registry URL
DOCKER_CREDS_USR - Your username
DOCKER_CREDS_PSW - Your password
If you are using Jenkins Declarative Pipeline, you can do this in the
environment section of your Jenkinsfile (see the example Jenkinsfile). This
script also support various env name suffix to adapt different environment:
DOCKER_REGISTRY, DOCKER_REG, DOCKER_DOMAIN
DOCKER_USER, DOCKER_USR, DOCKER_LOGIN
DOCKER_PASS, DOCKER_PSW, DOCKER_PASSWORD
Finally, it is possible to support multiple logins, by using an increment
in variables names. When a value is missing, it fallback on default env:
DOCKER_REGISTRY=registry1.com
DOCKER_USER=user-default
DOCKER1_REGISTRY=registry-dev.com
DOCKER2_REGISTRY=registry-prod.com
DOCKER2_USER=user-prod
installation:
This script can also be used as docker credential helper.
To set this up, install the $APP_NAME script somewhere in the
Jenkins users PATH (this script needs to be named docker-credential-helper),
then you can use the '$APP_NAME install' command to override your docker
configuration. Otherwise, configure the Jenkins user's ~/.docker/config.json
file to use it:
{ "credsStore": "helper" }
EOF
}
# App initialization
cli_init ()
{
# Useful shortcuts
export GIT_DIR=$(git rev-parse --show-toplevel 2>/dev/null)
export SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
export WORK_DIR=${GIT_DIR:-${SCRIPT_DIR:-$PWD}}
export PWD_DIR=${PWD}
# Define requirements
local prog
for prog in ${APP_DEPENDENCIES-} ; do
_check_bin "$prog" || {
_log WARN "Can't find command '$prog'"
}
done
}
# Parse CLI options
cli_options ()
{
while [[ -n "${1:-}" ]]; do
# : "parse-opt-start"
case "$1" in
-e|--prefix)
: "DOCKER,Environment variable prefix [APP_ENV_PREFIX]"
[[ -n "${2:-}" ]] || _die 1 "Missing env prefix value"
_log INFO "Credential mode set to: $2"
APP_ENV_PREFIX=$2
shift 2
;;
-r|--registry)
: "URL,Default Docker registry [DOCKER_REGISTRY]"
[[ -n "${2:-}" ]] || _die 1 "Missing registry value"
CLI_REGISTRY=$2
shift 2
;;
-u|--user)
: "USER,Default Docker user login [DOCKER_USER]"
[[ -n "${2:-}" ]] || _die 1 "Missing user login value"
CLI_USER=$2
shift 2
;;
-p|--pass)
: "PASS,Default Docker user password [DOCKER_PASS]"
[[ -n "${2:-}" ]] || _die 1 "Missing user password value"
CLI_PASS=$2
shift 2
;;
-f|--force)
: ",Enable force mode [APP_FORCE]"
_log INFO "Force mode enabled"
APP_FORCE=true
shift
;;
-v|--verbose)
: "[LEVEL],Set verbosity level [APP_LOG_LEVEL]"
[[ -n "${2:-}" ]] || _die 1 "Missing log level value"
_log INFO "Log level set to: $2"
APP_LOG_LEVEL=$2
shift 2
;;
-h|--help)
: ",Show this help message"
clish_help cli;
_die 0
;;
-*)
_die 1 "Unknown option: $1"
;;
*)
args+=( "$1" )
shift 1
;;
esac
done
}
cli ()
{
# Init
trap '_sh_trap_error $? ${LINENO} trap_exit 42' EXIT
# Parse CLI flags
clish_parse_opts cli "$@"
set -- "${args[@]}"
# Route commands before requirements
local cmd=${1:-help}
shift 1 || true
case "$cmd" in
-h|--help|help) clish_help cli; return ;;
esac
# Init app
cli_init
# Dispatch subcommand
clish_dispatch cli__ "$cmd" "$@" \
|| _die $? "Command '$cmd' returned error: $?"
}
cli "${@}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment