diff --git a/src/gitlab-tofu.sh b/src/gitlab-tofu.sh index a1dd4cda57051fa6575205868ec9ec57ffadcb80..c4dc9c8cf91c8866d887d0f4c9244781cabd6ca7 100644 --- a/src/gitlab-tofu.sh +++ b/src/gitlab-tofu.sh @@ -15,13 +15,16 @@ # 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` +# 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. +# 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. # # Respected OpenTofu Environment Variables: # > these are variables that are @@ -30,7 +33,13 @@ # ---------------------------------- # 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_CLI_CONFIG_FILE: will not overwrite if set. Defaults to `$HOME/.terraformrc` if it exists. +# 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 @@ -46,6 +55,10 @@ # - 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 the TF_TOKEN_<host> variable. +# CI_SERVER_PROTOCOL: +# - used to construct the TF_TOKEN_<host> variable. # set some shell options set -o errexit @@ -60,8 +73,8 @@ fi # There are no feature flags at the moment. -# Helpers - +# Source Mode +# =========== # Evaluate if this script is being sourced or executed directly. # See https://stackoverflow.com/a/28776166 sourced=0 @@ -135,105 +148,99 @@ if [ -n "$TF_ROOT" ]; then fi fi -jq_plan=' - ( - [.resource_changes[]?.change.actions?] | flatten - ) | { - "create":(map(select(.=="create")) | length), - "update":(map(select(.=="update")) | length), - "delete":(map(select(.=="delete")) | length) - } -' +# Handle environment variables +# ============================ -# Default state backend credentials to gitlab-ci-token/CI_JOB_TOKEN -state_backend_username="gitlab-ci-token" -state_backend_password="${CI_JOB_TOKEN}" +# 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}}" -# If GITLAB_TOFU_STATE_ADDRESS is unset but GITLAB_TOFU_STATE_NAME is provided, then default to GitLab backend in current project -if [ -n "${GITLAB_TOFU_STATE_NAME}" ] && [ -z "${GITLAB_TOFU_STATE_ADDRESS}" ]; then - # auto url-encode GITLAB_TOFU_STATE_NAME - GITLAB_TOFU_STATE_NAME="$(jq -rn --arg x "${GITLAB_TOFU_STATE_NAME}" '$x|@uri')" - GITLAB_TOFU_STATE_ADDRESS="${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${GITLAB_TOFU_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 GITLAB_TOFU_ROOT_DIR is set then use the -chdir option +# 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}/${GITLAB_TOFU_PLAN_NAME}.cache" - default_tf_plan_json="${abs_tf_root}/${GITLAB_TOFU_PLAN_NAME}.json" -fi - - -# If GITLAB_TOFU_PLAN_CACHE is not set then use either the plan.cache file within GITLAB_TOFU_ROOT_DIR 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}" -fi - -# If GITLAB_TOFU_PLAN_JSON is not set then use either the plan.json file within GITLAB_TOFU_ROOT_DIR 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}" + default_tf_plan_cache="${abs_tf_root}/${base_plan_name}.cache" + default_tf_plan_json="${abs_tf_root}/${base_plan_name}.json" fi -# Set variables for the HTTP backend to default to TF_* values -export TF_HTTP_ADDRESS="${TF_HTTP_ADDRESS:-${GITLAB_TOFU_STATE_ADDRESS}}" -export TF_HTTP_LOCK_ADDRESS="${TF_HTTP_LOCK_ADDRESS:-${GITLAB_TOFU_STATE_ADDRESS}/lock}" -export TF_HTTP_LOCK_METHOD="${TF_HTTP_LOCK_METHOD:-POST}" -export TF_HTTP_UNLOCK_ADDRESS="${TF_HTTP_UNLOCK_ADDRESS:-${GITLAB_TOFU_STATE_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 - -# 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 +# 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}.cache}}" +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) + } +' +# 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() { - # 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 @@ -243,28 +250,21 @@ tofu_authenticate_private_registry() { fi } -# If GITLAB_TOFU_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. -GITLAB_TOFU_IMPLICIT_INIT=${GITLAB_TOFU_IMPLICIT_INIT:-true} - -# Allows users to continue the actual command in case init failed -GITLAB_TOFU_IGNORE_INIT_ERRORS=${GITLAB_TOFU_IGNORE_INIT_ERRORS:-false} - +# tofu_init runs `tofu init` with all things considered. tofu_init() { - # If GITLAB_TOFU_INIT_NO_RECONFIGURE is not set to 'true', - # a `-reconfigure` flag is added to the `terraform init` command. - if [ "$GITLAB_TOFU_INIT_NO_RECONFIGURE" != 'true' ]; then - tf_init_reconfigure_flag='-reconfigure' + if ! $should_init_without_reconfigure; then + tofu_init_reconfigure_flag='-reconfigure' fi - # We want to allow word splitting here for GITLAB_TOFU_INIT_FLAGS - # shellcheck disable=SC2086 - tofu "${tf_chdir_opt}" init "${@}" -input=false ${tf_init_reconfigure_flag} ${GITLAB_TOFU_INIT_FLAGS} \ - 1>&2 || $GITLAB_TOFU_IGNORE_INIT_ERRORS + # 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 } -# If this script is executed and not sourced, a terraform command is ran. +# We always want to configure the tofu variables, even in source-mode. +configure_variables_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 @@ -272,22 +272,22 @@ if [ $sourced -eq 0 ]; then tofu_authenticate_private_registry var_file_args="" - if [ -n "${GITLAB_TOFU_VAR_FILE}" ]; then - var_file_args="--var-file=${GITLAB_TOFU_VAR_FILE}" + if [ -n "${var_file}" ]; then + var_file_args="--var-file=${var_file}" fi case "${1}" in "apply") - $GITLAB_TOFU_IMPLICIT_INIT && tofu_init - if [ "$GITLAB_TOFU_APPLY_NO_PLAN" = false ]; then - tofu "${tf_chdir_opt}" "${@}" -input=false -auto-approve "${GITLAB_TOFU_PLAN_CACHE}" + $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") - $GITLAB_TOFU_IMPLICIT_INIT && tofu_init + $should_do_implicit_init && tofu_init tofu "${tf_chdir_opt}" "${@}" -auto-approve ;; "fmt") @@ -300,17 +300,17 @@ if [ $sourced -eq 0 ]; then ;; "plan") plan_args='' - if [ "${GITLAB_TOFU_USE_DETAILED_EXITCODE}" = 'true' ]; then + if $plan_with_detailed_exitcode; then plan_args='-detailed-exitcode' fi - $GITLAB_TOFU_IMPLICIT_INIT && tofu_init + $should_do_implicit_init && tofu_init # shellcheck disable=SC2086 - tofu "${tf_chdir_opt}" "${@}" -input=false -out="${GITLAB_TOFU_PLAN_CACHE}" ${var_file_args} ${plan_args} && ret=$? || ret=$? + tofu "${tf_chdir_opt}" "${@}" -input=false -out="${plan_cache_path}" ${var_file_args} ${plan_args} && ret=$? || ret=$? - if [ "${GITLAB_TOFU_PLAN_WITH_JSON}" = 'true' ]; then + if $plan_with_json_file; then 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 + if ! tofu "${tf_chdir_opt}" show -json "${plan_cache_path}" | jq -r "${plan_jq_filter}" > "${plan_json_path}"; then exit $? fi @@ -322,20 +322,20 @@ if [ $sourced -eq 0 ]; then exit "$ret" ;; "plan-json") - tofu "${tf_chdir_opt}" show -json "${GITLAB_TOFU_PLAN_CACHE}" | jq -r "${jq_plan}" > "${GITLAB_TOFU_PLAN_JSON}" + tofu "${tf_chdir_opt}" show -json "${plan_cache_path}" | jq -r "${plan_jq_filter}" > "${plan_json_path}" ;; "validate") - $GITLAB_TOFU_IMPLICIT_INIT && tofu_init -backend=false + $should_do_implicit_init && tofu_init -backend=false # shellcheck disable=SC2086 tofu "${tf_chdir_opt}" "${@}" ${var_file_args} ;; "test") - $GITLAB_TOFU_IMPLICIT_INIT && tofu_init -backend=false + $should_do_implicit_init && tofu_init -backend=false # shellcheck disable=SC2086 tofu "${tf_chdir_opt}" "${@}" ${var_file_args} ;; "graph") - $GITLAB_TOFU_IMPLICIT_INIT && tofu_init + $should_do_implicit_init && tofu_init # shellcheck disable=SC2086 tofu "${tf_chdir_opt}" "${@}" ${var_file_args} ;; diff --git a/tests/unit/gitlab-tofu.bats b/tests/unit/gitlab-tofu.bats index ecdd7d3fa63817efd1a86c60620b80a66227826d..dcdc59c6ea3555002c836c21d0ef14905b685d1f 100644 --- a/tests/unit/gitlab-tofu.bats +++ b/tests/unit/gitlab-tofu.bats @@ -189,7 +189,7 @@ EOF set -x export GITLAB_TOFU_STATE_NAME=production/europe . $(which gitlab-tofu) -test "$GITLAB_TOFU_STATE_NAME" = "production%2Feurope" +test "$backend_state_name" = "production%2Feurope" EOF $SHELL test.sh