Newer
Older
# gitlab-tofu is a wrapper around the tofu command
# from the OpenTofu project.
#
# It's main purpose is to setup tofu to work inside
# GitLab pipelines and together with the
# OpenTofu CI/CD component.
# Detailed information about it is in the README:
# https://gitlab.com/components/opentofu
#
# Respected Environment Variables:
# --------------------------------
# GITLAB_TOFU_SOURCE: forces this script in source-mode. Required when source auto-detection fails.
# GITLAB_TOFU_APPLY_NO_PLAN: if set to true, the apply command does not use a plan cache file.
# GITLAB_TOFU_PLAN_NAME: the name of the plan cache and json files. Defaults to `plan`.
# GITLAB_TOFU_PLAN_CACHE: if set is the full path of the plan cache file. Defaults to `<root>/$GITLAB_TOFU_PLAN_NAME.cache`
# GITLAB_TOFU_PLAN_JSON: if set is the full path of the plan json file. Defaults to `<root>/$GITLAB_TOFU_PLAN_NAME.json`
# Respected OpenTofu Environment Variables:
# > these are variables that are
# > respected if set and avoid using
# > the gitlab-tofu values for them.
# ----------------------------------
# TF_HTTP_USERNAME: username for the HTTP backend. Defaults to `gitlab-ci-token`.
# TF_HTTP_PASSWORD: password for the HTTP backend. Defaults to `$CI_JOB_TOKEN`.
#
# Respected GitLab CI/CD Variables:
# > these are variables exposed by
# > GitLab CI/CD and respected by
# > the gitlab-tofu script for
# > certain configurations.
# CI_JOB_TOKEN:
# - used as default value for TF_HTTP_PASSWORD.
# - used as value for TF_TOKEN_<host> variable.
# set some shell options
set -o errexit
if [ "${DEBUG_OUTPUT}" = "true" ]; then
# Feature Flags
# =============
# Below are a bunch of variables that we use as "feature flags".
# There are no feature flags at the moment.
# Helpers
# Evaluate if this script is being sourced or executed directly.
# See https://stackoverflow.com/a/28776166
sourced=0
# shellcheck disable=SC2153 # it's actually a different variable, thanks shelllcheck.
if [ "$GITLAB_TOFU_SOURCE" = 'true' ]; then
sourced=1
elif [ -n "$ZSH_VERSION" ]; then
case $ZSH_EVAL_CONTEXT in *:file) sourced=1;; esac
elif [ -n "$KSH_VERSION" ]; then
# shellcheck disable=SC2296
[ "$(cd -- "$(dirname -- "$0")" && pwd -P)/$(basename -- "$0")" != "$(cd -- "$(dirname -- "${.sh.file}")" && pwd -P)/$(basename -- "${.sh.file}")" ] && sourced=1
elif [ -n "$BASH_VERSION" ]; then
(return 0 2>/dev/null) && sourced=1
else # All other shells: examine $0 for known shell binary filenames.
# Detects `sh` and `dash`; add additional shell filenames as needed.
case ${0##*/} in sh|-sh|dash|-dash) sourced=1;; esac
fi
# Dependencies
# ============
# Defines all the external dependencies and checks if they exist, if not, abort with an error.
dependencies="dirname basename pwd sed idn2 jq tofu"
if [ -n "$ZSH_VERSION" ]; then
# ZSH is the only supported SHELL that does not split by word by default,
# so we set this option to actually do it.
setopt sh_word_split
fi
for dep in $dependencies; do
if ! command -v "$dep" >/dev/null 2>&1; then
echo "Error: gitlab-tofu is missing dependency: '$dep'" >&2
exit 1
fi
done
if [ -n "$ZSH_VERSION" ]; then
# see comment above when setting sh_word_split.
unsetopt sh_word_split
fi
JQ_PLAN='
(
[.resource_changes[]?.change.actions?] | flatten
) | {
"create":(map(select(.=="create")) | length),
"update":(map(select(.=="update")) | length),
"delete":(map(select(.=="delete")) | length)
}
'
# Default state backend credentials to gitlab-ci-token/CI_JOB_TOKEN
state_backend_username="gitlab-ci-token"
state_backend_password="${CI_JOB_TOKEN}"
# If TF_ADDRESS is unset but TF_STATE_NAME is provided, then default to GitLab backend in current project
if [ -n "${TF_STATE_NAME}" ] && [ -z "${TF_ADDRESS}" ]; then
# auto url-encode TF_STATE_NAME
TF_STATE_NAME="$(jq -rn --arg x "${TF_STATE_NAME}" '$x|@uri')"
TF_ADDRESS="${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}"
if [ -z "${GITLAB_TOFU_PLAN_NAME}" ]; then
GITLAB_TOFU_PLAN_NAME=plan
if [ -z "${GITLAB_TOFU_APPLY_NO_PLAN}" ]; then
GITLAB_TOFU_APPLY_NO_PLAN=false
# If TF_ROOT is set then use the -chdir option
if [ -n "${TF_ROOT}" ]; then
abs_tf_root=$(cd "${CI_PROJECT_DIR}"; realpath "${TF_ROOT}")
TF_CHDIR_OPT="-chdir=${abs_tf_root}"
default_tf_plan_cache="${abs_tf_root}/${GITLAB_TOFU_PLAN_NAME}.cache"
default_tf_plan_json="${abs_tf_root}/${GITLAB_TOFU_PLAN_NAME}.json"
# If GITLAB_TOFU_PLAN_CACHE is not set then use either the plan.cache file within TF_ROOT if set, or plan.cache in CWD
if [ -z "${GITLAB_TOFU_PLAN_CACHE}" ]; then
GITLAB_TOFU_PLAN_CACHE="${default_tf_plan_cache:-${GITLAB_TOFU_PLAN_NAME}.cache}"
# If GITLAB_TOFU_PLAN_JSON is not set then use either the plan.json file within TF_ROOT if set, or plan.json in CWD
if [ -z "${GITLAB_TOFU_PLAN_JSON}" ]; then
GITLAB_TOFU_PLAN_JSON="${default_tf_plan_json:-${GITLAB_TOFU_PLAN_NAME}.json}"
fi
# Set variables for the HTTP backend to default to TF_* values
export TF_HTTP_ADDRESS="${TF_HTTP_ADDRESS:-${TF_ADDRESS}}"
export TF_HTTP_LOCK_ADDRESS="${TF_HTTP_LOCK_ADDRESS:-${TF_ADDRESS}/lock}"
export TF_HTTP_LOCK_METHOD="${TF_HTTP_LOCK_METHOD:-POST}"
export TF_HTTP_UNLOCK_ADDRESS="${TF_HTTP_UNLOCK_ADDRESS:-${TF_ADDRESS}/lock}"
export TF_HTTP_UNLOCK_METHOD="${TF_HTTP_UNLOCK_METHOD:-DELETE}"
export TF_HTTP_USERNAME="${TF_HTTP_USERNAME:-${state_backend_username}}"
export TF_HTTP_PASSWORD="${TF_HTTP_PASSWORD:-${state_backend_password}}"
export TF_HTTP_RETRY_WAIT_MIN="${TF_HTTP_RETRY_WAIT_MIN:-5}"
# Expose Gitlab specific variables to terraform since no -tf-var is available
# The following variables are deprecated because they do not conform to
# HCL naming best practices. Use the lower snake_case variants below instead.
export TF_VAR_CI_JOB_ID="${TF_VAR_CI_JOB_ID:-${CI_JOB_ID}}"
export TF_VAR_CI_COMMIT_SHA="${TF_VAR_CI_COMMIT_SHA:-${CI_COMMIT_SHA}}"
export TF_VAR_CI_JOB_STAGE="${TF_VAR_CI_JOB_STAGE:-${CI_JOB_STAGE}}"
export TF_VAR_CI_PROJECT_ID="${TF_VAR_CI_PROJECT_ID:-${CI_PROJECT_ID}}"
export TF_VAR_CI_PROJECT_NAME="${TF_VAR_CI_PROJECT_NAME:-${CI_PROJECT_NAME}}"
export TF_VAR_CI_PROJECT_NAMESPACE="${TF_VAR_CI_PROJECT_NAMESPACE:-${CI_PROJECT_NAMESPACE}}"
export TF_VAR_CI_PROJECT_PATH="${TF_VAR_CI_PROJECT_PATH:-${CI_PROJECT_PATH}}"
export TF_VAR_CI_PROJECT_URL="${TF_VAR_CI_PROJECT_URL:-${CI_PROJECT_URL}}"
export TF_VAR_ci_job_id="${TF_VAR_ci_job_id:-${CI_JOB_ID}}"
export TF_VAR_ci_commit_sha="${TF_VAR_ci_commit_sha:-${CI_COMMIT_SHA}}"
export TF_VAR_ci_job_stage="${TF_VAR_ci_job_stage:-${CI_JOB_STAGE}}"
export TF_VAR_ci_project_id="${TF_VAR_ci_project_id:-${CI_PROJECT_ID}}"
export TF_VAR_ci_project_name="${TF_VAR_ci_project_name:-${CI_PROJECT_NAME}}"
export TF_VAR_ci_project_namespace="${TF_VAR_ci_project_namespace:-${CI_PROJECT_NAMESPACE}}"
export TF_VAR_ci_project_path="${TF_VAR_ci_project_path:-${CI_PROJECT_PATH}}"
export TF_VAR_ci_project_url="${TF_VAR_ci_project_url:-${CI_PROJECT_URL}}"
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
# Use terraform automation mode (will remove some verbose unneeded messages)
export TF_IN_AUTOMATION=true
DEFAULT_TF_CONFIG_PATH="$HOME/.terraformrc"
# Set a Terraform CLI Configuration File
if [ -z "${TF_CLI_CONFIG_FILE}" ] && [ -f "${DEFAULT_TF_CONFIG_PATH}" ]; then
export TF_CLI_CONFIG_FILE="${DEFAULT_TF_CONFIG_PATH}"
fi
terraform_authenticate_private_registry() {
# From Terraform 1.2.0 and later (or all versions of OpenTofu), we can use TF_TOKEN_your_domain_name to authenticate to registry.
# The credential environment variable has the following requirements:
# - Domain names containing non-ASCII characters are converted to their punycode equivalent with an ACE prefix
# - Periods are encoded as underscores
# - Hyphens are encoded as double underscores
# For more info, see https://www.terraform.io/cli/config/config-file#environment-variable-credentials
if [ "${CI_SERVER_PROTOCOL}" = "https" ] && [ -n "${CI_SERVER_HOST}" ]; then
tf_token_var_name=TF_TOKEN_$(idn2 "${CI_SERVER_HOST}" | sed 's/\./_/g' | sed 's/-/__/g')
# If TF_TOKEN_ for the Gitlab domain is not set then use the CI_JOB_TOKEN
if [ -z "$(eval "echo \${${tf_token_var_name}:-}")" ]; then
export "${tf_token_var_name}"="${CI_JOB_TOKEN}"
fi
fi
}
# If TF_IMPLICIT_INIT is not set, we set it to `true`.
# If set to `true` it will call `terraform init` prior
# to calling the wrapper `terraform` commands.
TF_IMPLICIT_INIT=${TF_IMPLICIT_INIT:-true}
# Allows users to continue the actual command in case init failed
TF_IGNORE_INIT_ERRORS=${TF_IGNORE_INIT_ERRORS:-false}
terraform_init() {
# If TF_INIT_NO_RECONFIGURE is not set to 'true',
# a `-reconfigure` flag is added to the `terraform init` command.
if [ "$TF_INIT_NO_RECONFIGURE" != 'true' ]; then
tf_init_reconfigure_flag='-reconfigure'
fi
# We want to allow word splitting here for TF_INIT_FLAGS
# shellcheck disable=SC2086
tofu "${TF_CHDIR_OPT}" init "${@}" -input=false ${tf_init_reconfigure_flag} ${TF_INIT_FLAGS} \
1>&2 || $TF_IGNORE_INIT_ERRORS
}
# If this script is executed and not sourced, a terraform command is ran.
# Otherwise, nothing happens and the sourced shell can use the defined variables
# and helper functions exposed by this script.
if [ $sourced -eq 0 ]; then
# Authenticate to private registry
terraform_authenticate_private_registry
if [ -n "${GITLAB_TOFU_VAR_FILE}" ]; then
var_file_args="--var-file=${GITLAB_TOFU_VAR_FILE}"
case "${1}" in
"apply")
$TF_IMPLICIT_INIT && terraform_init
if [ "$GITLAB_TOFU_APPLY_NO_PLAN" = false ]; then
tofu "${TF_CHDIR_OPT}" "${@}" -input=false -auto-approve "${GITLAB_TOFU_PLAN_CACHE}"
tofu "${TF_CHDIR_OPT}" "${@}" -input=false -auto-approve ${var_file_args}
;;
"destroy")
$TF_IMPLICIT_INIT && terraform_init
tofu "${TF_CHDIR_OPT}" "${@}" -auto-approve
;;
"fmt")
tofu "${TF_CHDIR_OPT}" "${@}" -check -diff -recursive
;;
"init")
# shift argument list „one to the left“ to not call 'terraform init init'
shift
terraform_init "${@}"
;;
"plan")
if [ "${GITLAB_TOFU_USE_DETAILED_EXITCODE}" = 'true' ]; then
plan_args='-detailed-exitcode'
fi
$TF_IMPLICIT_INIT && terraform_init
tofu "${TF_CHDIR_OPT}" "${@}" -input=false -out="${GITLAB_TOFU_PLAN_CACHE}" ${var_file_args} ${plan_args} && ret=$? || ret=$?
Timo Furrer
committed
if [ "${GITLAB_TOFU_PLAN_WITH_JSON}" = 'true' ]; then
Timo Furrer
committed
if [ "$ret" -eq 0 ] || [ "$ret" -eq 2 ]; then
if ! tofu "${TF_CHDIR_OPT}" show -json "${GITLAB_TOFU_PLAN_CACHE}" | jq -r "${JQ_PLAN}" > "${GITLAB_TOFU_PLAN_JSON}"; then
Timo Furrer
committed
exit $?
fi
# NOTE: we want to exit with the tofu plan exit code if the tofu show command call is successful.
exit "$ret"
fi
fi
exit "$ret"
;;
"plan-json")
tofu "${TF_CHDIR_OPT}" show -json "${GITLAB_TOFU_PLAN_CACHE}" | jq -r "${JQ_PLAN}" > "${GITLAB_TOFU_PLAN_JSON}"
;;
"validate")
$TF_IMPLICIT_INIT && terraform_init -backend=false
# shellcheck disable=SC2086
tofu "${TF_CHDIR_OPT}" "${@}" ${var_file_args}
"test")
$TF_IMPLICIT_INIT && terraform_init -backend=false
# shellcheck disable=SC2086
tofu "${TF_CHDIR_OPT}" "${@}" ${var_file_args}
# shellcheck disable=SC2086
tofu "${TF_CHDIR_OPT}" "${@}" ${var_file_args}
--)
shift
tofu "${TF_CHDIR_OPT}" "${@}"
;;
*)
tofu "${TF_CHDIR_OPT}" "${@}"
;;
esac
else
# This variable can be used if the script is sourced
# shellcheck disable=SC2034
GITLAB_TOFU_SOURCED=true