Last active
April 26, 2024 16:18
-
-
Save mrjk/9609b2a8be1215f63987fd1ca5102610 to your computer and use it in GitHub Desktop.
Generic docker-credential-helper
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
# TEMPLATE_VERSION=2024-04-25 | |
set -eu | |
# App Global variable | |
# ================= | |
APP_NAME="${0##*/}" | |
APP_AUTHOR="mrjk" | |
APP_EMAIL="[email protected]" | |
APP_LICENSE="GPLv3" | |
APP_URL="https://github.com/$APP_AUTHOR/$APP_NAME" | |
APP_REPO="https://github.com/$APP_AUTHOR/$APP_NAME.git" | |
APP_GIT="[email protected]:$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