├── test ├── helm │ └── Makefile └── terraform │ ├── Makefile │ ├── installed.bats │ ├── lint.bats │ ├── terraform-docs.bats │ ├── get-plugins.bats │ ├── get-modules.bats │ ├── init.bats │ ├── plan.bats │ ├── apply.bats │ ├── input-descriptions.bats │ ├── output-descriptions.bats │ ├── validate.bats │ ├── idempotent.bats │ ├── module-pinning.bats │ ├── lib.bash │ └── provider-pinning.bats ├── .gitignore ├── .dockerignore ├── .github ├── workflows │ ├── auto-release.yml │ └── docker.yml ├── CODEOWNERS └── auto-release.yml ├── docs └── targets.md ├── .editorconfig ├── atmos.yaml ├── Makefile ├── README.yaml ├── Dockerfile ├── README.md └── LICENSE /test/helm/Makefile: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled files 2 | **/.terraform/* 3 | *.tfstate 4 | *.tfstate.* 5 | *.tfvars 6 | 7 | # Module directory 8 | .terraform 9 | **/.idea 10 | **/*.iml 11 | 12 | **/.build-harness 13 | **/build-harness 14 | .atmos 15 | -------------------------------------------------------------------------------- /test/terraform/Makefile: -------------------------------------------------------------------------------- 1 | TMP ?= /tmp 2 | TERRAFORM ?= $(BUILD_HARNESS_PATH)/vendor/terraform 3 | TERRAFORM_VERSION ?= 0.14.11 4 | TERRAFORM_URL ?= https://releases.hashicorp.com/terraform/$(TERRAFORM_VERSION)/terraform_$(TERRAFORM_VERSION)_$(OS)_amd64.zip 5 | -------------------------------------------------------------------------------- /test/terraform/installed.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | @test "check if terraform is installed" { 4 | skip_unless_terraform 5 | run which terraform 6 | if [ $status -ne 0 ]; then 7 | log "'which terraform' failed" 8 | return 1 9 | fi 10 | } 11 | -------------------------------------------------------------------------------- /test/terraform/lint.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | @test "check if terraform code needs formatting" { 4 | skip_if_disabled 5 | skip_unless_terraform 6 | run terraform fmt -write=false 7 | log "$output" 8 | [ $status -eq 0 ] 9 | [ -z "$output" ] 10 | } 11 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .editorconfig 4 | 5 | # Compiled files 6 | **/.terraform/* 7 | *.tfstate 8 | *.tfstate.* 9 | *.tfvars 10 | 11 | # Module directory 12 | .terraform 13 | **/.idea 14 | **/*.iml 15 | 16 | **/.build-harness 17 | **/build-harness 18 | -------------------------------------------------------------------------------- /test/terraform/terraform-docs.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | @test "check if terraform-docs is installed" { 4 | skip_unless_terraform 5 | run which terraform-docs 6 | if [ $status -ne 0 ]; then 7 | log "'which terraform-docs' failed" 8 | return 1 9 | fi 10 | } 11 | -------------------------------------------------------------------------------- /test/terraform/get-plugins.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | function setup() { 4 | clean 5 | } 6 | 7 | function teardown() { 8 | clean 9 | } 10 | 11 | @test "check if terraform plugins are valid" { 12 | skip_unless_terraform 13 | skip "Terraform no longer supports separate testing of plugins" 14 | } 15 | -------------------------------------------------------------------------------- /test/terraform/get-modules.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | function setup() { 4 | clean 5 | } 6 | 7 | function teardown() { 8 | clean 9 | } 10 | 11 | @test "check if terraform modules are valid" { 12 | skip_unless_terraform 13 | skip "Terraform no longer supports separate testing of module loading" 14 | } 15 | -------------------------------------------------------------------------------- /test/terraform/init.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | function setup() { 4 | clean 5 | } 6 | 7 | function teardown() { 8 | clean 9 | } 10 | 11 | @test "check if terraform init succeeds" { 12 | skip_unless_terraform 13 | run terraform init -input=false 14 | log "$output" 15 | [ $status -eq 0 ] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/auto-release.yml: -------------------------------------------------------------------------------- 1 | name: auto-release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: 9 | contents: write 10 | id-token: write 11 | 12 | jobs: 13 | release: 14 | uses: cloudposse/.github/.github/workflows/shared-auto-release.yml@main 15 | secrets: inherit 16 | 17 | -------------------------------------------------------------------------------- /docs/targets.md: -------------------------------------------------------------------------------- 1 | 2 | ## Makefile Targets 3 | ```text 4 | Available targets: 5 | 6 | build Build docker image 7 | help Help screen 8 | help/all Display help for all targets 9 | help/short This help short screen 10 | 11 | ``` 12 | 13 | -------------------------------------------------------------------------------- /test/terraform/plan.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | function setup() { 4 | skip_unless_terraform 5 | clean 6 | export TF_CLI_ARGS_plan="-input=false -detailed-exitcode" 7 | terraform init 8 | } 9 | 10 | function teardown() { 11 | clean 12 | unset TF_CLI_ARGS_plan 13 | } 14 | 15 | @test "check if terraform plan works" { 16 | skip_unless_terraform 17 | run terraform plan 18 | log "$output" 19 | [ $status -eq 0 ] || [ $status -eq 2 ] 20 | } 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | # Override for Makefile 10 | [{Makefile, makefile, GNUmakefile}] 11 | indent_style = tab 12 | indent_size = 4 13 | 14 | [Makefile.*] 15 | indent_style = tab 16 | indent_size = 4 17 | 18 | [shell] 19 | indent_style = tab 20 | indent_size = 4 21 | 22 | [*.sh] 23 | indent_style = tab 24 | indent_size = 4 25 | -------------------------------------------------------------------------------- /test/terraform/apply.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | function setup() { 4 | skip_unless_terraform 5 | clean 6 | export TF_CLI_ARGS_apply="-auto-approve -input=false" 7 | export TF_CLI_ARGS_destroy="-auto-approve" 8 | terraform init 9 | } 10 | 11 | function teardown() { 12 | terraform destroy 13 | clean 14 | unset TF_CLI_ARGS_apply 15 | unset TF_CLI_ARGS_destroy 16 | } 17 | 18 | @test "check if terraform apply works" { 19 | skip_unless_terraform 20 | run terraform apply 21 | log_on_error "$status" "$output" 22 | } 23 | -------------------------------------------------------------------------------- /test/terraform/input-descriptions.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | function setup() { 4 | TMPFILE="$(mktemp /tmp/terraform-docs-XXXXXXXXXXX.json)" 5 | } 6 | 7 | function teardown() { 8 | rm -f $TMPFILE 9 | } 10 | 11 | @test "check if terraform inputs have descriptions" { 12 | skip_unless_terraform 13 | terraform-docs json . > $TMPFILE 14 | run bash -c "jq -rS '.inputs[] | select (.description == \"\" or .description == null) | .name + \" is missing a description\"' < $TMPFILE" 15 | log "$output" 16 | [ -z "$output" ] 17 | } 18 | -------------------------------------------------------------------------------- /test/terraform/output-descriptions.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | function setup() { 4 | TMPFILE="$(mktemp /tmp/terraform-docs-XXXXXXXXXXX.json)" 5 | } 6 | 7 | function teardown() { 8 | rm -f $TMPFILE 9 | } 10 | 11 | @test "check if terraform outputs have descriptions" { 12 | skip_unless_terraform 13 | terraform-docs json . > $TMPFILE 14 | run bash -c "jq -rS '.outputs[] | select (.description == \"\" or .description == null) | .name + \" is missing a description\"' < $TMPFILE" 15 | log "$output" 16 | [ -z "$output" ] 17 | } 18 | -------------------------------------------------------------------------------- /atmos.yaml: -------------------------------------------------------------------------------- 1 | # Atmos Configuration — powered by https://atmos.tools 2 | # 3 | # This configuration enables centralized, DRY, and consistent project scaffolding using Atmos. 4 | # 5 | # Included features: 6 | # - Organizational custom commands: https://atmos.tools/core-concepts/custom-commands 7 | # - Automated README generation: https://atmos.tools/cli/commands/docs/generate 8 | # 9 | 10 | # Import shared configuration used by all modules 11 | import: 12 | - https://raw.githubusercontent.com/cloudposse/.github/refs/heads/main/.github/atmos/default.yaml 13 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Use this file to define individuals or teams that are responsible for code in a repository. 2 | # Read more: 3 | # 4 | # Order is important: the last matching pattern takes the most precedence 5 | 6 | # These owners will be the default owners for everything 7 | * @cloudposse/engineering 8 | 9 | # Cloud Posse must review any changes to Makefiles 10 | **/Makefile @cloudposse/engineering 11 | **/Makefile.* @cloudposse/engineering 12 | 13 | # Cloud Posse must review any changes to GitHub actions 14 | .github/* @cloudposse/engineering 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export DOCKER_ORG ?= cloudposse 2 | export DOCKER_IMAGE ?= $(DOCKER_ORG)/test-harness 3 | export DOCKER_TAG ?= latest 4 | export DOCKER_IMAGE_NAME ?= $(DOCKER_IMAGE):$(DOCKER_TAG) 5 | export DOCKER_BUILD_FLAGS = --platform linux/amd64 6 | export README_DEPS ?= docs/targets.md 7 | 8 | -include $(shell curl -sSL -o .build-harness "https://cloudposse.tools/build-harness"; echo .build-harness) 9 | 10 | .DEFAULT_GOAL : build 11 | 12 | ## Build docker image 13 | build: 14 | @make --no-print-directory docker/build 15 | 16 | readme/build: 17 | @atmos docs generate readme 18 | 19 | readme: 20 | @atmos docs generate readme 21 | -------------------------------------------------------------------------------- /test/terraform/validate.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | function setup() { 4 | skip_unless_terraform 5 | clean 6 | export TF_CLI_ARGS_init="-input=false -backend=false" 7 | terraform init >/dev/null 8 | } 9 | 10 | function teardown() { 11 | clean 12 | unset TF_CLI_ARGS_init 13 | unset AWS_DEFAULT_REGION 14 | } 15 | 16 | @test "check if terraform code is valid" { 17 | skip_unless_terraform 18 | if [[ "`terraform version | head -1`" =~ ^0\.11 ]]; then 19 | run terraform validate -check-variables=false 20 | log_on_error "$status" "$output" 21 | [ -z "$output" ] || log_on_error "99" "$output" 22 | else 23 | export AWS_DEFAULT_REGION="us-east-2" 24 | run terraform validate . 25 | log_on_error "$status" "$output" 26 | fi 27 | } 28 | -------------------------------------------------------------------------------- /.github/auto-release.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: '$RESOLVED_VERSION' 3 | version-template: '$MAJOR.$MINOR.$PATCH' 4 | version-resolver: 5 | major: 6 | labels: 7 | - 'major' 8 | minor: 9 | labels: 10 | - 'minor' 11 | - 'enhancement' 12 | patch: 13 | labels: 14 | - 'patch' 15 | - 'fix' 16 | - 'bugfix' 17 | - 'bug' 18 | - 'hotfix' 19 | - 'no-release' 20 | default: 'minor' 21 | 22 | categories: 23 | - title: '🚀 Enhancements' 24 | labels: 25 | - 'enhancement' 26 | - title: '🐛 Bug Fixes' 27 | labels: 28 | - 'fix' 29 | - 'bugfix' 30 | - 'bug' 31 | - 'hotfix' 32 | 33 | change-template: | 34 |
35 | $TITLE @$AUTHOR (#$NUMBER) 36 | 37 | $BODY 38 |
39 | 40 | template: | 41 | $CHANGES 42 | -------------------------------------------------------------------------------- /test/terraform/idempotent.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | function setup() { 4 | skip_unless_terraform 5 | export TF_CLI_ARGS_plan="-input=false -detailed-exitcode" 6 | export TF_CLI_ARGS_apply="-auto-approve -input=false" 7 | export TF_CLI_ARGS_destroy="-auto-approve" 8 | terraform init 9 | } 10 | 11 | function teardown() { 12 | skip_unless_terraform 13 | clean 14 | unset TF_CLI_ARGS_plan 15 | unset TF_CLI_ARGS_apply 16 | unset TF_CLI_ARGS_destroy 17 | } 18 | 19 | @test "check if terraform is idempotent" { 20 | # https://www.terraform.io/docs/commands/plan.html#usage 21 | 22 | # Run `terraform plan` (expect changes?) 23 | run terraform plan 24 | log "$output" 25 | [ $status -eq 2 ] 26 | 27 | # Run `terraform apply` 28 | run terraform apply 29 | log "$output" 30 | [ $status -eq 0 ] 31 | 32 | # Run `terraform plan` (expect no changes) 33 | run terraform plan 34 | log "$output" 35 | [ $status -eq 0 ] 36 | 37 | # Run `terraform destroy` 38 | run terraform destroy 39 | log "$output" 40 | [ $status -eq 0 ] 41 | 42 | # Run `terraform plan` (expect changes?) 43 | run terraform plan 44 | log "$output" 45 | [ $status -eq 2 ] 46 | 47 | } 48 | -------------------------------------------------------------------------------- /test/terraform/module-pinning.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | function setup() { 4 | if ! which terraform-config-inspect; then 5 | log "'terraform-config-inspect' must be installed in the test harness. Check https://github.com/hashicorp/terraform-config-inspect for instructions " 6 | false 7 | fi 8 | TMPFILE="$(mktemp /tmp/terraform-modules-XXXXXXXXXXX.txt)" 9 | } 10 | 11 | function teardown() { 12 | #rm -f $TMPFILE 13 | : 14 | } 15 | 16 | @test "check if terraform modules are properly pinned" { 17 | skip_unless_terraform 18 | ## Extract all module calls (except submodules in ./modules/) into string with source then | then version (if version parameter exists) 19 | ## Add || true at the end because a pipe failure just means this module has no calls to other modules 20 | ## The grep -v '^"\.\./' is to exclude modules in the same repo (e.g. "../stack") from the check 21 | terraform-config-inspect --json . | jq '.module_calls[] | "\(.source)|\(.version)"' | grep -v -F '"./modules' | grep -v '^"\.\./' > $TMPFILE || true 22 | ## check if module url have version in tags or if version pinned with 'version' parameter for Terraform Registry notation 23 | ## check diff between terraform-config-inspect output and regexp check to see if all cases are passing checks 24 | fail=$(grep -vE '^(\".*?v?[0-9]+\.[0-9]+.*\|null\"\s?|\".*?\|v?[0-9]+\.[0-9]+.*\"\s?)+' $TMPFILE) || true 25 | if [[ -n "$fail" ]]; then 26 | output_msg=$'\nCloud Posse requires all module sources to be pinned to a specific version, e.g. 0.9.1 or v0.9.1\n' 27 | output_msg+=$'Please fix these module sources:\n' 28 | nl=$'\n' 29 | output_msg+=$(printf "%s\n" "${fail[@]}" | sed -e 's/"/ - /' -e 's/|null//' | sed -E 's/^ - ([^|]+)\|(.*)$/ - source = "\1"'"\\$nl"' version = "\2"/g') 30 | log "$output_msg" 31 | return 1 32 | fi 33 | true 34 | } 35 | -------------------------------------------------------------------------------- /test/terraform/lib.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | shopt -s nullglob 4 | 5 | BATS_LOG="${BATS_LOG:-test.log}" 6 | 7 | function skip_if_disabled() { 8 | local env 9 | env=${BATS_TEST_FILENAME} 10 | env=$(basename $env .bats) 11 | env=$(basename $BATS_TEST_DIRNAME)_$env 12 | env=${env^^} 13 | env=${env//-/_} 14 | env=${env//./_} 15 | env=TEST_${env} 16 | if [ "${!env}" == "false" ]; then 17 | skip "${env} is false" 18 | fi 19 | } 20 | 21 | function output_only() { 22 | local output="$*" 23 | if [ -n "${output}" ]; then 24 | ( 25 | echo 26 | echo "Test: ${BATS_TEST_DESCRIPTION}" 27 | echo "File: $(basename ${BATS_TEST_FILENAME})" 28 | echo "---------------------------------" 29 | echo "${output}" 30 | echo "---------------------------------" 31 | echo 32 | ) 33 | fi 34 | } 35 | 36 | function log() { 37 | local output="$*" 38 | if [ -n "${output}" ]; then 39 | ( 40 | echo 41 | if [ -n "$LOG_MARKDOWN" ]; then 42 | echo "
" 43 | echo "Test: ${BATS_TEST_DESCRIPTION}" 44 | echo 45 | echo '```' 46 | else 47 | echo "Test: ${BATS_TEST_DESCRIPTION}" 48 | fi 49 | echo "File: $(basename ${BATS_TEST_FILENAME})" 50 | echo "---------------------------------" 51 | echo "${output}" 52 | echo "---------------------------------" 53 | if [ -n "$LOG_MARKDOWN" ]; then 54 | echo '```' 55 | echo "
" 56 | fi 57 | echo 58 | ) | tee -a ${BATS_LOG} >&3 59 | fi 60 | } 61 | 62 | function log_on_error() { 63 | local status="$1" 64 | shift 65 | local output="$*" 66 | if [[ $status == "0" ]]; then 67 | output_only "$output" 68 | else 69 | log "$output" 70 | fi 71 | return $status 72 | } 73 | 74 | function clean() { 75 | rm -rf .terraform .terraform.lock.hcl 76 | } 77 | 78 | function skip_unless_terraform() { 79 | [[ -n $(echo *.tf) ]] || skip "no *.tf files" 80 | } 81 | -------------------------------------------------------------------------------- /README.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # 3 | # This is the canonical configuration for the `README.md` 4 | # Run `make readme` to rebuild the `README.md` 5 | # 6 | 7 | # Name of this project 8 | name: "test-harness" 9 | 10 | # Logo for this project 11 | #logo: docs/logo.png 12 | 13 | # License of this project 14 | license: "APACHE2" 15 | 16 | # Canonical GitHub repo 17 | github_repo: "cloudposse/test-harness" 18 | 19 | # Badges to display 20 | badges: 21 | - name: "Codefresh Build Status" 22 | image: "https://g.codefresh.io/api/badges/pipeline/cloudposse/cloudposse%2Ftest-harness%2Ftest-harness?type=cf-1" 23 | url: "https://g.codefresh.io/public/accounts/cloudposse/pipelines/cloudposse/test-harness/test-harness" 24 | - name: "Latest Release" 25 | image: "https://img.shields.io/github/release/cloudposse/test-harness.svg" 26 | url: "https://github.com/cloudposse/test-harness/releases/latest" 27 | - name: "Slack Community" 28 | image: "https://slack.cloudposse.com/badge.svg" 29 | url: "https://slack.cloudposse.com" 30 | 31 | references: 32 | - name: "Cloud Posse Documentation" 33 | description: "Complete documentation for the Cloud Posse solution" 34 | url: "https://docs.cloudposse.com" 35 | 36 | # Short description of this project 37 | description: |- 38 | Collection of Makefiles and test scripts to facilitate testing Terraform modules, Kubernetes resources, Helm charts, and more. 39 | 40 | ## Prerequisites 41 | 42 | 1. [Bats-core](https://github.com/bats-core/bats-core) 43 | 1. Bash v5+ 44 | - If you're on Mac, you're possibly running Bash v3. You can upgrade via homebrew: `brew install bash` 45 | 46 | # How to use this project 47 | usage: |- 48 | Use the `test-harness` Docker image as the base image in the application `Dockerfile`, and copy the modules from `tests` folder into `/tests/` folder in the Docker container. 49 | 50 | ```dockerfile 51 | FROM cloudposse/test-harness:0.25.0 as test-harness 52 | 53 | # Get latest release from https://github.com/cloudposse/geodesic/releases 54 | FROM cloudposse/geodesic:2.11.3-alpine 55 | 56 | # Copy root modules into /conf folder 57 | COPY --from=test-harness /tests/ /tests/ 58 | ``` 59 | 60 | include: 61 | - "docs/targets.md" 62 | 63 | # Contributors to this project 64 | contributors: 65 | - name: "Erik Osterman" 66 | github: "osterman" 67 | - name: "Andriy Knysh" 68 | github: "aknysh" 69 | - name: "Igor Rodionov" 70 | github: "goruha" 71 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: "docker" 2 | on: 3 | workflow_dispatch: 4 | 5 | pull_request: 6 | types: [opened, synchronize, reopened] 7 | release: 8 | types: 9 | - published 10 | schedule: 11 | - cron: '30 23 * * *' 12 | 13 | jobs: 14 | build-and-push: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: "Checkout source code at current commit" 18 | uses: actions/checkout@v4 19 | - name: Prepare tags for Docker image 20 | if: (github.event_name == 'release' && github.event.action == 'published') || github.event.pull_request.head.repo.full_name == github.repository || (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') 21 | id: prepare 22 | run: | 23 | TAGS=${{ github.repository }}:sha-${GITHUB_SHA:0:7} 24 | if [[ $GITHUB_REF == refs/tags/* ]]; then 25 | VERSION=${GITHUB_REF#refs/tags/} 26 | elif [[ $GITHUB_REF == refs/pull/* ]]; then 27 | VERSION=pr-${{ github.event.pull_request.number }}-merge 28 | fi 29 | if [[ -n $VERSION ]]; then 30 | TAGS="$TAGS,${{ github.repository }}:${VERSION}" 31 | fi 32 | if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then 33 | TAGS="$TAGS,${{ github.repository }}:latest" 34 | fi 35 | if [[ ${{ github.event_name }} == 'schedule' ]]; then 36 | TAGS="$TAGS,${{ github.repository }}:latest,${{ github.repository }}:nightly" 37 | elif [[ $GITHUB_REF != refs/pull/* ]]; then 38 | TAGS="$TAGS,${{ github.repository }}:latest" 39 | fi 40 | echo "tags=${TAGS}" >> $GITHUB_OUTPUT 41 | - name: Set up Docker Buildx 42 | uses: docker/setup-buildx-action@v3 43 | - name: Login to DockerHub 44 | if: (github.event_name == 'release' && github.event.action == 'published') || github.event.pull_request.head.repo.full_name == github.repository || (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') 45 | uses: docker/login-action@v3 46 | with: 47 | username: ${{ secrets.DOCKERHUB_USERNAME }} 48 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 49 | - name: "Build and push docker image to DockerHub" 50 | id: docker_build 51 | uses: docker/build-push-action@v5 52 | with: 53 | push: ${{ (github.event_name == 'release' && github.event.action == 'published') || github.event.pull_request.head.repo.full_name == github.repository || (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} 54 | tags: ${{ steps.prepare.outputs.tags }} 55 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM cloudposse/build-harness:latest 2 | 3 | RUN echo '@community https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories 4 | 5 | RUN apk del --no-interactive terraform-1 terraform 6 | RUN apk add --update --no-cache go bats vert@cloudposse \ 7 | terraform-config-inspect@cloudposse terraform-docs@cloudposse \ 8 | terraform-0.11@cloudposse terraform-0.12@cloudposse terraform-0.13@cloudposse \ 9 | terraform-0.14@cloudposse terraform-0.15@cloudposse \ 10 | opentofu@community \ 11 | atmos@cloudposse 12 | 13 | 14 | # https://www.hashicorp.com/en/blog/installing-hashicorp-tools-in-alpine-linux-containers 15 | ENV PRODUCT="terraform" 16 | ENV VERSION="1.13.3" 17 | 18 | RUN apk add --update --virtual .deps --no-cache gnupg && \ 19 | cd /tmp && \ 20 | wget https://releases.hashicorp.com/${PRODUCT}/${VERSION}/${PRODUCT}_${VERSION}_linux_amd64.zip && \ 21 | wget https://releases.hashicorp.com/${PRODUCT}/${VERSION}/${PRODUCT}_${VERSION}_SHA256SUMS && \ 22 | wget https://releases.hashicorp.com/${PRODUCT}/${VERSION}/${PRODUCT}_${VERSION}_SHA256SUMS.sig && \ 23 | wget -qO- https://www.hashicorp.com/.well-known/pgp-key.txt | gpg --import && \ 24 | gpg --verify ${PRODUCT}_${VERSION}_SHA256SUMS.sig ${PRODUCT}_${VERSION}_SHA256SUMS && \ 25 | grep ${PRODUCT}_${VERSION}_linux_amd64.zip ${PRODUCT}_${VERSION}_SHA256SUMS | sha256sum -c && \ 26 | unzip /tmp/${PRODUCT}_${VERSION}_linux_amd64.zip -d /tmp && \ 27 | mkdir -p /usr/share/terraform/1/bin && \ 28 | mv /tmp/${PRODUCT} /usr/share/terraform/1/bin/${PRODUCT} && \ 29 | rm -f /tmp/${PRODUCT}_${VERSION}_linux_amd64.zip ${PRODUCT}_${VERSION}_SHA256SUMS ${VERSION}/${PRODUCT}_${VERSION}_SHA256SUMS.sig && \ 30 | apk del .deps --force-broken-world 31 | 32 | RUN update-alternatives --install /usr/bin/terraform-1 terraform-1 /usr/share/terraform/1/bin/${PRODUCT} 1 33 | 34 | # Install `terraform-1` as an alternative to `terraform`, if it is available. 35 | # Set priority to 5, which is lower than any other Cloud Posse Terraform package, 36 | # so that it is available, if Terraform is not installed, but does not interfere with Terraform installations. 37 | RUN update-alternatives --install /usr/bin/terraform terraform /usr/share/terraform/1/bin/${PRODUCT} 4 38 | 39 | 40 | # Install `tofu` as an alternative to `terraform`, if it is available. 41 | # Set priority to 5, which is lower than any other Cloud Posse Terraform package, 42 | # so that it is available, if Terraform is not installed, but does not interfere with Terraform installations. 43 | RUN command -v tofu >/dev/null && update-alternatives --install /usr/bin/terraform terraform $(command -v tofu) 5 44 | 45 | COPY test/ /test/ 46 | 47 | # Our old Makefiles conditionally set TF_CLI_ARGS_init=-get-plugins=true but that 48 | # became a no-op in Terraform 0.13 and is rejected by Terraform 0.15. 49 | # We set it here to blank to keep the Makefile from setting it, although this 50 | # may break Terraform 0.12 in some cases. 51 | ENV TF_CLI_ARGS_init="" 52 | 53 | WORKDIR / 54 | -------------------------------------------------------------------------------- /test/terraform/provider-pinning.bats: -------------------------------------------------------------------------------- 1 | load 'lib' 2 | 3 | function setup() { 4 | if ! which terraform-config-inspect; then 5 | log "'terraform-config-inspect' must be installed in the test harness. Check https://github.com/hashicorp/terraform-config-inspect for instructions " 6 | false 7 | fi 8 | export TF_CLI_ARGS_init="-get-plugins -backend=false -input=false" 9 | rm -rf .terraform 10 | TMPFILE="$(mktemp /tmp/terraform-providers-XXXXXXXXXXX.txt)" 11 | } 12 | 13 | function teardown() { 14 | rm -rf .terraform 15 | rm -f $TMPFILE 16 | unset TF_CLI_ARGS_init 17 | } 18 | 19 | @test "check if terraform providers are properly pinned" { 20 | skip_unless_terraform 21 | 22 | # check if all required_providers have version_constraints 23 | no_version_provider=$(terraform-config-inspect --json . | jq '.required_providers[] | select (.version_constraints == null)[]') 24 | if [[ -n "$no_version_provider" ]]; then 25 | fail_msg=$'Providers without version constraint found:\n' 26 | log "${fail_msg}${no_version_provider}" 27 | return 1 28 | fi 29 | 30 | ## extract all required providers into string with 'provider' | then version constraint 31 | ## Note that when using the builtin "terraform" provider, version_constraints is always null 32 | terraform-config-inspect --json . | jq '.required_providers | to_entries[] | select(.key != "terraform") | " - \(.key)|\(.value.version_constraints[])"' > $TMPFILE 33 | ## Ensure provider version constraint is '>=' 34 | fail=$(grep -v '|>=' $TMPFILE) || true 35 | if [[ -n "$fail" ]]; then 36 | output_msg=$'\nCloud Posse requires all providers to be pinned with ">=" constraints and only ">=" constraints\n' 37 | output_msg+=$'Please fix these constraints:\n' 38 | output_msg+=$(printf "%s\n" "${fail[@]}" | sed 's/|/: /g' | sed 's/"//g') 39 | log "$output_msg" 40 | fi 41 | [[ -z "$fail" ]] 42 | } 43 | 44 | @test "check if terraform providers have explicit source locations for TF =>0.13" { 45 | skip_unless_terraform 46 | 47 | if vert "$(terraform-config-inspect --json . | jq -r '.required_core[]')" 0.12.25 >/devnull; then 48 | # Terraform version '$TERRAFORM_CORE_VERSION' less then 13. Skipping check for explicit provider source locations 49 | # ref: https://www.terraform.io/upgrade-guides/0-13.html#explicit-provider-source-locations 50 | skip "Minimum Terraform version less than 0.12.26. Skipping check for explicit provider source locations" 51 | else 52 | ## extract all required providers with sources into string with 'provider' | then 'source' 53 | ## Note that when using the builtin "terraform" provider, source is always null 54 | terraform-config-inspect --json . | jq '.required_providers | to_entries[] | select(.key != "terraform") | " - \(.key)|\(.value.source)"' > $TMPFILE 55 | ## check if provider source exists for every provider 56 | fail=$(grep -F '|null' $TMPFILE) || true 57 | if [[ -n "$fail" ]]; then 58 | output_msg=$'\nCloud Posse requires all providers to use registry format introduced in Terraform 0.13, for example\n' 59 | output_msg+=$' aws = {\n source = "hashicorp/aws"\n version = ">= 3.0"\n }\n\n' 60 | output_msg+=$'Please add constraints for these providers:\n' 61 | output_msg+=$(printf "%s\n" "${fail[@]}" | cut '-d|' -f 1 | sed 's/"//g') 62 | log "$output_msg" 63 | return 1 64 | fi 65 | true 66 | fi 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # test-harness 5 | 6 | 7 | Codefresh Build StatusLatest ReleaseSlack CommunityGet Support 8 | 9 | 10 | 11 | 12 | 32 | 33 | Collection of Makefiles and test scripts to facilitate testing Terraform modules, Kubernetes resources, Helm charts, and more. 34 | 35 | ## Prerequisites 36 | 37 | 1. [Bats-core](https://github.com/bats-core/bats-core) 38 | 1. Bash v5+ 39 | - If you're on Mac, you're possibly running Bash v3. You can upgrade via homebrew: `brew install bash` 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ## Usage 48 | 49 | Use the `test-harness` Docker image as the base image in the application `Dockerfile`, and copy the modules from `tests` folder into `/tests/` folder in the Docker container. 50 | 51 | ```dockerfile 52 | FROM cloudposse/test-harness:0.25.0 as test-harness 53 | 54 | # Get latest release from https://github.com/cloudposse/geodesic/releases 55 | FROM cloudposse/geodesic:2.11.3-alpine 56 | 57 | # Copy root modules into /conf folder 58 | COPY --from=test-harness /tests/ /tests/ 59 | ``` 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | ## Makefile Targets 72 | ```text 73 | Available targets: 74 | 75 | build Build docker image 76 | help Help screen 77 | help/all Display help for all targets 78 | help/short This help short screen 79 | 80 | ``` 81 | 82 | 83 | 84 | 85 | 86 | ## References 87 | 88 | For additional context, refer to some of these links. 89 | 90 | - [Cloud Posse Documentation](https://docs.cloudposse.com) - Complete documentation for the Cloud Posse solution 91 | 92 | 93 | 94 | 95 | ## ✨ Contributing 96 | 97 | This project is under active development, and we encourage contributions from our community. 98 | 99 | 100 | 101 | Many thanks to our outstanding contributors: 102 | 103 | 104 | 105 | 106 | 107 | For 🐛 bug reports & feature requests, please use the [issue tracker](https://github.com/cloudposse/test-harness/issues). 108 | 109 | In general, PRs are welcome. We follow the typical "fork-and-pull" Git workflow. 110 | 1. Review our [Code of Conduct](https://github.com/cloudposse/test-harness/?tab=coc-ov-file#code-of-conduct) and [Contributor Guidelines](https://github.com/cloudposse/.github/blob/main/CONTRIBUTING.md). 111 | 2. **Fork** the repo on GitHub 112 | 3. **Clone** the project to your own machine 113 | 4. **Commit** changes to your own branch 114 | 5. **Push** your work back up to your fork 115 | 6. Submit a **Pull Request** so that we can review your changes 116 | 117 | **NOTE:** Be sure to merge the latest changes from "upstream" before making a pull request! 118 | 119 | 120 | ## Running Terraform Tests 121 | 122 | We use [Atmos](https://atmos.tools) to streamline how Terraform tests are run. It centralizes configuration and wraps common test workflows with easy-to-use commands. 123 | 124 | All tests are located in the [`test/`](test) folder. 125 | 126 | Under the hood, tests are powered by Terratest together with our internal [Test Helpers](https://github.com/cloudposse/test-helpers) library, providing robust infrastructure validation. 127 | 128 | Setup dependencies: 129 | - Install Atmos ([installation guide](https://atmos.tools/install/)) 130 | - Install Go [1.24+ or newer](https://go.dev/doc/install) 131 | - Install Terraform or OpenTofu 132 | 133 | To run tests: 134 | 135 | - Run all tests: 136 | ```sh 137 | atmos test run 138 | ``` 139 | - Clean up test artifacts: 140 | ```sh 141 | atmos test clean 142 | ``` 143 | - Explore additional test options: 144 | ```sh 145 | atmos test --help 146 | ``` 147 | The configuration for test commands is centrally managed. To review what's being imported, see the [`atmos.yaml`](https://raw.githubusercontent.com/cloudposse/.github/refs/heads/main/.github/atmos/terraform-module.yaml) file. 148 | 149 | Learn more about our [automated testing in our documentation](https://docs.cloudposse.com/community/contribute/automated-testing/) or implementing [custom commands](https://atmos.tools/core-concepts/custom-commands/) with atmos. 150 | 151 | ### 🌎 Slack Community 152 | 153 | Join our [Open Source Community](https://cpco.io/slack?utm_source=github&utm_medium=readme&utm_campaign=cloudposse/test-harness&utm_content=slack) on Slack. It's **FREE** for everyone! Our "SweetOps" community is where you get to talk with others who share a similar vision for how to rollout and manage infrastructure. This is the best place to talk shop, ask questions, solicit feedback, and work together as a community to build totally *sweet* infrastructure. 154 | 155 | ### 📰 Newsletter 156 | 157 | Sign up for [our newsletter](https://cpco.io/newsletter?utm_source=github&utm_medium=readme&utm_campaign=cloudposse/test-harness&utm_content=newsletter) and join 3,000+ DevOps engineers, CTOs, and founders who get insider access to the latest DevOps trends, so you can always stay in the know. 158 | Dropped straight into your Inbox every week — and usually a 5-minute read. 159 | 160 | ### 📆 Office Hours 161 | 162 | [Join us every Wednesday via Zoom](https://cloudposse.com/office-hours?utm_source=github&utm_medium=readme&utm_campaign=cloudposse/test-harness&utm_content=office_hours) for your weekly dose of insider DevOps trends, AWS news and Terraform insights, all sourced from our SweetOps community, plus a _live Q&A_ that you can’t find anywhere else. 163 | It's **FREE** for everyone! 164 | ## License 165 | 166 | License 167 | 168 |
169 | Preamble to the Apache License, Version 2.0 170 |
171 |
172 | 173 | Complete license is available in the [`LICENSE`](LICENSE) file. 174 | 175 | ```text 176 | Licensed to the Apache Software Foundation (ASF) under one 177 | or more contributor license agreements. See the NOTICE file 178 | distributed with this work for additional information 179 | regarding copyright ownership. The ASF licenses this file 180 | to you under the Apache License, Version 2.0 (the 181 | "License"); you may not use this file except in compliance 182 | with the License. You may obtain a copy of the License at 183 | 184 | https://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, 187 | software distributed under the License is distributed on an 188 | "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 189 | KIND, either express or implied. See the License for the 190 | specific language governing permissions and limitations 191 | under the License. 192 | ``` 193 |
194 | 195 | ## Trademarks 196 | 197 | All other trademarks referenced herein are the property of their respective owners. 198 | 199 | 200 | --- 201 | Copyright © 2017-2025 [Cloud Posse, LLC](https://cpco.io/copyright) 202 | 203 | 204 | README footer 205 | 206 | Beacon 207 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018 Cloud Posse, LLC 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------