#!/usr/bin/env sh # 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`. # # 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_ variable. # set some shell options set -o errexit if [ "${DEBUG_OUTPUT}" = "true" ]; then set -o xtrace fi # 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}" fi if [ -z "${GITLAB_TOFU_PLAN_NAME}" ]; then GITLAB_TOFU_PLAN_NAME=plan fi if [ -z "${GITLAB_TOFU_APPLY_NO_PLAN}" ]; then GITLAB_TOFU_APPLY_NO_PLAN=false fi # 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" fi # If TF_PLAN_CACHE is not set then use either the plan.cache file within TF_ROOT if set, or plan.cache in CWD if [ -z "${TF_PLAN_CACHE}" ]; then TF_PLAN_CACHE="${default_tf_plan_cache:-${GITLAB_TOFU_PLAN_NAME}.cache}" fi # If TF_PLAN_JSON is not set then use either the plan.json file within TF_ROOT if set, or plan.json in CWD if [ -z "${TF_PLAN_JSON}" ]; then TF_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}}" # 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 var_file_args="" if [ -n "${GITLAB_TOFU_VAR_FILE}" ]; then var_file_args="--var-file=${GITLAB_TOFU_VAR_FILE}" fi 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 "${TF_PLAN_CACHE}" else # shellcheck disable=SC2086 tofu "${TF_CHDIR_OPT}" "${@}" -input=false -auto-approve ${var_file_args} fi ;; "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") plan_args='' if [ "${GITLAB_TOFU_USE_DETAILED_EXITCODE}" = 'true' ]; then plan_args='-detailed-exitcode' fi $TF_IMPLICIT_INIT && terraform_init # shellcheck disable=SC2086 tofu "${TF_CHDIR_OPT}" "${@}" -input=false -out="${TF_PLAN_CACHE}" ${var_file_args} ${plan_args} && ret=$? || ret=$? if [ "${GITLAB_TOFU_PLAN_WITH_JSON}" = 'true' ]; then if [ "$ret" -eq 0 ] || [ "$ret" -eq 2 ]; then if ! tofu "${TF_CHDIR_OPT}" show -json "${TF_PLAN_CACHE}" | jq -r "${JQ_PLAN}" > "${TF_PLAN_JSON}"; then 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 "${TF_PLAN_CACHE}" | jq -r "${JQ_PLAN}" > "${TF_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} ;; "graph") $TF_IMPLICIT_INIT && terraform_init # 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 fi