Skip to content

Instantly share code, notes, and snippets.

@3coma3
Last active March 11, 2022 04:21
Show Gist options
  • Save 3coma3/0238e83a8e5d8b452071b1f2fbd29508 to your computer and use it in GitHub Desktop.
Save 3coma3/0238e83a8e5d8b452071b1f2fbd29508 to your computer and use it in GitHub Desktop.
extensible Terraform wrapper "to type less", for category/module structures, with regex/direct match|queued/chained operations|macros|tfvar expansion|+
#!/bin/bash
shopt -s extglob
# Globals ======================================================================
export script_path="$(readlink -f "$0")"
export script_name="${script_path##*/}"
export mods_root="$(readlink -f "${0%/*}/../provision")" category='' module=''
export -a all_mods=()
export -A global_sws=() cat_sws=() mod_sws=() switches=()
export -A global_cmds=() cat_cmds=() mod_cmds=()
export -A tf_cmds=() tfvar_aliases=()
# Utilities ====================================================================
# outputs associative array keys separated by a delimiter
get_keys() {
local array="$1" delim="${2:-|}" list
local -a 'ks=("${!'"$array"'[@]}")'
printf -v list "%s${delim}" "${ks[@]}"
echo ${list%?}
}
# tests if the key exists in the associative array
#
# RETURNS
# 0 if key exists, 1 if key doesn't exist
has_key() {
local array="$1" key="$2"
[[ "$(get_element $@)" == "1" ]]
}
# shortcut to test for a flag set from command line switch
is_on() {
has_key switches $1
}
# prints the element if the key exists in the associative array
#
# RETURNS
# 0 if key exists, 1 if key doesn't exist
get_element() {
local array="$1" key="$2"
local -a 'clone=("${'"$array"'[@]}")'
[[ -v clone["$key"] ]] && {
echo "${clone[$key]}"
return 0
}
}
# prints all the elements in the array that match the regex
#
# RETURNS
# 0 if matches, 1 if no matches
get_elements_regex() {
local array="$1" regex="$2" matches=0
local -a 'clone=("${'"$array"'[@]}")'
for i in "${clone[@]}"; do
[[ "$i" =~ $regex ]] && {
echo "$i"
(( matches++ ))
}
done
(( matches != 0 ))
}
# lists all categories, all modules in a category, a combination of module and
# category, or categories/modules with the specified name
#
# PARAMS (can be passed in any order)
# cat=<"all" | category_name>
# mod=<"all" | module_name>
#
# RETURNS
# error code of the call to elements_regex
catmodfind() {
while [[ -n "$1" ]]; do
local "$1" ; shift
done
case "$cat" in
all) get_elements_regex all_mods . | catmodfilter cat | uniq
;;
'') get_elements_regex all_mods "/$mod\$"
;;
*) case "$mod" in
all) get_elements_regex all_mods "^$cat/" | catmodfilter mod
;;
'') get_elements_regex all_mods "^$cat/" | catmodfilter cat | uniq
;;
*) get_elements_regex all_mods "^$cat/$mod\$"
;;
esac
;;
esac
return ${PIPESTATUS[0]}
}
# filters the category or module name from a stream of category/module entries
#
# PARAMS
# $1 "cat" | "mod"
# $2 optional one-time entry
catmodfilter() {
local what="$1" onetime="$2" cat mod
[[ -n "$onetime" ]] && {
IFS=/ read cat mod <<< "$onetime"
echo "${!1}"
return
}
while IFS=/ read cat mod; do
echo "${!1}"
done
}
# expands the left hand side of a tfvar alias to its aliased value, formatted
# for use in a Terraform command line
#
# PARAMS
# $@ a tfvar alias string
tfvar_alias() {
local lhs="${@%%=*}" rhs="${@##*=}"
[[ -v tfvar_aliases[${lhs##@}] ]] && lhs="@${tfvar_aliases[${lhs##@}]}"
echo -var="${lhs##@}=${rhs}"
}
# prints path information relative to CWD before changing directory
cd_relative_to_cwd() {
local path="$@"
[[ "$path" == "$(pwd)" ]] && return
local relpath="$(sed -r "s|$(readlink -f $(pwd))/||" <<< "$path")"
echo
header "Changing CWD to '$relpath'"
cd "$path"
}
# User I/O and error handling ==================================================
# outputs its parameters (or stdin if there are no parameters) to standard error
#
# PARAMS
# $@ optional string
msg() {
(( $# )) && {
echo -e "$@" >&2
return
}
local msg
while read msg; do
echo -e "$msg"
done >&2
}
# prints the arguments as a header, filling the line with $char up to $col
header() {
local str="$@" char='\x2d' col=80
local line="$(eval printf '${char}%.0s' {1..$(( col - ${#str} ))})"
msg "$@ $line\n"
}
# prompts a yes no question
#
# PARAMS
# $@ the prompt
#
# RETURNS
# 0 yes
# 1 no
yesno() {
local prompt="${1:-"Are you sure? (y/n)"}" result=1
while :; do
read -p "$prompt " -n 1 -r result
echo
case "$result" in
y|Y) return 0 ;;
n|N) return 1 ;;
*) continue ;;
esac
done
}
# acts and exits according to an error code
#
# PARAMS
# $1 error code as defined in the case statement and/or the errcodes hash
# $@ any remaining arguments passed to the error handler
#
# RETURNS
# Matching errcodes[] value (defaults to EUNKNOWN)
error() {
local code="$1"
shift
local -A errcodes
errcodes[ENOARGS]=10
errcodes[ECMDNOCAT]=11
errcodes[ENOCAT]=12
errcodes[ENOMOD]=13
errcodes[ENOMODSELECT]=14
errcodes[EBADFQID]=15
errcodes[EMODEXISTS]=20
errcodes[EUNKNOWN]=255
case $code in
ECMDNOCAT)
msg <<EOF
A category command ($@) was specified, but no category is known at this point
Try to pass the command *after* a category locator.
Available module categories:
$(catmodfind cat=all)
EOF
;;
ENOCAT)
msg <<EOF
$([[ -z "$category" ]] || msg "Category not found: '$category'\n")Available module categories:
$(catmodfind cat=all)
EOF
;;
ENOMOD)
msg <<EOF
$([[ -z "$module" ]] || msg "Module not found: '$category/$module'\n")Available modules in the '$category' category:
$(catmodfind cat="$category" mod=all)
EOF
;;
ENOMODSELECT)
msg "\nAborting."
;;
EMODEXISTS)
msg "Can't create '$@': already exists."
;;
EBADFQID)
msg <<EOF
Invalid flag queue index: $1
The problem seems to be around "... ${2} ${3# } \\\e[4m${4}\\\e[0m"
EOF
;;
EUNKNOWN) ;&
*) msg "\nUnknown error: ${code:-"no code was given"}, around line ${BASH_LINENO[0]}\n"
code=EUNKNOWN
;;
esac
[[ -v errcodes[$code] ]] || code=EUNKNOWN
exit ${errcodes[$code]}
}
# Switch handlers ==============================================================
sw_simple() {
local scope="$1" item="${2#--}" state="$3"
[[ "$state" == "off" ]]
switches[$scope_$item]=$?
}
# Command handlers =============================================================
cmd_help() {
header "$script_name v2"
msg <<EOF
This help is a stub
SYNTAX
category_locator [category_commands | category_switches | global_switches]
module_locator [module_commands | module_switches | global_switches]
category_locator
category
tmod category
module_locator
category module
tmod category module
tmod module
tmod regex
EOF
}
cmd_add_module() {
shift
local newmod="$1"
[[ -d "$newmod" ]] && error EMODEXISTS "$category/$newmod"
header "Add '$category/$newmod'"
is_on gl_dry && return
mkdir -v "$newmod"
}
cmd_forward_shell() {
local cmdline="$@"
header "Forward to shell: '$cmdline'"
is_on gl_dry && return
$cmdline
}
cmd_forward_terraform() {
local cmdline
if (( ! $# )); then
msg <<EOF
You can add one of the below Terraform subcommands directly to this command line
The entire command line will be forwarded to Terraform.
--------------------------------------------------------------------------------
EOF
cmdline="-help"
else cmdline="$@"
fi
header "Forward to Terraform: '$cmdline'"
is_on gl_dry && return
terraform $@
}
cmd_mod_clean() {
header "Clean '$category/$module' state"
is_on gl_dry && return
find -iname "*tfstate*" -exec rm -v {} \;
(( $# )) && main $@
}
cmd_mod_depclean() {
header "Clean '$category/$module' dependency cache"
is_on gl_dry && return
find -iname .terraform -exec rm -vrf {} \;
(( $# )) && main $@
}
cmd_mod_reset() {
header "Reset '$category/$module'"
is_on gl_dry && return
cmd_mod_depclean clean init
(( $# )) && main $@
}
cmd_mod_cycle() {
header "Cycle '$category/$module'"
cmd_mod_depclean init
main destroy $@ apply @
}
cmd_mod_remove() {
header "Remove '$category/$module'"
is_on gl_dry && return
msg <<EOF
WARNING
You are about to REMOVE the module '$module' from the category '$category'
This operation cannot be reverted. Please confirm:
EOF
yesno && {
cd ..
rm -rfv "$module"
}
}
# Main functions ===============================================================
# performs value initializations
#
# PARAMS
# $@ <list of initialization sections to run>
init() {
local sections=( "$@" )
for section in ${sections[@]}; do
case $section in
commands)
global_cmds[help]=cmd_help
cat_cmds[help]=cmd_help
cat_cmds[add]=cmd_add_module
cat_cmds[ls]=cmd_forward_shell
cat_cmds[git]=cmd_forward_shell
cat_cmds[grep]=cmd_forward_shell
cat_cmds[find]=cmd_forward_shell
mod_cmds[clean]=cmd_mod_clean
mod_cmds[depclean]=cmd_mod_depclean
mod_cmds[reset]=cmd_mod_reset
mod_cmds[cycle]=cmd_mod_cycle
mod_cmds[remove]=cmd_mod_remove
declare -gA 'tf_cmds=('"$(terraform | sed -rn 's/^[ ]+([^ ]+) .*/[\1]=\1/p' | tr '\n' ' ')"')'
;;
switches)
global_sws[dry]=sw_simple
;;
mods)
readarray -t all_mods< <(cd "$mods_root" ; find -maxdepth 2 -type d | sed -nr 's|^\./(.*/.*)$|\1|p' | sort)
;;
tfvar_aliases)
if [[ "$category" == "bundles" ]]; then
tfvar_aliases[node]=bundle_name
tfvar_aliases[name]=bundle_name
tfvar_aliases[domain]=bundle_name
else
tfvar_aliases[node]=guest_name
tfvar_aliases[name]=guest_name
tfvar_aliases[domain]=guest_name
fi
tfvar_aliases[ws]=workspace
tfvar_aliases[addr]=cm_address
tfvar_aliases[cm]=cm_ansible_extra_flags
;;
esac
done
}
# searches for category+module (using the global $category), or regexp
# if no matches were found return an error
# if one match was found, set the global category and module
# if multiple matches were found, present a menu. If the user selects all
# matches, call back the command line processing on them and exit.
# TODO: decople this from the control logic, just output the results
#
# PARAMS
# $1 <direct|regex> method to use for the search
# $2 string or regex to search
#
# RETURNS
# 0 if no errors and one match was found
# 1 no matches (direct method)
# 2 no matches (regex method)
# 3 no search string was provided
mod_select() {
local -a matches
set -f
local method="$1" search="$2" ; shift 2
# cowardly refuse to run if the search string is not provided
[[ -z "$search" ]] && return 3
case $method in
direct) readarray -t matches< <(catmodfind cat="$category" mod="$search") ;;
regex) readarray -t matches< <(get_elements_regex all_mods "$search") ;;
esac
set +f
case ${#matches[@]} in
0) [[ "$method" == "regex" ]]
return $(( $? + 1 ))
;;
1) category="$(catmodfilter cat "${matches[0]}")"
module="$(catmodfilter mod "${matches[0]}")"
return 0
;;
*) echo "Multiple modules match '$search':"
for i in ${!matches[@]}; do
echo "$i: ${matches[$i]}"
done
msg <<EOF
Enter a valid index or 'a' (all) to process entries, any other input aborts.
"Be careful"
EOF
local i
read -p "selection: " i
case $i in
a) while IFS=/ read category module; do
main "$@"
done< <(printf '%s\n' "${matches[@]}")
exit
;;
+([[:digit:]]))
[[ -v matches[$i] ]] || error ENOMODSELECT
category="$(catmodfilter cat "${matches[i]}")"
module="$(catmodfilter mod "${matches[i]}")"
return 0
;;
*) error ENOMODSELECT
;;
esac
;;
esac
}
main() {
local arg
[[ -z "$category" ]] && {
while arg="$1"; do
case "$arg" in
--@($(get_keys global_sws)))
${global_sws[${arg#--}]} gl $arg on
;;
@($(get_keys global_cmds)))
${global_cmds[$arg]} $arg $@
return
;;
*) break
;;
esac
shift
done
# if called by "symlink alias" solving the category is easy
[[ -h "$0" ]] && category="${0##*/}s"
while arg="$1"; do
shift
case "$arg" in
--@($(get_keys global_sws)))
${global_sws[${arg#--}]} gl $arg on
;;
--@($(get_keys cat_sws)))
${cat_sws[${arg#--}]} cat $arg on
;;
@($(get_keys cat_cmds)))
[[ -z "$category" ]] && error ECMDNOCAT $arg
cd_relative_to_cwd "$mods_root/$category"
${cat_cmds[$arg]} $arg $@
return
;;
# full category/module resolution - trickiest section
*) [[ -z "$category" ]] && {
category="$(catmodfind cat="$arg")"
[[ -n "$category" ]] && continue
}
mod_select direct "$arg" $@ || {
[[ -z "$category" ]] && mod_select regex "$arg" $@
}
(( $? )) && {
[[ -z "$category" ]] && {
category="$arg"
error ENOCAT
}
module="$arg"
error ENOMOD
}
[[ -z "$module" ]] && error ENOMOD
break
;;
esac
! (( $# )) && break
done
# initialize here tfvar aliases that may depend on knowing the category
init tfvar_aliases
}
# remaining work (including any recursive call) done in the module context
cd_relative_to_cwd "$mods_root/$category/$module"
# Commands and flags for terraform are queued before final dispatch
# Note: tf_flag_queue is actually a "list of queues", holding flags per each
# command queue entry. Each time that a command appears a "new flag queue"
# is started (flag queues are just concatenated strings).
# To copy previous flag queues to the current one, use @ with an optional
# index. Ommiting the index copies the previous queue contents.
local -a tf_cmd_queue=() tf_flag_queue=() fqid=0 cqid=0
while arg="$1"; do
shift
case "$arg" in
terraform)
continue
;;
'') break
;;
@($(get_keys tf_cmds)))
cqid=${#tf_cmd_queue[@]}
tf_cmd_queue+=( "$arg" )
;;
\@*([[:digit:]]))
fqid=${arg:1}
(( ${fqid:=$(( cqid - 1 ))} < 0 || fqid >= cqid )) \
&& error EBADFQID $fqid ${tf_cmd_queue[$cqid]} "${tf_flag_queue[$cqid]}" $arg
tf_flag_queue[$cqid]+=" ${tf_flag_queue[$fqid]}"
;;
\@*)
tf_flag_queue[$cqid]+=" $(tfvar_alias $arg)"
;;
-[^-]*)
tf_flag_queue[$cqid]+=" $arg"
;;
--@($(get_keys mod_sws)))
${mod_sws[${arg#--}]} mod $arg on
;;
@($(get_keys mod_cmds)))
${mod_cmds[$arg]} $@
return
;;
# external commands may only come at the beginning or the very end
# otherwise the behaviour is not controlled
*) cmd_forward_shell $arg $@
return
;;
esac
! (( $# )) && break
done
# final pass: dispatch the "pure" terraform command line
if (( ${#tf_cmd_queue[@]} )); then
for cqid in ${!tf_cmd_queue[@]}; do
cmd_forward_terraform ${tf_cmd_queue[$cqid]} ${tf_flag_queue[$cqid]}
done
else
cmd_forward_terraform -help
fi
}
init commands switches mods
main "$@"
@3coma3
Copy link
Author

3coma3 commented Mar 9, 2020

Updates

switches system

  • improve has_test to check for existence, simplify get_element.
  • add a new generic handler to set global switches

dry run execution

  • add a --dry switch so final commands can only show their headers

clean up and improve tracking TF command/flag queues

revert to command queue length check

  • don't use cqid because it can be 0 with a single command

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment