Skip to content
Snippets Groups Projects
Select Git revision
  • 05c7c45413330a10aa87bc457e950b7df11c3f0c
  • main default protected
  • 0.50.0
  • 0.49.0
  • 0.48.0
  • 0.48.0-rc1
  • 0.47.0
  • 0.47.0-rc2
  • 0.47.0-rc1
  • 0.46.0
  • 0.45.0
  • 0.44.0
  • 0.43.0
  • 0.42.0
  • 0.42.0-rc4
  • 0.42.0-rc3
  • 0.42.0-rc2
  • 0.42.0-rc1
  • 0.41.0
  • 0.40.0
  • 0.40.0-rc7
  • 0.40.0-rc6
22 results

gitlab-tofu.sh

Blame
  • user avatar
    Timo Furrer authored
    Refs https://gitlab.com/components/opentofu/-/issues/83
    
    Changelog: added
    d4ec9fba
    History
    gitlab-tofu.sh 17.57 KiB
    #!/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_DEBUG`: if set to true will enable xtrace.
    # - `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 to the full path of the plan cache file. Defaults to `<root>/$GITLAB_TOFU_PLAN_NAME.cache`
    # - `GITLAB_TOFU_PLAN_JSON`: if set to the full path of the plan json file. Defaults to `<root>/$GITLAB_TOFU_PLAN_NAME.json`
    # - `GITLAB_TOFU_IMPLICIT_INIT`: if set to true will perform an implicit `tofu init` before any command that require it. Defaults to `true`.
    # - `GITLAB_TOFU_IGNORE_INIT_ERRORS`: if set to true will ignore errors in the `tofu init` command.
    # - `GITLAB_TOFU_INIT_NO_RECONFIGURE`: if set to true will not pass `-reconfigure` to the `tofu init` command. Defaults to `false`.
    # - `GITLAB_TOFU_STATE_NAME`: the name of the GitLab-managed Terraform state backend endpoint.
    # - `GITLAB_TOFU_STATE_ADDRESS`: the address of the GitLab-managed Terraform state backend. Defaults to `$CI_API_V4_URL/projects/$CI_PROJECT_ID/terraform/state/$GITLAB_TOFU_STATE_NAME`.
    # - `GITLAB_TOFU_USE_DETAILED_EXITCODE`: if set to true, `-detailed-exitcode` is supplied to `tofu plan`. Defaults to `false`.
    # - `GITLAB_TOFU_PLAN_WITH_JSON`: if set to true, will directly generate a JSON plan file when running `gitlab-tofu plan`. Defaults to `false`.
    # - `GITLAB_TOFU_VAR_FILE`: if set to a path it will pass `-var-file` to all `tofu` commands that support it.
    # - `GITLAB_TOFU_AUTO_ENCRYPTION`: if set to true, enables auto state and plan encryption. Defaults to `false`.
    # - `GITLAB_TOFU_AUTO_ENCRYPTION_PASSPHRASE`: the passphrase to use for state and plan encryption. Required if `GITLAB_TOFU_AUTO_ENCRYPTION` is true.
    # - `GITLAB_TOFU_AUTO_ENCRYPTION_ENABLE_MIGRATION_FROM_UNENCRYPTED_ENABLED`: if set to true, enables a fallback for state and plan encryption to migrate unencrypted plans and states to encrypted ones. Defaults to `false`.
    #
    # #### 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`.
    # - `TF_HTTP_ADDRESS`: address for the HTTP backend. Defaults to `$CI_API_V4_URL/projects/$CI_PROJECT_ID/terraform/state/<urlencode($GITLAB_TOFU_STATE_NAME)>`.
    # - `TF_HTTP_LOCK_ADDRESS`: lock address for the HTTP backend. Defaults to `$TF_HTTP_ADDRESS/lock`.
    # - `TF_HTTP_LOCK_METHOD`: lock method for the HTTP backend. Defaults to `POST`.
    # - `TF_HTTP_UNLOCK_ADDRESS`: unlock address for the HTTP backend. Defaults to `lock`.
    # - `TF_HTTP_UNLOCK_METHOD`: unlock address for the HTTP backend. Defaults to `unlock`.
    # - `TF_HTTP_RETRY_WAIT_MIN`: retry minimum waiting time in seconds. Defaults to `5`.
    # - `TF_CLI_CONFIG_FILE`: config file path. Defaults to `$HOME/.terraformrc` if it exists.
    #
    # #### 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.
    # - `CI_PROJECT_DIR`:
    #   - used as default value for root directory.
    # - `CI_PROJECT_ID`:
    #   - used as default value in constructing the `GITLAB_TOFU_STATE_ADDRESS`.
    # - `CI_API_V4_URL`:
    #   - used as default value in constructing the `GITLAB_TOFU_STATE_ADDRESS`.
    # - `CI_SERVER_HOST`:
    #   - used to construct for `TF_TOKEN_<host>` variable.
    # - `CI_SERVER_PROTOCOL`:
    #   - used to construct for `TF_TOKEN_<host>` variable.
    #
    # %%%%
    
    # set some shell options
    set -o errexit
    
    if [ "${GITLAB_TOFU_DEBUG}" = "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.
    
    # Source Mode
    # ===========
    # 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
    
    # Deprecations
    # ============
    if [ -n "$TF_STATE_NAME" ]; then
      echo 'WARNING: you have manually set the deprecated TF_STATE_NAME environment variable. Please use the GITLAB_TOFU_STATE_NAME environment variable instead. The TF_STATE_NAME variable will be removed soon.' >&2
    
      if [ -n "$GITLAB_TOFU_STATE_NAME" ]; then
        echo 'WARNING: you have set GITLAB_TOFU_STATE_NAME environment variable in addition to the deprecated TF_STATE_NAME. This causes a conflict and GITLAB_TOFU_STATE_NAME will be used exclusively' >&2
      else
        GITLAB_TOFU_STATE_NAME="$TF_STATE_NAME"
      fi
    fi
    
    if [ -n "$TF_ADDRESS" ]; then
      echo 'WARNING: you have manually set the deprecated TF_ADDRESS environment variable. Please use the GITLAB_TOFU_STATE_ADDRESS environment variable instead. The TF_ADDRESS variable will be removed soon.' >&2
    
      if [ -n "$GITLAB_TOFU_STATE_ADDRESS" ]; then
        echo 'WARNING: you have set GITLAB_TOFU_STATE_ADDRESS environment variable in addition to the deprecated TF_ADDRESS. This causes a conflict and GITLAB_TOFU_STATE_ADDRESS will be used exclusively' >&2
      else
        GITLAB_TOFU_STATE_ADDRESS="$TF_ADDRESS"
      fi
    fi
    
    if [ -n "$TF_ROOT" ]; then
      echo 'WARNING: you have manually set the deprecated TF_ROOT environment variable. Please use the GITLAB_TOFU_ROOT_DIR environment variable instead. The TF_ROOT variable will be removed soon.' >&2
    
      if [ -n "$GITLAB_TOFU_ROOT_DIR" ]; then
        echo 'WARNING: you have set GITLAB_TOFU_ROOT_DIR environment variable in addition to the deprecated TF_ROOT. This causes a conflict and GITLAB_TOFU_ROOT_DIR will be used exclusively' >&2
      else
        GITLAB_TOFU_ROOT_DIR="$TF_ROOT"
      fi
    fi
    
    # Handle environment variables
    # ============================
    
    # Backend related variables
    backend_username="gitlab-ci-token"
    backend_password="${CI_JOB_TOKEN}"
    backend_state_name="$(jq -rn --arg x "${GITLAB_TOFU_STATE_NAME:-default}" '$x|@uri')"
    backend_address="${GITLAB_TOFU_STATE_ADDRESS:-${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${backend_state_name}}"
    
    # Root directory related variables
    base_plan_name="${GITLAB_TOFU_PLAN_NAME:-plan}"
    if [ -n "${GITLAB_TOFU_ROOT_DIR}" ]; then
      abs_tf_root=$(cd "${CI_PROJECT_DIR}"; realpath "${GITLAB_TOFU_ROOT_DIR}")
    
      tf_chdir_opt="-chdir=${abs_tf_root}"
    
      default_tf_plan_cache="${abs_tf_root}/${base_plan_name}.cache"
      default_tf_plan_json="${abs_tf_root}/${base_plan_name}.json"
    fi
    
    # Init related variables
    init_flags=${GITLAB_TOFU_INIT_FLAGS}
    should_do_implicit_init=${GITLAB_TOFU_IMPLICIT_INIT:-true}
    should_ignore_init_errors=${GITLAB_TOFU_IGNORE_INIT_ERRORS:-false}
    should_init_without_reconfigure=${GITLAB_TOFU_INIT_NO_RECONFIGURE:-false}
    
    # Plan variables
    apply_without_plan=${GITLAB_TOFU_APPLY_NO_PLAN:-false}
    plan_cache_path="${GITLAB_TOFU_PLAN_CACHE:-${default_tf_plan_cache:-${base_plan_name}.cache}}"
    plan_json_path="${GITLAB_TOFU_PLAN_JSON:-${default_tf_plan_json:-${base_plan_name}.json}}"
    plan_with_detailed_exitcode=${GITLAB_TOFU_USE_DETAILED_EXITCODE:-false}
    plan_with_json_file=${GITLAB_TOFU_PLAN_WITH_JSON:-false}
    plan_jq_filter='
      (
        [.resource_changes[]?.change.actions?] | flatten
      ) | {
        "create":(map(select(.=="create")) | length),
        "update":(map(select(.=="update")) | length),
        "delete":(map(select(.=="delete")) | length)
      }
    '
    
    # auto encryption related variables
    auto_encryption_enabled=${GITLAB_TOFU_AUTO_ENCRYPTION:-false}
    auto_encryption_passphrase="${GITLAB_TOFU_AUTO_ENCRYPTION_PASSPHRASE}"
    auto_encryption_migration_from_unencrypted_enabled=${GITLAB_TOFU_AUTO_ENCRYPTION_ENABLE_MIGRATION_FROM_UNENCRYPTED:-false}
    if $auto_encryption_enabled && [ -z "${auto_encryption_passphrase}" ]; then
      # shellcheck disable=SC2016 # we actually want to print the variable names and not expand them.
      echo 'ERROR: a passphrase ($GITLAB_TOFU_AUTO_ENCRYPTION_PASSPHRASE) must be provided when enabled auto encryption ($GITLAB_TOFU_AUTO_ENCRYPTION)' >&2
      exit 1
    fi
    if ! $auto_encryption_enabled && $auto_encryption_migration_from_unencrypted_enabled; then
      # shellcheck disable=SC2016 # we actually want to print the variable names and not expand them.
      echo 'ERROR: you must enable auto encryption ($GITLAB_TOFU_AUTO_ENCRYPTION) to enable migration from an unencrypted state ($GITLAB_TOFU_AUTO_ENCRYPTION_ENABLE_MIGRATION_FROM_UNENCRYPTED)' >&2
      exit 1
    fi
    
    # Misc variables
    var_file="${GITLAB_TOFU_VAR_FILE}"
    
    # Helper functions
    # ================
    
    # configure_variables_for_tofu sets and exports all relevant variables for subsequent `tofu` command invocations.
    configure_variables_for_tofu() {
      # Use terraform automation mode (will remove some verbose unneeded messages)
      export TF_IN_AUTOMATION=true
    
      # Set variables for the HTTP backend to default to TF_* values
      export TF_HTTP_ADDRESS="${TF_HTTP_ADDRESS:-${backend_address}}"
      export TF_HTTP_LOCK_ADDRESS="${TF_HTTP_LOCK_ADDRESS:-${backend_address}/lock}"
      export TF_HTTP_LOCK_METHOD="${TF_HTTP_LOCK_METHOD:-POST}"
      export TF_HTTP_UNLOCK_ADDRESS="${TF_HTTP_UNLOCK_ADDRESS:-${backend_address}/lock}"
      export TF_HTTP_UNLOCK_METHOD="${TF_HTTP_UNLOCK_METHOD:-DELETE}"
      export TF_HTTP_USERNAME="${TF_HTTP_USERNAME:-${backend_username}}"
      export TF_HTTP_PASSWORD="${TF_HTTP_PASSWORD:-${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}}"
    
      # Set a Terraform CLI Configuration File
      default_tf_cli_config_file="$HOME/.terraformrc"
      if [ -z "${TF_CLI_CONFIG_FILE}" ] && [ -f "${default_tf_cli_config_file}" ]; then
        export TF_CLI_CONFIG_FILE="${default_tf_cli_config_file}"
      fi
    }
    
    # tofu_authenticate_private_registry sets the TF_TOKEN_* variable to authenticate private registries.
    tofu_authenticate_private_registry() {
      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
    }
    
    # tofu_init runs `tofu init` with all things considered.
    tofu_init() {
      if ! $should_init_without_reconfigure; then
        tofu_init_reconfigure_flag='-reconfigure'
      fi
    
      # shellcheck disable=SC2086 # We want to allow word splitting here for `init_flags`
      tofu "${tf_chdir_opt}" init "${@}" -input=false ${tofu_init_reconfigure_flag} ${init_flags} \
        1>&2 || $should_ignore_init_errors
    }
    
    
    # configure_encryption_for_tofu configures automatic state and plan encryption
    configure_encryption_for_tofu() {
      if ! $auto_encryption_enabled; then
        return
      fi
    
      if $auto_encryption_migration_from_unencrypted_enabled; then
        TF_ENCRYPTION=$(cat <<EOF
    method "unencrypted" "gitlab_tofu_auto_encryption_migrate" {}
    
    key_provider "pbkdf2" "gitlab_tofu_auto_encryption" {
      passphrase = "${auto_encryption_passphrase}"
    }
    
    method "aes_gcm" "gitlab_tofu_auto_encryption" {
      keys = key_provider.pbkdf2.gitlab_tofu_auto_encryption
    }
    
    state {
      method = method.aes_gcm.gitlab_tofu_auto_encryption
    
      fallback {
        method = method.unencrypted.gitlab_tofu_auto_encryption_migrate
      }
    }
    
    plan {
      method = method.aes_gcm.gitlab_tofu_auto_encryption
    
      fallback {
        method = method.unencrypted.gitlab_tofu_auto_encryption_migrate
      }
    }
    EOF
    )
      else
        TF_ENCRYPTION=$(cat <<EOF
    key_provider "pbkdf2" "gitlab_tofu_auto_encryption" {
      passphrase = "${auto_encryption_passphrase}"
    }
    
    method "aes_gcm" "gitlab_tofu_auto_encryption" {
      keys = key_provider.pbkdf2.gitlab_tofu_auto_encryption
    }
    
    state {
      method = method.aes_gcm.gitlab_tofu_auto_encryption
    }
    
    plan {
      method = method.aes_gcm.gitlab_tofu_auto_encryption
    }
    EOF
    )
      fi
    
      export TF_ENCRYPTION
    }
    
    # We always want to configure the tofu variables, even in source-mode.
    configure_variables_for_tofu
    configure_encryption_for_tofu
    
    # If this script is executed and not sourced, a tofu 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
      tofu_authenticate_private_registry
    
      var_file_args=""
      if [ -n "${var_file}" ]; then
        var_file_args="--var-file=${var_file}"
      fi
    
      case "${1}" in
        "apply")
          $should_do_implicit_init && tofu_init
          if ! $apply_without_plan; then
            tofu "${tf_chdir_opt}" "${@}" -input=false -auto-approve "${plan_cache_path}"
          else
            # shellcheck disable=SC2086
            tofu "${tf_chdir_opt}" "${@}" -input=false -auto-approve ${var_file_args}
          fi
        ;;
        "destroy")
          $should_do_implicit_init && tofu_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
          tofu_init "${@}"
        ;;
        "plan")
          plan_args=''
          if $plan_with_detailed_exitcode; then
            plan_args='-detailed-exitcode'
          fi
    
          $should_do_implicit_init && tofu_init
          # shellcheck disable=SC2086
          tofu "${tf_chdir_opt}" "${@}" -input=false -out="${plan_cache_path}" ${var_file_args} ${plan_args} && ret=$? || ret=$?
    
          if $plan_with_json_file; then
            if [ "$ret" -eq 0 ] || [ "$ret" -eq 2 ]; then
              if ! tofu "${tf_chdir_opt}" show -json "${plan_cache_path}" | jq -r "${plan_jq_filter}" > "${plan_json_path}"; 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 "${plan_cache_path}" | jq -r "${plan_jq_filter}" > "${plan_json_path}"
        ;;
        "validate")
          $should_do_implicit_init && tofu_init -backend=false
          # shellcheck disable=SC2086
          tofu "${tf_chdir_opt}" "${@}" ${var_file_args}
        ;;
        "test")
          $should_do_implicit_init && tofu_init -backend=false
          # shellcheck disable=SC2086
          tofu "${tf_chdir_opt}" "${@}" ${var_file_args}
        ;;
        "graph")
          $should_do_implicit_init && tofu_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